// ═══════════════════════════════════════════════════ LEON Countertops
// The team quotes countertops today in Moraware CounterGo — a separate paid
// SaaS. This is that workflow, against LEON's own records: the same six-step
// drawing wizard, the same estimate structure, but reading the real vendors,
// the real supplier finishes and the real slabs already in the Hub, and
// handing its pieces to LEON Stone's cut list at the end.
//
// The reason CounterGo is fast is that drawing a countertop is NOT freeform
// CAD in the usual sense. It is six decisions taken one at a time over the
// WHOLE drawing — the outline, then the corners, then which edges are
// finished, then the sinks, then the material, then the money. Nobody picks an
// edge profile while still fighting a dimension. That structure is copied here
// deliberately.
//
// The model lives in data.jsx under "LEON Countertops — drawing, take-off and
// estimating" (CT_* vocabularies, makeCt* factories). Nothing is redeclared
// here; this file is the engine and the screens.
//
// FOUR RULES THE ESTIMATE TURNS ON. Get one wrong and the quote is worthless:
//   1. Material is charged BY THE SLAB, not by the countertop. 69.2 sq ft of
//      counter that needs two 128"x64" slabs bills 113.8 sq ft. So the slab
//      count has to be DERIVED, and where it has not been laid out by hand the
//      quote line says out loud that the count is an estimate.
//   2. Fabrication and installation are charged on the drawn countertop area.
//   3. Edge is charged per linear foot of FINISHED segments only. A run
//      against a wall is not edge and must never be counted as if it were.
//   4. A miter bills BOTH pieces — a miter on a 24" edge is 4 lin ft, not 2.
//      A waterfall is a miter plus its own installation charge.
//
// AND ONE RULE ABOUT PRICES THAT ARE NOT SET. An unset price is not zero.
// `-No price-` means nobody has decided yet; `$0.00` means it is genuinely
// free. They are different claims and this module never collapses them — a
// quote with an unset fabrication rate reports itself INCOMPLETE rather than
// quietly totalling as though fabrication were free, because that is exactly
// how a job gets sold at a loss.
//
// THERE IS NO WASTE PERCENTAGE HERE, ON PURPOSE. Waste is already absorbed
// twice: material is priced by the whole slab, and the square footage always
// rounds UP. A waste % on top of those would charge the same waste three times.

// LEON Countertop is ONE software covering both halves of the job: drawing and
// quoting (this file) and slabs, remnants, allocation, costing, cut list and
// slab layout (softwares/stone.jsx, already built). These sections are
// published so the host can render them in ONE subtab bar rather than two —
// the keys are prefixed `ct` precisely so they cannot collide with
// STONE_SUBTABS' own `dashboard` / `layout`.
//
// There is deliberately no Slab Layout section here: laying pieces on a slab
// is LEON Stone's Slab Layout tab, and a second one would be two answers to
// one question. Step 5 carries its own "Slabs & Layout" panel for the
// area-level plan the quote depends on, and points at that tab for the rest.
const CT_SECTIONS = [
  { key: 'ctOverview', label: 'Quote Overview', icon: '📊' },
  { key: 'ctQuotes', label: 'Quotes', icon: '🧾' },
  { key: 'ctDrawing', label: 'Drawing', icon: '📐' },
  { key: 'ctSheet', label: 'Shop Drawing', icon: '📄' },
  { key: 'ctPriceLists', label: 'Price Lists', icon: '💲' },
];

// Tunables live in the central per-software settings panel (SOFTWARE_SETTINGS
// in data.jsx), not in a settings screen of this module's own — and not as
// hardcoded constants, which is how two modules end up disagreeing about the
// same number. Guarded because a load-order change should degrade to the
// fallback rather than blank the app.
function ctSetting(key, fallback) {
  if (typeof softwareSetting !== 'function') return fallback;
  const v = softwareSetting('countertops', key);
  return (v === undefined || v === null || v === '') ? fallback : v;
}
function ctKerfIn() {
  // The saw kerf is LEON Stone's dial, because it is the same blade.
  if (typeof softwareSetting === 'function') {
    const mm = Number(softwareSetting('stone', 'kerfMm'));
    if (isFinite(mm) && mm > 0) return mm / MM_PER_INCH;
  }
  return 0.5;
}

// The wizard. One kind of question per step — that is the whole point.
const CT_STEPS = [
  { key: 'dims', n: 1, label: 'Counter Dimensions', icon: '📏' },
  { key: 'curves', n: 2, label: 'Curves & Bumpouts', icon: '◟' },
  { key: 'edges', n: 3, label: 'Splash & Edge', icon: '📎' },
  { key: 'cutouts', n: 4, label: 'Sink & Cooktop', icon: '🚰' },
  { key: 'color', n: 5, label: 'Color & Edge', icon: '🎨' },
  { key: 'price', n: 6, label: 'Price Details', icon: '💵' },
];

// Red for a run against a wall, green for a billable finished edge — the same
// reading the team already has in CounterGo, so the drawing means the same
// thing to them on day one.
const CT_KIND_COLORS = {
  Unfinished: '#c2453f', Finished: '#3a7d44', Splash: '#2563a8', Appliance: '#a67b1f',
};
const CT_KIND_LETTER = { Unfinished: 'U', Finished: 'F', Splash: 'S', Appliance: 'A' };
// EVERY NEW SEGMENT IS FINISHED. That is CounterGo's own default and the
// reasoning behind it is sound: a missing edge charge costs the shop money and
// nobody notices, while an extra one gets queried by the client and corrected.
// Demoting a run to Unfinished is therefore a deliberate human act in step 3.
// "Appliance" is a real flat cut-and-polish where the counter meets an
// appliance — it is not a synonym for "no edge".
const CT_DEFAULT_SEGMENT_KIND = 'Finished';

const CT_CORNER_ABBR = {
  'Standard': '-Std-', 'Outside Radius': 'O-Rad', 'Inside Radius': 'I-Rad', 'Clipped': 'Clip',
  'Bumped Out': 'Bump', 'Notched': 'Notch', 'Inside Diagonal': 'I-Diag', 'Recessed Diagonal': 'R-Diag',
};
const CT_CORNER_NEEDS_SIZE = ['Outside Radius', 'Inside Radius', 'Clipped', 'Bumped Out', 'Notched',
                              'Inside Diagonal', 'Recessed Diagonal'];
// The best idea in CounterGo's model, generalised. A Full Radius and a Bump-Out
// Arc DRAW IDENTICALLY and differ only in accounting: the arc adds its depth to
// the billable edge length, the full radius adds none. One boolean, not two
// shapes. It is offered on every treatment that curves or projects an edge.
const CT_CORNER_DEFAULT_ADDS_LEN = { 'Bumped Out': true };
function ctCornerCanAddLen(tr) {
  return ['Outside Radius', 'Inside Radius', 'Bumped Out', 'Clipped', 'Inside Diagonal', 'Recessed Diagonal'].indexOf(tr) >= 0;
}

// The profiles that appear under "Finished Edges" on the price list — exactly
// the client's own list. Waterfall and Laminated are deliberately NOT here:
// they are priced under Mitered Edges & Waterfalls, on a different rule (both
// pieces), and listing them twice would let the same edge be billed twice.
const CT_FINISHED_EDGE_PROFILES = ['Bevel', 'Bullnose', 'Cove', 'Demi Bullnose', 'Double Bevel',
                                   'DuPont', 'Eased', 'Half Bullnose', 'Miter', 'Ogee', 'Square'];
// A profile priced on the miter rule rather than the finished-edge rule.
const CT_MITER_PROFILES = ['Miter', 'Waterfall', 'Laminated / Built-up'];

// Curves & Bumpouts rows. The first seven are corner TREATMENTS the drawing can
// set on a vertex; the last three are features an estimator counts by hand
// because they are not a property of one corner. Full Radius Edges carries two
// prices, which is why it is not just a number like the rest.
const CT_CURVE_ROWS = [
  { key: 'Outside Radius', label: 'Outside Radius Corners [any radius]', corner: true },
  { key: 'Inside Radius', label: 'Inside Radius Corners [any radius]', corner: true },
  { key: 'Clipped', label: 'Clipped Corners', corner: true },
  { key: 'Bumped Out', label: 'Bumped Out Corners', corner: true },
  { key: 'Notched', label: 'Notched Corners', corner: true },
  { key: 'Inside Diagonal', label: 'Inside Diagonal Corners', corner: true },
  { key: 'Recessed Diagonal', label: 'Recessed Diagonal Corners', corner: true },
  { key: 'Bump Outs', label: 'Bump Outs', corner: false },
  { key: 'Bump Ins', label: 'Bump Ins', corner: false },
  { key: 'Full Radius Edges', label: 'Full Radius Edges', corner: false, twoPrice: true },
];

// Standard sink openings, so nobody types 23 x 17 from memory forty times.
// These are CUTOUT SIZES, not prices and not bowl sizes — the opening is what
// prices the cutout. The price list stays empty until LEON fills it in.
const CT_SINK_PRESETS = [
  { label: 'Single bowl 23" x 17"', sinkType: 'Undermount', widthIn: 23, depthIn: 17, faucetHoles: 3 },
  { label: 'Single bowl 27" x 17"', sinkType: 'Undermount', widthIn: 27, depthIn: 17, faucetHoles: 3 },
  { label: 'Single bowl 30" x 17"', sinkType: 'Undermount', widthIn: 30, depthIn: 17, faucetHoles: 3 },
  { label: 'Double bowl 32" x 17"', sinkType: 'Undermount', widthIn: 32, depthIn: 17, faucetHoles: 3 },
  { label: 'Farmhouse 30" x 18"', sinkType: 'Farmhouse', widthIn: 30, depthIn: 18, faucetHoles: 1 },
  { label: 'Drop-in 33" x 22"', sinkType: 'Drop-In', widthIn: 33, depthIn: 22, faucetHoles: 4 },
  { label: 'Vanity oval 19" x 14"', sinkType: 'Undermount', widthIn: 19, depthIn: 14, faucetHoles: 3 },
  { label: 'Vessel cut 4"', sinkType: 'Vessel', widthIn: 4, depthIn: 4, faucetHoles: 1 },
  { label: 'Cooktop 30" x 21"', kind: 'Cooktop', widthIn: 30, depthIn: 21, faucetHoles: 0 },
  { label: 'Cooktop 36" x 21"', kind: 'Cooktop', widthIn: 36, depthIn: 21, faucetHoles: 0 },
];
// Outlets are a COUNT, never a placed shape — where an outlet lands does not
// change what it costs, so drawing one would be work with no answer behind it.
// Every outlet on an area consolidates into a single quote line.

// This module draws in inches; every RECORD in the Hub is millimetres. The
// conversion happens once, at the hand-off to LEON Stone, and nowhere else.
const CT_STONE_EDGE_MAP = { 'Miter': 'Mitered' };

// One drawing, many audiences. The customer quote, the internal copy and the
// shop sheet are the SAME drawing rendered through different toggles — which
// is why there is no separate shop-drawing generator in this module.
const CT_FORM_TOGGLES = [
  { k: 'showPrices', label: 'Prices' },
  { k: 'showLineItems', label: 'Line items' },
  { k: 'showZeroLines', label: 'Zero-dollar lines' },
  { k: 'showSeams', label: 'Seams' },
  { k: 'showSlabImages', label: 'Slab images' },
  { k: 'showLayoutLabels', label: 'Layout labels' },
  { k: 'showMeasurements', label: 'Measurements' },
  { k: 'showSlabCounts', label: 'Slab counts' },
];
const CT_FORM_PRESETS = {
  'Customer Quote': { showPrices: true, showLineItems: true, showZeroLines: false, showSeams: false, showSlabImages: false, showLayoutLabels: false, showMeasurements: true, showSlabCounts: false },
  'Internal Copy': { showPrices: true, showLineItems: true, showZeroLines: true, showSeams: true, showSlabImages: true, showLayoutLabels: true, showMeasurements: true, showSlabCounts: true },
  'Shop Sheet': { showPrices: false, showLineItems: false, showZeroLines: false, showSeams: true, showSlabImages: true, showLayoutLabels: true, showMeasurements: true, showSlabCounts: true },
};
function ctForm(quote) {
  return Object.assign({}, CT_FORM_PRESETS['Internal Copy'], (quote && quote.form) || {});
}

// ── Numbers, money and the unset price ────────────────────────────────────

function ctNum(v) { const n = Number(v); return isFinite(n) ? n : 0; }
// null / '' / undefined mean NOT SET. Zero is a real, deliberate price.
function ctIsSet(v) { return v !== null && v !== undefined && v !== '' && isFinite(Number(v)); }
function ctPriceVal(v) { return ctIsSet(v) ? Number(v) : null; }
// The whole distinction, in one function. Never render an unset price as $0.
function ctPriceText(v, suffix) {
  if (!ctIsSet(v)) return '-No price-';
  const s = '$' + Number(v).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
  return suffix ? s + suffix : s;
}
function ctMoneyText(v) {
  if (v === null || v === undefined || !isFinite(v)) return '—';
  return (v < 0 ? '-$' : '$') + Math.abs(v).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function ctRoundTo(v, step) {
  const s = Number(step);
  if (!isFinite(v)) return v;
  if (!s || !isFinite(s) || s <= 0) return v;
  return Math.round(v / s) * s;
}
// MATERIAL QUANTITIES ONLY EVER ROUND UP. CounterGo's own words, and the
// reason is one-directional: rounding down means quoting less stone than the
// job eats. `roundMaterialTo` is the step — 0.1 for the nearest tenth of a
// foot, 1 for the next whole foot.
function ctRoundUpTo(v, step) {
  const s = Number(step);
  if (!isFinite(v)) return v;
  if (!s || !isFinite(s) || s <= 0) return v;
  return Math.ceil((v / s) - 1e-9) * s;
}
const CT_ROUNDING_RULE = 'Square footage always rounds UP, never down, so a quote can never come in under the stone the job actually eats.';
function ctQtyText(v, step) {
  const r = ctRoundTo(v, step || 0.01);
  if (Math.abs(r - Math.round(r)) < 0.0001) return String(Math.round(r));
  return String(Math.round(r * 100) / 100);
}

// ── Units: ONE canonical system, converted only at the boundary ───────────
//
// THE CANONICAL UNIT IN THIS FILE IS THE INCH. Every geometry number on a
// record — a point, a side length, a splash height, a slab size, a cutout — is
// an inch, always, whatever anyone is looking at. Every RATE is held per
// SQUARE FOOT or per LINEAR FOOT, likewise always.
//
// Nothing is ever stored as "a number plus a unit flag". Do that and every
// comparison has to ask which system it is in, and that is how these bugs
// start. A quote and a price list carry a DISPLAY preference and nothing else,
// so switching a quote to metric cannot move a single figure on it: the
// arithmetic runs in inches and dollars-per-square-foot underneath, and only
// the presentation is converted. That is also why a metric quote's line
// quantity × its metric rate still comes to exactly the same money — the two
// conversion factors cancel.
//
// ENTRY ACCEPTS EITHER SYSTEM whatever the display says, because parseDim
// already understands 3'-0", 36 1/2", 914mm and 2438 mm. Only a BARE number is
// ambiguous, and a bare number is read in the system being displayed.
const CT_SQFT_PER_SQM = 10.763910416709722;
const CT_LINFT_PER_M = 3.280839895013123;
const CT_UNIT_SYSTEMS = ['Imperial', 'Metric'];
// The price list's own field is CT_UNITS ('inches' | 'millimeters'); the
// software setting uses the same words. Both mean a display system here.
function ctSysOf(v) { return (v === 'millimeters' || v === 'Metric' || v === 'mm') ? 'Metric' : 'Imperial'; }
function ctListUnits(pl) { return ctSysOf(pl && pl.units); }
// A quote's own switch wins; failing that it follows the price list it was
// built on; failing that the software setting. Absent everywhere reads
// IMPERIAL, which is what every quote written before this existed was in.
function ctQuoteUnits(quote, pl) {
  const own = quote && quote.unitSystem;
  if (CT_UNIT_SYSTEMS.indexOf(own) >= 0) return own;
  if (pl && pl.units) return ctListUnits(pl);
  return ctSysOf(ctSetting('units', 'inches'));
}
const CT_UNIT_LABELS = {
  Imperial: { 'sq ft': 'sq ft', 'lin ft': 'lin ft' },
  Metric: { 'sq ft': 'm²', 'lin ft': 'lin m' },
};
function ctUnitLabel(unit, sys) {
  const m = CT_UNIT_LABELS[sys === 'Metric' ? 'Metric' : 'Imperial'];
  return (m && m[unit]) || unit || '';
}
// The suffix printed after a rate: "/sq ft", "/m²", " each".
function ctUnitSuffix(unit, sys) {
  const u = ctUnitLabel(unit, sys);
  if (unit === 'sq ft' || unit === 'lin ft') return `/${u}`;
  return u ? ` ${u}` : '';
}
// $/sq ft × 10.7639 = $/m²; $/lin ft × 3.2808 = $/lin m. A quantity converts
// by the reciprocal, which is precisely why the money never moves.
function ctRateFactor(unit, sys) {
  if (sys !== 'Metric') return 1;
  if (unit === 'sq ft') return CT_SQFT_PER_SQM;
  if (unit === 'lin ft') return CT_LINFT_PER_M;
  return 1;
}
function ctQtyToDisplay(qty, unit, sys) {
  if (qty === null || qty === undefined || !isFinite(qty)) return qty;
  return qty / ctRateFactor(unit, sys);
}
function ctQtyToCanon(qty, unit, sys) {
  if (qty === null || qty === undefined || !isFinite(qty)) return qty;
  return qty * ctRateFactor(unit, sys);
}
function ctRateToDisplay(rate, unit, sys) {
  if (!ctIsSet(rate)) return null;
  return ctRoundTo(Number(rate) * ctRateFactor(unit, sys), 0.0001);
}
function ctRateToCanon(v, unit, sys) {
  if (!ctIsSet(v)) return null;
  return ctRoundTo(Number(v) / ctRateFactor(unit, sys), 0.000001);
}
function ctQtyLabel(qty, unit, sys) {
  return `${ctQtyText(ctQtyToDisplay(qty, unit, sys))} ${ctUnitLabel(unit, sys)}`.trim();
}
// A price-list row declares its unit as a printed suffix ('/sq ft', ' each').
// This says which RATE KIND that is, so the row can convert; null means the
// rate is per item and means the same number in either system.
function ctRateUnitKind(suffix) {
  const s = String(suffix || '').toLowerCase();
  if (s.indexOf('sq ft') >= 0 || s.indexOf('m²') >= 0) return 'sq ft';
  if (s.indexOf('lin ft') >= 0 || s.indexOf('lin m') >= 0) return 'lin ft';
  return null;
}

// A bare number here is INCHES under Imperial and MILLIMETRES under Metric.
// parseDim's rule that anything over 200 must already be millimetres is right
// for a door and wrong for a countertop — a 240" galley run is an ordinary
// thing to type — so bare numbers are handled before falling through to it for
// "8'-4"", "36 1/2"", "2438 mm", every one of which parses in either system.
function ctParseIn(text, sys) {
  if (text === null || text === undefined) return null;
  const s = String(text).trim();
  if (!s) return null;
  if (/^-?\d*\.?\d+$/.test(s)) {
    const n = parseFloat(s);
    return sys === 'Metric' ? n / MM_PER_INCH : n;
  }
  const mm = parseDim(s, sys === 'Metric' ? 'Metric' : 'Imperial');
  return mm === null ? null : mm / MM_PER_INCH;
}
function ctFmtIn(v, sys) {
  if (v === null || v === undefined || !isFinite(v)) return '—';
  if (sys === 'Metric') return fmtDim(v * MM_PER_INCH, 'Metric');
  return fmtDim(v * MM_PER_INCH, 'Imperial', { inchesOnly: true });
}
// The compact form used inside the drawing and in tight table cells, where a
// unit mark on every figure would be noise.
function ctDimShort(v, sys) {
  if (v === null || v === undefined || !isFinite(v)) return '—';
  return sys === 'Metric' ? String(Math.round(v * MM_PER_INCH)) : ctQtyText(v);
}
function ctSlabSizeText(L, W, sys) {
  return sys === 'Metric'
    ? `${Math.round(ctNum(L) * MM_PER_INCH)} × ${Math.round(ctNum(W) * MM_PER_INCH)} mm`
    : `${ctQtyText(L)}" × ${ctQtyText(W)}"`;
}
// Stone thickness is specified in centimetres in the trade and stored that
// way; a metric reader still expects millimetres, so only the display moves.
function ctThicknessText(cm, sys) {
  return sys === 'Metric' ? `${ctQtyText(ctNum(cm) * 10)}` : `${ctQtyText(ctNum(cm))}`;
}
function ctParseThicknessCm(text, sys) {
  const n = ctNum(text);
  if (!(n > 0)) return null;
  return sys === 'Metric' ? n / 10 : n;
}
function ctSnap16(v) { return Math.round(v * 16) / 16; }

// ── Splash basis ──────────────────────────────────────────────────────────
// Backsplash is priced three genuinely different ways, and they are not
// variations of one rule:
//   · sqft      — its OWN $/sq ft, with rate bands by splash height. This is
//                 what the team actually uses, so it leads the list.
//   · material  — square feet at the MATERIAL's rate (CounterGo's default).
//                 The splash is then cut from the same stone, so its area is
//                 added to the slab demand and paid for through the slab line.
//   · linearFt  — per linear foot, which makes the splash HEIGHT irrelevant.
// The area is the same number in all three — the splash run × its height, per
// side, read off the drawing. Only the RATE changes.
const CT_SPLASH_BASES = [
  { k: 'sqft', label: 'Its own $/sq ft, with rate bands by height' },
  { k: 'material', label: '$/sq ft using the material’s own price' },
  { k: 'linearFt', label: '$/lin ft' },
];
const CT_SPLASH_BASIS_NAMES = {
  sqft: 'the splash’s own rate per square foot',
  material: 'the material’s own rate per square foot',
  linearFt: 'a rate per linear foot',
};
// BACKFILL, NEVER MIGRATE. A list saved before this read `mode: 'material'`,
// and one saved with no splash object at all behaved as material too — so an
// absent or unrecognised value resolves to material and no existing quote can
// change. 'linearFoot' was the old spelling and is still accepted for ever.
function ctSplashBasis(pl) {
  const raw = ((pl || {}).splash || {}).mode;
  if (raw === 'sqft') return 'sqft';
  if (raw === 'linearFt' || raw === 'linearFoot') return 'linearFt';
  return 'material';
}

// ── Geometry ──────────────────────────────────────────────────────────────
// A counter is a closed polygon in inches. Segment i runs from point i to
// point i+1 (wrapping), so a segment, a corner and a dimension are all
// addressed by the same index and nothing has to be kept in step by hand.

function ctSignedArea(pts) {
  let s = 0;
  for (let i = 0; i < pts.length; i++) {
    const a = pts[i], b = pts[(i + 1) % pts.length];
    s += a.x * b.y - b.x * a.y;
  }
  return s / 2;
}
function ctPolyAreaSqIn(pts) { return Math.abs(ctSignedArea(pts || [])); }
function ctDist(a, b) { return Math.hypot(b.x - a.x, b.y - a.y); }
function ctSegmentAt(counter, i) {
  const pts = counter.points || [];
  const a = pts[i], b = pts[(i + 1) % pts.length];
  const seg = (counter.segments || [])[i] || {};
  const len = ctDist(a, b);
  const dx = len ? (b.x - a.x) / len : 1, dy = len ? (b.y - a.y) / len : 0;
  // Outward normal, from the polygon's own winding. On a concave leg this
  // points INTO the notch, which is exactly where the setback dimension
  // belongs — the inside dimension comes free rather than as a special case.
  const sgn = ctSignedArea(pts) > 0 ? 1 : -1;
  return {
    i, a, b, len, dx, dy, nx: sgn * dy, ny: sgn * -dx,
    mid: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 },
    kind: seg.kind || CT_DEFAULT_SEGMENT_KIND,
    edgeProfile: seg.edgeProfile || '',
    splashHeight: ctNum(seg.splashHeight),
    // A mitred profile's own drop — the waterfall panel, the built-up apron or
    // the mitred return. This rebuilds the segment from the stored one, so any
    // field it forgets to carry silently reads as zero everywhere downstream.
    dropIn: ctNum(seg.dropIn),
    overhangIn: ctIsSet(seg.overhangIn) ? ctNum(seg.overhangIn) : null,
    note: seg.note || '',
    parts: seg.parts || null,
  };
}
function ctAllSegments(counter) {
  const out = [];
  for (let i = 0; i < (counter.points || []).length; i++) out.push(ctSegmentAt(counter, i));
  return out;
}
// One side, split into real sub-segments. CounterGo has no segment object, so
// its documented way to put two profiles on one run is to insert a 0.1"
// phantom bump-in and "segment this edge" — fabricating geometry to carry
// information. A side here owns a `parts` array instead, so two profiles or
// two splash heights on one run need no invented shape at all. Lengths are in
// inches and the remainder is carried by the last part, so the parts always
// add up to the side exactly.
function ctSegmentParts(seg) {
  const parts = seg.parts;
  if (!parts || !parts.length) {
    return [{ index: 0, len: seg.len, kind: seg.kind, edgeProfile: seg.edgeProfile,
              splashHeight: seg.splashHeight, dropIn: seg.dropIn,
              overhangIn: seg.overhangIn, note: seg.note,
              whole: true, start: 0 }];
  }
  const out = [];
  let used = 0;
  parts.forEach((p, i) => {
    const last = i === parts.length - 1;
    let l = last ? Math.max(0, seg.len - used) : Math.min(Math.max(0, ctNum(p.lengthIn)), Math.max(0, seg.len - used));
    out.push({
      index: i, len: l, start: used,
      kind: p.kind || seg.kind, edgeProfile: p.edgeProfile || seg.edgeProfile,
      splashHeight: ctIsSet(p.splashHeight) ? ctNum(p.splashHeight) : seg.splashHeight,
      dropIn: ctIsSet(p.dropIn) ? ctNum(p.dropIn) : seg.dropIn,
      overhangIn: ctIsSet(p.overhangIn) ? ctNum(p.overhangIn) : seg.overhangIn,
      note: p.note || '', whole: false,
    });
    used += l;
  });
  return out;
}
// Interior angle at vertex i, in degrees, plus whether the corner is concave —
// the inside corner CounterGo labels 90° on an L.
function ctCornerAngle(pts, i) {
  const n = pts.length;
  if (n < 3) return { deg: 180, concave: false };
  const p = pts[(i - 1 + n) % n], c = pts[i], q = pts[(i + 1) % n];
  const a1 = Math.atan2(p.y - c.y, p.x - c.x), a2 = Math.atan2(q.y - c.y, q.x - c.x);
  let d = a2 - a1;
  while (d <= -Math.PI) d += 2 * Math.PI;
  while (d > Math.PI) d -= 2 * Math.PI;
  const ccw = ctSignedArea(pts) > 0;
  const interior = ccw ? (d < 0 ? -d : 2 * Math.PI - d) : (d > 0 ? d : 2 * Math.PI + d);
  const deg = interior * 180 / Math.PI;
  return { deg, concave: deg > 180.5 };
}
function ctBounds(counters) {
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
  (counters || []).forEach(c => (c.points || []).forEach(p => {
    minX = Math.min(minX, p.x); minY = Math.min(minY, p.y);
    maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y);
  }));
  if (!isFinite(minX)) return { minX: 0, minY: 0, maxX: 100, maxY: 60, w: 100, h: 60 };
  return { minX, minY, maxX, maxY, w: maxX - minX, h: maxY - minY };
}

// ── Shape templates, legs, and free drawing ───────────────────────────────
// CounterGo has NO shape templates — every named shape in their documentation
// is a recipe of corner operations on a freehand outline. Templates here are a
// deliberate addition, because typing "L-shape, 120 x 96 x 25 1/2" beats
// dragging it. But the team is trained on drawing leg by leg, so all three
// ways in are supported and a drawing is never assumed to be template-derived:
//   · a template, whose named parameters stay editable;
//   · legs — direction and length, typed, one run at a time;
//   · free points — click to add, drag to move, or type x/y.

function ctDefaultParams(template) {
  const D = ctNum(ctSetting('defaultDepthIn', 25.5)) || 25.5;
  const I = ctNum(ctSetting('defaultIslandDepthIn', 38)) || 38;
  switch (template) {
    case 'Single Run': return { a: 96, depth: D };
    case 'L-Shape': return { a: 120, b: 96, depth: D };
    case 'U-Shape': return { a: 144, b: 96, depth: D };
    case 'Galley': return { a: 144, b: 144, depth: D, gap: 42 };
    case 'Island': return { a: 96, depth: I };
    case 'Peninsula': return { a: 72, depth: Math.max(D, 30) };
    case 'Vanity': return { a: 60, depth: 22 };
    default: return { a: 96, depth: D };
  }
}
function ctParamFields(template) {
  switch (template) {
    case 'L-Shape': return [{ k: 'a', label: 'Run A (along the back wall)' }, { k: 'b', label: 'Run B (the return)' }, { k: 'depth', label: 'Counter depth' }];
    case 'U-Shape': return [{ k: 'a', label: 'Overall width' }, { k: 'b', label: 'Leg length' }, { k: 'depth', label: 'Counter depth' }];
    case 'Galley': return [{ k: 'a', label: 'Run A length' }, { k: 'b', label: 'Run B length' }, { k: 'depth', label: 'Counter depth' }, { k: 'gap', label: 'Aisle between the runs' }];
    default: return [{ k: 'a', label: 'Length' }, { k: 'depth', label: 'Depth' }];
  }
}
// `walls` lists the segments that would USUALLY sit against a wall for this
// shape. It is a suggestion offered as a one-click action in step 3, never
// applied automatically — every segment is born Finished.
function ctTemplateGeometry(template, params) {
  const P = Object.assign({}, ctDefaultParams(template), params || {});
  const A = Math.max(6, ctNum(P.a)), B = Math.max(6, ctNum(P.b || P.a)), D = Math.max(4, ctNum(P.depth));
  const rect = (x, y, w, h, walls) => ({
    points: [{ x, y }, { x: x + w, y }, { x: x + w, y: y + h }, { x, y: y + h }],
    walls: walls || [],
  });
  switch (template) {
    case 'Single Run': return [rect(0, 0, A, D, [0, 1, 3])];
    case 'Vanity': return [rect(0, 0, A, D, [0, 1, 3])];
    case 'Island': return [rect(0, 0, A, D, [])];
    case 'Peninsula': return [rect(0, 0, A, D, [3])];
    case 'Galley': return [rect(0, 0, A, D, [0, 1, 3]), rect(0, D + Math.max(24, ctNum(P.gap)), B, D, [1, 2, 3])];
    case 'L-Shape': return [{
      points: [{ x: 0, y: 0 }, { x: A, y: 0 }, { x: A, y: D }, { x: D, y: D }, { x: D, y: B }, { x: 0, y: B }],
      walls: [0, 5],
    }];
    case 'U-Shape': return [{
      points: [{ x: 0, y: 0 }, { x: A, y: 0 }, { x: A, y: B }, { x: A - D, y: B }, { x: A - D, y: D },
               { x: D, y: D }, { x: D, y: B }, { x: 0, y: B }],
      walls: [0, 1, 7],
    }];
    default: return [rect(0, 0, A, D, [0])];
  }
}
// ── How a cutout is DIMENSIONED ─────────────────────────────────────────────
// A template shop does not read a cutout off an x/y coordinate. It reads a
// CENTERLINE — how far along the run the middle of the sink sits — and a
// SETBACK — how far in from the front edge. Those two figures are what gets
// cut to, and they were the two figures this drawing could not state.
//
// They are NOT stored. x/y stays the one record of where the cutout is, and
// these are computed from it and write back to it, so the drawing and the
// dimensions on it can never disagree. Only the two REFERENCES are stored,
// because which end and which edge you measure from genuinely differs job to
// job.
function ctCounterBox(counter) {
  const pts = (counter && counter.points) || [];
  if (pts.length < 3) return null;
  let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity;
  pts.forEach(p => {
    x1 = Math.min(x1, ctNum(p.x)); x2 = Math.max(x2, ctNum(p.x));
    y1 = Math.min(y1, ctNum(p.y)); y2 = Math.max(y2, ctNum(p.y));
  });
  return { x1, y1, x2, y2, w: x2 - x1, h: y2 - y1 };
}
// Which side of the counter faces the room. The model already knows this: a run
// that carries a splash, or is unfinished, is against a wall, so the front is
// the opposite side. Guessing "the bottom of the drawing" is only the fallback
// for a counter nobody has marked up yet.
function ctFrontIsMaxY(counter) {
  const pts = (counter && counter.points) || [];
  const b = ctCounterBox(counter);
  if (!b || pts.length < 3) return true;
  const midY = (b.y1 + b.y2) / 2;
  let wallLow = 0, wallHigh = 0;
  ctAllSegments(counter).forEach(s => {
    const a = pts[s.i], c = pts[(s.i + 1) % pts.length];
    if (!a || !c) return;
    // Only the runs that face front or back can tell us anything.
    if (Math.abs(ctNum(c.y) - ctNum(a.y)) > Math.abs(ctNum(c.x) - ctNum(a.x))) return;
    const against = s.kind === 'Splash' || s.kind === 'Unfinished' || ctNum(s.splashHeight) > 0;
    if (!against) return;
    if ((ctNum(a.y) + ctNum(c.y)) / 2 < midY) wallLow += s.len; else wallHigh += s.len;
  });
  if (wallLow === wallHigh) return true;      // nothing marked up — front is the bottom
  return wallLow > wallHigh;                  // the wall is at the top, so the front is at the bottom
}
function ctCutoutDims(counter, cu) {
  const b = ctCounterBox(counter);
  if (!b || !cu) return null;
  const w = ctNum(cu.widthIn), d = ctNum(cu.depthIn);
  // A CUTOUT'S x/y IS ITS CENTRE. That is what the canvas draws
  // (`x={cu.x - w/2}`) and what the drag writes, so it is the convention the
  // whole module already runs on — reading it as a top-left corner put every
  // derived figure half a cutout out.
  const cx = (cu.kind === 'Faucet Hole' && typeof ctFaucetX === 'function')
    ? ctFaucetX(counter, cu)
    : ctNum(cu.x);
  const frontMaxY = ctFrontIsMaxY(counter);
  // The setback is to the NEAR EDGE of the opening, not to its centre — that is
  // the figure the shop cuts to. A faucet has no opening, so for it the near
  // edge and the centre are the same point.
  const halfD = d / 2;
  const nearY = frontMaxY ? ctNum(cu.y) + halfD : ctNum(cu.y) - halfD;
  const farY = frontMaxY ? ctNum(cu.y) - halfD : ctNum(cu.y) + halfD;
  return {
    box: b, frontMaxY,
    centerline: (cu.dimAlong === 'right') ? (b.x2 - cx) : (cx - b.x1),
    setback: (cu.dimFrom === 'back')
      ? Math.abs((frontMaxY ? b.y1 : b.y2) - farY)
      : Math.abs((frontMaxY ? b.y2 : b.y1) - nearY),
  };
}
// Writing a dimension back. The counter moves the cutout; nothing else changes.
function ctCutoutFromDims(counter, cu, next) {
  const dims = ctCutoutDims(counter, cu);
  if (!dims) return null;
  const b = dims.box, w = ctNum(cu.widthIn), d = ctNum(cu.depthIn);
  const halfD = d / 2;
  const out = {};
  if (next.centerline !== undefined && next.centerline !== null) {
    out.x = (cu.dimAlong === 'right') ? (b.x2 - next.centerline) : (b.x1 + next.centerline);
  }
  if (next.setback !== undefined && next.setback !== null) {
    const back = (cu.dimFrom === 'back');
    // Solve for the CENTRE from whichever edge is being measured to.
    if (dims.frontMaxY) out.y = back ? (b.y1 + next.setback + halfD) : (b.y2 - next.setback - halfD);
    else out.y = back ? (b.y2 - next.setback - halfD) : (b.y1 + next.setback + halfD);
  }
  return out;
}

// A mitred profile's drop is a different thing under each name, and calling it
// all "drop" would leave a fabricator guessing which. A waterfall runs to the
// floor; a built-up edge is an apron; a plain mitre is a returned face.
function ctDropLabel(profile) {
  return profile === 'Waterfall' ? 'Drop to floor'
       : profile === 'Laminated / Built-up' ? 'Apron height'
       : 'Return height';
}
function ctDropHint(profile) {
  return profile === 'Waterfall'
    ? 'The panel that runs to the floor. This is stone off the same slab, so it counts toward the slab count — leave it blank and the waterfall bills its cut and none of the material it eats.'
    : profile === 'Laminated / Built-up'
      ? 'The height of the apron laminated under the edge. It is cut from the same slab.'
      : 'How far the mitred face returns. Cut from the same slab.';
}
// ── Overhang ────────────────────────────────────────────────────────────────
// The overhang is the gap between the CABINET FACE and the edge of the stone,
// and it is what the installer sets out to. It was on the record and drawn
// nowhere.
//
// It is decided per SIDE, because that is what a counter has — a polygon of
// sides, not a front/left/right/back. A side against a wall has none by
// definition, so its kind answers for it rather than making someone type a
// zero on every splash run.
const CT_DEFAULT_OVERHANG_IN = 1.5;
// The unsupported span a stone top will carry before it needs corbels or
// brackets. The Natural Stone Institute's figures, and they turn on thickness:
// 3 cm carries 10", 2 cm carries 6". Past that it is a support detail, not a
// preference — which is why this is a warning and not a style setting.
const CT_OVERHANG_SUPPORT_IN = { 2: 6, 3: 10 };
function ctSupportLimitIn(counter) {
  const cm = Math.round(ctNum((counter && counter.thicknessCm) || 3));
  return CT_OVERHANG_SUPPORT_IN[cm] || (cm >= 3 ? 10 : 6);
}
// A side against a wall carries no overhang. Everything else takes its own
// figure, then the counter's default, then the standard 1 1/2".
function ctSegOverhang(seg, counter) {
  if (!seg) return 0;
  if (seg.kind === 'Splash' || seg.kind === 'Unfinished') return 0;
  if (ctIsSet(seg.overhangIn)) return Math.max(0, ctNum(seg.overhangIn));
  if (counter && ctIsSet(counter.overhangDefaultIn)) return Math.max(0, ctNum(counter.overhangDefaultIn));
  return CT_DEFAULT_OVERHANG_IN;
}
// Every side that carries one, with its support verdict. The panel and the
// drawing read the SAME list, so what is dimensioned and what is warned about
// cannot disagree.
function ctOverhangRuns(counter) {
  if (!counter) return [];
  const limit = ctSupportLimitIn(counter);
  return ctAllSegments(counter).map(seg => {
    const over = ctSegOverhang(seg, counter);
    return {
      i: seg.i, seg, lenIn: seg.len, overhangIn: over,
      againstWall: seg.kind === 'Splash' || seg.kind === 'Unfinished',
      needsSupport: over > limit, limitIn: limit,
      own: ctIsSet(seg.overhangIn),
    };
  });
}

// The CABINET LINE, all the way round. Each side is offset inward by its own
// overhang and consecutive offsets are intersected, so the line closes as one
// loop with mitred corners rather than four stubs that stop short of each
// other. A side against a wall has no overhang, so the line simply runs along
// the stone there — which is where the cabinet actually meets the wall.
function ctCabinetLine(counter) {
  const pts = (counter && counter.points) || [];
  const n = pts.length;
  if (n < 3) return [];
  const segs = ctAllSegments(counter);
  const lines = segs.map(sg => {
    const over = ctSegOverhang(sg, counter);
    const inx = -sg.nx, iny = -sg.ny;
    return { px: sg.a.x + inx * over, py: sg.a.y + iny * over, dx: sg.dx, dy: sg.dy,
             fx: sg.b.x + inx * over, fy: sg.b.y + iny * over };
  });
  const out = [];
  for (let i = 0; i < n; i++) {
    const A = lines[i], B = lines[(i + 1) % n];
    // Where the two offset lines cross IS the mitred corner.
    const den = A.dx * B.dy - A.dy * B.dx;
    if (Math.abs(den) < 1e-9) { out.push({ x: A.fx, y: A.fy }); continue; }  // parallel
    const t = ((B.px - A.px) * B.dy - (B.py - A.py) * B.dx) / den;
    out.push({ x: A.px + A.dx * t, y: A.py + A.dy * t });
  }
  return out;
}

// ── Where a faucet sits ─────────────────────────────────────────────────────
// A faucet is set out from the SINK, not from the counter — it is centred on
// the bowl, or deliberately offset to one side of it. Storing an independent
// x would mean moving the sink left the faucet behind, which is exactly the
// disagreement a shop drawing exists to prevent.
const CT_FAUCET_ALIGNS = [
  { key: 'center', label: 'Centred on the sink' },
  { key: 'left', label: 'Offset left' },
  { key: 'right', label: 'Offset right' },
];
function ctFaucetSink(counter, faucet) {
  const list = (counter && counter.cutouts) || [];
  if (faucet && faucet.sinkCutoutId) {
    const s = list.find(z => z.id === faucet.sinkCutoutId);
    if (s) return s;
  }
  // No link yet: the only sink on the counter is the obvious answer, and a
  // faucet with nothing to sit behind stays where it was put.
  const sinks = list.filter(z => z.kind === 'Sink');
  return sinks.length === 1 ? sinks[0] : null;
}
// The offset is HALF the sink's own half-width — the middle of one bowl on a
// double, which is where an offset faucet actually lands.
function ctFaucetX(counter, faucet) {
  const sink = ctFaucetSink(counter, faucet);
  if (!sink) return ctNum(faucet.x);
  const half = ctNum(sink.widthIn) / 4;
  const a = faucet.faucetAlign || 'center';
  return ctNum(sink.x) + (a === 'left' ? -half : a === 'right' ? half : 0);
}

function ctBlankSegment() {
  // dropIn is what a mitred profile actually consumes. A waterfall runs to the
  // floor and a built-up edge has an apron; both are stone, cut from the same
  // slab, and neither was being counted anywhere — only the mitre cut itself
  // was billed. A 36" waterfall down 34" is 8.5 sq ft of material per side.
  // overhangIn: null means "follow the counter's default". A side against a
  // wall has no overhang at all, which is decided by its KIND rather than by
  // asking someone to type a zero on every splash run.
  return { kind: CT_DEFAULT_SEGMENT_KIND, edgeProfile: '', splashHeight: 0, note: '',
           dropIn: 0, overhangIn: null, parts: null };
}
// Regenerating a parametric counter keeps everything already decided — corner
// treatments, segment kinds, edge profiles, cutouts — because the template
// always yields the same point and segment COUNT. Changing a dimension must
// not silently reset step 2 and step 3.
function ctApplyParams(counter, params) {
  const geo = ctTemplateGeometry(counter.template, params)[0];
  if (!geo) return;
  const oldPts = counter.points || [], oldSegs = counter.segments || [];
  counter.params = Object.assign({}, ctDefaultParams(counter.template), params || {});
  counter.points = geo.points.map((p, i) => Object.assign(makeCtPoint(p.x, p.y),
    oldPts[i] ? { treatment: oldPts[i].treatment, radius: oldPts[i].radius, addsLen: oldPts[i].addsLen } : {},
    { x: p.x, y: p.y }));
  counter.segments = geo.points.map((p, i) => Object.assign(ctBlankSegment(), oldSegs[i] || {}));
  counter.wallHint = geo.walls;
}
function ctNewCounter(template, params, name, offsetY) {
  const geos = ctTemplateGeometry(template, params);
  return geos.map((geo, gi) => {
    const c = makeCtCounter({
      name: name || (geos.length > 1 ? `${template} ${gi + 1}` : template),
      // A galley is genuinely two pieces of stone, so it is stored as two
      // counters. Calling it one polygon would hand the layout a piece that
      // does not exist.
      template: geos.length > 1 ? 'Custom' : template,
      points: geo.points.map(p => makeCtPoint(p.x, p.y + ctNum(offsetY))),
      segments: geo.points.map(() => ctBlankSegment()),
    });
    if (geos.length === 1) c.params = Object.assign({}, ctDefaultParams(template), params || {});
    c.wallHint = geo.walls;
    c.texts = [];
    return c;
  });
}
// Leg-by-leg entry: the typed equivalent of CounterGo's drag-with-a-pause. A
// leg is a direction and a length; the outline is the legs offset by the
// counter depth and closed back on itself.
const CT_LEG_DIRS = [
  { k: 'E', label: 'Right (east)', dx: 1, dy: 0 },
  { k: 'S', label: 'Down (south)', dx: 0, dy: 1 },
  { k: 'W', label: 'Left (west)', dx: -1, dy: 0 },
  { k: 'N', label: 'Up (north)', dx: 0, dy: -1 },
];
function ctLegVector(leg) {
  if (ctIsSet(leg.angle)) {
    const r = Number(leg.angle) * Math.PI / 180;
    return { dx: Math.cos(r), dy: Math.sin(r) };
  }
  const d = CT_LEG_DIRS.find(x => x.k === (leg.dir || 'E')) || CT_LEG_DIRS[0];
  return { dx: d.dx, dy: d.dy };
}
// The centreline of the legs, thickened to the counter depth. The wall side is
// the spine itself and the room side is the spine offset by the depth, mitred
// at each vertex — offset = (n1 + n2) · 2D / |n1 + n2|², which is exact for any
// angle and collapses to a plain D offset on a straight run. Without the mitre
// an L overlaps itself at the corner and the area comes out wrong.
function ctLegsToPoints(legs, depth) {
  const D = Math.max(2, ctNum(depth));
  const spine = [{ x: 0, y: 0 }];
  const norms = [];
  (legs || []).forEach(l => {
    const v = ctLegVector(l);
    const len = Math.max(1, ctNum(l.lengthIn));
    const last = spine[spine.length - 1];
    spine.push({ x: last.x + v.dx * len, y: last.y + v.dy * len });
    norms.push({ x: v.dy, y: -v.dx });          // the room side of this leg
  });
  if (spine.length < 2) return null;
  const front = spine.map((p, i) => {
    const n1 = norms[Math.max(0, i - 1)], n2 = norms[Math.min(norms.length - 1, i)];
    const vx = n1.x + n2.x, vy = n1.y + n2.y;
    const m2 = vx * vx + vy * vy;
    if (m2 < 1e-6) return { x: p.x + n2.x * D, y: p.y + n2.y * D };   // a full reversal — no mitre to make
    const s = 2 * D / m2;
    return { x: p.x + vx * s, y: p.y + vy * s };
  });
  return spine.concat(front.slice().reverse());
}
function ctCounterFromLegs(legs, depth, name) {
  const pts = ctLegsToPoints(legs, depth);
  if (!pts || pts.length < 3) return null;
  const c = makeCtCounter({
    name: name || 'Counter', template: 'Custom',
    points: pts.map(p => makeCtPoint(ctSnap16(p.x), ctSnap16(p.y))),
    segments: pts.map(() => ctBlankSegment()),
  });
  c.legs = legs;
  c.texts = [];
  return c;
}

// ── Drag to draw ──────────────────────────────────────────────────────────
// The client draws countertops for a living and draws them by DRAGGING. So
// does Moraware CounterGo, which they are trained on: press in empty canvas,
// drag out a rectangle, and PAUSE without releasing to turn a corner — the
// dwell is the "add another leg" signal, an arrow appears, you move that way
// and pause again. Release ends the counter.
//
// Dragging is how a shape is STARTED; typing is how a dimension is FINISHED.
// Nothing here replaces the templates, the leg-by-leg entry or the typed
// fields — a drafter who wants to type 120" still types 120".
//
// THE ONE HARD RULE: a dragged counter must be indistinguishable downstream
// from a template one. Steps 2–6 address a corner, a side, a cutout and a
// dimension by INDEX into `counter.points`, so the drag produces the same
// point list a template would — one segment per point, every segment born
// blank and Finished, and the same winding (positive signed area, which is
// what ctTemplateGeometry's rect yields). That is true by construction here,
// not by a special case downstream: one leg dragged down-and-right comes out
// as literally rect(0, 0, run, depth), and a two-leg drag as a 6-point L whose
// area is run·depth + (return − depth)·depth, the same as the L-Shape template.
const CT_DRAG_MIN_RUN_IN = 6;          // ctTemplateGeometry's own clamp on a run
const CT_DRAG_MIN_DEPTH_IN = 4;        // and on a depth — a stray click makes nothing
const CT_DRAG_DWELL_MS = 600;          // the pause that means "turn a corner"
const CT_DRAG_DEPTH_MAGNET_IN = 1.5;   // how near the default depth still lands on it
// 5 mm, the step when 1/16" rounding is off. Guarded the way ctSetting is: a
// load-order change should degrade, not blank the app on a top-level read.
const CT_DRAG_METRIC_STEP_IN = 5 / (typeof MM_PER_INCH === 'number' ? MM_PER_INCH : 25.4);

// The spine is the wall side of the run — the polyline the pointer actually
// drew. The outline is that spine plus its offset by the counter depth,
// MITRED at each corner, which is the same offset ctLegsToPoints uses and for
// the same reason: without the mitre an L overlaps itself at the corner and
// the area comes out wrong. `side` is +1/−1 and says which side of the spine
// the stone sits on, so a drag down-and-right puts the depth below the run and
// a drag up-and-right puts it above.
function ctDragOutline(spine, depthIn, side) {
  const D = Math.abs(ctNum(depthIn));
  if (!spine || spine.length < 2 || !(D > 0)) return null;
  const s = side < 0 ? -1 : 1;
  const norms = [];
  for (let i = 0; i < spine.length - 1; i++) {
    const dx = spine[i + 1].x - spine[i].x, dy = spine[i + 1].y - spine[i].y;
    const l = Math.hypot(dx, dy);
    if (!(l > 0)) return null;
    norms.push({ x: s * (dy / l), y: s * (-dx / l) });
  }
  const front = spine.map((p, i) => {
    const n1 = norms[Math.max(0, i - 1)], n2 = norms[Math.min(norms.length - 1, i)];
    const vx = n1.x + n2.x, vy = n1.y + n2.y;
    const m2 = vx * vx + vy * vy;
    if (m2 < 1e-6) return { x: p.x + n2.x * D, y: p.y + n2.y * D };   // a full reversal — no mitre to make
    const k = 2 * D / m2;
    return { x: p.x + vx * k, y: p.y + vy * k };
  });
  const fix = v => Math.round(v * 1000) / 1000;      // float dust off the mitre, nothing more
  let pts = spine.concat(front.slice().reverse()).map(p => ({ x: fix(p.x), y: fix(p.y) }));
  // The spine legs are the runs that would usually sit against a wall, which
  // is exactly what a template's `walls` list means — so step 3's "Mark the
  // usual wall runs" works on a drawn counter with no special case.
  let wall = [];
  for (let i = 0; i < spine.length - 1; i++) wall.push(i);
  const n = pts.length;
  // Match the templates' winding. Reversing an array of points also reverses
  // the segment order: original segment i becomes segment n−2−i.
  if (ctSignedArea(pts) < 0) {
    pts = pts.slice().reverse();
    wall = wall.map(i => (n - 2 - i + n) % n);
  }
  return { points: pts, wallHint: wall };
}

// The in-progress gesture, resolved into geometry. ONE function serves both
// the live preview and the commit, so what is released can never differ from
// what was shown while dragging.
function ctDragShape(st) {
  if (!st || !st.spine || !st.spine.length) return null;
  const spine = st.spine.slice();
  // The leg still being drawn is carried into the shape — unless a corner has
  // already been turned and it is not yet a real run, in which case a 2"
  // wobble after the last corner would otherwise throw away the L that was
  // actually drawn. The preview applies the same rule, so what is released can
  // never differ from what was shown.
  const carry = st.axis && st.legLen > 0 && (st.spine.length < 2 || st.legLen >= CT_DRAG_MIN_RUN_IN);
  if (carry) {
    const o = spine[spine.length - 1];
    spine.push(st.axis === 'x' ? { x: o.x + st.dir * st.legLen, y: o.y }
                               : { x: o.x, y: o.y + st.dir * st.legLen });
  }
  const legs = [];
  for (let i = 0; i < spine.length - 1; i++) {
    legs.push({ a: spine[i], b: spine[i + 1], len: ctDist(spine[i], spine[i + 1]) });
  }
  const built = (spine.length >= 2 && st.depth > 0) ? ctDragOutline(spine, st.depth, st.side) : null;
  return {
    spine, legs, depth: ctNum(st.depth), side: st.side < 0 ? -1 : 1,
    points: built ? built.points : [], wallHint: built ? built.wallHint : [],
  };
}
// Below this a drag is a stray click and is thrown away in silence rather than
// leaving a 2 mm counter on the job for someone to find later.
function ctDragUsable(shape) {
  return !!(shape && shape.points.length >= 3 && shape.depth >= CT_DRAG_MIN_DEPTH_IN &&
            shape.legs.length >= 1 && shape.legs.every(l => l.len >= CT_DRAG_MIN_RUN_IN));
}
// A counter from a drawn outline, built exactly the way ctCounterFromLegs and
// ctNewCounter build theirs — same factories, same blank segments, template
// 'Custom' (which is what the blank rectangle already uses, and what makes the
// sides table the typed half of the job).
function ctCounterFromOutline(points, wallHint, name) {
  const pts = (points || []).filter(p => p && isFinite(p.x) && isFinite(p.y));
  if (pts.length < 3) return null;
  const c = makeCtCounter({
    name: name || 'Counter', template: 'Custom',
    points: pts.map(p => makeCtPoint(p.x, p.y)),
    segments: pts.map(() => ctBlankSegment()),
  });
  c.wallHint = wallHint || [];
  c.texts = [];
  return c;
}
// The live position of a point or cutout being dragged is held in the DRAWING's
// own state and only written on pointer-up, so the counter shown mid-drag is a
// throwaway clone. A write per pointermove is a localStorage save per mouse
// movement, and this codebase has made that mistake before.
function ctWithLiveDrag(counters, drag) {
  if (!drag || !drag.pos) return counters;
  return (counters || []).map(c => {
    if (c.id !== drag.counterId) return c;
    const copy = cloneDeep(c);
    if (drag.kind === 'point' && (copy.points || [])[drag.idx]) {
      copy.points[drag.idx].x = drag.pos.x;
      copy.points[drag.idx].y = drag.pos.y;
    } else if (drag.kind === 'cutout') {
      const cu = (copy.cutouts || []).find(z => z.id === drag.idx);
      if (cu) { cu.x = drag.pos.x; cu.y = drag.pos.y; }
    }
    return copy;
  });
}

// Setting a raw side length on a CUSTOM outline. The points that move are the
// ones from the far end of the side forward until the polygon turns back on
// itself — the antiparallel side absorbs the change, which is what keeps a
// rectilinear outline closed and rectilinear.
function ctSetSegmentLength(counter, i, newLen) {
  const pts = counter.points || [];
  const n = pts.length;
  if (n < 3) return;
  const a = pts[i], b = pts[(i + 1) % n];
  const cur = ctDist(a, b);
  if (!(cur > 0) || !(newLen > 0)) return;
  const dx = (b.x - a.x) / cur, dy = (b.y - a.y) / cur;
  const delta = newLen - cur;
  const moved = [];
  for (let k = 1; k < n; k++) {
    const idx = (i + k) % n;
    moved.push(idx);
    const nx = (idx + 1) % n;
    const seg = { x: pts[nx].x - pts[idx].x, y: pts[nx].y - pts[idx].y };
    const l = Math.hypot(seg.x, seg.y) || 1;
    if (((seg.x / l) * dx + (seg.y / l) * dy) < -0.9) break;
  }
  moved.forEach(idx => { pts[idx].x += dx * delta; pts[idx].y += dy * delta; });
}

// ── Rectangle decomposition → the pieces that actually get cut ────────────
// An L-shaped counter is not cut as an L: it is cut as two runs. Sweeping the
// outline in x and merging identical strips gives exactly those runs for any
// rectilinear outline, which is what the slab layout and the cut list need. A
// non-rectilinear outline cannot be decomposed this way honestly, so it falls
// back to its bounding box and SAYS so rather than quietly overstating.
function ctIsRectilinear(counter) {
  const segs = ctAllSegments(counter);
  return segs.length >= 3 && segs.every(s => Math.abs(s.dx) < 0.001 || Math.abs(s.dy) < 0.001);
}
function ctPointInPoly(pts, x, y) {
  let inside = false;
  for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
    const xi = pts[i].x, yi = pts[i].y, xj = pts[j].x, yj = pts[j].y;
    if (((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi)) inside = !inside;
  }
  return inside;
}
function ctDecompose(counter) {
  const pts = counter.points || [];
  if (pts.length < 3) return { rects: [], exact: false };
  if (!ctIsRectilinear(counter)) {
    const b = ctBounds([counter]);
    return { rects: [{ x: b.minX, y: b.minY, w: b.w, h: b.h }], exact: false };
  }
  const xs = Array.from(new Set(pts.map(p => Math.round(p.x * 1000) / 1000))).sort((a, b) => a - b);
  const ys = Array.from(new Set(pts.map(p => Math.round(p.y * 1000) / 1000))).sort((a, b) => a - b);
  const strips = [];
  for (let i = 0; i < xs.length - 1; i++) {
    const x0 = xs[i], x1 = xs[i + 1];
    if (x1 - x0 < 0.01) continue;
    const mx = (x0 + x1) / 2;
    const bands = [];
    for (let j = 0; j < ys.length - 1; j++) {
      const y0 = ys[j], y1 = ys[j + 1];
      if (y1 - y0 < 0.01) continue;
      if (ctPointInPoly(pts, mx, (y0 + y1) / 2)) {
        const last = bands[bands.length - 1];
        if (last && Math.abs(last.y1 - y0) < 0.01) last.y1 = y1;
        else bands.push({ y0, y1 });
      }
    }
    strips.push({ x0, x1, bands });
  }
  // Merge neighbouring strips with identical bands, so a plain rectangle stays
  // one piece instead of coming back as a stack of slices.
  const merged = [];
  strips.forEach(s => {
    const prev = merged[merged.length - 1];
    const same = prev && prev.bands.length === s.bands.length && Math.abs(prev.x1 - s.x0) < 0.01 &&
      prev.bands.every((b, k) => Math.abs(b.y0 - s.bands[k].y0) < 0.01 && Math.abs(b.y1 - s.bands[k].y1) < 0.01);
    if (same) prev.x1 = s.x1;
    else merged.push({ x0: s.x0, x1: s.x1, bands: s.bands.map(b => ({ y0: b.y0, y1: b.y1 })) });
  });
  const rects = [];
  merged.forEach(s => s.bands.forEach(b => rects.push({ x: s.x0, y: b.y0, w: s.x1 - s.x0, h: b.y1 - b.y0 })));
  return { rects, exact: true };
}

// SEAMS ARE MANUAL, AND THEY ARE FOR ESTIMATING AND VEINING MATCH — NOT A CUT
// FILE. There is no rules engine here and no automatic seam optimisation: a
// seam is a fabricator's judgement about the stone in front of them. What the
// module does do is place a suggested split where a run is simply longer than
// the slab, label it as suggested, and let anyone move it or delete it.
// A seam is stored as an axis and a position rather than CounterGo's two
// perimeter endpoints, because their direction is locked to the counter's
// default-depth edge and cannot be mixed within one counter — this way each
// seam carries its own direction and can be placed anywhere.
function ctMakeSeam(data) {
  return Object.assign({ id: uid('ctseam'), axis: 'y', atIn: 0, auto: false, note: '' }, data || {});
}
function ctSplitRectBySeams(rect, seams) {
  let parts = [rect];
  (seams || []).forEach(s => {
    const next = [];
    parts.forEach(r => {
      const at = ctNum(s.atIn);
      if (s.axis === 'x') {          // a cut running left-to-right, splitting height
        if (at > r.y + 0.05 && at < r.y + r.h - 0.05) {
          next.push({ x: r.x, y: r.y, w: r.w, h: at - r.y });
          next.push({ x: r.x, y: at, w: r.w, h: r.y + r.h - at });
          return;
        }
      } else {                        // a cut running top-to-bottom, splitting width
        if (at > r.x + 0.05 && at < r.x + r.w - 0.05) {
          next.push({ x: r.x, y: r.y, w: at - r.x, h: r.h });
          next.push({ x: at, y: r.y, w: r.x + r.w - at, h: r.h });
          return;
        }
      }
      next.push(r);
    });
    parts = next;
  });
  return parts;
}
// The pieces a fabricator would cut. Manual seams first, then — only where a
// run is still longer than the usable slab — an equal split, which is stated
// as the rule it is and stays editable.
function ctCounterPieces(counter, maxLenIn, overrides) {
  const dec = ctDecompose(counter);
  const out = [];
  dec.rects.forEach((r0, ri) => {
    ctSplitRectBySeams(r0, counter.seams).forEach((r, si) => {
      const long = Math.max(r.w, r.h), short = Math.min(r.w, r.h);
      const groupKey = `${counter.id}:${ri}:${si}`;
      const ov = (overrides || {})[groupKey];
      let parts;
      if (ov && Array.isArray(ov.lengths) && ov.lengths.length) {
        parts = ov.lengths.map(n => ctNum(n)).filter(n => n > 0);
      } else if (maxLenIn > 0 && long > maxLenIn) {
        const n = Math.ceil(long / maxLenIn);
        parts = new Array(n).fill(long / n);
      } else {
        parts = [long];
      }
      parts.forEach((len, pi) => out.push({
        key: `${groupKey}:${pi}`, groupKey, counterId: counter.id, counterName: counter.name,
        rectIndex: ri, partIndex: pi, partCount: parts.length,
        lengthIn: len, widthIn: short, exact: dec.exact,
        suggestedSplit: parts.length > 1 && !(ov && ov.lengths),
        manual: !!(ov && ov.lengths),
        seamed: si > 0 || (counter.seams || []).length > 0,
      }));
    });
  });
  return out;
}

// ── Slab packing ──────────────────────────────────────────────────────────
// Shelf first-fit-decreasing with a kerf on both axes, rotation allowed. It is
// a real packing, not area ÷ area — but it is still a machine's guess at a
// human job, so every line it feeds is labelled an ESTIMATE until someone has
// actually laid the pieces out.
function ctPackSlabs(pieces, slabL, slabW, kerfIn) {
  const K = ctNum(kerfIn), L = ctNum(slabL), W = ctNum(slabW);
  const slabs = [];
  if (!(L > 0) || !(W > 0)) return slabs;
  const sorted = pieces.slice().sort((a, b) => Math.min(b.lengthIn, b.widthIn) - Math.min(a.lengthIn, a.widthIn));
  sorted.forEach(p => {
    const cands = [{ w: p.lengthIn, h: p.widthIn, rot: 0 }, { w: p.widthIn, h: p.lengthIn, rot: 90 }];
    let placed = false;
    for (let si = 0; si < slabs.length && !placed; si++) {
      const s = slabs[si];
      for (let ci = 0; ci < cands.length && !placed; ci++) {
        const c = cands[ci];
        if (c.w > L || c.h > W) continue;
        for (let sh = 0; sh < s.shelves.length && !placed; sh++) {
          const shelf = s.shelves[sh];
          if (c.h <= shelf.h && shelf.x + c.w + K <= L) {
            s.placements.push({ key: p.key, x: shelf.x, y: shelf.y, w: c.w, h: c.h, rot: c.rot });
            shelf.x += c.w + K;
            placed = true;
          }
        }
        if (!placed) {
          const top = s.shelves.length ? s.shelves[s.shelves.length - 1] : null;
          const y = top ? top.y + top.h + K : 0;
          if (y + c.h <= W && c.w <= L) {
            s.shelves.push({ y, h: c.h, x: c.w + K });
            s.placements.push({ key: p.key, x: 0, y, w: c.w, h: c.h, rot: c.rot });
            placed = true;
          }
        }
      }
    }
    if (!placed) {
      const c = (p.lengthIn <= L && p.widthIn <= W) ? { w: p.lengthIn, h: p.widthIn, rot: 0 } : { w: p.widthIn, h: p.lengthIn, rot: 90 };
      const fits = c.w <= L && c.h <= W;
      slabs.push({
        shelves: fits ? [{ y: 0, h: c.h, x: c.w + K }] : [],
        placements: fits ? [{ key: p.key, x: 0, y: 0, w: c.w, h: c.h, rot: c.rot }] : [],
        oversize: fits ? [] : [p.key],
      });
    }
  });
  return slabs.map((s, i) => ({ index: i, placements: s.placements, oversize: s.oversize || [] }));
}

// ── Take-off ──────────────────────────────────────────────────────────────
// Everything the estimate needs, read off the drawing once. Nothing derived is
// stored on the record: change a dimension and this recomputes, so the quote
// and the drawing can never disagree.
//
// THE SQUARE FOOTAGE IS THE OUTLINE AREA. A notch, a clipped corner or an
// inside radius is an annotation on a vertex, not a hole cut in the outline —
// so the polygon already covers the full bounding rectangle at that corner,
// which is exactly CounterGo's rule that a notch bills "as if the notch piece
// had not been removed". Cutouts do not deduct either: the stone is still
// bought and still fabricated. They add a charge; they never subtract area.

function ctAreaEdgeProfile(area, opt) {
  const o = opt || ctSelectedOption(area);
  return (o && o.edgeProfile) || 'Eased';
}
function ctAreaTakeoff(area, opt) {
  const counters = (area && area.counters) || [];
  const defProf = ctAreaEdgeProfile(area, opt);
  const t = {
    topSqIn: 0, perimeterIn: 0, splashSqIn: 0, splashLinIn: 0,
    // Splash runs bucketed BY THEIR OWN HEIGHT, because a 4" splash and a
    // full-height one are two different rates on the same drawing. A quote
    // where every run uses the area's default height collapses to a single
    // bucket, so the ordinary case is unchanged.
    splashBuckets: {},
    finishedByProfile: {}, applianceIn: 0, miterIn: 0, waterfallCount: 0,
    miterPanelSqIn: 0, miterPanels: [],
    corners: {}, cornerAddedLenIn: 0, cutouts: [], faucetHoles: 0,
    outlets: Math.max(0, ctNum(area && area.outletCount)), counters: [],
  };
  counters.forEach(c => {
    const sqIn = ctPolyAreaSqIn(c.points);
    t.topSqIn += sqIn;
    const segs = ctAllSegments(c);
    segs.forEach(s => {
      t.perimeterIn += s.len;
      ctSegmentParts(s).forEach(part => {
        const prof = part.edgeProfile || defProf;
        if (part.kind === 'Finished' || part.kind === 'Splash') {
          if (CT_MITER_PROFILES.indexOf(prof) >= 0) {
            // A miter is cut on BOTH pieces. This is the line that halves every
            // waterfall in the job if it is written as `+= part.len`.
            t.miterIn += part.len * 2;
            if (prof === 'Waterfall') t.waterfallCount += 1;
            // The PANEL below the mitre is stone too. Without this a waterfall
            // bills its cut and none of the slab it eats.
            const drop = Math.max(0, ctNum(part.dropIn));
            if (drop > 0) {
              t.miterPanelSqIn += part.len * drop;
              t.miterPanels.push({ profile: prof, lenIn: part.len, dropIn: drop });
            }
          } else {
            t.finishedByProfile[prof] = (t.finishedByProfile[prof] || 0) + part.len;
          }
        } else if (part.kind === 'Appliance') {
          t.applianceIn += part.len;
        }
        if (part.kind === 'Splash' || part.splashHeight > 0) {
          const h = part.splashHeight > 0 ? part.splashHeight : ctNum(area.splashHeightIn);
          if (h > 0) {
            t.splashSqIn += part.len * h; t.splashLinIn += part.len;
            const hh = Math.round(h * 16) / 16;
            const k = String(hh);
            if (!t.splashBuckets[k]) t.splashBuckets[k] = { heightIn: hh, sqIn: 0, linIn: 0 };
            t.splashBuckets[k].sqIn += part.len * h;
            t.splashBuckets[k].linIn += part.len;
          }
        }
      });
    });
    (c.points || []).forEach(p => {
      const tr = p.treatment || 'Standard';
      if (tr === 'Standard') return;
      t.corners[tr] = (t.corners[tr] || 0) + 1;
      // addsMaterialToLength: a Full Radius and a Bump-Out Arc draw identically
      // and differ ONLY here — the arc adds its depth to the billable edge, the
      // full radius adds nothing.
      const adds = p.addsLen === undefined ? !!CT_CORNER_DEFAULT_ADDS_LEN[tr] : !!p.addsLen;
      if (adds) t.cornerAddedLenIn += Math.max(0, ctNum(p.radius));
    });
    (c.cutouts || []).forEach(cu => {
      if (cu.kind === 'Faucet Hole') { t.faucetHoles += Math.max(1, ctNum(cu.faucetHoles) || 1); return; }
      if (cu.kind === 'Outlet') { t.outlets += Math.max(1, ctNum(cu.qty) || 1); return; }
      t.cutouts.push(Object.assign({}, cu, { counterId: c.id }));
      t.faucetHoles += ctNum(cu.faucetHoles);
    });
    t.counters.push({ id: c.id, name: c.name, sqIn, segs });
  });
  // The added length from bump-out arcs lands on the area's default profile —
  // it is edge, and it is the edge the rest of that run is being charged at.
  if (t.cornerAddedLenIn > 0) t.finishedByProfile[defProf] = (t.finishedByProfile[defProf] || 0) + t.cornerAddedLenIn;
  t.topSqFt = t.topSqIn / 144;
  t.miterPanelSqFt = t.miterPanelSqIn / 144;
  t.splashSqFt = t.splashSqIn / 144;
  t.splashLinFt = t.splashLinIn / 12;
  t.perimeterFt = t.perimeterIn / 12;
  t.splashHeights = Object.keys(t.splashBuckets)
    .map(k => t.splashBuckets[k])
    .map(b => ({ heightIn: b.heightIn, sqFt: b.sqIn / 144, linFt: b.linIn / 12 }))
    .sort((a, b) => a.heightIn - b.heightIn);
  return t;
}

// ── The material, the colour and the slab ─────────────────────────────────

function ctSelectedOption(area) {
  const opts = (area && area.colorOptions) || [];
  return opts.find(o => o.selected) || opts[0] || null;
}
function ctMaterialOf(pl, opt) {
  if (!pl || !opt) return null;
  return (pl.materials || []).find(m => m.id === opt.materialId) || null;
}
function ctColorOf(mat, opt) {
  if (!mat || !opt) return null;
  return (mat.colors || []).find(c => c.id === opt.colorId) || null;
}
// A PRICE GROUP IS SELECTABLE AS A COLOUR. When a client knows their price
// point but has not chosen a colour, the group is what gets quoted — that is
// how a showroom quote actually starts.
function ctGroupOf(mat, opt) {
  if (!mat || !opt || !opt.priceGroupId) return null;
  return (mat.priceGroups || []).find(g => g.id === opt.priceGroupId) || null;
}
function ctOptionLabel(pl, opt) {
  const mat = ctMaterialOf(pl, opt);
  const col = ctColorOf(mat, opt);
  const grp = ctGroupOf(mat, opt);
  return [mat ? mat.name : '', col ? col.name : (grp ? `${grp.name} [price group]` : '')].filter(Boolean).join(' — ') || '(no material chosen)';
}
// Colour price wins, then the colour's own price group, then the group chosen
// directly as the colour. Nothing found stays null all the way to the quote
// line, where it prints as -No price-.
function ctMaterialRate(mat, col, opt) {
  if (col && ctIsSet(col.pricePerSqFt)) return Number(col.pricePerSqFt);
  if (col && col.priceGroupId && mat) {
    const g = (mat.priceGroups || []).find(x => x.id === col.priceGroupId);
    if (g && ctIsSet(g.pricePerSqFt)) return Number(g.pricePerSqFt);
  }
  const direct = ctGroupOf(mat, opt);
  if (direct && ctIsSet(direct.pricePerSqFt)) return Number(direct.pricePerSqFt);
  return null;
}
// EACH COLOUR OPTION IS PRICED FROM ITS OWN SLAB SIZE. CounterGo calculates
// every option off the LEFTMOST one's slab size, which quietly misprices any
// comparison between materials whose slabs differ — and slabs differ all the
// time. Not inherited here, and the screen says so.
function ctNominalSlab(mat, col) {
  const L = col && ctIsSet(col.slabLengthIn) ? Number(col.slabLengthIn) : (mat ? ctNum(mat.slabLengthIn) : 0);
  const W = col && ctIsSet(col.slabWidthIn) ? Number(col.slabWidthIn) : (mat ? ctNum(mat.slabWidthIn) : 0);
  return { lengthIn: L, widthIn: W };
}
function ctJobSlabs(ctx, projectId) {
  return (ctx.slabs || []).filter(s => s.projectId === projectId);
}
function ctPlacementSlabCount(placements) {
  const idx = {};
  Object.keys(placements || {}).forEach(k => { idx[placements[k].slab] = true; });
  return Object.keys(idx).length;
}
// The slab plan. Three sources, in descending order of how far they can be
// trusted — and the source travels with the number, so the quote line can say
// which one it is instead of presenting a guess as a fact.
function ctAreaSlabPlan(area, pl, ctx, projectId, opt) {
  const option = opt || ctSelectedOption(area);
  const t = ctAreaTakeoff(area, option);
  const mat = ctMaterialOf(pl, option);
  const col = ctColorOf(mat, option);
  const plan = (area && area.slabPlan) || {};
  const chosen = plan.slabRecordId ? ctJobSlabs(ctx, projectId).find(s => s.id === plan.slabRecordId) : null;
  const nominal = ctNominalSlab(mat, col);
  let L = nominal.lengthIn, W = nominal.widthIn, sizeSource = 'the material’s nominal slab size';
  if (chosen && ctNum(chosen.lengthMm) > 0) {
    L = ctNum(chosen.lengthMm) / MM_PER_INCH;
    W = ctNum(chosen.widthMm) / MM_PER_INCH;
    sizeSource = `slab ${chosen.slabId || chosen.id} in LEON Stone`;
  }
  if (ctIsSet(plan.lengthIn) && ctIsSet(plan.widthIn)) {
    L = Number(plan.lengthIn); W = Number(plan.widthIn); sizeSource = 'a size entered here';
  }
  const kerf = ctIsSet(plan.kerfIn) ? Number(plan.kerfIn) : ctKerfIn();
  const usableLen = L > 0 ? L - kerf : 0;
  const pieces = [];
  ((area && area.counters) || []).forEach(c => {
    ctCounterPieces(c, usableLen, plan.pieceOverrides || {}).forEach(p => pieces.push(p));
  });
  const packed = ctPackSlabs(pieces, L, W, kerf);
  const oversize = packed.reduce((n, s) => n + (s.oversize || []).length, 0);
  const slabSqFtEach = (L * W) / 144;
  // Splash is cut from the same stone, so it belongs in the demand even though
  // it is not one of the drawn polygons — but only when it is priced through
  // the material, since a per-lin-ft splash rate already covers its own stone.
  const splashInMaterial = ctSplashBasis(pl) === 'material';
  const splashSlabs = (splashInMaterial && slabSqFtEach > 0 && t.splashSqFt > 0) ? Math.ceil(t.splashSqFt / slabSqFtEach) : 0;
  // A waterfall panel, a built-up apron and a mitred return are all stone off
  // this same slab. The mitre RATE pays for the cut, never for the material —
  // so without this a waterfall is quoted with the slab it eats missing.
  const miterSlabs = (slabSqFtEach > 0 && t.miterPanelSqFt > 0) ? Math.ceil(t.miterPanelSqFt / slabSqFtEach) : 0;
  const autoCount = packed.length + splashSlabs + miterSlabs;
  const laidOut = !!(plan.placements && Object.keys(plan.placements).length);
  const manual = ctIsSet(plan.count);
  const count = manual ? Math.max(0, Math.round(Number(plan.count)))
    : (laidOut ? Math.max(1, ctPlacementSlabCount(plan.placements)) : autoCount);
  return {
    count, autoCount, source: manual ? 'manual' : (laidOut ? 'laid-out' : 'estimated'),
    laidOut, manual, lengthIn: L, widthIn: W, kerf, sizeSource,
    slabSqFt: count * slabSqFtEach, usableSlabSqFt: slabSqFtEach,
    pieces, packed, oversize, anyInexact: pieces.some(p => !p.exact),
    splashSlabs, splashInMaterial, miterSlabs, miterPanelSqFt: t.miterPanelSqFt,
    demandSqFt: t.topSqFt + (splashInMaterial ? t.splashSqFt : 0) + t.miterPanelSqFt,
    slabRecord: chosen || null,
  };
}

// ── Price list plumbing ───────────────────────────────────────────────────
// The four controls (hide on quote, allow discount, editable on quote, tax
// code) and the per-material override have to hang off EVERY priced row. Some
// rows in the model are documented as a plain number — `finishedEdges` is
// "profile -> $/lin ft", `corners` is "treatment -> price each" — and turning
// those into objects would break the shape data.jsx documents. So the controls
// for those rows live in one sparse parallel map, keyed the same way, and the
// numbers stay exactly the numbers the model says they are.
function ctMeta(pl, key) { return ((pl && pl.itemMeta) || {})[key] || {}; }
function ctRateFor(pl, key, base, materialId) {
  const pm = ctMeta(pl, key).perMaterial || {};
  if (materialId && ctIsSet(pm[materialId])) return Number(pm[materialId]);
  return ctPriceVal(base);
}
function ctItemFlags(pl, key) {
  const m = ctMeta(pl, key);
  return {
    hideOnQuote: !!m.hideOnQuote,
    allowDiscount: m.allowDiscount !== false,
    editableOnQuote: m.editableOnQuote !== false,
    taxCode: m.taxCode || '',
  };
}
function ctTaxable(taxCode) {
  const c = String(taxCode || '').toLowerCase();
  return !(c === 'exempt' || c === 'non-taxable' || c === 'nontaxable' || c === 'none');
}
// Sink cutouts are keyed by TYPE first, then one of three bases: a flat price
// each, a size tier chosen by the sink's LONGEST edge, or the material's own
// per-material price. Nothing here is seeded — the team types their numbers.
const CT_SINK_BANDS = [
  { key: 'sinkUndermount', type: 'Undermount', label: 'Undermount Sink Cutouts' },
  { key: 'sinkDropIn', type: 'Drop-In', label: 'Drop-In Sink Cutouts' },
  { key: 'sinkFarmhouse', type: 'Farmhouse', label: 'Farmhouse Sink Cutouts' },
];
const CT_SINK_BASES = [
  { k: 'tier', label: 'By size tier (longest edge)' },
  { k: 'flat', label: 'Flat price each' },
  { k: 'material', label: 'By material (per-material price)' },
];
function ctSinkBandKey(sinkType) {
  const b = CT_SINK_BANDS.find(x => x.type === sinkType);
  return b ? b.key : 'sinkUndermount';
}
function ctSinkBandFor(pl, sinkType, longestEdgeIn) {
  const list = ((pl.cutouts || {})[ctSinkBandKey(sinkType)] || []);
  const sized = list.filter(r => ctIsSet(r.upToIn)).sort((a, b) => Number(a.upToIn) - Number(b.upToIn));
  return sized.find(r => longestEdgeIn <= Number(r.upToIn)) || list.find(r => !ctIsSet(r.upToIn)) || null;
}
// Unit-Priced Items — how tear-out, travel and delivery are actually quoted: a
// named item, a unit, and size ranges each carrying BOTH a base fee and a
// per-unit rate. There is no separate tear-out entity and no minimum-square-
// footage field anywhere: a minimum is expressed as the base fee on the
// lowest range, which is the same number said honestly.
function ctMakeUnitItem(data) {
  return Object.assign({
    id: uid('ctuni'), kind: 'unit', label: '', unit: 'sq ft',
    ranges: [{ id: uid('ctrng'), upTo: null, baseFee: null, perUnit: null }],
    hideOnQuote: false, allowDiscount: true, editableOnQuote: true, taxCode: '', perMaterial: {},
  }, data || {});
}
const CT_UNIT_ITEM_UNITS = ['sq ft', 'lin ft', 'miles', 'hours', 'each'];
function ctUnitItemRange(item, qty) {
  const rs = (item.ranges || []).slice().sort((a, b) => (ctIsSet(a.upTo) ? Number(a.upTo) : Infinity) - (ctIsSet(b.upTo) ? Number(b.upTo) : Infinity));
  return rs.find(r => ctIsSet(r.upTo) && qty <= Number(r.upTo)) || rs.find(r => !ctIsSet(r.upTo)) || null;
}
function ctUnitItemPrice(item, qty) {
  const r = ctUnitItemRange(item, qty);
  if (!r) return { amount: null, band: null };
  const base = ctPriceVal(r.baseFee), per = ctPriceVal(r.perUnit);
  if (base === null && per === null) return { amount: null, band: r };
  return { amount: (base || 0) + (per || 0) * qty, band: r };
}

// A blank, fully-structured price list. Every row a quote can ever reference
// exists from the start with NO price on it. -No price- is the shipped state.
function ctBlankPriceList(name, by) {
  const pl = makeCtPriceList({ name: name || 'New Price List' }, by);
  pl.finishedEdges = {};
  CT_FINISHED_EDGE_PROFILES.forEach(p => { pl.finishedEdges[p] = null; });
  pl.edgeGroups = [];                    // [{ id, name, pricePerLinFt }] — one rate several profiles point at
  pl.edgeGroupOf = {};                   // profile -> edgeGroup id
  pl.corners = {};
  CT_CURVE_ROWS.forEach(r => { pl.corners[r.key] = r.twoPrice ? { flat: null, linear: null } : null; });
  // Splash has three bases (CT_SPLASH_BASES). A NEW list starts on `sqft` —
  // the splash's own $/sq ft, banded by height — because that is what the team
  // actually quotes. Existing lists are untouched: ctSplashBasis backfills a
  // missing or unrecognised value to `material`, which is what they did before
  // this existed, so no quote already sent can move.
  pl.splash = { mode: 'sqft', pricePerSqFt: null, pricePerLinFt: null, byHeight: [] };
  pl.cutouts = {
    sinkUndermount: [makeCtPriceItem({ label: '[Any Size]', unit: 'each', upToIn: null })],
    sinkDropIn: [makeCtPriceItem({ label: '[Any Size]', unit: 'each', upToIn: null })],
    sinkFarmhouse: [makeCtPriceItem({ label: '[Any Size]', unit: 'each', upToIn: null })],
    sinkBasis: { Undermount: 'tier', 'Drop-In': 'tier', Farmhouse: 'tier' },
    sinkFlat: { Undermount: null, 'Drop-In': null, Farmhouse: null },
    faucetHole: null, cooktop: null, outlet: null, other: null,
    shortcuts: [],                       // named sink openings, for fast placing in step 4
  };
  pl.itemMeta = {};
  return pl;
}

// ── The estimate ──────────────────────────────────────────────────────────
// Structured exactly like the quote the team already sends, because that is
// what the client is used to reading and what reconciles against their own
// records. Every line records whether its rate was actually SET; a line with
// no rate contributes nothing to the total and makes the quote incomplete.
//
// A DISCOUNT REDUCES THE UNIT PRICE, NOT THE LINE TOTAL, and a discounted line
// is marked "D". A line with no D either had its price overridden on the quote
// or has Allow Discount switched off — which is information the reader needs,
// so the marker is reproduced along with the arithmetic.

function ctLine(o) {
  return Object.assign({
    id: uid('ctln'), key: '', label: '', subs: [], qty: null, unit: '', rate: null, listRate: null,
    amount: 0, unpriced: false, hidden: false, discountable: true, discounted: false,
    // `overridden` means the rate was TYPED ON THIS QUOTE rather than read from
    // the price list. `typedRate` carries what was typed, `lockedRate` says the
    // price list forbids typing over this row at all (Editable unticked).
    overridden: false, typedRate: null, lockedRate: false,
    taxable: true, note: '',
  }, o);
}
// `sys` is a DISPLAY system only. Every qty and rate below stays canonical —
// square feet, linear feet, dollars per those — and `sys` decides nothing but
// the words and figures printed on the line. Switching a quote to metric
// therefore cannot move a single amount on it.
function ctPriceArea(area, pl, ctx, projectId, opt, discountPct, sysIn) {
  const sys = sysIn === 'Metric' ? 'Metric' : 'Imperial';
  const option = opt || ctSelectedOption(area);
  const t = ctAreaTakeoff(area, option);
  const mat = ctMaterialOf(pl, option);
  const col = ctColorOf(mat, option);
  const matId = mat ? mat.id : null;
  const plan = ctAreaSlabPlan(area, pl, ctx, projectId, option);
  const rq = (pl && ctIsSet(pl.roundMaterialTo)) ? Number(pl.roundMaterialTo) : 0.1;
  const rl = (pl && ctIsSet(pl.roundLinesTo)) ? Number(pl.roundLinesTo) : 0.01;
  const disc = Math.max(0, ctNum(discountPct));
  const overrides = (area && area.lineOverrides) || {};
  const lines = [];

  // One place decides what a line costs, so the discount rule and the override
  // rule cannot drift apart between line types.
  function push(o) {
    const key = o.key;
    const ov = overrides[key];
    let rate = o.rate;
    let overridden = false;
    const flags = o.flags || { hideOnQuote: false, allowDiscount: true, editableOnQuote: true, taxCode: '' };
    const locked = flags.editableOnQuote === false;
    // A RATE TYPED ON THIS QUOTE WINS OVER THE LIST — including where the list
    // has no rate at all, which is the whole point: a quote goes out while the
    // company list is still half-filled. A typed rate makes the line PRICED, so
    // it stops counting towards the quote's incompleteness below.
    if (ctIsSet(ov) && !locked) { rate = Number(ov); overridden = true; }
    const listRate = o.rate;
    let discounted = false;
    if (!overridden && disc > 0 && flags.allowDiscount !== false && rate !== null) {
      rate = rate * (1 - disc / 100);      // the DISCOUNT LANDS ON THE UNIT PRICE
      discounted = true;
    }
    const qty = o.qty === null || o.qty === undefined ? 1 : o.qty;
    lines.push(ctLine(Object.assign({}, o, {
      rate, listRate, overridden, discounted,
      typedRate: overridden ? Number(ov) : null,
      lockedRate: locked,
      amount: rate === null ? 0 : ctRoundTo(qty * rate, rl),
      unpriced: rate === null,
      hidden: !!flags.hideOnQuote,
      discountable: flags.allowDiscount !== false,
      taxable: ctTaxable(flags.taxCode),
    })));
  }

  const vendorName = mat && mat.vendorId ? (((ctx.vendors || []).find(v => v.id === mat.vendorId) || {}).name || '') : '';
  const matLabel = [vendorName, ctOptionLabel(pl, option)].filter(Boolean).join(' ');

  // 1 — MATERIAL, by the slab. Quantity rounds UP.
  {
    const rate = ctMaterialRate(mat, col, option);
    const qty = ctRoundUpTo(plan.slabSqFt, rq);
    const subs = [`${ctQtyLabel(ctRoundUpTo(t.topSqFt, rq), 'sq ft', sys)} Countertop — priced by the slab`];
    if (t.splashSqFt > 0 && plan.splashInMaterial) subs.push(`${ctQtyLabel(ctRoundUpTo(t.splashSqFt, rq), 'sq ft', sys)} Backsplash — charged on ${CT_SPLASH_BASIS_NAMES.material}, so its stone is bought through this slab line.`);
    if (t.miterPanelSqFt > 0) subs.push(`${ctQtyLabel(ctRoundUpTo(t.miterPanelSqFt, rq), 'sq ft', sys)} of waterfall panel, apron and mitred return — stone off this same slab. The mitre rate pays for the cut, not for the material.`);
    if (plan.source === 'estimated') {
      subs.push(`Slab count ESTIMATED, not laid out — ${plan.count} slab${plan.count === 1 ? '' : 's'} from a first-fit layout of ${plan.pieces.length} piece${plan.pieces.length === 1 ? '' : 's'} on ${ctSlabSizeText(plan.lengthIn, plan.widthIn, sys)} (${plan.sizeSource}), ${ctFmtIn(plan.kerf, sys)} kerf.`);
    } else if (plan.source === 'manual') { subs.push('Slab count set by hand.'); }
    else { subs.push('Slab count from the laid-out plan.'); }
    if (plan.anyInexact) subs.push('A counter here is not rectilinear, so its piece is the bounding box — lay it out by hand before relying on this count.');
    push({
      key: 'material', label: `${ctQtyLabel(qty, 'sq ft', sys)} — ${plan.count} Slab${plan.count === 1 ? '' : 's'} ${matLabel}`,
      subs, qty, unit: 'sq ft', rate,
      flags: { hideOnQuote: !!ctMeta(pl, `material:${matId}`).hideOnQuote, allowDiscount: mat ? mat.allowDiscount !== false : true, editableOnQuote: mat ? mat.editableOnQuote !== false : true, taxCode: mat ? mat.taxCode : '' },
      // Only complain where nothing has been typed on the quote either — a
      // rate typed here IS the price, and saying otherwise on the document
      // would be wrong.
      note: (rate === null && !ctIsSet(overrides.material)) ? 'No $/sq ft set for this colour or its price group.' : '',
    });
  }

  // 1b — SPLASH on its own basis. Three genuinely different rules, not
  // variations of one: the AREA is the same number in all three — each side's
  // splash run times its own height, straight off the drawing — and only the
  // RATE changes. `material` produces no line at all here, because that stone
  // was bought through the slab line above.
  const splashBasis = ctSplashBasis(pl);
  if (t.splashSqFt > 0 && splashBasis !== 'material') {
    const sp = pl.splash || {};
    if (splashBasis === 'linearFt') {
      const qty = ctRoundUpTo(t.splashLinFt, rq);
      push({
        key: 'splash', label: `${ctQtyLabel(qty, 'lin ft', sys)} Backsplash`,
        subs: [`Charged on ${CT_SPLASH_BASIS_NAMES.linearFt}, so the splash HEIGHT does not change this price — only the run does.`],
        qty, unit: 'lin ft', rate: ctRateFor(pl, 'splash', sp.pricePerLinFt, matId),
        flags: ctItemFlags(pl, 'splash'),
      });
    } else {
      // Its OWN $/sq ft, banded by height — a 4" splash and a full-height one
      // are different rates on the same drawing, so each RUN is banded by its
      // own height rather than by the area's default.
      const byH = (sp.byHeight || []).slice()
        .sort((a, b) => (ctIsSet(a.upToIn) ? Number(a.upToIn) : Infinity) - (ctIsSet(b.upToIn) ? Number(b.upToIn) : Infinity));
      const runs = (t.splashHeights || []).length
        ? t.splashHeights
        : [{ heightIn: ctNum(area.splashHeightIn), sqFt: t.splashSqFt, linFt: t.splashLinFt }];
      const single = runs.length <= 1;
      runs.forEach(run => {
        const band = byH.find(r => ctIsSet(r.upToIn) && run.heightIn <= Number(r.upToIn)) || byH.find(r => !ctIsSet(r.upToIn));
        const rate = band ? ctPriceVal(band.price) : ctRateFor(pl, 'splash', sp.pricePerSqFt, matId);
        const qty = ctRoundUpTo(run.sqFt, rq);
        if (!(qty > 0)) return;
        const subs = [`Charged on ${CT_SPLASH_BASIS_NAMES.sqft}. That rate is understood to include its stone, so this area is NOT added to the slab count above.`];
        subs.push(band
          ? `${ctFmtIn(run.heightIn, sys)} high — ${band.label || 'height band'}${ctIsSet(band.upToIn) ? ` (up to ${ctFmtIn(Number(band.upToIn), sys)})` : ' (any height)'}.`
          : `${ctFmtIn(run.heightIn, sys)} high — no height band covers this run, so the any-height rate applies.`);
        // A band that exists but carries no rate is a real blank, not a
        // reason to fall back — somebody made that band and has not priced it.
        if (band && rate === null) subs.push('That height band has no rate set. It is -No price-, not $0.00, and this quote reports itself incomplete until it is priced or a rate is typed on the quote.');
        push({
          key: single ? 'splash' : `splash:${run.heightIn}`,
          label: `${ctQtyLabel(qty, 'sq ft', sys)} Backsplash${single ? '' : ` — ${ctFmtIn(run.heightIn, sys)} high`}`,
          subs, qty, unit: 'sq ft', rate, flags: ctItemFlags(pl, 'splash'),
        });
      });
    }
  }

  // 2 — FABRICATION and INSTALLATION, on the drawn countertop area.
  [['fabrication', 'Fabrication', pl.fabricationPerSqFt], ['installation', 'Installation', pl.installationPerSqFt]].forEach(pair => {
    const qty = ctRoundUpTo(t.topSqFt, rq);
    if (qty <= 0) return;
    push({ key: pair[0], label: `${ctQtyLabel(qty, 'sq ft', sys)} ${pair[1]}`, qty, unit: 'sq ft',
           rate: ctRateFor(pl, pair[0], pair[2], matId), flags: ctItemFlags(pl, pair[0]) });
  });

  // 3 — FINISHED EDGE, per profile, per linear foot. Unfinished runs are not here.
  Object.keys(t.finishedByProfile).sort().forEach(prof => {
    const gid = (pl.edgeGroupOf || {})[prof];
    const grp = gid ? (pl.edgeGroups || []).find(g => g.id === gid) : null;
    const base = ctIsSet((pl.finishedEdges || {})[prof]) ? pl.finishedEdges[prof] : (grp ? grp.pricePerLinFt : null);
    const qty = ctRoundUpTo(t.finishedByProfile[prof] / 12, rq);
    push({ key: `edge:${prof}`, label: `${ctQtyLabel(qty, 'lin ft', sys)} Finished Edge — ${prof}`, qty, unit: 'lin ft',
           rate: ctRateFor(pl, `edge:${prof}`, base, matId), flags: ctItemFlags(pl, `edge:${prof}`) });
  });

  // 4 — APPLIANCE EDGE — a real cut and polish where the counter meets an appliance.
  if (t.applianceIn > 0) {
    const qty = ctRoundUpTo(t.applianceIn / 12, rq);
    push({ key: 'applianceEdge', label: `${ctQtyLabel(qty, 'lin ft', sys)} Appliance Edge`, qty, unit: 'lin ft',
           rate: ctRateFor(pl, 'applianceEdge', pl.applianceEdgePerLinFt, matId), flags: ctItemFlags(pl, 'applianceEdge') });
  }

  // 5 — MITERED EDGE. Both pieces, already doubled in the take-off.
  if (t.miterIn > 0) {
    const qty = ctRoundUpTo(t.miterIn / 12, rq);
    push({ key: 'miter', label: `${ctQtyLabel(qty, 'lin ft', sys)} Mitered Edge`, qty, unit: 'lin ft',
           subs: [`A miter is cut on BOTH pieces, so ${ctQtyLabel(t.miterIn / 24, 'lin ft', sys)} of edge bills as ${ctQtyLabel(t.miterIn / 12, 'lin ft', sys)}.`],
           rate: ctRateFor(pl, 'miter', pl.miterPerLinFt, matId), flags: ctItemFlags(pl, 'miter') });
  }
  if (t.waterfallCount > 0) {
    push({ key: 'waterfall', label: `${t.waterfallCount} Waterfall Installation`, qty: t.waterfallCount, unit: 'each',
           rate: ctRateFor(pl, 'waterfall', pl.waterfallInstallCharge, matId), flags: ctItemFlags(pl, 'waterfall') });
  }

  // 6 — CURVES & BUMPOUTS. Every treated corner is a countable, priced line.
  Object.keys(t.corners).sort().forEach(tr => {
    const n = t.corners[tr];
    const raw = (pl.corners || {})[tr];
    push({ key: `corner:${tr}`, label: `${n} — ${tr} Corner${n === 1 ? '' : 's'}`, qty: n, unit: 'each',
           rate: ctRateFor(pl, `corner:${tr}`, (raw && typeof raw === 'object') ? raw.flat : raw, matId),
           flags: ctItemFlags(pl, `corner:${tr}`) });
  });

  // 7 — CUTOUTS. They never reduce the square footage: the stone is still
  // bought and still fabricated, so each one is a charge, not a deduction.
  const groups = {};
  t.cutouts.forEach(cu => {
    const longest = Math.max(ctNum(cu.widthIn), ctNum(cu.depthIn));
    let key, label, rate;
    if (cu.kind === 'Sink') {
      const basis = ((pl.cutouts || {}).sinkBasis || {})[cu.sinkType] || 'tier';
      if (basis === 'flat') {
        key = `cutout:${cu.sinkType}:flat`;
        label = `${cu.sinkType} Sink Cutout`;
        rate = ctRateFor(pl, `sinkflat:${cu.sinkType}`, ((pl.cutouts || {}).sinkFlat || {})[cu.sinkType], matId);
      } else if (basis === 'material') {
        key = `cutout:${cu.sinkType}:material`;
        label = `${cu.sinkType} Sink Cutout`;
        rate = ctRateFor(pl, `sinkmat:${cu.sinkType}`, null, matId);
      } else {
        const band = ctSinkBandFor(pl, cu.sinkType, longest);
        key = `cutout:${cu.sinkType}:${band ? band.id : 'none'}`;
        label = `${ctFmtIn(longest, sys)} ${cu.sinkType} Sink Cutout`;
        rate = band ? ctRateFor(pl, `cutband:${band.id}`, band.price, matId) : null;
      }
    } else if (cu.kind === 'Cooktop') {
      key = 'cutout:cooktop'; label = 'Cooktop Cutout'; rate = ctRateFor(pl, 'cooktop', (pl.cutouts || {}).cooktop, matId);
    } else {
      key = 'cutout:other'; label = cu.name || 'Other Cutout'; rate = ctRateFor(pl, 'otherCutout', (pl.cutouts || {}).other, matId);
    }
    if (!groups[key]) groups[key] = { key, label, rate, n: 0 };
    groups[key].n += 1;
  });
  Object.keys(groups).forEach(k => {
    const g = groups[k];
    push({ key: g.key, label: `${g.n} — ${g.label}`, qty: g.n, unit: 'each', rate: g.rate, flags: ctItemFlags(pl, g.key) });
  });
  if (t.faucetHoles > 0) {
    push({ key: 'faucetHole', label: `${t.faucetHoles} Faucet Hole${t.faucetHoles === 1 ? '' : 's'}`,
           subs: ['Counted separately from the sink cutout.'], qty: t.faucetHoles, unit: 'each',
           rate: ctRateFor(pl, 'faucetHole', (pl.cutouts || {}).faucetHole, matId), flags: ctItemFlags(pl, 'faucetHole') });
  }
  if (t.outlets > 0) {
    push({ key: 'outlet', label: `${t.outlets} Outlet Cutout${t.outlets === 1 ? '' : 's'}`,
           subs: ['Entered as a count — where an outlet lands does not change what it costs.'],
           qty: t.outlets, unit: 'each',
           rate: ctRateFor(pl, 'outlet', (pl.cutouts || {}).outlet, matId), flags: ctItemFlags(pl, 'outlet') });
  }

  // 8 — Everything an estimator adds by hand: sinks sold as product, other
  // items, unit-priced items, the curve rows that are not corners, and an
  // AD-HOC line typed straight onto this quote — description, quantity, unit
  // and rate — for the thing the company price list has never had a row for.
  ((area && area.extraItems) || []).forEach(ex => {
    const n = Math.max(0, ctNum(ex.qty) || 1);
    let label = ex.label || '', rate = null, amountOverride = null;
    let unit = 'each';
    let flags = { hideOnQuote: false, allowDiscount: true, editableOnQuote: true, taxCode: '' };
    const subs = [];
    if (ex.source === 'adhoc') {
      label = ex.label || 'Item';
      unit = ex.unit === 'sq ft' || ex.unit === 'lin ft' ? ex.unit : 'each';
      rate = ctPriceVal(ex.price);
      subs.push('Typed on this quote — this line is not on the company price list.');
    } else if (ex.source === 'sink') {
      const s = (pl.sinks || []).find(x => x.id === ex.refId);
      label = s ? (s.label || s.name || 'Sink') : (ex.label || 'Sink');
      rate = s ? ctRateFor(pl, `sink:${s.id}`, s.price, matId) : ctPriceVal(ex.price);
      if (s) flags = { hideOnQuote: !!s.hideOnQuote, allowDiscount: s.allowDiscount !== false, editableOnQuote: s.editableOnQuote !== false, taxCode: s.taxCode || '' };
    } else if (ex.source === 'other') {
      const o = (pl.otherItems || []).find(x => x.id === ex.refId);
      if (o && o.kind === 'unit') {
        const res = ctUnitItemPrice(o, n);
        label = o.label || 'Unit-priced item';
        amountOverride = res.amount;
        rate = res.amount === null ? null : (n > 0 ? res.amount / n : res.amount);
        if (res.band) subs.push(`${ctPriceText(res.band.baseFee)} base fee + ${ctPriceText(res.band.perUnit)} per ${o.unit}${ctIsSet(res.band.upTo) ? `, up to ${res.band.upTo} ${o.unit}` : ''}.`);
        flags = { hideOnQuote: !!o.hideOnQuote, allowDiscount: o.allowDiscount !== false, editableOnQuote: o.editableOnQuote !== false, taxCode: o.taxCode || '' };
      } else if (o) {
        label = o.label; rate = ctRateFor(pl, `other:${o.id}`, o.price, matId);
        flags = { hideOnQuote: !!o.hideOnQuote, allowDiscount: o.allowDiscount !== false, editableOnQuote: o.editableOnQuote !== false, taxCode: o.taxCode || '' };
      } else { rate = ctPriceVal(ex.price); }
    } else if (ex.source === 'curve') {
      const row = CT_CURVE_ROWS.find(r => r.key === ex.refId);
      label = row ? row.label : (ex.label || 'Curve');
      const raw = (pl.corners || {})[ex.refId];
      if (raw && typeof raw === 'object') {
        // Full Radius Edges carries two prices: a flat charge per feature and a
        // linear charge. Both are shown so neither is silently dropped.
        const flat = ctPriceVal(raw.flat), lin = ctPriceVal(raw.linear);
        if (flat === null && lin === null) rate = null;
        else {
          rate = (flat || 0) + (lin || 0) * Math.max(0, ctNum(ex.lengthFt));
          subs.push(`${ctPriceText(raw.flat)} flat + ${ctPriceText(raw.linear)} per lin ft × ${ctQtyText(ctNum(ex.lengthFt))} lin ft.`);
        }
      } else { rate = ctRateFor(pl, `corner:${ex.refId}`, raw, matId); }
      flags = ctItemFlags(pl, `corner:${ex.refId}`);
    } else {
      rate = ctPriceVal(ex.price);
    }
    push({ key: `extra:${ex.id}`,
           label: unit === 'each' ? `${n} — ${label}` : `${ctQtyLabel(n, unit, sys)} — ${label}`,
           subs, qty: n, unit, rate, flags, note: ex.note || '' });
    if (amountOverride !== null) {
      const l = lines[lines.length - 1];
      // A unit-priced item's total is base + per-unit × qty, not qty × a rate,
      // so the amount is written straight rather than multiplied back out.
      l.amount = ctRoundTo(amountOverride * (l.discounted ? (1 - disc / 100) : 1), rl);
    }
  });

  const subtotal = lines.reduce((s, l) => s + (l.rate === null ? 0 : l.amount), 0);
  const unpriced = lines.filter(l => l.unpriced && (l.qty === null || l.qty > 0));
  return { area, option, takeoff: t, plan, material: mat, color: col, lines, subtotal, unpriced, sys };
}

function ctPriceQuote(quote, pl, ctx, projectId) {
  const list = pl || ctBlankPriceList();
  const discountPct = ctNum(quote && quote.discount);
  const sys = ctQuoteUnits(quote, pl);
  const areas = ((quote && quote.areas) || []).map(a => ctPriceArea(a, list, ctx, projectId, null, discountPct, sys));
  const subtotal = areas.reduce((s, a) => s + a.subtotal, 0);
  const taxRate = ctIsSet(quote && quote.taxRate) ? Number(quote.taxRate)
    : (list && ctIsSet(list.defaultTaxRate) ? Number(list.defaultTaxRate) : null);
  const taxableBase = areas.reduce((s, a) => s + a.lines.reduce((n, l) => n + (l.rate !== null && l.taxable ? l.amount : 0), 0), 0);
  const tax = taxRate === null ? null : Math.max(0, taxableBase) * (taxRate / 100);
  // What the discount actually took off, from the lines that actually carry a
  // D. A price OVERRIDDEN on the quote is not a discount and is not counted
  // here — the two are different claims and the quote shows them differently.
  const discountGiven = areas.reduce((s, a) => s + a.lines.reduce(
    (n, l) => n + (l.discounted && l.listRate !== null ? Math.max(0, (l.qty || 0) * l.listRate - l.amount) : 0), 0), 0);
  const unpriced = areas.reduce((n, a) => n.concat(a.unpriced.map(l => ({ area: a.area.name, label: l.label }))), []);
  return {
    areas, subtotal, discountPct, discount: discountGiven,
    taxRate, tax, total: subtotal + (tax || 0),
    unpriced, taxUnset: taxRate === null,
    // INCOMPLETE MEANS A LINE HAS NEITHER A LIST RATE NOR A TYPED ONE. Once
    // someone types a rate on the quote the line is priced, and the quote stops
    // reporting itself incomplete — `unpriced` is built from `l.unpriced`,
    // which push() sets from the EFFECTIVE rate, override included.
    complete: unpriced.length === 0, sys,
  };
}

// ── Writing to the record ─────────────────────────────────────────────────
// Quotes hang off the project, so they persist with it and add no top-level
// state. ctx.updateProject and ctx.logAction, never the bare ones — those are
// declared inside App() and calling them here throws inside the draft callback
// and loses the whole mutation without anything appearing on screen.

// ── A rate typed on the quote ─────────────────────────────────────────────
// `area.lineOverrides[lineKey]` is a rate typed on THIS quote. It replaces the
// company price list's rate for that one line — and, where the list has no
// rate at all, it IS the rate, which is how a quote goes out while the list is
// still half-filled. It is held in the same canonical unit as the list's own
// rate ($/sq ft, $/lin ft, or per item), so switching the quote between
// Imperial and Metric cannot change what a typed rate means.
//
// `area.lineOverridesPrev[lineKey]` remembers the last typed rate that was
// reverted. Reverting is one click and putting it back is one more, so a
// mis-click can never destroy a number somebody typed. (The editor's own undo
// stack covers it too — this is the belt as well as the braces.)
function ctSetLineRate(a, key, rate) {
  a.lineOverrides = Object.assign({}, a.lineOverrides || {});
  if (rate === null) { delete a.lineOverrides[key]; return; }
  a.lineOverrides[key] = Number(rate);
  const prev = Object.assign({}, a.lineOverridesPrev || {});
  delete prev[key];
  a.lineOverridesPrev = prev;
}
function ctRevertLineRate(a, key) {
  const cur = (a.lineOverrides || {})[key];
  a.lineOverrides = Object.assign({}, a.lineOverrides || {});
  delete a.lineOverrides[key];
  if (ctIsSet(cur)) a.lineOverridesPrev = Object.assign({}, a.lineOverridesPrev || {}, { [key]: Number(cur) });
}
function ctRestoreLineRate(a, key) {
  const prev = (a.lineOverridesPrev || {})[key];
  if (!ctIsSet(prev)) return;
  ctSetLineRate(a, key, Number(prev));
}
// An ad-hoc line: description, quantity, unit and rate, typed straight onto
// the quote without first editing the company price list. It rides on the
// existing extraItems array so it prints, discounts, taxes and shares like
// every other line rather than being a second kind of thing.
function ctMakeAdhocItem() {
  return { id: uid('ctex'), source: 'adhoc', label: '', qty: 1, unit: 'each', price: null, note: '', lengthFt: 0 };
}
const CT_ADHOC_UNITS = ['each', 'sq ft', 'lin ft'];

function ctQuotesOf(project) { return (project && project.countertopQuotes) || []; }

function ctWriteQuotes(ctx, projectId, fn, logLine) {
  if (!projectId || typeof ctx.updateProject !== 'function') return;
  ctx.updateProject(projectId, draft => {
    draft.countertopQuotes = fn((draft.countertopQuotes || []).slice());
    if (logLine && typeof ctx.logAction === 'function') ctx.logAction(draft, logLine);
  });
}
function ctWriteQuote(ctx, projectId, quoteId, fn, logLine) {
  ctWriteQuotes(ctx, projectId, list => list.map(q => {
    if (q.id !== quoteId) return q;
    const next = cloneDeep(q);
    fn(next);
    next.modifiedBy = ctx.currentUserName || '';
    next.modifiedDate = todayISO();
    return next;
  }), logLine);
}
function ctReplaceQuote(ctx, projectId, quoteId, snapshot, logLine) {
  ctWriteQuotes(ctx, projectId, list => list.map(q => (q.id === quoteId ? cloneDeep(snapshot) : q)), logLine);
}
function ctPriceListFor(ctx, quote) {
  const all = ctx.ctPriceLists || [];
  return all.find(p => p.id === (quote && quote.priceListId)) || all.find(p => p.status === 'Active') || all[0] || null;
}
function ctQuoteExpired(q) {
  return !!(q && q.expirationDate && q.expirationDate < todayISO() && ['Draft', 'Active', 'Sent'].includes(q.status));
}
function ctStatusTone(s) {
  if (s === 'Accepted' || s === 'Ordered') return 'green';
  if (s === 'Lost' || s === 'Expired') return 'red';
  if (s === 'Sent') return 'yellow';
  if (s === 'Active') return 'blue';
  return 'neutral';
}

// Which quote someone is working on, remembered across a section switch. The
// HOST owns the subtab bar now, so this cannot live in a section's own state —
// switching tabs unmounts it and the drawing would forget what it was drawing.
// Session-only and deliberately never persisted: it is a cursor, not a record.
let __ctOpenQuote = {};
function ctOpenQuoteId(pid) { return __ctOpenQuote[pid] || ''; }
function ctSetOpenQuote(pid, qid) { if (pid) __ctOpenQuote[pid] = qid || ''; }

// ═══════════════════════════════════════════════════ section host
// ONE section, no chrome of its own. LEON Countertop is a single software with
// a single subtab bar; the drawing/quoting sections here come first and LEON
// Stone's slab, cut-list and layout sections follow, because you draw and
// price before you buy and cut.
function CtSection({ ctx, sectionKey, projectId, onProject, onSection }) {
  // toolProjects, not ctx.projects — the second excludes the person's own
  // unassigned workspace, so the picker offered "Standalone" and then the
  // section could not find what the picker had just selected.
  const projects = ctx.deptProjects(typeof ctx.toolProjects === 'function' ? ctx.toolProjects() : (ctx.projects || []));
  const project = projects.find(p => p.id === projectId) || null;
  const editable = ctx.canEdit('softwares');
  // ?quote=<id> completes the deep link: a drawing is the thing worth sending
  // somebody a link to, and stopping at the quote picker is one click short of
  // it. The param is written into the open-quote STORE rather than into state,
  // because the effect below re-reads that store on every project change and
  // would otherwise wipe a value held only in state.
  const [quoteId, setQuoteIdRaw] = useState(() => ctOpenQuoteId(projectId));
  useEffect(() => {
    const boot = typeof swBootParam === 'function' ? swBootParam('quote') : null;
    if (boot && projectId && !ctOpenQuoteId(projectId)) {
      ctSetOpenQuote(projectId, boot);
      setQuoteIdRaw(boot);
    }
  }, []);
  useEffect(() => { setQuoteIdRaw(ctOpenQuoteId(projectId)); }, [projectId]);
  const setQuoteId = qid => { ctSetOpenQuote(projectId, qid); setQuoteIdRaw(qid); };
  const quote = ctQuotesOf(project).find(q => q.id === quoteId) || null;

  function open(pid, qid, sec) {
    if (pid !== projectId && typeof onProject === 'function') onProject(pid);
    ctSetOpenQuote(pid, qid);
    setQuoteIdRaw(qid);
    if (typeof onSection === 'function') onSection(sec || 'ctDrawing');
  }

  if (sectionKey === 'ctPriceLists') return <CtPriceListsPanel ctx={ctx} editable={editable} />;
  if (sectionKey === 'ctOverview') return <CtOverview ctx={ctx} projects={projects} onOpen={open} />;
  if (sectionKey === 'ctQuotes') {
    return <CtQuotesPanel ctx={ctx} projects={projects} project={project} editable={editable}
      quoteId={quoteId} onSelect={setQuoteId} onOpen={open} />;
  }
  if (sectionKey === 'ctSheet') {
    if (!project) return <CtNeed text="A shop drawing belongs to a quote. Pick a job above — or Standalone." />;
    if (!quote) {
      return (
        <div className="space-y-3">
          <CtNeed text="Pick a quote to draw the sheet from." />
          <CtQuotePicker ctx={ctx} project={project} onPick={qid => setQuoteId(qid)} />
        </div>
      );
    }
    return <CtShopDrawingPanel ctx={ctx} project={project} quote={quote} editable={editable} />;
  }
  if (sectionKey === 'ctDrawing') {
    if (!project) return <CtNeed text="A drawing belongs to a quote. Pick a job above — or Standalone, to draw one now and move it onto a job later." />;
    if (!quote) {
      return (
        <div className="space-y-3">
          <CtNeed text="Pick a quote to draw. Every quote on this job is listed below." />
          <CtQuotePicker ctx={ctx} project={project} onPick={qid => setQuoteId(qid)} />
        </div>
      );
    }
    return <CtQuoteEditor ctx={ctx} project={project} quote={quote} editable={editable}
      onExit={() => { if (typeof onSection === 'function') onSection('ctQuotes'); }} />;
  }
  return <EmptyState text={`Unknown section "${sectionKey}".`} />;
}

function CtNeed({ text }) {
  return (
    <div className="rounded-lg border border-dashed border-[var(--leon-line)] p-8 text-center">
      <div className="text-3xl mb-2">🧿</div>
      <div className="text-sm text-[var(--leon-black)]/60 max-w-md mx-auto">{text}</div>
    </div>
  );
}
function CtQuotePicker({ ctx, project, onPick }) {
  const quotes = ctQuotesOf(project);
  if (!quotes.length) return <EmptyState text="No quotes on this job yet. Start one under Quotes." />;
  return (
    <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
      {quotes.map(q => (
        <button key={q.id} onClick={() => onPick(q.id)}
          className="text-left rounded-lg border border-[var(--leon-line)] p-3 hover:border-[var(--leon-brown)]">
          <div className="font-semibold">{q.name}</div>
          <div className="text-xs text-[var(--leon-black)]/50">
            {(q.areas || []).map(a => a.name).join(', ') || 'no areas yet'} · Rev. {q.revision || 0}
          </div>
        </button>
      ))}
    </div>
  );
}

// ═══════════════════════════════════════════════════ standalone shell
// A thin wrapper over CT_SECTIONS + CtSection, so the standalone screen and
// the merged LEON Countertop bar run the SAME implementation and cannot drift.
function CountertopSoftware({ ctx }) {
  const [section, setSection] = useState('ctOverview');
  const [projectId, setProjectId] = useState('');
  const projects = ctx.deptProjects(typeof ctx.toolProjects === 'function' ? ctx.toolProjects() : (ctx.projects || []));

  return (
    <div className="space-y-4">
      <div className="flex items-start justify-between gap-4 flex-wrap">
        <div>
          <h2 className="text-xl font-bold">🧿 LEON Countertop</h2>
          <p className="text-sm text-[var(--leon-black)]/60 max-w-2xl">
            Draw the kitchen and it prices itself. Six steps — outline, corners, edges, sinks, colour,
            money — against LEON's own vendors, finishes and slabs. Material is charged by the slab,
            fabrication by the countertop, and only a finished edge is billable edge.
          </p>
        </div>
        <Field label="Project">
          <Select className="!w-60" value={projectId} onChange={e => setProjectId(e.target.value)}>
            <option value="">— select a project —</option>
            {projects.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
          </Select>
        </Field>
      </div>

      <div className="flex gap-1 border-b border-[var(--leon-line)] flex-wrap">
        {CT_SECTIONS.map(t => (
          <button key={t.key} onClick={() => setSection(t.key)}
            className={`px-3 py-2 text-sm font-semibold border-b-2 whitespace-nowrap ${section === t.key ? 'border-[var(--leon-brown)] text-[var(--leon-brown)]' : 'border-transparent text-[var(--leon-black)]/50 hover:text-[var(--leon-black)]'}`}>
            <span aria-hidden="true" className="mr-1.5 opacity-80">{t.icon}</span>{t.label}
          </button>
        ))}
      </div>
      <HubTools title="LEON Countertop" heading="LEON Countertop" />

      <CtSection ctx={ctx} sectionKey={section} projectId={projectId}
        onProject={setProjectId} onSection={setSection} />
    </div>
  );
}

// ── Quote overview ────────────────────────────────────────────────────────

function CtOverview({ ctx, projects, onOpen }) {
  const rows = [];
  projects.forEach(p => ctQuotesOf(p).forEach(q => rows.push({ p, q })));
  const priced = rows.map(r => Object.assign({}, r, { res: ctPriceQuote(r.q, ctPriceListFor(ctx, r.q), ctx, r.p.id) }));
  const today = todayISO();
  const expiring = priced.filter(r => r.q.expirationDate && r.q.expirationDate >= today &&
    daysBetween(today, r.q.expirationDate) <= 14 && ['Draft', 'Active', 'Sent'].includes(r.q.status))
    .sort((a, b) => String(a.q.expirationDate).localeCompare(String(b.q.expirationDate)));
  const incomplete = priced.filter(r => !r.res.complete);
  const open = priced.filter(r => ['Draft', 'Active', 'Sent'].includes(r.q.status) && !ctQuoteExpired(r.q));
  const openValue = open.reduce((n, r) => n + r.res.total, 0);

  if (!rows.length) return <EmptyState text="No countertop quotes yet. Open Quotes, pick a job and start one — the drawing prices itself as you go." />;

  return (
    <div className="space-y-4">
      <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
        <CtStat label="Open quotes" value={String(open.length)} sub={`${rows.length} in total`} />
        {ctx.canSeeFin && <CtStat label="Open value" value={fmtMoney(openValue)} sub="Lines with no price set count as nothing" />}
        <CtStat label="Expiring within 14 days" value={String(expiring.length)} tone={expiring.length ? 'yellow' : 'neutral'} />
        <CtStat label="Incomplete pricing" value={String(incomplete.length)} tone={incomplete.length ? 'red' : 'green'}
          sub={incomplete.length ? 'A rate is missing somewhere' : 'Every line has a rate'} />
      </div>

      <Collapsible id="ct-dash-status" title="Quotes by status" defaultOpen count={rows.length}>
        <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
          {CT_QUOTE_STATUSES.map(s => {
            const set = priced.filter(r => (ctQuoteExpired(r.q) ? 'Expired' : r.q.status) === s);
            return (
              <div key={s} className="rounded-md border border-[var(--leon-line)] p-3">
                <div className="flex items-center justify-between mb-1">
                  <Badge tone={ctStatusTone(s)}>{s}</Badge>
                  <span className="text-lg font-bold">{set.length}</span>
                </div>
                {ctx.canSeeFin && <div className="text-xs text-[var(--leon-black)]/50">{fmtMoney(set.reduce((n, r) => n + r.res.total, 0))}</div>}
              </div>
            );
          })}
        </div>
      </Collapsible>

      <Collapsible id="ct-dash-expiring" title="Expiring soon" defaultOpen count={expiring.length}>
        {expiring.length === 0 ? <EmptyState text="Nothing expires in the next two weeks." /> : (
          <table className="w-full text-sm">
            <thead><tr className="text-left text-[11px] uppercase tracking-wide text-[var(--leon-black)]/50">
              <th className="py-1">Quote</th><th>Job</th><th>Expires</th><th>Status</th><th></th>
            </tr></thead>
            <tbody>
              {expiring.map(r => (
                <tr key={r.q.id} className="border-t border-[var(--leon-line)]">
                  <td className="py-1.5 font-semibold">{r.q.name}</td>
                  <td>{r.p.name}</td>
                  <td>{fmtDate(r.q.expirationDate)}</td>
                  <td><Badge tone={ctStatusTone(r.q.status)}>{r.q.status}</Badge></td>
                  <td className="text-right"><Button size="sm" variant="ghost" onClick={() => onOpen(r.p.id, r.q.id, 'ctDrawing')}>Open</Button></td>
                </tr>
              ))}
            </tbody>
          </table>
        )}
      </Collapsible>

      <Collapsible id="ct-dash-incomplete" title="Quotes with a missing rate" count={incomplete.length}>
        {incomplete.length === 0 ? <EmptyState text="Every quote prices completely." /> : (
          <div className="space-y-2">
            <p className="text-xs text-[var(--leon-black)]/55">
              A line with no rate is not a free line. These totals are what has been priced SO FAR — not what the job costs.
            </p>
            {incomplete.map(r => (
              <div key={r.q.id} className="rounded-md border border-[#f0d9d9] bg-[#fdf6f6] p-2.5 text-sm">
                <div className="flex items-center justify-between gap-2">
                  <div><span className="font-semibold">{r.q.name}</span> <span className="text-[var(--leon-black)]/50">· {r.p.name}</span></div>
                  <Button size="sm" variant="ghost" onClick={() => onOpen(r.p.id, r.q.id, 'ctDrawing')}>Open</Button>
                </div>
                <div className="text-xs text-[#b83b3b] mt-1">
                  {r.res.unpriced.slice(0, 4).map(u => `${u.area}: ${u.label}`).join(' · ')}
                  {r.res.unpriced.length > 4 ? ` · +${r.res.unpriced.length - 4} more` : ''}
                </div>
              </div>
            ))}
          </div>
        )}
      </Collapsible>
    </div>
  );
}

function CtStat({ label, value, sub, tone }) {
  const bg = tone === 'red' ? 'bg-[#fdf6f6] border-[#f0d9d9]' : tone === 'yellow' ? 'bg-[#fdfaf2] border-[#eee0c4]'
    : tone === 'green' ? 'bg-[#f5faf6] border-[#d8e9db]' : 'bg-white border-[var(--leon-line)]';
  return (
    <div className={`rounded-lg border p-3 ${bg}`}>
      <div className="text-[11px] uppercase tracking-wide text-[var(--leon-black)]/50">{label}</div>
      <div className="text-2xl font-bold leading-tight">{value}</div>
      {sub && <div className="text-[11px] text-[var(--leon-black)]/45 mt-0.5">{sub}</div>}
    </div>
  );
}

// ── Quotes ────────────────────────────────────────────────────────────────

function CtQuotesPanel({ ctx, projects, project, editable, quoteId, onSelect, onOpen }) {
  const [q, setQ] = useState('');
  const [status, setStatus] = useState('');
  const [sort, setSort] = useState('modified');
  const [adding, setAdding] = useState(false);
  const [revisionsOf, setRevisionsOf] = useState(null);

  const scope = project ? [project] : projects;
  let rows = [];
  scope.forEach(p => ctQuotesOf(p).forEach(x => rows.push({ p, q: x })));
  const needle = q.trim().toLowerCase();
  if (needle) rows = rows.filter(r => [r.q.name, r.q.estimateNo, r.q.address, r.p.name].join(' ').toLowerCase().includes(needle));
  if (status) rows = rows.filter(r => (ctQuoteExpired(r.q) ? 'Expired' : r.q.status) === status);
  rows.sort((a, b) => {
    if (sort === 'name') return String(a.q.name).localeCompare(String(b.q.name));
    if (sort === 'status') return String(a.q.status).localeCompare(String(b.q.status));
    if (sort === 'expires') return String(a.q.expirationDate || '9999').localeCompare(String(b.q.expirationDate || '9999'));
    return String(b.q.modifiedDate || '').localeCompare(String(a.q.modifiedDate || ''));
  });

  function duplicate(p, src) {
    const copy = cloneDeep(src);
    copy.id = uid('ctq');
    copy.name = `${src.name} (copy)`;
    copy.status = 'Draft'; copy.revision = 0; copy.revisions = [];
    copy.createdBy = ctx.currentUserName || ''; copy.createdDate = todayISO();
    ctWriteQuotes(ctx, p.id, list => list.concat([copy]), `LEON Countertop — quote duplicated: ${copy.name}`);
  }
  // A revision is a frozen, numbered snapshot. Their real quote was on
  // Revision 7 — the history IS the point, so nothing is ever overwritten.
  function revise(p, src) {
    ctWriteQuote(ctx, p.id, src.id, next => {
      const snap = cloneDeep(next);
      delete snap.revisions;
      next.revisions = (next.revisions || []).concat([{
        id: uid('ctrev'), number: (next.revision || 0) + 1, date: todayISO(),
        by: ctx.currentUserName || '', note: '', snapshot: snap,
      }]);
      next.revision = (next.revision || 0) + 1;
    }, `LEON Countertop — quote revision ${(src.revision || 0) + 1} captured: ${src.name}`);
  }
  function remove(p, src) {
    ctWriteQuotes(ctx, p.id, list => list.filter(x => x.id !== src.id), `LEON Countertop — quote removed: ${src.name}`);
  }

  return (
    <div className="space-y-3">
      <div className="flex items-end gap-2 flex-wrap">
        <Field label="Search"><TextInput className="!w-52" value={q} onChange={e => setQ(e.target.value)} placeholder="name, estimate no, address" /></Field>
        <Field label="Status">
          <Select className="!w-40" value={status} onChange={e => setStatus(e.target.value)}>
            <option value="">All</option>
            {CT_QUOTE_STATUSES.map(s => <option key={s}>{s}</option>)}
          </Select>
        </Field>
        <Field label="Sort">
          <Select className="!w-44" value={sort} onChange={e => setSort(e.target.value)}>
            <option value="modified">Last modified</option>
            <option value="name">Name</option>
            <option value="status">Status</option>
            <option value="expires">Expiration</option>
          </Select>
        </Field>
        <div className="grow" />
        {editable && <Button onClick={() => setAdding(true)} disabled={!projects.length}>+ New quote</Button>}
      </div>

      {rows.length === 0 ? <EmptyState text={project ? 'No quotes on this job yet.' : 'No countertop quotes yet.'} /> : (
        <div className="overflow-x-auto">
          <table className="w-full text-sm">
            <thead><tr className="text-left text-[11px] uppercase tracking-wide text-[var(--leon-black)]/50">
              <th className="py-1.5">Quote</th><th>Job</th><th>Areas</th><th>Status</th><th>Rev.</th>
              <th>Expires</th>{ctx.canSeeFin && <th className="text-right">Total</th>}<th></th>
            </tr></thead>
            <tbody>
              {rows.map(r => {
                const res = ctPriceQuote(r.q, ctPriceListFor(ctx, r.q), ctx, r.p.id);
                return (
                  <tr key={r.q.id} className={`border-t border-[var(--leon-line)] align-middle ${r.q.id === quoteId ? 'bg-[var(--leon-cream)]' : ''}`}>
                    <td className="py-2">
                      <button className="font-semibold text-[var(--leon-brown)] hover:underline text-left"
                        onClick={() => onOpen(r.p.id, r.q.id, 'ctDrawing')}>{r.q.name}</button>
                      {r.q.estimateNo && <div className="text-[11px] text-[var(--leon-black)]/45">Est. {r.q.estimateNo}</div>}
                    </td>
                    <td>{r.p.name}</td>
                    <td>{(r.q.areas || []).length}</td>
                    <td><Badge tone={ctStatusTone(ctQuoteExpired(r.q) ? 'Expired' : r.q.status)}>{ctQuoteExpired(r.q) ? 'Expired' : r.q.status}</Badge></td>
                    <td>{r.q.revision || 0}</td>
                    <td className="whitespace-nowrap">{r.q.expirationDate ? fmtDate(r.q.expirationDate) : '—'}</td>
                    {ctx.canSeeFin && (
                      <td className="text-right whitespace-nowrap">
                        {ctMoneyText(res.total)}
                        {!res.complete && <div className="text-[10px] font-semibold text-[#b83b3b]">incomplete</div>}
                      </td>
                    )}
                    <td className="text-right whitespace-nowrap">
                      <IconAction icon="📐" title="Open the drawing" onClick={() => onOpen(r.p.id, r.q.id, 'ctDrawing')} />
                      {editable && <IconAction icon="⧉" title="Duplicate this quote" onClick={() => duplicate(r.p, r.q)} />}
                      {editable && <IconAction icon="🔖" title="Capture a numbered revision" onClick={() => revise(r.p, r.q)} />}
                      <IconAction icon="🕘" title="See all revisions" onClick={() => setRevisionsOf(r)} />
                      {editable && <IconAction icon="✕" title="Remove this quote" onClick={() => remove(r.p, r.q)} />}
                    </td>
                  </tr>
                );
              })}
            </tbody>
          </table>
        </div>
      )}

      <CtNewQuoteModal open={adding} onClose={() => setAdding(false)} ctx={ctx} projects={projects}
        project={project} onCreated={(pid, qid) => { setAdding(false); onOpen(pid, qid, 'ctDrawing'); }} />
      <CtRevisionsModal open={!!revisionsOf} onClose={() => setRevisionsOf(null)} ctx={ctx} row={revisionsOf} editable={editable} />
    </div>
  );
}

function CtNewQuoteModal({ open, onClose, ctx, projects, project, onCreated }) {
  const [form, setForm] = useState({});
  useEffect(() => {
    if (!open) return;
    const pl = (ctx.ctPriceLists || []).find(p => p.status === 'Active') || (ctx.ctPriceLists || [])[0] || null;
    const days = (pl && ctIsSet(pl.expirationDays)) ? Number(pl.expirationDays) : ctNum(ctSetting('expirationDays', 30)) || 30;
    const exp = new Date(); exp.setDate(exp.getDate() + days);
    setForm({
      projectId: project ? project.id : (projects[0] ? projects[0].id : ''),
      name: 'Countertops', priceListId: pl ? pl.id : '',
      estimateNo: String(ctSetting('estimateNoPrefix', '') || ''),
      areaName: 'KITCHEN', expirationDate: toISO(exp),
      paymentTerms: (pl && pl.defaultPaymentTerms) || '',
      salespersonId: ctx.currentUserId || '', accountId: '', address: '', notes: '',
    });
  }, [open]);
  const set = (k, v) => setForm(f => Object.assign({}, f, { [k]: v }));
  const proj = projects.find(p => p.id === form.projectId) || null;
  const people = (ctx.teamDirectory || []).filter(p => p.active !== false);

  function create() {
    if (!form.projectId) return;
    const q = makeCtQuote({
      name: form.name || 'Countertops', projectId: form.projectId,
      accountId: form.accountId || (proj ? proj.accountId : null) || null,
      salespersonId: form.salespersonId || null, priceListId: form.priceListId || null,
      estimateNo: form.estimateNo || '', paymentTerms: form.paymentTerms || '',
      address: form.address || (proj ? proj.address : '') || '',
      expirationDate: form.expirationDate || null, notes: form.notes || '', status: 'Draft',
      form: Object.assign({}, CT_FORM_PRESETS['Internal Copy']),
      areas: [makeCtArea({ name: form.areaName || 'KITCHEN',
        splashHeightIn: ctNum(ctSetting('defaultSplashHeightIn', 4)) || 4 })],
    }, ctx.currentUserName || '');
    ctWriteQuotes(ctx, form.projectId, list => list.concat([q]), `LEON Countertop — quote created: ${q.name}`);
    onCreated(form.projectId, q.id);
  }

  return (
    <Modal wide open={open} onClose={onClose} title="New countertop quote"
      footer={<><Button variant="ghost" onClick={onClose}>Cancel</Button><Button onClick={create} disabled={!form.projectId}>Create</Button></>}>
      <div className="grid gap-3 sm:grid-cols-2">
        <Field label="Project">
          <Select value={form.projectId || ''} onChange={e => set('projectId', e.target.value)}>
            {projects.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
          </Select>
        </Field>
        <Field label="Quote name"><TextInput value={form.name || ''} onChange={e => set('name', e.target.value)} /></Field>
        <Field label="Price list" hint="Which company price list this quote is built on.">
          <Select value={form.priceListId || ''} onChange={e => set('priceListId', e.target.value)}>
            <option value="">— none —</option>
            {(ctx.ctPriceLists || []).map(p => <option key={p.id} value={p.id}>{p.name} (Rev. {p.revision})</option>)}
          </Select>
        </Field>
        <Field label="First area" hint="An area is a room: KITCHEN, MASTER BATH, BAR.">
          <TextInput value={form.areaName || ''} onChange={e => set('areaName', e.target.value)} />
        </Field>
        <Field label="Estimate no."><TextInput value={form.estimateNo || ''} onChange={e => set('estimateNo', e.target.value)} /></Field>
        <Field label="Expires"><TextInput type="date" value={form.expirationDate || ''} onChange={e => set('expirationDate', e.target.value)} /></Field>
        <Field label="Salesperson">
          <Select value={form.salespersonId || ''} onChange={e => set('salespersonId', e.target.value)}>
            <option value="">— none —</option>
            {people.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
          </Select>
        </Field>
        <Field label="Payment terms"><TextInput value={form.paymentTerms || ''} onChange={e => set('paymentTerms', e.target.value)} /></Field>
        <Field label="Address" className="sm:col-span-2"><TextInput value={form.address || ''} onChange={e => set('address', e.target.value)} placeholder={proj ? proj.address : ''} /></Field>
        <Field label="Notes" className="sm:col-span-2"><TextArea rows={2} value={form.notes || ''} onChange={e => set('notes', e.target.value)} /></Field>
      </div>
    </Modal>
  );
}

function CtRevisionsModal({ open, onClose, ctx, row, editable }) {
  const revs = row ? (row.q.revisions || []).slice().sort((a, b) => b.number - a.number) : [];
  function restore(rev) {
    const snap = cloneDeep(rev.snapshot);
    snap.id = row.q.id; snap.revisions = row.q.revisions; snap.revision = row.q.revision;
    ctReplaceQuote(ctx, row.p.id, row.q.id, snap, `LEON Countertop — quote restored to Revision ${rev.number}: ${row.q.name}`);
    onClose();
  }
  return (
    <Modal wide open={open && !!row} onClose={onClose} title={row ? `Revisions — ${row.q.name}` : 'Revisions'}
      footer={<Button variant="ghost" onClick={onClose}>Close</Button>}>
      {revs.length === 0 ? <EmptyState text="No revisions captured yet. Capturing one freezes the quote exactly as it stands today." /> : (
        <table className="w-full text-sm">
          <thead><tr className="text-left text-[11px] uppercase tracking-wide text-[var(--leon-black)]/50">
            <th className="py-1">Rev.</th><th>Date</th><th>By</th><th>Areas</th><th></th>
          </tr></thead>
          <tbody>
            {revs.map(r => (
              <tr key={r.id} className="border-t border-[var(--leon-line)]">
                <td className="py-1.5 font-semibold">{r.number}</td>
                <td>{fmtDate(r.date)}</td>
                <td>{r.by || '—'}</td>
                <td>{((r.snapshot || {}).areas || []).map(a => a.name).join(', ') || '—'}</td>
                <td className="text-right">{editable && <Button size="sm" variant="ghost" onClick={() => restore(r)}>Restore</Button>}</td>
              </tr>
            ))}
          </tbody>
        </table>
      )}
    </Modal>
  );
}

// ═══════════════════════════════════════════════════ the drawing
// One SVG serves every step and every audience. The step decides what is
// interactive; the FORM decides what is visible — which is how the customer
// quote, the internal copy and the shop sheet come out of one drawing instead
// of a second module that has to be kept in step with this one.

// Which counter is under a point. A right-click has to know what it hit before
// it can offer to delete it.
function ctHitCounter(area, p) {
  if (!p || !area) return null;
  const list = area.counters || [];
  for (let i = list.length - 1; i >= 0; i--) {
    const c = list[i];
    const pts = c.points || [];
    if (pts.length < 3) continue;
    let inside = false;
    for (let a = 0, b = pts.length - 1; a < pts.length; b = a++) {
      const xi = pts[a].x, yi = pts[a].y, xj = pts[b].x, yj = pts[b].y;
      if (((yi > p.y) !== (yj > p.y)) && (p.x < ((xj - xi) * (p.y - yi)) / ((yj - yi) || 1e-9) + xi)) inside = !inside;
    }
    if (inside) return c;
  }
  return null;
}

function CtDrawing({ area, form, step, sel, onPick, onMove, onDraw, onCounterMenu, snapIn, view, height, editable, showAll, sys }) {
  const svgRef = useRef(null);
  const [drag, setDrag] = useState(null);
  const [hover, setHover] = useState(null);
  // The gesture in flight. It lives HERE and nowhere else until pointer-up —
  // see ctWithLiveDrag. `drawRef` mirrors it because the dwell timer fires
  // outside the render and has to read the current state, not a closed-over
  // copy from whenever the timer was armed.
  const [draw, setDraw] = useState(null);
  const drawRef = useRef(null);
  const dwellRef = useRef(null);
  const counters = (area && area.counters) || [];
  const f = form || CT_FORM_PRESETS['Internal Copy'];
  const H = height || 380;
  // The geometry is inches whatever this says; only the printed dimension moves.
  const U = sys === 'Metric' ? 'Metric' : 'Imperial';
  // Drawing is offered on step 1 only — step 1 is where geometry is decided,
  // which is the same rule CounterGo's wizard follows.
  const drawable = !!(editable && onDraw && step === 'dims');
  // Snap to the rounding setting; 0 means the estimator has turned rounding
  // off and wants the raw figure.
  const snapStep = (snapIn === 0) ? 0
    : (isFinite(snapIn) && snapIn > 0 ? Number(snapIn)
      : (ctSetting('roundToSixteenths', true) !== false ? 1 / 16 : CT_DRAG_METRIC_STEP_IN));
  const defDepth = ctNum(ctSetting('defaultDepthIn', 25.5)) || 25.5;

  function putDraw(next) { drawRef.current = next; setDraw(next); }
  function clearDwell() { if (dwellRef.current) { clearTimeout(dwellRef.current); dwellRef.current = null; } }
  // Shift suspends snapping — the escape hatch for the one dimension that has
  // to land between the stops.
  function snap(v, shift) { return (shift || !snapStep) ? Math.round(v * 1000) / 1000 : ctRoundTo(v, snapStep); }
  // The depth is magnetised to the default counter depth, because 25 1/2" is
  // the answer on most kitchens and nobody should have to fight the mouse onto
  // it. Shift turns the magnet off with the rest of the snapping.
  function snapDepth(v, shift) {
    if (!shift && Math.abs(v - defDepth) <= CT_DRAG_DEPTH_MAGNET_IN) return defDepth;
    return snap(v, shift);
  }

  // Escape abandons the gesture and leaves nothing behind. Listening only
  // while a drag is live, and removed by the effect's own cleanup, so there is
  // no global listener to remember to take down.
  useEffect(() => {
    if (!draw) return undefined;
    const onKey = e => { if (e.key === 'Escape') { clearDwell(); putDraw(null); } };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [!!draw]);
  useEffect(() => () => { if (dwellRef.current) clearTimeout(dwellRef.current); }, []);

  const b = ctBounds(counters);
  const pad = Math.max(14, Math.max(b.w, b.h) * 0.16);
  const zoom = Math.max(0.2, (view && view.zoom) || 1);
  const baseW = b.w + pad * 2, baseH = b.h + pad * 2;
  const vbW = baseW / zoom, vbH = baseH / zoom;
  const vbX = b.minX - pad + ((view && view.panX) || 0) + (baseW - vbW) / 2;
  const vbY = b.minY - pad + ((view && view.panY) || 0) + (baseH - vbH) / 2;
  const fs = Math.max(2.2, vbW * 0.021);
  const sw = Math.max(0.12, vbW * 0.0016);
  const dimOff = pad * 0.44;
  const INK = 'var(--leon-black)', BROWN = 'var(--leon-brown)';

  // Screen point → inches. Via the SVG's own screen matrix, because the
  // viewBox is letterboxed inside the element by preserveAspectRatio and the
  // old width-ratio arithmetic silently mis-scaled every drag whenever the
  // aspect ratios differed. getScreenCTM is exact and needs no assumptions.
  function toIn(evt) {
    const el = svgRef.current;
    if (!el) return null;
    if (typeof el.createSVGPoint === 'function' && typeof el.getScreenCTM === 'function') {
      const m = el.getScreenCTM();
      if (m) {
        const pt = el.createSVGPoint();
        pt.x = evt.clientX; pt.y = evt.clientY;
        const q = pt.matrixTransform(m.inverse());
        if (isFinite(q.x) && isFinite(q.y)) return { x: q.x, y: q.y };
      }
    }
    const r = el.getBoundingClientRect();
    if (!r.width || !r.height) return null;
    const k = Math.min(r.width / vbW, r.height / vbH);          // 'meet'
    const ox = (r.width - vbW * k) / 2, oy = (r.height - vbH * k) / 2;
    return { x: vbX + (evt.clientX - r.left - ox) / k, y: vbY + (evt.clientY - r.top - oy) / k };
  }
  function capture(evt) {
    const el = svgRef.current;
    if (el && el.setPointerCapture) { try { el.setPointerCapture(evt.pointerId); } catch (e) { /* older engine */ } }
  }
  function captured(evt) {
    const el = svgRef.current;
    return !!(el && el.hasPointerCapture && el.hasPointerCapture(evt.pointerId));
  }
  function onDown(counterId, kind, idx, evt) {
    if (onPick) onPick(counterId, kind, idx);
    if (!editable || !onMove) return;
    evt.preventDefault();
    evt.stopPropagation();
    capture(evt);
    setDrag({ counterId, kind, idx, pos: null });
  }

  // ── the drag-to-draw gesture ────────────────────────────────────────────
  function onCanvasDown(evt) {
    if (!drawable) return;
    const p = toIn(evt);
    if (!p) return;
    evt.preventDefault();
    capture(evt);
    clearDwell();
    const o = { x: snap(p.x, evt.shiftKey), y: snap(p.y, evt.shiftKey) };
    putDraw({ spine: [o], axis: null, lastAxis: null, dir: 1, legLen: 0,
              depth: null, side: 1, magnet: false, armed: false, cur: o, dwellPt: o });
  }
  // The dwell. A pause with the pointer near-stationary commits the leg being
  // drawn and starts the next one from its end — CounterGo's "add another leg"
  // signal. It can only fire on a leg that is already a real run, so the pause
  // right after a corner cannot commit a zero-length one.
  function armDwell() {
    const st = drawRef.current;
    if (!st || !st.axis) return;
    if (!(st.legLen >= CT_DRAG_MIN_RUN_IN) || !(st.depth >= CT_DRAG_MIN_DEPTH_IN)) return;
    const o = st.spine[st.spine.length - 1];
    const end = st.axis === 'x' ? { x: o.x + st.dir * st.legLen, y: o.y }
                                : { x: o.x, y: o.y + st.dir * st.legLen };
    putDraw(Object.assign({}, st, {
      spine: st.spine.concat([end]),
      lastAxis: st.axis, axis: null, legLen: 0, armed: true, dwellPt: end, cur: end,
    }));
  }
  function onDrawMove(evt) {
    const st = drawRef.current;
    if (!st) return;
    const p = toIn(evt);
    if (!p) return;
    const shift = evt.shiftKey;
    const o = st.spine[st.spine.length - 1];
    const dx = p.x - o.x, dy = p.y - o.y;
    // The first leg is free; every leg after it turns a corner, so its axis is
    // the perpendicular of the one before. That is what the arrow offers, and
    // it also makes a leg that doubles back on itself impossible to draw.
    const axis = st.lastAxis ? (st.lastAxis === 'x' ? 'y' : 'x')
                             : (Math.abs(dx) >= Math.abs(dy) ? 'x' : 'y');
    const along = axis === 'x' ? dx : dy;
    const dir = along >= 0 ? 1 : -1;
    const legLen = Math.max(0, snap(Math.abs(along), shift));
    let depth = st.depth, side = st.side, magnet = st.magnet;
    if (st.depth === null || st.spine.length === 1) {
      // Leg one also establishes the depth and which side of the run the stone
      // sits on. The perpendicular is measured against the same leg normal
      // ctDragOutline offsets by, so the two can never disagree.
      const nx = axis === 'x' ? 0 : dir, ny = axis === 'x' ? -dir : 0;
      const perp = dx * nx + dy * ny;
      // THE DEPTH STARTS AT THE STANDARD DEPTH, not at zero. It was the
      // perpendicular distance from the start point — so dragging ALONG a
      // counter run, which is what anyone does, left the depth at nothing, and
      // the corner dwell (which needs a real depth) could never arm. That is
      // why an L or a U could not be drawn: the gesture was waiting for a
      // measurement nobody had any reason to make.
      // A deliberate perpendicular drag still sets the depth; a run drawn
      // straight simply gets 25", which is what a counter is.
      const meaningful = Math.abs(perp) >= CT_DRAG_MIN_DEPTH_IN;
      if (meaningful) {
        side = perp >= 0 ? 1 : -1;
        depth = snapDepth(Math.abs(perp), shift);
        magnet = !shift && Math.abs(Math.abs(perp) - defDepth) <= CT_DRAG_DEPTH_MAGNET_IN;
      } else {
        side = st.side || 1;
        depth = defDepth;
        magnet = true;
      }
    }
    const next = Object.assign({}, st, { axis, dir, legLen, depth, side, magnet, cur: p });
    // Re-arm the dwell only once the pointer has genuinely moved on, so the
    // timer measures a pause rather than the time since the drag started.
    const ref = st.dwellPt || o;
    if (Math.hypot(p.x - ref.x, p.y - ref.y) > Math.max(0.4, vbW * 0.006)) {
      next.dwellPt = p;
      next.armed = false;
      clearDwell();
      dwellRef.current = setTimeout(armDwell, CT_DRAG_DWELL_MS);
    }
    putDraw(next);
  }
  // THE SINGLE COMMIT. Everything above this line is local state; this is the
  // only place a drawn counter reaches the quote, once, on release.
  function onDrawUp() {
    clearDwell();
    const st = drawRef.current;
    putDraw(null);
    if (!st) return;
    const shape = ctDragShape(st);
    if (!ctDragUsable(shape)) return;         // a stray click, dropped in silence
    if (onDraw) onDraw(shape.points, shape.wallHint);
  }

  function onSvgMove(evt) {
    if (drawRef.current) { onDrawMove(evt); return; }
    if (!drag) return;
    const p = toIn(evt);
    if (!p) return;
    const pos = { x: snap(p.x, evt.shiftKey), y: snap(p.y, evt.shiftKey) };
    setDrag(d => (d ? Object.assign({}, d, { pos }) : d));
  }
  function onSvgUp() {
    if (drawRef.current) { onDrawUp(); return; }
    if (drag && drag.pos && onMove) onMove(drag.counterId, drag.kind, drag.idx, drag.pos.x, drag.pos.y, true);
    setDrag(null);
  }
  function onSvgLeave(evt) {
    // With pointer capture the pointer never really leaves; this is only the
    // fallback for an engine where capture was refused.
    if (captured(evt)) return;
    if (drawRef.current) { clearDwell(); putDraw(null); }
    setDrag(null);
  }
  function onSvgCancel() {
    clearDwell(); putDraw(null); setDrag(null);
  }

  if (!counters.length && !drawable) {
    return <div className="rounded-lg border border-dashed border-[var(--leon-line)] p-10 text-center text-sm text-[var(--leon-black)]/50">
      No counters in this area yet. Add one in step 1.
    </div>;
  }

  // What is DRAWN is the committed counters with the in-flight point or cutout
  // move applied to a clone. The viewBox above is still measured from the
  // committed geometry on purpose: a viewBox that moved while the pointer was
  // down would drag the drawing out from under the pointer.
  const shown = ctWithLiveDrag(counters, drag);

  const parts = [];
  shown.forEach(c => {
    const pts = c.points || [];
    if (pts.length < 3) return;
    const d = pts.map((p, i) => `${i ? 'L' : 'M'}${p.x},${p.y}`).join(' ') + ' Z';
    parts.push(<path key={`fill-${c.id}`} d={d} fill="#f6f3ee" stroke="none" />);
  });
  shown.forEach(c => {
    const pts = c.points || [];
    if (pts.length < 3) return;
    const segs = ctAllSegments(c);
    const isSelC = sel && sel.counterId === c.id;
    // A point governs the two sides that meet at it. While it is being
    // dragged those two dimensions are called out live, whatever the form
    // toggles say, because they are the only two figures the drag can change.
    const movingPt = (drag && drag.pos && drag.kind === 'point' && drag.counterId === c.id) ? drag.idx : -1;
    const governs = movingPt >= 0 ? [(movingPt - 1 + pts.length) % pts.length, movingPt] : [];

    // Perimeter, coloured by what each side IS — and by its sub-segments where
    // a side carries more than one profile or splash height.
    segs.forEach(s => {
      ctSegmentParts(s).forEach(part => {
        const x1 = s.a.x + s.dx * part.start, y1 = s.a.y + s.dy * part.start;
        const x2 = s.a.x + s.dx * (part.start + part.len), y2 = s.a.y + s.dy * (part.start + part.len);
        const selected = isSelC && sel.kind === 'segment' && sel.idx === s.i;
        // THE SPLASH, DRAWN. In plan a backsplash is a strip of stone standing
        // against the wall, so it is a BAND of the slab's own thickness running
        // along that side — not a coloured line saying one is there. Drawn
        // first, so the edge line above reads as the front face of it.
        const splashH = ctNum(part.splashHeight) > 0 ? ctNum(part.splashHeight)
          : (part.kind === 'Splash' ? ctNum(area && area.splashHeightIn) : 0);
        if (splashH > 0) {
          const th = Math.max(0.5, (ctNum(c.thicknessCm) || 3) / 2.54);   // the slab, in inches
          const inx = -s.nx, iny = -s.ny;
          const p1 = `${x1},${y1}`, p2 = `${x2},${y2}`;
          const p3 = `${x2 + inx * th},${y2 + iny * th}`, p4 = `${x1 + inx * th},${y1 + iny * th}`;
          parts.push(<polygon key={`sp-${c.id}-${s.i}-${part.index}`} points={`${p1} ${p2} ${p3} ${p4}`}
            fill={CT_KIND_COLORS.Splash} fillOpacity="0.22"
            stroke={CT_KIND_COLORS.Splash} strokeWidth={sw * 0.9} strokeOpacity="0.75" />);
          // Its HEIGHT is the thing a plan cannot show, so it is written on it.
          if (part.len > fs * 5 && (f.showMeasurements !== false)) {
            const sx = (x1 + x2) / 2 + inx * th * 0.5, sy = (y1 + y2) / 2 + iny * th * 0.5;
            const vert = Math.abs(s.dy) > Math.abs(s.dx);
            parts.push(<text key={`spt-${c.id}-${s.i}-${part.index}`} x={sx} y={sy + fs * 0.3}
              fontSize={fs * 0.7} textAnchor="middle" fill={CT_KIND_COLORS.Splash}
              fontWeight="600" opacity="0.95"
              transform={vert ? `rotate(-90 ${sx} ${sy + fs * 0.3})` : undefined}>
              {ctFmtIn(splashH, U)} SPLASH
            </text>);
          }
        }
        parts.push(<line key={`sg-${c.id}-${s.i}-${part.index}`} x1={x1} y1={y1} x2={x2} y2={y2}
          stroke={CT_KIND_COLORS[part.kind] || INK} strokeWidth={selected ? sw * 4 : sw * 2.6}
          strokeLinecap="square"
          style={{ cursor: step === 'edges' && editable ? 'pointer' : 'default' }}
          onPointerDown={e => { if (step === 'edges') onDown(c.id, 'segment', s.i, e); }} />);
        // U / F / S / A, the same letters the team already reads.
        if (step === 'edges' || (showAll && f.showMeasurements)) {
          const mx = (x1 + x2) / 2 + s.nx * fs * 0.9, my = (y1 + y2) / 2 + s.ny * fs * 0.9;
          parts.push(<text key={`lt-${c.id}-${s.i}-${part.index}`} x={mx} y={my + fs * 0.35} fontSize={fs}
            textAnchor="middle" fill={CT_KIND_COLORS[part.kind] || INK} fontWeight="700">
            {CT_KIND_LETTER[part.kind] || '?'}</text>);
        }
      });
    });

    // Dimension strings, outside the outline — and, where a leg is set back,
    // inside it, because the outward normal of a concave side points into the
    // notch on its own.
    if (f.showMeasurements || governs.length) {
      segs.forEach(s => {
        if (s.len < 1) return;
        const emph = governs.indexOf(s.i) >= 0;
        if (!f.showMeasurements && !emph) return;
        const ox = s.nx * dimOff, oy = s.ny * dimOff;
        parts.push(<line key={`dl-${c.id}-${s.i}`} x1={s.a.x + ox} y1={s.a.y + oy} x2={s.b.x + ox} y2={s.b.y + oy}
          stroke={BROWN} strokeWidth={sw} />);
        parts.push(<line key={`de1-${c.id}-${s.i}`} x1={s.a.x} y1={s.a.y} x2={s.a.x + ox} y2={s.a.y + oy} stroke={BROWN} strokeWidth={sw * 0.6} opacity="0.6" />);
        parts.push(<line key={`de2-${c.id}-${s.i}`} x1={s.b.x} y1={s.b.y} x2={s.b.x + ox} y2={s.b.y + oy} stroke={BROWN} strokeWidth={sw * 0.6} opacity="0.6" />);
        const tickA = { x: -s.dy, y: s.dx };
        [[s.a, ox, oy], [s.b, ox, oy]].forEach((t, k) => {
          parts.push(<line key={`dt-${c.id}-${s.i}-${k}`}
            x1={t[0].x + ox - tickA.x * fs * 0.3} y1={t[0].y + oy - tickA.y * fs * 0.3}
            x2={t[0].x + ox + tickA.x * fs * 0.3} y2={t[0].y + oy + tickA.y * fs * 0.3}
            stroke={BROWN} strokeWidth={sw} />);
        });
        const tx = s.mid.x + ox, ty = s.mid.y + oy;
        const vertical = Math.abs(s.dy) > Math.abs(s.dx);
        const label = ctFmtIn(s.len, U);
        if (emph) {
          parts.push(<rect key={`dp-${c.id}-${s.i}`} x={tx - (label.length * fs * 0.34) / 2 - fs * 0.25}
            y={ty - fs * 1.5} width={label.length * fs * 0.34 + fs * 0.5} height={fs * 1.5} rx={fs * 0.25}
            fill="#fff" stroke={BROWN} strokeWidth={sw}
            transform={vertical ? `rotate(-90 ${tx} ${ty})` : undefined} />);
        }
        parts.push(<text key={`dx-${c.id}-${s.i}`} x={tx} y={ty - fs * 0.35} fontSize={emph ? fs * 1.05 : fs} textAnchor="middle"
          fill={BROWN} fontWeight={emph ? '800' : '600'}
          transform={vertical ? `rotate(-90 ${tx} ${ty})` : undefined}>{label}</text>);
      });
    }

    // Corner labels and the inside-corner angle callout.
    pts.forEach((p, i) => {
      const ang = ctCornerAngle(pts, i);
      const treat = p.treatment || 'Standard';
      const selected = isSelC && sel.kind === 'point' && sel.idx === i;
      if (step === 'curves') {
        parts.push(<g key={`cn-${c.id}-${i}`} style={{ cursor: editable ? 'pointer' : 'default' }}
          onPointerDown={e => onDown(c.id, 'point', i, e)}>
          <rect x={p.x - fs * 1.6} y={p.y - fs * 0.75} width={fs * 3.2} height={fs * 1.5} rx={fs * 0.3}
            fill={selected ? BROWN : '#fff'} stroke={selected ? BROWN : INK} strokeWidth={sw} opacity="0.95" />
          <text x={p.x} y={p.y + fs * 0.35} fontSize={fs * 0.85} textAnchor="middle"
            fill={selected ? '#fff' : INK} fontWeight="700">{CT_CORNER_ABBR[treat] || treat}</text>
        </g>);
      } else if (treat !== 'Standard') {
        parts.push(<circle key={`cm-${c.id}-${i}`} cx={p.x} cy={p.y} r={fs * 0.4} fill={BROWN} />);
      }
      if (step === 'dims') {
        // A point is only draggable on a CUSTOM outline — on a parametric
        // shape the dimensions are the parameters, and a handle that looks
        // draggable and refuses to move is worse than no handle. So the
        // parametric one is drawn as a plain marker and says why.
        const grabbable = editable && c.template === 'Custom';
        const hovered = hover && hover.counterId === c.id && hover.idx === i;
        const moving = movingPt === i;
        if (grabbable && (hovered || moving || selected)) {
          parts.push(<circle key={`ph-${c.id}-${i}`} cx={p.x} cy={p.y} r={fs * 1.15} fill={BROWN}
            opacity={moving ? 0.28 : 0.16} pointerEvents="none" />);
        }
        parts.push(<circle key={`pt-${c.id}-${i}`} cx={p.x} cy={p.y}
          r={fs * (moving ? 0.78 : hovered ? 0.72 : selected ? 0.6 : 0.5)}
          fill={selected || moving ? BROWN : '#fff'} stroke={grabbable ? BROWN : INK}
          strokeWidth={grabbable ? sw * 1.8 : sw}
          style={{ cursor: grabbable ? 'move' : 'default' }}
          onPointerEnter={() => grabbable && setHover({ counterId: c.id, idx: i })}
          onPointerLeave={() => setHover(h => (h && h.counterId === c.id && h.idx === i ? null : h))}
          onPointerDown={e => { if (grabbable) onDown(c.id, 'point', i, e); else if (onPick) onPick(c.id, 'point', i); }}>
          <title>{grabbable ? 'Drag this corner — the two sides it governs are dimensioned as it moves. Type an exact length in the sides table.'
                            : 'This shape’s dimensions are its parameters — type them on the right, or convert it to a custom outline to drag.'}</title>
        </circle>);
      }
      if (f.showMeasurements && (ang.concave || Math.abs(ang.deg - 90) > 1.5) && ang.deg < 359) {
        const n = pts.length;
        const prev = pts[(i - 1 + n) % n], next = pts[(i + 1) % n];
        const bx = ((prev.x - p.x) + (next.x - p.x)), by = ((prev.y - p.y) + (next.y - p.y));
        const bl = Math.hypot(bx, by) || 1;
        parts.push(<text key={`ag-${c.id}-${i}`} x={p.x + (bx / bl) * fs * 2.2} y={p.y + (by / bl) * fs * 2.2 + fs * 0.3}
          fontSize={fs * 0.8} textAnchor="middle" fill={INK} opacity="0.65">{Math.round(ang.deg)}°</text>);
      }
    });

    // THE OVERHANG, drawn as the CABINET LINE — a dashed line set in from the
    // finished edge by the overhang, which is where the box below actually
    // stops. That line is what an installer sets out to, and it was the one
    // thing on this drawing you could not see.
    if (f.showOverhang !== false) {
      const ohRuns = ctOverhangRuns(c);
      // ONE CLOSED LOOP. Each side is offset by its own overhang and the
      // corners are the intersections, so the cabinet line runs all the way
      // round instead of stopping short at every corner.
      const cabPts = ctCabinetLine(c);
      if (cabPts.length >= 3 && ohRuns.some(r => r.overhangIn > 0)) {
        parts.push(<path key={`ohp-${c.id}`}
          d={cabPts.map((p, i) => `${i ? 'L' : 'M'}${p.x},${p.y}`).join(' ') + ' Z'}
          fill="none" stroke={INK} strokeWidth={sw * 1.1}
          strokeDasharray={`${fs * 0.7},${fs * 0.45}`} opacity="0.55" />);
        // A side past its support limit is redrawn heavy in red on top, so the
        // loop stays one line and the problem side still reads at a glance.
        ohRuns.filter(r => r.needsSupport).forEach(r => {
          const a = cabPts[(r.i - 1 + cabPts.length) % cabPts.length], b = cabPts[r.i];
          if (!a || !b) return;
          parts.push(<line key={`ohw-${c.id}-${r.i}`} x1={a.x} y1={a.y} x2={b.x} y2={b.y}
            stroke="#b83b3b" strokeWidth={sw * 1.6}
            strokeDasharray={`${fs * 0.7},${fs * 0.45}`} opacity="0.95" />);
        });
      }
      ohRuns.forEach(r => {
        if (!(r.overhangIn > 0) || !(r.lenIn > 0)) return;
        const sg = r.seg;
        const inx = -sg.nx, iny = -sg.ny;
        // The figure itself, on a short leader across the setback at the middle
        // of the run — a dimension nobody can read is not a dimension.
        if (f.showMeasurements !== false && r.lenIn > fs * 6) {
          const mx = sg.mid.x, my = sg.mid.y;
          parts.push(<line key={`ohl-${c.id}-${r.i}`} x1={mx} y1={my}
            x2={mx + inx * r.overhangIn} y2={my + iny * r.overhangIn}
            stroke={r.needsSupport ? '#b83b3b' : INK} strokeWidth={sw} opacity="0.8" />);
          const lx = mx + inx * (r.overhangIn + fs * 1.6), ly = my + iny * (r.overhangIn + fs * 1.6);
          // Written the way every other dimension on this drawing is written —
          // a bare number beside `96"` reads as a different kind of thing.
          const lab = ctFmtIn(r.overhangIn, U) + (r.needsSupport ? ' ⚠' : '');
          const vert = Math.abs(iny) > Math.abs(inx);
          parts.push(<text key={`oht-${c.id}-${r.i}`} x={lx} y={ly + fs * 0.3} fontSize={fs * 0.85}
            textAnchor="middle" fill={r.needsSupport ? '#b83b3b' : INK}
            fontWeight={r.needsSupport ? '700' : '500'} opacity="0.85"
            transform={vert ? undefined : `rotate(-90 ${lx} ${ly + fs * 0.3})`}>{lab}</text>);
        }
      });
    }

    // Cutouts, at real size. Faucet holes are drawn as small circles above the
    // sink because that is where the fabricator drills them, and they are
    // counted separately from the sink itself.
    (c.cutouts || []).forEach(cu => {
      if (cu.kind === 'Outlet') return;              // it lives in the splash, not the deck
      const selected0 = isSelC && sel.kind === 'cutout' && sel.idx === cu.id;
      // A FAUCET is bores, not a rectangle. Drawn at the real diameter and the
      // real spread, so what is on the drawing is what gets drilled.
      if (cu.kind === 'Faucet Hole') {
        const bores = ctFaucetHoles(cu);
        // x is DERIVED from the sink when one is linked, so the faucet moves
        // with the bowl rather than being left behind by it.
        const fx0 = ctFaucetX(c, cu);
        const fsink = ctFaucetSink(c, cu);
        parts.push(<g key={`cu-${cu.id}`} transform={`rotate(${ctNum(cu.rotation)} ${fx0} ${cu.y})`}
          style={{ cursor: step === 'cutouts' && editable ? 'move' : 'default' }}
          onPointerDown={e => { if (step === 'cutouts') onDown(c.id, 'cutout', cu.id, e); }}>
          {bores.map((b, k) => (
            <circle key={k} cx={fx0 + b.dx} cy={cu.y} r={b.dia / 2} fill="#fff"
              stroke={selected0 ? BROWN : INK} strokeWidth={selected0 ? sw * 3 : sw * 1.6} />
          ))}
          {bores.length > 1 && (
            <line x1={fx0 + bores[0].dx} y1={cu.y} x2={fx0 + bores[bores.length - 1].dx} y2={cu.y}
              stroke={INK} strokeWidth={sw} strokeDasharray={`${fs * 0.4},${fs * 0.3}`} opacity="0.55" />
          )}
          {/* The sink's centreline, so "centred on the sink" is something you
              can see rather than something the panel claims. */}
          {fsink && (
            <line x1={ctNum(fsink.x)} y1={cu.y - fs * 1.4} x2={ctNum(fsink.x)} y2={ctNum(fsink.y) + ctNum(fsink.depthIn) / 2}
              stroke={BROWN} strokeWidth={sw * 0.8} strokeDasharray={`${fs * 0.9},${fs * 0.4},${fs * 0.2},${fs * 0.4}`}
              opacity="0.6" />
          )}
          <text x={fx0} y={cu.y - Math.max(bores[0].dia, fs) * 0.9} fontSize={fs * 0.75}
            textAnchor="middle" fill={INK} opacity="0.75">
            {bores.length} × {ctDimShort(bores[0].dia, U)}
            {ctNum(cu.spreadIn) > 0 ? ` @ ${ctDimShort(ctNum(cu.spreadIn), U)}` : ''}
          </text>
        </g>);
        return;
      }
      const w = ctNum(cu.widthIn), h = ctNum(cu.depthIn);
      if (!(w > 0) || !(h > 0)) return;
      const selected = selected0;
      parts.push(<g key={`cu-${cu.id}`} transform={`rotate(${ctNum(cu.rotation)} ${cu.x} ${cu.y})`}>
        <rect x={cu.x - w / 2} y={cu.y - h / 2} width={w} height={h} rx={Math.min(w, h) * 0.06}
          fill="#fff" stroke={selected ? BROWN : INK} strokeWidth={selected ? sw * 3 : sw * 1.6}
          strokeDasharray={cu.kind === 'Cooktop' ? `${fs * 0.5},${fs * 0.3}` : undefined}
          style={{ cursor: step === 'cutouts' && editable ? 'move' : 'default' }}
          onPointerDown={e => { if (step === 'cutouts') onDown(c.id, 'cutout', cu.id, e); }} />
        {Array.from({ length: Math.max(0, ctNum(cu.faucetHoles)) }).map((_, k, arr) => (
          <circle key={k} cx={cu.x - ((arr.length - 1) * fs * 1.1) / 2 + k * fs * 1.1}
            cy={cu.y - h / 2 - fs * 1.1} r={fs * 0.42} fill="#fff" stroke={INK} strokeWidth={sw} />
        ))}
        <text x={cu.x} y={cu.y + fs * 0.3} fontSize={fs * 0.8} textAnchor="middle" fill={INK} opacity="0.7">
          {cu.kind === 'Sink' ? `${ctDimShort(w, U)}×${ctDimShort(h, U)}` : cu.kind}
        </text>
      </g>);
    });

    // Seams — an estimating and veining-match artifact, never a cut file.
    if (f.showSeams) {
      const cb = ctBounds([c]);
      (c.seams || []).forEach(s => {
        const at = ctNum(s.atIn);
        const line = s.axis === 'x'
          ? { x1: cb.minX, y1: at, x2: cb.maxX, y2: at }
          : { x1: at, y1: cb.minY, x2: at, y2: cb.maxY };
        parts.push(<line key={`sm-${s.id}`} {...line} stroke={BROWN} strokeWidth={sw * 1.6}
          strokeDasharray={`${fs * 0.8},${fs * 0.5}`} opacity="0.85" />);
      });
    }

    // Free text the estimator has pinned on the drawing.
    (c.texts || []).forEach(tx => {
      parts.push(<text key={tx.id} x={tx.x} y={tx.y} fontSize={fs} fill={INK} fontWeight="600">{tx.text}</text>);
    });

    if (f.showLayoutLabels) {
      const cb = ctBounds([c]);
      parts.push(<text key={`nm-${c.id}`} x={cb.minX} y={cb.minY - fs * 0.6} fontSize={fs * 0.95}
        fill={INK} opacity="0.7" fontWeight="700">{c.name}</text>);
    }
  });

  // ── the live gesture, drawn on top of everything ────────────────────────
  const ghost = [];
  const shape = draw ? ctDragShape(draw) : null;
  if (shape) {
    const s = shape.side;
    if (shape.points.length >= 3) {
      const d = shape.points.map((p, i) => `${i ? 'L' : 'M'}${p.x},${p.y}`).join(' ') + ' Z';
      ghost.push(<path key="gp" d={d} fill={BROWN} fillOpacity="0.10" stroke={BROWN}
        strokeWidth={sw * 2.6} strokeDasharray={`${fs * 0.8},${fs * 0.45}`} strokeLinejoin="round" />);
    }
    // Run and depth, live, in the quote's own unit system.
    shape.legs.forEach((l, k) => {
      if (!(l.len > 0)) return;
      const len = l.len;
      const ux = (l.b.x - l.a.x) / len, uy = (l.b.y - l.a.y) / len;
      const nx = uy * s, ny = -ux * s;                       // the stone side of this leg
      const mx = (l.a.x + l.b.x) / 2 - nx * fs * 1.5, my = (l.a.y + l.b.y) / 2 - ny * fs * 1.5;
      const vertical = Math.abs(uy) > Math.abs(ux);
      ghost.push(<text key={`gl-${k}`} x={mx} y={my + fs * 0.35} fontSize={fs * 1.1} textAnchor="middle"
        fill={BROWN} fontWeight="800"
        transform={vertical ? `rotate(-90 ${mx} ${my})` : undefined}>{ctFmtIn(len, U)}</text>);
      if (k === 0 && shape.depth > 0) {
        const dx2 = l.b.x + nx * shape.depth / 2, dy2 = l.b.y + ny * shape.depth / 2;
        const dv = Math.abs(ny) > Math.abs(nx);   // a depth measured vertically reads rotated
        ghost.push(<text key="gd" x={dx2} y={dy2 + fs * 0.35} fontSize={fs} textAnchor="middle"
          fill={BROWN} fontWeight="700"
          transform={dv ? `rotate(-90 ${dx2} ${dy2})` : undefined}>
          {ctFmtIn(shape.depth, U)}{draw.magnet ? ' ✓' : ''}</text>);
      }
    });
    // The arrow that makes the pause gesture discoverable rather than folklore.
    if (draw.armed) {
      const o = shape.spine[shape.spine.length - 1];
      const dirs = draw.lastAxis === 'x' ? [[0, -1], [0, 1]] : [[-1, 0], [1, 0]];
      dirs.forEach((v, k) => {
        const L = fs * 3, hh = fs * 0.85;
        const x2 = o.x + v[0] * L, y2 = o.y + v[1] * L;
        ghost.push(<g key={`ga-${k}`}>
          <line x1={o.x + v[0] * fs * 0.8} y1={o.y + v[1] * fs * 0.8} x2={x2} y2={y2} stroke={BROWN} strokeWidth={sw * 2.2} />
          <path d={`M${x2},${y2} L${x2 - v[0] * hh + v[1] * hh * 0.55},${y2 - v[1] * hh - v[0] * hh * 0.55} `
                 + `L${x2 - v[0] * hh - v[1] * hh * 0.55},${y2 - v[1] * hh + v[0] * hh * 0.55} Z`} fill={BROWN} />
        </g>);
      });
      ghost.push(<circle key="gk" cx={o.x} cy={o.y} r={fs * 0.55} fill="#fff" stroke={BROWN} strokeWidth={sw * 2} />);
    }
    if (draw.cur) {
      const msg = draw.armed
        ? `Corner set — move ${draw.lastAxis === 'x' ? 'up or down' : 'left or right'} for the next leg`
        : 'Hold still to turn a corner · Shift ignores snapping · Esc cancels';
      ghost.push(<text key="gh" x={draw.cur.x + fs * 0.9} y={draw.cur.y - fs * 0.9} fontSize={fs * 0.85}
        fill={BROWN} fontWeight="600" opacity="0.9">{msg}</text>);
    }
  }
  // An empty canvas should invite the drag rather than wait to be told about it.
  if (drawable && !counters.length && !draw) {
    ghost.push(<g key="gi" pointerEvents="none">
      <rect x={b.minX + b.w * 0.12} y={b.minY + b.h * 0.18} width={b.w * 0.76} height={b.h * 0.64}
        rx={fs} fill="none" stroke={BROWN} strokeWidth={sw * 1.6} strokeDasharray={`${fs},${fs * 0.7}`} opacity="0.45" />
      <text x={b.minX + b.w / 2} y={b.minY + b.h / 2} fontSize={fs * 1.25} textAnchor="middle"
        fill={BROWN} fontWeight="800" opacity="0.75">Drag here to draw a counter</text>
      <text x={b.minX + b.w / 2} y={b.minY + b.h / 2 + fs * 1.8} fontSize={fs * 0.9} textAnchor="middle"
        fill={BROWN} opacity="0.6">Pause without releasing to turn a corner</text>
    </g>);
  }

  return (
    <svg ref={svgRef} viewBox={`${vbX} ${vbY} ${vbW} ${vbH}`} width="100%" height={H}
      style={{ background: '#fff', touchAction: 'none' }}
      className="rounded-lg border border-[var(--leon-line)]"
      onPointerMove={onSvgMove} onPointerUp={onSvgUp} onPointerCancel={onSvgCancel} onPointerLeave={onSvgLeave}
      onContextMenu={e => {
        // CounterGo puts rotate, duplicate and delete on the right button, and
        // that is where anyone who has used it will look for them.
        if (!onCounterMenu) return;
        e.preventDefault();
        const p = toIn(e);
        const hit = ctHitCounter(area, p);
        onCounterMenu(hit ? hit.id : null, e.clientX, e.clientY);
      }}
      role="img" aria-label={`Countertop drawing — ${(area && area.name) || ''}`}>
      {/* The canvas itself, so a press on empty space starts a drawing. It is
          first, so every counter drawn after it takes the press instead. */}
      <rect x={vbX} y={vbY} width={vbW} height={vbH} fill="transparent"
        pointerEvents={drawable ? 'all' : 'none'}
        style={{ cursor: drawable ? 'crosshair' : 'default' }}
        onPointerDown={onCanvasDown} />
      {parts}
      {ghost}
    </svg>
  );
}

// ═══════════════════════════════════════════════════ the six-step wizard

function CtQuoteEditor({ ctx, project, quote, editable, onExit }) {
  // The right-click menu's position and target. CounterGo's own four items.
  const [ctxMenu, setCtxMenu] = useState(null);
  const [step, setStep] = useState('dims');
  const [areaId, setAreaId] = useState(() => ((quote.areas || [])[0] || {}).id || '');
  const [counterId, setCounterId] = useState('');
  const [sel, setSel] = useState(null);
  const [view, setView] = useState({ zoom: 1, panX: 0, panY: 0 });
  const [snap16, setSnap16] = useState(() => ctSetting('roundToSixteenths', true) !== false);
  const [tick, setTick] = useState(0);
  const [savedAt, setSavedAt] = useState(null);
  const hist = useRef({ past: [], future: [] });

  const areas = quote.areas || [];
  const area = areas.find(a => a.id === areaId) || areas[0] || null;
  const counters = (area && area.counters) || [];
  const counter = counters.find(c => c.id === counterId) || counters[0] || null;
  const pl = ctPriceListFor(ctx, quote);
  // The DISPLAY system for this quote. Geometry stays in inches and rates stay
  // per square/linear foot underneath; this decides nothing but what is shown
  // and how a bare typed number is read.
  const sys = ctQuoteUnits(quote, pl);

  useEffect(() => { if (!areas.find(a => a.id === areaId) && areas[0]) setAreaId(areas[0].id); }, [quote.id, areas.length]);

  // Undo is a whole-quote snapshot rather than a per-field diff. It is cheap
  // here (a quote is small), it can never leave the record half-undone, and it
  // is the only honest thing to offer for an operation like "convert to a
  // custom outline" that rewrites the geometry.
  function apply(fn, logLine) {
    if (!editable) return;
    hist.current.past.push(JSON.stringify(quote));
    if (hist.current.past.length > 60) hist.current.past.shift();
    hist.current.future = [];
    ctWriteQuote(ctx, project.id, quote.id, fn, logLine);
    setTick(t => t + 1);
  }
  function undo() {
    const prev = hist.current.past.pop();
    if (!prev) return;
    hist.current.future.push(JSON.stringify(quote));
    ctReplaceQuote(ctx, project.id, quote.id, JSON.parse(prev), `LEON Countertop — undo on ${quote.name}`);
    setTick(t => t + 1);
  }
  function redo() {
    const next = hist.current.future.pop();
    if (!next) return;
    hist.current.past.push(JSON.stringify(quote));
    ctReplaceQuote(ctx, project.id, quote.id, JSON.parse(next), `LEON Countertop — redo on ${quote.name}`);
    setTick(t => t + 1);
  }
  function withArea(fn, log) {
    apply(q => { const a = (q.areas || []).find(x => x.id === (area ? area.id : null)); if (a) fn(a, q); }, log);
  }
  // ── Counter operations, the four CounterGo puts on the right button ──────
  // Rotation is about the counter's own centre, so it stays where it was
  // rather than swinging off across the page.
  function rotateCounter(cid, deg) {
    const r = deg * Math.PI / 180, cos = Math.cos(r), sin = Math.sin(r);
    withArea(a => {
      const c = (a.counters || []).find(z => z.id === cid);
      if (!c || !(c.points || []).length) return;
      const xs = c.points.map(p => p.x), ys = c.points.map(p => p.y);
      const cx = (Math.min(...xs) + Math.max(...xs)) / 2;
      const cy = (Math.min(...ys) + Math.max(...ys)) / 2;
      c.points = c.points.map(p => ({
        x: cx + (p.x - cx) * cos - (p.y - cy) * sin,
        y: cy + (p.x - cx) * sin + (p.y - cy) * cos,
      }));
      (c.cutouts || []).forEach(cu => {
        const nx = cx + (cu.x - cx) * cos - (cu.y - cy) * sin;
        const ny = cy + (cu.x - cx) * sin + (cu.y - cy) * cos;
        cu.x = nx; cu.y = ny;
      });
    }, `Rotated a counter ${deg > 0 ? 'right' : 'left'}.`);
  }
  function duplicateCounter(cid) {
    withArea(a => {
      const c = (a.counters || []).find(z => z.id === cid);
      if (!c) return;
      const copy = cloneDeep(c);
      copy.id = uid('ctc');
      copy.name = `${c.name || 'Counter'} copy`;
      // Offset it so the copy is visible rather than sitting exactly on top.
      copy.points = (copy.points || []).map(p => ({ x: p.x + 6, y: p.y + 6 }));
      (copy.cutouts || []).forEach(cu => { cu.id = uid('ctcut'); cu.x += 6; cu.y += 6; });
      (copy.segments || []).forEach(sg => { if (sg && sg.id) sg.id = uid('ctseg'); });
      a.counters.push(copy);
    }, 'Duplicated a counter.');
  }
  function deleteCounter(cid) {
    withArea(a => { a.counters = (a.counters || []).filter(z => z.id !== cid); },
      'Deleted a counter.');
    setSel(null);
  }

  function withCounter(fn, log) {
    withArea(a => { const c = (a.counters || []).find(x => x.id === (counter ? counter.id : null)); if (c) fn(c, a); }, log);
  }
  const round = v => (snap16 ? ctSnap16(v) : Math.round(v * 1000) / 1000);

  // ONE write, on pointer-up. The drawing holds the moving point in its own
  // state and calls this once when the pointer is released — a write per
  // pointermove is a project write, i.e. a localStorage save, per mouse
  // movement. `snapped` says the drawing has already applied the rounding
  // setting (and honoured Shift), so re-rounding here would undo it.
  function onMove(cid, kind, idx, x, y, snapped) {
    const R = snapped ? (v => Math.round(v * 1000) / 1000) : round;
    withArea(a => {
      const c = (a.counters || []).find(z => z.id === cid);
      if (!c) return;
      if (kind === 'point') {
        // A parametric shape's dimensions ARE its parameters, so its points do
        // not move freely; the drawing draws those handles as plain markers.
        if (c.template !== 'Custom') return;
        c.points[idx].x = R(x); c.points[idx].y = R(y);
      } else if (kind === 'cutout') {
        const cu = (c.cutouts || []).find(z => z.id === idx);
        if (cu) { cu.x = R(x); cu.y = R(y); }
      }
    }, kind === 'point' ? `LEON Countertop — corner moved on the drawing` : `LEON Countertop — cutout moved on the drawing`);
  }

  // ONE write for a counter drawn on the canvas. Everything up to the release
  // lived in CtDrawing's local state; this is where it reaches the quote, and
  // it goes through `apply` exactly as a template does — so undo, redo and the
  // change log cover a dragged counter and a templated one identically.
  function onDraw(points, wallHint) {
    if (!area) return;
    const c = ctCounterFromOutline(points, wallHint, `Counter ${counters.length + 1}`);
    if (!c) return;
    // Cloned inside the updater the way addCounters does it, so the object put
    // on the quote is never shared with anything outside the write.
    withArea(a => { a.counters = (a.counters || []).concat([cloneDeep(c)]); },
      `LEON Countertop — ${c.name} drawn on the canvas in ${area.name}`);
    setCounterId(c.id);
    setSel(null);
  }

  const stepIdx = CT_STEPS.findIndex(s => s.key === step);
  const form = ctForm(quote);

  return (
    <div className="space-y-3">
      {/* header */}
      <div className="flex items-center justify-between gap-3 flex-wrap rounded-lg border border-[var(--leon-line)] bg-white px-3 py-2">
        <div className="flex items-center gap-2 flex-wrap">
          <span className="text-lg">🧿</span>
          <div>
            <div className="font-bold leading-tight">{quote.name}</div>
            <div className="text-[11px] text-[var(--leon-black)]/50">
              {project.name} · Rev. {quote.revision || 0} · last saved {quote.modifiedDate ? fmtDate(quote.modifiedDate) : '—'}
              {quote.modifiedBy ? ` by ${quote.modifiedBy}` : ''}
            </div>
          </div>
        </div>
        <div className="flex items-center gap-1 flex-wrap">
          <IconAction icon="↶" title="Undo" onClick={undo} disabled={!hist.current.past.length} />
          <IconAction icon="↷" title="Redo" onClick={redo} disabled={!hist.current.future.length} />
          <span className="w-px h-5 bg-[var(--leon-line)] mx-1" />
          <Button size="sm" variant="outline" onClick={() => apply(q => {
            const snap = cloneDeep(q); delete snap.revisions;
            q.revisions = (q.revisions || []).concat([{ id: uid('ctrev'), number: (q.revision || 0) + 1, date: todayISO(), by: ctx.currentUserName || '', note: '', snapshot: snap }]);
            q.revision = (q.revision || 0) + 1;
          }, `LEON Countertop — revision captured on ${quote.name}`)} disabled={!editable}>Revisions</Button>
          <Button size="sm" variant="outline" onClick={() => { setSavedAt(new Date().toLocaleTimeString()); }}
            title="Every change is written to the job as you make it — there is no separate save step.">
            {savedAt ? `Saved ${savedAt}` : 'Saved'}
          </Button>
          <Button size="sm" variant="ghost" onClick={onExit}>Exit</Button>
        </div>
      </div>

      {/* the six steps */}
      <div className="flex gap-1 flex-wrap">
        {CT_STEPS.map((s, i) => (
          <button key={s.key} onClick={() => setStep(s.key)}
            className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-semibold whitespace-nowrap border ${step === s.key ? 'bg-[var(--leon-brown)] text-white border-[var(--leon-brown)]' : 'bg-white border-[var(--leon-line)] text-[var(--leon-black)]/65 hover:border-[var(--leon-brown-light)]'}`}>
            <span className={`inline-flex items-center justify-center w-5 h-5 rounded-full text-[11px] ${step === s.key ? 'bg-white/25' : 'bg-[var(--leon-line)]'}`}>{s.n}</span>
            {s.label}
          </button>
        ))}
      </div>

      {/* area selector */}
      <div className="flex items-end gap-2 flex-wrap">
        <Field label="Area">
          <Select className="!w-48" value={area ? area.id : ''} onChange={e => { setAreaId(e.target.value); setCounterId(''); setSel(null); }}>
            {areas.map(a => <option key={a.id} value={a.id}>{a.name}</option>)}
          </Select>
        </Field>
        {counters.length > 1 && (
          <Field label="Counter">
            <Select className="!w-44" value={counter ? counter.id : ''} onChange={e => { setCounterId(e.target.value); setSel(null); }}>
              {counters.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
            </Select>
          </Field>
        )}
        <Field label="Units" hint={quote.unitSystem ? 'Set on this quote' : `Following ${pl ? 'the price list' : 'the software setting'}`}>
          <Select className="!w-44" value={quote.unitSystem || ''} disabled={!editable}
            onChange={e => apply(q => { q.unitSystem = e.target.value || null; },
              `LEON Countertop — quote units set to ${e.target.value || 'follow the price list'}`)}>
            <option value="">Follow the price list ({ctSysOf(pl && pl.units)})</option>
            <option value="Imperial">Imperial — in, sq ft, lin ft</option>
            <option value="Metric">Metric — mm, m², lin m</option>
          </Select>
        </Field>
        <div className="grow" />
        <div className="text-xs text-[var(--leon-black)]/55">
          Step {stepIdx + 1} of 6 — {CT_STEPS[stepIdx] ? CT_STEPS[stepIdx].label : ''}
        </div>
      </div>
      <div className="text-[11px] text-[var(--leon-black)]/50 -mt-1">
        Showing <strong>{sys}</strong> — {sys === 'Metric'
          ? 'millimetres, m² and linear metres'
          : 'inches and sixteenths (36 1/2"), square feet and linear feet — a countertop run is spoken in inches, so it is not printed as feet'}.
        The drawing is held in inches and every rate per square or linear foot underneath, so switching this
        cannot move a figure on the quote — only how it reads. Either system is accepted when you type:{' '}
        <code>3'-0"</code>, <code>36 1/2"</code>, <code>914mm</code> and <code>2438 mm</code> all parse whatever
        is shown; a bare number is read as {sys === 'Metric' ? 'millimetres' : 'inches'}.
      </div>

      <div className="grid gap-3 xl:grid-cols-[minmax(0,1fr)_400px]">
        {/* canvas + right rail */}
        <div className="flex gap-2 items-start">
          <div className="grow min-w-0">
            <CtDrawing area={area} form={form} step={step} sel={sel} editable={editable} sys={sys}
              onCounterMenu={(cid, x, y) => { if (editable) setCtxMenu({ cid, x, y }); }}
              onDraw={onDraw} snapIn={snap16 ? 1 / 16 : CT_DRAG_METRIC_STEP_IN}
              onPick={(cid, kind, idx) => {
                setCounterId(cid);
                setSel({ counterId: cid, kind, idx });
                if (step === 'edges' && editable && kind === 'segment') {
                  withArea(a => {
                    const c = (a.counters || []).find(z => z.id === cid);
                    if (!c) return;
                    const cur = (c.segments[idx] || {}).kind || CT_DEFAULT_SEGMENT_KIND;
                    const order = CT_SEGMENT_KINDS;
                    const next = order[(order.indexOf(cur) + 1) % order.length];
                    c.segments[idx] = Object.assign(ctBlankSegment(), c.segments[idx] || {}, { kind: next });
                  }, `LEON Countertop — edge kind changed on ${quote.name}`);
                }
              }}
              onMove={onMove} view={view} height={430} />
            <CtDrawingLegend step={step} />
            {ctxMenu && (
              <>
                <div className="fixed inset-0 z-40" onClick={() => setCtxMenu(null)}
                  onContextMenu={e => { e.preventDefault(); setCtxMenu(null); }} />
                <div className="fixed z-50 rounded-lg border border-[var(--leon-line)] bg-white shadow-lg py-1 min-w-[190px]"
                  style={{ left: ctxMenu.x, top: ctxMenu.y }}>
                  {!ctxMenu.cid ? (
                    <div className="px-3 py-2 text-xs text-[var(--leon-black)]/45">
                      Right-click on a counter for its options.
                    </div>
                  ) : [
                    ['↺  Rotate counter left', () => rotateCounter(ctxMenu.cid, -90)],
                    ['↻  Rotate counter right', () => rotateCounter(ctxMenu.cid, 90)],
                    ['⧉  Duplicate counter', () => duplicateCounter(ctxMenu.cid)],
                    ['🗑  Delete counter', () => deleteCounter(ctxMenu.cid)],
                  ].map(([label, fn]) => (
                    <button key={label} onClick={() => { fn(); setCtxMenu(null); }}
                      className="w-full text-left px-3 py-1.5 text-sm hover:bg-[var(--leon-cream)]">
                      {label}
                    </button>
                  ))}
                </div>
              </>
            )}
          </div>
          <CtRightRail ctx={ctx} editable={editable} view={view} setView={setView} snap16={snap16} setSnap16={setSnap16}
            onText={() => {
              const t = window.prompt('Text to place on the drawing');
              if (!t) return;
              const b = ctBounds(counters);
              withCounter(c => { c.texts = (c.texts || []).concat([{ id: uid('cttx'), x: b.minX, y: b.minY - 4, text: t }]); },
                `LEON Countertop — note added to the drawing`);
            }}
            onPageBreak={() => withCounter(c => { c.pageBreakBefore = !c.pageBreakBefore; },
              `LEON Countertop — page break toggled on a counter`)}
            onOtherCounter={() => setStep('dims')} />
        </div>

        {/* the step panel */}
        <div className="min-w-0">
          {step === 'dims' && <CtStepDimensions ctx={ctx} quote={quote} area={area} counter={counter} sel={sel}
            editable={editable} withArea={withArea} withCounter={withCounter} apply={apply} round={round}
            onSelectCounter={setCounterId} onSel={setSel} sys={sys} />}
          {step === 'curves' && <CtStepCurves area={area} counter={counter} sel={sel} editable={editable}
            withCounter={withCounter} onSel={setSel} sys={sys} />}
          {step === 'edges' && <CtStepEdges area={area} counter={counter} sel={sel} editable={editable}
            withArea={withArea} withCounter={withCounter} onSel={setSel} pl={pl} sys={sys} />}
          {step === 'cutouts' && <CtStepCutouts ctx={ctx} area={area} counter={counter} sel={sel} editable={editable}
            withArea={withArea} withCounter={withCounter} onSel={setSel} pl={pl} round={round} sys={sys} />}
          {step === 'color' && <CtStepColor ctx={ctx} project={project} quote={quote} area={area} editable={editable}
            withArea={withArea} apply={apply} pl={pl} onArea={setAreaId} sys={sys} />}
          {step === 'price' && <CtStepPrice ctx={ctx} project={project} quote={quote} editable={editable}
            apply={apply} pl={pl} sys={sys} />}
        </div>
      </div>
    </div>
  );
}

function CtDrawingLegend({ step }) {
  return (
    <div className="flex items-center gap-3 flex-wrap mt-2 text-[11px] text-[var(--leon-black)]/60">
      {CT_SEGMENT_KINDS.map(k => (
        <span key={k} className="inline-flex items-center gap-1">
          <span className="inline-block w-4 h-1.5 rounded-sm" style={{ background: CT_KIND_COLORS[k] }} />
          {CT_KIND_LETTER[k]} — {k}
        </span>
      ))}
      {step === 'edges' && <span className="italic">Click a side to cycle it.</span>}
      {step === 'dims' && (
        <span className="italic">
          Drag on the canvas to draw a counter — pause without releasing to turn a corner, release to finish,
          Esc to abandon it. <strong>Dragging is how a shape is started; typing a dimension is how it is
          finished.</strong> Shift ignores snapping. Zoom out to make room beside a counter you already have.
        </span>
      )}
    </div>
  );
}

function CtRightRail({ ctx, editable, view, setView, snap16, setSnap16, onText, onPageBreak, onOtherCounter }) {
  const btn = (icon, title, fn, active) => (
    <button onClick={fn} title={title} disabled={!fn}
      className={`w-9 h-9 rounded-md border text-sm flex items-center justify-center ${active ? 'bg-[var(--leon-brown)] text-white border-[var(--leon-brown)]' : 'bg-white border-[var(--leon-line)] text-[var(--leon-black)]/70 hover:border-[var(--leon-brown-light)]'} disabled:opacity-35`}>
      {icon}
    </button>
  );
  return (
    <div className="no-print flex flex-col gap-1 shrink-0">
      {btn('T', 'Place a text note on the drawing', editable ? onText : null)}
      {btn('⤓', 'Page break before this counter when printed', editable ? onPageBreak : null)}
      {btn('▤', 'Other Counter — add another counter in step 1', onOtherCounter)}
      {btn('¹⁄₁₆', 'Round every typed dimension to the nearest 1/16"', () => setSnap16(!snap16), snap16)}
      <span className="h-px bg-[var(--leon-line)] my-1" />
      {btn('＋', 'Zoom in', () => setView(v => ({ ...v, zoom: Math.min(8, (v.zoom || 1) * 1.25 ) })))}
      {btn('－', 'Zoom out', () => setView(v => ({ ...v, zoom: Math.max(0.25, (v.zoom || 1) / 1.25) })))}
      {btn('⟳', 'Reset zoom and pan', () => setView({ zoom: 1, panX: 0, panY: 0 }))}
      <span className="h-px bg-[var(--leon-line)] my-1" />
      {btn('←', 'Pan left', () => setView(v => ({ ...v, panX: (v.panX || 0) - 6 })))}
      {btn('→', 'Pan right', () => setView(v => ({ ...v, panX: (v.panX || 0) + 6 })))}
      {btn('↑', 'Pan up', () => setView(v => ({ ...v, panY: (v.panY || 0) - 6 })))}
      {btn('↓', 'Pan down', () => setView(v => ({ ...v, panY: (v.panY || 0) + 6 })))}
    </div>
  );
}

// ── Step 1 · Counter Dimensions ───────────────────────────────────────────
// A SHAPE IS STARTED BY DRAGGING AND FINISHED BY TYPING. Dragging on the
// canvas is the first thing offered, because that is how the team draws in
// CounterGo and it is how a shape gets roughed out at speed. Every run length,
// depth and corner is then an editable number here, and typing one moves the
// geometry — which is how a dimension becomes exact. Neither replaces the
// other, and the templates and leg-by-leg entry stay for the shapes that are
// quicker described than drawn.

function CtStepDimensions({ ctx, quote, area, counter, sel, editable, withArea, withCounter, apply, round, onSelectCounter, onSel, sys }) {
  const [adding, setAdding] = useState(false);
  const [addMode, setAddMode] = useState('template');
  const [tpl, setTpl] = useState('L-Shape');
  const [tplParams, setTplParams] = useState(() => ctDefaultParams('L-Shape'));
  const [legs, setLegs] = useState([{ id: uid('ctleg'), dir: 'E', lengthIn: 96 }]);
  const [legDepth, setLegDepth] = useState(() => ctNum(ctSetting('defaultDepthIn', 25.5)) || 25.5);

  if (!area) return <EmptyState text="Add an area in step 5 first." />;
  const counters = area.counters || [];

  function addCounters(list) {
    if (!list || !list.length) return;
    const b = ctBounds(counters);
    const offset = counters.length ? (b.maxY - b.minY) + 24 + b.minY : 0;
    withArea(a => {
      list.forEach(c => {
        const copy = cloneDeep(c);
        if (counters.length) copy.points = copy.points.map(p => Object.assign({}, p, { y: p.y + offset }));
        a.counters = (a.counters || []).concat([copy]);
      });
    }, `LEON Countertop — ${list.length} counter${list.length === 1 ? '' : 's'} added to ${area.name}`);
    setAdding(false);
  }

  return (
    <div className="space-y-3">
      <CtPanel title="Counters in this area"
        right={editable && <Button size="sm" onClick={() => setAdding(v => !v)}>{adding ? 'Close' : '+ Other Counter'}</Button>}>
        {counters.length === 0 ? (
          <div className="rounded-md border border-dashed border-[var(--leon-line)] p-3 text-sm text-[var(--leon-black)]/60">
            <div className="font-semibold text-[var(--leon-black)]/80 mb-1">Drag on the drawing to draw one.</div>
            Press in the empty canvas and drag out the run and the depth; <strong>pause without releasing</strong> to
            turn a corner, so an L or a U comes out of one gesture. Dragging is how a shape is started — typing a
            dimension is how it is finished, in the table that appears here once it exists. Or add one from a
            template, leg by leg, or as a blank rectangle.
          </div>
        ) : (
          <div className="space-y-1">
            {counters.map(c => (
              <div key={c.id} className={`flex items-center gap-2 rounded-md border px-2 py-1.5 text-sm ${counter && c.id === counter.id ? 'border-[var(--leon-brown)] bg-[var(--leon-cream)]' : 'border-[var(--leon-line)]'}`}>
                <button className="grow text-left" onClick={() => { onSelectCounter(c.id); onSel(null); }}>
                  <span className="font-semibold">{c.name}</span>
                  <span className="text-[var(--leon-black)]/50"> · {c.template} · {ctQtyLabel(ctPolyAreaSqIn(c.points) / 144, 'sq ft', sys)}</span>
                </button>
                {editable && <IconAction icon="✕" title="Remove this counter"
                  onClick={() => withArea(a => { a.counters = (a.counters || []).filter(x => x.id !== c.id); },
                    `LEON Countertop — counter removed: ${c.name}`)} />}
              </div>
            ))}
          </div>
        )}

        {adding && (
          <div className="mt-3 rounded-md border border-[var(--leon-line)] p-3 space-y-3">
            <div className="flex gap-1">
              {[['template', 'From a template'], ['legs', 'Leg by leg'], ['free', 'Blank rectangle']].map(m => (
                <button key={m[0]} onClick={() => setAddMode(m[0])}
                  className={`px-2.5 py-1 rounded-md text-xs font-semibold border ${addMode === m[0] ? 'bg-[var(--leon-black)] text-white border-[var(--leon-black)]' : 'bg-white border-[var(--leon-line)]'}`}>{m[1]}</button>
              ))}
            </div>
            {addMode === 'template' && (
              <div className="space-y-2">
                <p className="text-[11px] text-[var(--leon-black)]/50">
                  CounterGo has no templates — every named shape there is a recipe of corner operations on a
                  freehand outline. Templates are ours, because typing a shape beats dragging it. Nothing stops
                  you drawing freely instead.
                </p>
                <Field label="Shape">
                  <Select value={tpl} onChange={e => { setTpl(e.target.value); setTplParams(ctDefaultParams(e.target.value)); }}>
                    {CT_SHAPE_TEMPLATES.filter(t => t !== 'Custom').map(t => <option key={t}>{t}</option>)}
                  </Select>
                </Field>
                <div className="grid grid-cols-2 gap-2">
                  {ctParamFields(tpl).map(f => (
                    <Field key={f.k} label={f.label}>
                      <TextInput defaultValue={ctFmtIn(ctNum(tplParams[f.k]), sys)}
                        onBlur={e => { const v = ctParseIn(e.target.value, sys); if (v) setTplParams(p => Object.assign({}, p, { [f.k]: v })); }} />
                    </Field>
                  ))}
                </div>
                <Button size="sm" onClick={() => addCounters(ctNewCounter(tpl, tplParams))}>Add {tpl}</Button>
              </div>
            )}
            {addMode === 'legs' && (
              <div className="space-y-2">
                <p className="text-[11px] text-[var(--leon-black)]/50">
                  The typed version of drawing a run and pausing to turn a corner. Each leg is a direction and a
                  length; the outline is the legs thickened to the counter depth.
                </p>
                <Field label="Counter depth">
                  <TextInput defaultValue={ctFmtIn(legDepth, sys)} onBlur={e => { const v = ctParseIn(e.target.value, sys); if (v) setLegDepth(v); }} />
                </Field>
                {legs.map((l, i) => (
                  <div key={l.id} className="flex items-end gap-2">
                    <Field label={`Leg ${i + 1}`} className="w-32">
                      <Select value={l.dir} onChange={e => setLegs(list => list.map(x => x.id === l.id ? Object.assign({}, x, { dir: e.target.value }) : x))}>
                        {CT_LEG_DIRS.map(d => <option key={d.k} value={d.k}>{d.label}</option>)}
                      </Select>
                    </Field>
                    <Field label="Length" className="w-28">
                      <TextInput defaultValue={ctFmtIn(l.lengthIn, sys)}
                        onBlur={e => { const v = ctParseIn(e.target.value, sys); if (v) setLegs(list => list.map(x => x.id === l.id ? Object.assign({}, x, { lengthIn: v }) : x)); }} />
                    </Field>
                    <Field label="Angle" className="w-24" >
                      <TextInput placeholder="—" defaultValue={ctIsSet(l.angle) ? l.angle : ''}
                        onBlur={e => setLegs(list => list.map(x => x.id === l.id ? Object.assign({}, x, { angle: e.target.value === '' ? null : Number(e.target.value) }) : x))} />
                    </Field>
                    <IconAction icon="✕" title="Remove this leg" onClick={() => setLegs(list => list.filter(x => x.id !== l.id))} />
                  </div>
                ))}
                <div className="flex gap-2">
                  <Button size="sm" variant="outline" onClick={() => setLegs(list => list.concat([{ id: uid('ctleg'), dir: 'S', lengthIn: 72 }]))}>+ Leg</Button>
                  <Button size="sm" onClick={() => { const c = ctCounterFromLegs(legs, legDepth); if (c) addCounters([c]); }}>Add this run</Button>
                </div>
              </div>
            )}
            {addMode === 'free' && (
              <div className="space-y-2">
                <p className="text-[11px] text-[var(--leon-black)]/50">
                  A plain rectangle to edit freely — move points, type coordinates, add points where you need them.
                </p>
                <Button size="sm" onClick={() => {
                  const c = ctNewCounter('Single Run', { a: 96, depth: ctNum(ctSetting('defaultDepthIn', 25.5)) || 25.5 })[0];
                  c.template = 'Custom'; delete c.params;
                  addCounters([c]);
                }}>Add a blank rectangle</Button>
              </div>
            )}
          </div>
        )}
      </CtPanel>

      {counter && (
        <>
          <CtPanel title={`${counter.name} — dimensions`}>
            <div className="grid grid-cols-2 gap-2 mb-3">
              <Field label="Name">
                <TextInput defaultValue={counter.name} disabled={!editable}
                  onBlur={e => withCounter(c => { c.name = e.target.value; }, `LEON Countertop — counter renamed`)} />
              </Field>
              <Field label="Thickness" hint={sys === 'Metric' ? 'mm — stone is specified in cm and stored that way' : 'cm, the way stone is specified'}>
                <TextInput defaultValue={ctThicknessText(counter.thicknessCm, sys)} disabled={!editable}
                  key={`th-${counter.id}-${sys}`}
                  onBlur={e => withCounter(c => { c.thicknessCm = ctParseThicknessCm(e.target.value, sys) || 3; }, `LEON Countertop — thickness set`)} />
              </Field>
            </div>

            {counter.template !== 'Custom' ? (
              <div className="space-y-2">
                <div className="text-[11px] text-[var(--leon-black)]/50">
                  Typing a value here moves the geometry and keeps every corner treatment, edge and cutout you
                  have already set — the shape always yields the same number of sides.
                </div>
                <div className="grid grid-cols-2 gap-2">
                  {ctParamFields(counter.template).map(f => (
                    <Field key={f.k} label={f.label}>
                      <TextInput defaultValue={ctFmtIn(ctNum((counter.params || ctDefaultParams(counter.template))[f.k]), sys)}
                        disabled={!editable}
                        onBlur={e => {
                          const v = ctParseIn(e.target.value, sys);
                          if (!v) return;
                          withCounter(c => ctApplyParams(c, Object.assign({}, c.params, { [f.k]: round(v) })),
                            `LEON Countertop — ${f.label} set on ${counter.name}`);
                        }} />
                    </Field>
                  ))}
                </div>
                {editable && (
                  <Button size="sm" variant="ghost" onClick={() => withCounter(c => { c.template = 'Custom'; delete c.params; },
                    `LEON Countertop — ${counter.name} converted to a custom outline`)}>
                    Convert to a custom outline
                  </Button>
                )}
                <div className="text-[11px] text-[var(--leon-black)]/45">
                  Converting is one way: once points move freely the shape parameters no longer describe it.
                </div>
              </div>
            ) : (
              <CtSidesTable counter={counter} editable={editable} withCounter={withCounter} round={round} sel={sel} onSel={onSel} sys={sys} />
            )}
          </CtPanel>

          <CtPanel title="Seams">
            <p className="text-[11px] text-[var(--leon-black)]/55 mb-2">
              A seam here is for ESTIMATING and veining match — it is not a cut file. There is no rules engine
              and no automatic optimisation: where a seam goes is the fabricator's judgement about the stone in
              front of them. Each seam carries its own direction, which CounterGo's cannot.
            </p>
            {(counter.seams || []).map(s => (
              <div key={s.id} className="flex items-end gap-2 mb-1">
                <Field label="Runs" className="w-36">
                  <Select value={s.axis} disabled={!editable}
                    onChange={e => withCounter(c => { const t = (c.seams || []).find(z => z.id === s.id); if (t) t.axis = e.target.value; }, `LEON Countertop — seam direction changed`)}>
                    <option value="y">Top to bottom</option>
                    <option value="x">Left to right</option>
                  </Select>
                </Field>
                <Field label="At" className="w-28">
                  <TextInput defaultValue={ctFmtIn(ctNum(s.atIn), sys)} disabled={!editable}
                    onBlur={e => { const v = ctParseIn(e.target.value, sys); if (v === null) return;
                      withCounter(c => { const t = (c.seams || []).find(z => z.id === s.id); if (t) t.atIn = round(v); }, `LEON Countertop — seam moved`); }} />
                </Field>
                {editable && <IconAction icon="✕" title="Remove this seam"
                  onClick={() => withCounter(c => { c.seams = (c.seams || []).filter(z => z.id !== s.id); }, `LEON Countertop — seam removed`)} />}
              </div>
            ))}
            {editable && (
              <Button size="sm" variant="outline" onClick={() => {
                const b = ctBounds([counter]);
                withCounter(c => { c.seams = (c.seams || []).concat([ctMakeSeam({ axis: 'y', atIn: round((b.minX + b.maxX) / 2) })]); },
                  `LEON Countertop — seam added to ${counter.name}`);
              }}>+ Seam</Button>
            )}
          </CtPanel>
        </>
      )}

      <CtPanel title="This area, as drawn">
        <CtAreaSummary area={area} sys={sys} />
      </CtPanel>
    </div>
  );
}

function CtSidesTable({ counter, editable, withCounter, round, sel, onSel, sys }) {
  const segs = ctAllSegments(counter);
  return (
    <div className="space-y-2">
      <div className="text-[11px] text-[var(--leon-black)]/50">
        Type a side length and the outline follows: the points beyond it move until the outline turns back on
        itself, so a rectilinear shape stays closed and rectilinear.
      </div>
      <table className="w-full text-sm">
        <thead><tr className="text-left text-[11px] uppercase tracking-wide text-[var(--leon-black)]/50">
          <th className="py-1">#</th><th>Length</th><th>Point</th><th></th>
        </tr></thead>
        <tbody>
          {segs.map(s => (
            <tr key={s.i} className={`border-t border-[var(--leon-line)] ${sel && sel.kind === 'segment' && sel.idx === s.i ? 'bg-[var(--leon-cream)]' : ''}`}>
              <td className="py-1">{s.i + 1}</td>
              <td>
                <TextInput className="!py-1 !w-24" defaultValue={ctFmtIn(s.len, sys)} disabled={!editable}
                  key={`len-${s.i}-${Math.round(s.len * 1000)}`}
                  onFocus={() => onSel({ counterId: counter.id, kind: 'segment', idx: s.i })}
                  onBlur={e => { const v = ctParseIn(e.target.value, sys); if (v > 0) withCounter(c => ctSetSegmentLength(c, s.i, round(v)), `LEON Countertop — side ${s.i + 1} set`); }} />
              </td>
              <td className="text-[11px] text-[var(--leon-black)]/55 whitespace-nowrap">
                <input className="w-16 border border-[var(--leon-line)] rounded px-1 py-0.5 mr-1" disabled={!editable}
                  key={`px-${s.i}-${Math.round(s.a.x * 100)}`} defaultValue={Math.round(s.a.x * 100) / 100}
                  onBlur={e => { const v = ctParseIn(e.target.value, sys); if (v !== null) withCounter(c => { c.points[s.i].x = round(v); }, `LEON Countertop — point moved`); }} />
                <input className="w-16 border border-[var(--leon-line)] rounded px-1 py-0.5" disabled={!editable}
                  key={`py-${s.i}-${Math.round(s.a.y * 100)}`} defaultValue={Math.round(s.a.y * 100) / 100}
                  onBlur={e => { const v = ctParseIn(e.target.value, sys); if (v !== null) withCounter(c => { c.points[s.i].y = round(v); }, `LEON Countertop — point moved`); }} />
              </td>
              <td className="text-right whitespace-nowrap">
                {editable && <IconAction icon="＋" title="Add a point in the middle of this side"
                  onClick={() => withCounter(c => {
                    const a = c.points[s.i], b2 = c.points[(s.i + 1) % c.points.length];
                    c.points.splice(s.i + 1, 0, makeCtPoint(round((a.x + b2.x) / 2), round((a.y + b2.y) / 2)));
                    c.segments.splice(s.i + 1, 0, Object.assign(ctBlankSegment(), c.segments[s.i] || {}));
                  }, `LEON Countertop — point added`)} />}
                {editable && segs.length > 3 && <IconAction icon="✕" title="Remove this point"
                  onClick={() => withCounter(c => { c.points.splice(s.i, 1); c.segments.splice(s.i, 1); }, `LEON Countertop — point removed`)} />}
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

function CtPanel({ title, right, children }) {
  return (
    <div className="rounded-lg border border-[var(--leon-line)] bg-white p-3">
      <div className="flex items-center justify-between gap-2 mb-2">
        <h4 className="font-bold text-sm lp-section-title">{title}</h4>
        {right}
      </div>
      {children}
    </div>
  );
}

function CtAreaSummary({ area, opt, sys }) {
  const t = ctAreaTakeoff(area, opt);
  const rows = [
    ['Countertop area', ctQtyLabel(t.topSqFt, 'sq ft', sys)],
    ['Perimeter', ctQtyLabel(t.perimeterFt, 'lin ft', sys)],
    ['Finished edge', ctQtyLabel(Object.keys(t.finishedByProfile).reduce((n, k) => n + t.finishedByProfile[k], 0) / 12, 'lin ft', sys)],
    ['Mitered edge (both pieces)', ctQtyLabel(t.miterIn / 12, 'lin ft', sys)],
    ['Waterfall / apron panel', ctQtyLabel(t.miterPanelSqFt, 'sq ft', sys)],
    ['Appliance edge', ctQtyLabel(t.applianceIn / 12, 'lin ft', sys)],
    ['Backsplash', ctQtyLabel(t.splashSqFt, 'sq ft', sys)],
    ['Cutouts', String(t.cutouts.length)],
    ['Faucet holes', String(t.faucetHoles)],
    ['Outlets', String(t.outlets)],
  ];
  return (
    <>
      <table className="w-full text-sm">
        <tbody>
          {rows.map(r => (
            <tr key={r[0]} className="border-t border-[var(--leon-line)] first:border-0">
              <td className="py-1 text-[var(--leon-black)]/60">{r[0]}</td>
              <td className="py-1 text-right font-semibold">{r[1]}</td>
            </tr>
          ))}
        </tbody>
      </table>
      <p className="text-[11px] text-[var(--leon-black)]/45 mt-2">{CT_ROUNDING_RULE}</p>
    </>
  );
}

// ── Step 2 · Curves & Bumpouts ────────────────────────────────────────────

function CtStepCurves({ area, counter, sel, editable, withCounter, onSel, sys }) {
  if (!counter) return <EmptyState text="Pick a counter in step 1 first." />;
  const pts = counter.points || [];
  return (
    <div className="space-y-3">
      <CtPanel title={`${counter.name} — corners`}>
        <p className="text-[11px] text-[var(--leon-black)]/55 mb-2">
          Every corner starts <strong>-Std-</strong> and is set on its own. A treated corner is a countable,
          priced line on the estimate, so this step is money, not decoration.
        </p>
        <div className="space-y-1">
          {pts.map((p, i) => {
            const ang = ctCornerAngle(pts, i);
            const treat = p.treatment || 'Standard';
            const needsSize = CT_CORNER_NEEDS_SIZE.indexOf(treat) >= 0;
            const canAdd = ctCornerCanAddLen(treat);
            const adds = p.addsLen === undefined ? !!CT_CORNER_DEFAULT_ADDS_LEN[treat] : !!p.addsLen;
            const selected = sel && sel.kind === 'point' && sel.idx === i && sel.counterId === counter.id;
            return (
              <div key={i} className={`rounded-md border p-2 ${selected ? 'border-[var(--leon-brown)] bg-[var(--leon-cream)]' : 'border-[var(--leon-line)]'}`}>
                <div className="flex items-end gap-2 flex-wrap">
                  <div className="text-xs font-bold w-10 pb-2">#{i + 1}</div>
                  <Field label="Treatment" className="grow min-w-[9rem]">
                    <Select className="!py-1" value={treat} disabled={!editable}
                      onFocus={() => onSel({ counterId: counter.id, kind: 'point', idx: i })}
                      onChange={e => withCounter(c => {
                        c.points[i].treatment = e.target.value;
                        if (c.points[i].addsLen === undefined) c.points[i].addsLen = !!CT_CORNER_DEFAULT_ADDS_LEN[e.target.value];
                      }, `LEON Countertop — corner ${i + 1} set to ${e.target.value}`)}>
                      {CT_CORNER_TREATMENTS.map(t => <option key={t}>{t}</option>)}
                    </Select>
                  </Field>
                  {needsSize && (
                    <Field label="Radius / size" className="w-28">
                      <TextInput className="!py-1" defaultValue={ctNum(p.radius) || ''} disabled={!editable}
                        onBlur={e => { const v = ctParseIn(e.target.value, sys); withCounter(c => { c.points[i].radius = v === null ? 0 : v; }, `LEON Countertop — corner ${i + 1} size set`); }} />
                    </Field>
                  )}
                  <div className="text-[11px] text-[var(--leon-black)]/45 pb-2">{Math.round(ang.deg)}°{ang.concave ? ' inside' : ''}</div>
                </div>
                {canAdd && (
                  <label className="flex items-start gap-2 mt-1 text-[11px] text-[var(--leon-black)]/60">
                    <input type="checkbox" checked={adds} disabled={!editable} className="mt-0.5"
                      onChange={e => withCounter(c => { c.points[i].addsLen = e.target.checked; }, `LEON Countertop — corner ${i + 1} edge-length rule changed`)} />
                    <span>
                      <strong>Adds material to the edge length.</strong> A full radius and a bump-out arc draw
                      identically and differ only here — the arc adds its depth to the billable edge, the full
                      radius adds none.
                    </span>
                  </label>
                )}
              </div>
            );
          })}
        </div>
      </CtPanel>
      <CtPanel title="What a corner treatment does to the area">
        <p className="text-[11px] text-[var(--leon-black)]/60">
          Nothing. A notch, a clip or an inside radius is an annotation on a corner, not a hole cut in the
          outline — so the square footage already covers the full bounding rectangle, which is exactly the rule
          the team is used to: a notch bills <em>as if the notch piece had not been removed</em>. Bump-outs and
          bump-ins that are not a property of a corner are added as counted items in step 6.
        </p>
      </CtPanel>
    </div>
  );
}

// ── Step 3 · Splash & Edge ────────────────────────────────────────────────
// THE SINGLE MOST IMPORTANT STEP IN THE ESTIMATE. Only a Finished side is
// billable edge; a run against a wall is not edge at all. Every side starts
// Finished on purpose — a missing edge charge costs the shop money and nobody
// notices, while an extra one gets queried by the client and corrected.

function CtStepEdges({ area, counter, sel, editable, withArea, withCounter, onSel, pl, sys }) {
  if (!counter) return <EmptyState text="Pick a counter in step 1 first." />;
  const segs = ctAllSegments(counter);
  const defProf = ctAreaEdgeProfile(area);
  const profiles = CT_EDGE_PROFILES;

  function setSeg(i, fields, line) {
    withCounter(c => { c.segments[i] = Object.assign(ctBlankSegment(), c.segments[i] || {}, fields); }, line);
  }

  return (
    <div className="space-y-3">
      <CtPanel title={`${counter.name} — sides`}
        right={editable && (counter.wallHint || []).length > 0 && (
          <Button size="sm" variant="outline" title="Set the sides that usually sit against a wall on this shape to Unfinished. A suggestion, applied only when you ask."
            onClick={() => withCounter(c => {
              (c.wallHint || []).forEach(i => { c.segments[i] = Object.assign(ctBlankSegment(), c.segments[i] || {}, { kind: 'Unfinished' }); });
            }, `LEON Countertop — usual wall runs marked on ${counter.name}`)}>
            Mark the usual wall runs
          </Button>
        )}>
        <p className="text-[11px] text-[var(--leon-black)]/55 mb-2">
          Click a side on the drawing to cycle it, or set it here. Red is unfinished, green is finished edge,
          blue carries a splash, amber is an appliance edge — a real flat cut and polish, not "no edge".
        </p>
        <div className="space-y-1">
          {segs.map(s => {
            const selected = sel && sel.kind === 'segment' && sel.idx === s.i && sel.counterId === counter.id;
            const hasParts = !!(s.parts && s.parts.length);
            return (
              <div key={s.i} className={`rounded-md border p-2 ${selected ? 'border-[var(--leon-brown)] bg-[var(--leon-cream)]' : 'border-[var(--leon-line)]'}`}>
                <div className="flex items-end gap-2 flex-wrap">
                  <div className="w-12 pb-2 text-xs">
                    <span className="font-bold">#{s.i + 1}</span>
                    <div className="text-[10px] text-[var(--leon-black)]/45">{ctFmtIn(s.len, sys)}</div>
                  </div>
                  <Field label="Side is" className="w-32">
                    <Select className="!py-1" value={s.kind} disabled={!editable || hasParts}
                      onFocus={() => onSel({ counterId: counter.id, kind: 'segment', idx: s.i })}
                      onChange={e => setSeg(s.i, { kind: e.target.value }, `LEON Countertop — side ${s.i + 1} set ${e.target.value}`)}>
                      {CT_SEGMENT_KINDS.map(k => <option key={k}>{k}</option>)}
                    </Select>
                  </Field>
                  <Field label="Edge profile" className="w-40">
                    <Select className="!py-1" value={s.edgeProfile || ''} disabled={!editable || hasParts}
                      onChange={e => setSeg(s.i, { edgeProfile: e.target.value }, `LEON Countertop — side ${s.i + 1} profile set`)}>
                      <option value="">Area default — {defProf}</option>
                      {profiles.map(p => <option key={p}>{p}</option>)}
                    </Select>
                  </Field>
                  <Field label="Splash height" className="w-28">
                    <TextInput className="!py-1" placeholder="—" disabled={!editable || hasParts}
                      defaultValue={s.splashHeight ? ctFmtIn(s.splashHeight, sys) : ''}
                      onBlur={e => { const v = ctParseIn(e.target.value, sys); setSeg(s.i, { splashHeight: v === null ? 0 : v }, `LEON Countertop — splash set on side ${s.i + 1}`); }} />
                  </Field>
                  {CT_MITER_PROFILES.indexOf(s.edgeProfile || defProf) >= 0 && (
                    <Field label={ctDropLabel(s.edgeProfile || defProf)} className="w-32"
                      hint={ctDropHint(s.edgeProfile || defProf)}>
                      <TextInput className="!py-1" placeholder="—" disabled={!editable || hasParts}
                        defaultValue={s.dropIn ? ctFmtIn(s.dropIn, sys) : ''}
                        onBlur={e => { const v = ctParseIn(e.target.value, sys); setSeg(s.i, { dropIn: v === null ? 0 : v }, `LEON Countertop — ${ctDropLabel(s.edgeProfile || defProf).toLowerCase()} set on side ${s.i + 1}`); }} />
                    </Field>
                  )}
                  {editable && (
                    <Button size="sm" variant="ghost" title="Split this side so it can carry two profiles or two splash heights"
                      onClick={() => withCounter(c => {
                        const cur = Object.assign(ctBlankSegment(), c.segments[s.i] || {});
                        cur.parts = hasParts ? null : [
                          { lengthIn: Math.round(s.len / 2 * 16) / 16, kind: cur.kind, edgeProfile: cur.edgeProfile, splashHeight: cur.splashHeight },
                          { lengthIn: 0, kind: cur.kind, edgeProfile: cur.edgeProfile, splashHeight: cur.splashHeight },
                        ];
                        c.segments[s.i] = cur;
                      }, `LEON Countertop — side ${s.i + 1} ${hasParts ? 'joined' : 'split'}`)}>
                      {hasParts ? 'Join' : 'Split'}
                    </Button>
                  )}
                </div>
                {hasParts && (
                  <div className="mt-2 pl-3 border-l-2 border-[var(--leon-line)] space-y-1">
                    <div className="text-[11px] text-[var(--leon-black)]/50">
                      Real sub-segments — no phantom bump-in required. The last part takes whatever length is left,
                      so the parts always add up to the side exactly.
                    </div>
                    {ctSegmentParts(s).map(part => (
                      <div key={part.index} className="flex items-end gap-2 flex-wrap">
                        <Field label={part.index === s.parts.length - 1 ? 'Remainder' : 'Length'} className="w-24">
                          <TextInput className="!py-1" disabled={!editable || part.index === s.parts.length - 1}
                            defaultValue={ctFmtIn(part.len, sys)}
                            onBlur={e => { const v = ctParseIn(e.target.value, sys); if (v === null) return;
                              withCounter(c => { c.segments[s.i].parts[part.index].lengthIn = v; }, `LEON Countertop — sub-segment length set`); }} />
                        </Field>
                        <Field label="Is" className="w-28">
                          <Select className="!py-1" value={part.kind} disabled={!editable}
                            onChange={e => withCounter(c => { c.segments[s.i].parts[part.index].kind = e.target.value; }, `LEON Countertop — sub-segment kind set`)}>
                            {CT_SEGMENT_KINDS.map(k => <option key={k}>{k}</option>)}
                          </Select>
                        </Field>
                        <Field label="Profile" className="w-36">
                          <Select className="!py-1" value={part.edgeProfile || ''} disabled={!editable}
                            onChange={e => withCounter(c => { c.segments[s.i].parts[part.index].edgeProfile = e.target.value; }, `LEON Countertop — sub-segment profile set`)}>
                            <option value="">Area default</option>
                            {profiles.map(p => <option key={p}>{p}</option>)}
                          </Select>
                        </Field>
                        {CT_MITER_PROFILES.indexOf(part.edgeProfile || defProf) >= 0 && (
                          <Field label={ctDropLabel(part.edgeProfile || defProf)} className="w-28">
                            <TextInput className="!py-1" placeholder="—" disabled={!editable}
                              defaultValue={part.dropIn ? ctFmtIn(part.dropIn, sys) : ''}
                              onBlur={e => { const v = ctParseIn(e.target.value, sys);
                                withCounter(c => { c.segments[s.i].parts[part.index].dropIn = v === null ? 0 : v; }, `LEON Countertop — sub-segment drop set`); }} />
                          </Field>
                        )}
                        <Field label="Splash" className="w-24">
                          <TextInput className="!py-1" placeholder="—" disabled={!editable}
                            defaultValue={part.splashHeight ? ctFmtIn(part.splashHeight, sys) : ''}
                            onBlur={e => { const v = ctParseIn(e.target.value, sys);
                              withCounter(c => { c.segments[s.i].parts[part.index].splashHeight = v === null ? 0 : v; }, `LEON Countertop — sub-segment splash set`); }} />
                        </Field>
                        {editable && (
                          <IconAction icon="＋" title="Another sub-segment"
                            onClick={() => withCounter(c => { c.segments[s.i].parts.splice(part.index + 1, 0, { lengthIn: 0, kind: part.kind, edgeProfile: part.edgeProfile, splashHeight: part.splashHeight }); }, `LEON Countertop — sub-segment added`)} />
                        )}
                        {editable && s.parts.length > 2 && (
                          <IconAction icon="✕" title="Remove this sub-segment"
                            onClick={() => withCounter(c => { c.segments[s.i].parts.splice(part.index, 1); }, `LEON Countertop — sub-segment removed`)} />
                        )}
                      </div>
                    ))}
                  </div>
                )}
              </div>
            );
          })}
        </div>
      </CtPanel>

      <CtPanel title="Backsplash for this area">
        <div className="grid grid-cols-2 gap-2">
          <Field label="Default splash height" hint="Used by any side marked Splash that carries no height of its own.">
            <TextInput defaultValue={ctFmtIn(ctNum(area.splashHeightIn), sys)} disabled={!editable}
              onBlur={e => { const v = ctParseIn(e.target.value, sys); withArea(a => { a.splashHeightIn = v === null ? 0 : v; }, `LEON Countertop — splash height set on ${area.name}`); }} />
          </Field>
        </div>
        <p className="text-[11px] text-[var(--leon-black)]/55 mt-2">
          This price list charges backsplash on <strong>{CT_SPLASH_BASIS_NAMES[ctSplashBasis(pl)]}</strong>.{' '}
          {ctSplashBasis(pl) === 'linearFt'
            ? 'The height above therefore does not change the price — only the run does.'
            : ctSplashBasis(pl) === 'material'
              ? 'Its stone is added to the slab count rather than billed at a rate of its own.'
              : 'Each run is banded by its OWN height, so a side carrying a different splash height is priced at its own band — set the bands under Price Lists → Splash.'}
        </p>
      </CtPanel>

      <CtOverhangPanel counter={counter} editable={editable} withCounter={withCounter} sys={sys} />

      <CtPanel title="This area, as drawn"><CtAreaSummary area={area} sys={sys} /></CtPanel>
    </div>
  );
}

// The two figures a template shop actually cuts to. They are DERIVED from the
// cutout's own x/y and write straight back to it — one record, seen the way the
// shop reads it. Only the two references are stored, because which end and
// which edge a job dimensions from genuinely varies.
function CtCutoutDimFields({ counter, cu, editable, withCounter, sys, lockAlong }) {
  const d = ctCutoutDims(counter, cu);
  if (!d) return null;
  function setDim(field, text) {
    const v = ctParseIn(text, sys);
    if (v === null) return;
    const next = ctCutoutFromDims(counter, cu, { [field]: v });
    if (!next) return;
    withCounter(c => {
      const t = (c.cutouts || []).find(z => z.id === cu.id);
      if (!t) return;
      if (next.x !== undefined) t.x = next.x;
      if (next.y !== undefined) t.y = next.y;
    }, `LEON Countertop — cutout ${field === 'setback' ? 'setback' : 'centerline'} set`);
  }
  function setRef(field, value) {
    withCounter(c => {
      const t = (c.cutouts || []).find(z => z.id === cu.id);
      if (t) t[field] = value;
    }, `LEON Countertop — cutout dimension reference changed`);
  }
  return (
    <div className="mt-2 pt-2 border-t border-[var(--leon-line)]">
      <div className="text-[10px] uppercase tracking-wide text-[var(--leon-black)]/45 mb-1">
        How the shop locates it
      </div>
      <div className="flex items-end gap-2 flex-wrap">
        <Field label="Centerline" className="w-28"
          hint={lockAlong ? 'Set by the sink this faucet follows.' : undefined}>
          <TextInput className="!py-1" disabled={!editable || lockAlong}
            key={`cl${Math.round(d.centerline * 64)}`}
            defaultValue={ctFmtIn(d.centerline, sys)}
            onBlur={e => setDim('centerline', e.target.value)} />
        </Field>
        <Field label="from" className="w-24">
          <Select className="!py-1" value={cu.dimAlong || 'left'} disabled={!editable}
            onChange={e => setRef('dimAlong', e.target.value)}>
            <option value="left">Left end</option>
            <option value="right">Right end</option>
          </Select>
        </Field>
        <Field label="Setback" className="w-28">
          <TextInput className="!py-1" disabled={!editable} key={`sb${Math.round(d.setback * 64)}`}
            defaultValue={ctFmtIn(d.setback, sys)}
            onBlur={e => setDim('setback', e.target.value)} />
        </Field>
        <Field label="from" className="w-28">
          <Select className="!py-1" value={cu.dimFrom || 'front'} disabled={!editable}
            onChange={e => setRef('dimFrom', e.target.value)}>
            <option value="front">Front edge</option>
            <option value="back">Back edge</option>
          </Select>
        </Field>
      </div>
      <p className="text-[11px] text-[var(--leon-black)]/45 mt-1">
        Dragging it on the drawing changes these, and typing one moves it &mdash; they are the same fact.
        {cu.kind === 'Faucet Hole'
          ? ' The setback is to the bore centreline, which is what a drill is set to.'
          : ' The setback is to the near edge of the opening, which is what gets cut to.'}
        {d.frontMaxY ? '' : ' The front of this counter is the top of the drawing, read from the runs marked against a wall.'}
      </p>
    </div>
  );
}

// An outlet is cut into the SPLASH, not into the deck, so it is located along a
// side and up from the deck rather than by x/y. Where it lands does not change
// what it costs — but it does change what the fabricator cuts, and that is the
// whole reason a shop drawing exists.
function CtOutletPanel({ area, counter, editable, withArea, withCounter, sys }) {
  const outlets = ((counter && counter.cutouts) || []).filter(c => c.kind === 'Outlet');
  const splashes = ctAllSegments(counter || {}).filter(s =>
    s.kind === 'Splash' || ctNum(s.splashHeight) > 0);
  const loose = Math.max(0, ctNum(area && area.outletCount));

  function add() {
    const seg = splashes[0];
    const size = ctOutletSizeIn(1);
    withCounter(c => {
      c.cutouts = (c.cutouts || []).concat([makeCtCutout({
        kind: 'Outlet', name: '', gangs: 1, qty: 1,
        widthIn: size.widthIn, depthIn: size.heightIn,
        segmentIndex: seg ? seg.i : null,
        alongIn: seg ? Math.round(seg.len / 2 * 16) / 16 : 0,
        heightIn: 1.5,
      })]);
    }, `LEON Countertop — outlet placed on ${counter.name}`);
  }
  function edit(id, fn, line) {
    withCounter(c => { const t = (c.cutouts || []).find(z => z.id === id); if (t) fn(t); }, line);
  }

  return (
    <CtPanel title="Outlets in the splash"
      right={editable && <Button size="sm" variant="outline" disabled={!splashes.length} onClick={add}>+ Place an outlet</Button>}>
      {!splashes.length && (
        <p className="text-[11px] text-[var(--leon-black)]/55 mb-2">
          No side of this counter carries a splash yet, so there is nothing to cut an outlet into.
          Set a side to Splash in step 3 first.
        </p>
      )}
      {outlets.length > 0 && (
        <div className="space-y-2 mb-2">
          {outlets.map(cu => {
            const seg = splashes.find(x => x.i === cu.segmentIndex) || splashes[0];
            const sz = ctOutletSizeIn(cu.gangs);
            return (
              <div key={cu.id} className="rounded-md border border-[var(--leon-line)] p-2">
                <div className="flex items-end gap-2 flex-wrap">
                  <Field label="In which splash" className="w-36">
                    <Select className="!py-1" value={cu.segmentIndex === null ? '' : String(cu.segmentIndex)} disabled={!editable}
                      onChange={e => edit(cu.id, t => { t.segmentIndex = e.target.value === '' ? null : Number(e.target.value); }, `LEON Countertop — outlet moved to another splash`)}>
                      {splashes.map(x => <option key={x.i} value={String(x.i)}>Side #{x.i + 1} — {ctFmtIn(x.len, sys)}</option>)}
                    </Select>
                  </Field>
                  <Field label="Gangs" className="w-20">
                    <Select className="!py-1" value={String(Math.max(1, ctNum(cu.gangs) || 1))} disabled={!editable}
                      onChange={e => edit(cu.id, t => {
                        t.gangs = Number(e.target.value);
                        const n = ctOutletSizeIn(t.gangs);
                        t.widthIn = n.widthIn; t.depthIn = n.heightIn;
                      }, `LEON Countertop — outlet gangs set`)}>
                      {[1, 2, 3, 4].map(g => <option key={g} value={String(g)}>{g}</option>)}
                    </Select>
                  </Field>
                  <Field label="Along, from the left" className="w-32">
                    <TextInput className="!py-1" disabled={!editable} defaultValue={ctFmtIn(ctNum(cu.alongIn), sys)}
                      onBlur={e => { const v = ctParseIn(e.target.value, sys); if (v === null) return;
                        edit(cu.id, t => { t.alongIn = Math.max(0, v); }, `LEON Countertop — outlet position set`); }} />
                  </Field>
                  <Field label="Height above the deck" className="w-36">
                    <TextInput className="!py-1" disabled={!editable} defaultValue={ctFmtIn(ctNum(cu.heightIn), sys)}
                      onBlur={e => { const v = ctParseIn(e.target.value, sys); if (v === null) return;
                        edit(cu.id, t => { t.heightIn = Math.max(0, v); }, `LEON Countertop — outlet height set`); }} />
                  </Field>
                  <Field label="Label" className="w-28">
                    <TextInput className="!py-1" disabled={!editable} defaultValue={cu.name || ''}
                      onBlur={e => edit(cu.id, t => { t.name = e.target.value; }, `LEON Countertop — outlet labelled`)} />
                  </Field>
                  {editable && <IconAction icon="✕" title="Remove this outlet"
                    onClick={() => withCounter(c => { c.cutouts = (c.cutouts || []).filter(z => z.id !== cu.id); }, `LEON Countertop — outlet removed`)} />}
                </div>
                <div className="text-[11px] text-[var(--leon-black)]/45 mt-1">
                  Opening {ctFmtIn(sz.widthIn, sys)} &times; {ctFmtIn(sz.heightIn, sys)}
                  {seg && ctNum(cu.alongIn) + sz.widthIn / 2 > seg.len && (
                    <b className="text-[var(--leon-red,#a33)]"> — this runs past the end of side #{seg.i + 1}.</b>
                  )}
                  {seg && ctNum(cu.heightIn) + sz.heightIn > (ctNum(seg.splashHeight) || ctNum(area.splashHeightIn)) && (
                    <b className="text-[var(--leon-red,#a33)]"> — this is taller than the splash it sits in.</b>
                  )}
                </div>
              </div>
            );
          })}
        </div>
      )}
      <div className="flex items-end gap-2">
        <Field label="Outlets counted but not placed" className="w-48">
          <TextInput defaultValue={loose} disabled={!editable}
            onBlur={e => withArea(a => { a.outletCount = Math.max(0, ctNum(e.target.value)); }, `LEON Countertop — outlet count set on ${area.name}`)} />
        </Field>
        <div className="pb-2 text-xs text-[var(--leon-black)]/60">
          {outlets.length} placed + {loose} counted = <b>{outlets.length + loose}</b> on this area
        </div>
      </div>
      <p className="text-[11px] text-[var(--leon-black)]/55 mt-2">
        Where an outlet lands does not change what it costs &mdash; every outlet in the area still
        consolidates into one quote line. It changes what the fabricator cuts, which is why it is worth
        drawing. Keep the count for the ones nobody has located yet.
      </p>
    </CtPanel>
  );
}

// A faucet is DRILLED — bores of a real diameter at a real spread, in the deck
// behind the sink. It was a COUNT on the sink cutout, drawn as circles at an
// arbitrary spacing and an arbitrary size, which nobody can drill from. The
// count is still accepted and still priced; this is the version a fabricator
// can work to.
function CtFaucetPanel({ counter, faucets, editable, withCounter, sys, sel, onSel }) {
  const [cfg, setCfg] = useState('widespread');

  function place() {
    const c0 = CT_FAUCET_CONFIGS.find(x => x.key === cfg) || CT_FAUCET_CONFIGS[0];
    const b = ctBounds([counter]);
    // It goes BEHIND THE SINK, so it lands against the bowl it serves rather
    // than in the middle of the deck.
    const sinks = (counter.cutouts || []).filter(z => z.kind === 'Sink');
    const sink = sinks[0] || null;
    const backIsMinY = ctFrontIsMaxY(counter);
    const y = sink
      ? (backIsMinY ? ctNum(sink.y) - ctNum(sink.depthIn) / 2 - CT_FAUCET_BEHIND_SINK_IN
                    : ctNum(sink.y) + ctNum(sink.depthIn) / 2 + CT_FAUCET_BEHIND_SINK_IN)
      : (backIsMinY ? b.minY + 3.5 : b.maxY - 3.5);
    withCounter(c => {
      c.cutouts = (c.cutouts || []).concat([makeCtCutout({
        kind: 'Faucet Hole', faucetConfig: c0.key, faucetHoles: c0.holes,
        spreadIn: c0.spreadIn, holeDiaIn: CT_FAUCET_HOLE_DIA_IN,
        widthIn: 0, depthIn: 0,
        sinkCutoutId: sink ? sink.id : null, faucetAlign: 'center',
        x: sink ? ctNum(sink.x) : (b.minX + b.maxX) / 2, y,
      })]);
    }, `LEON Countertop — ${c0.label.toLowerCase()} faucet placed on ${counter.name}`);
  }
  function edit(id, fn, line) {
    withCounter(c => { const t = (c.cutouts || []).find(z => z.id === id); if (t) fn(t); }, line);
  }

  return (
    <CtPanel title="Faucet"
      right={editable && (
        <span className="flex items-end gap-2">
          <Select className="!py-1 !w-52" value={cfg} onChange={e => setCfg(e.target.value)}>
            {CT_FAUCET_CONFIGS.map(c => <option key={c.key} value={c.key}>{c.label}</option>)}
          </Select>
          <Button size="sm" variant="outline" onClick={place}>+ Place</Button>
        </span>
      )}>
      {!faucets.length ? (
        <p className="text-[11px] text-[var(--leon-black)]/55">
          No faucet placed on this counter. A faucet is <b>bores</b>, not an opening &mdash; how many, how far
          apart and how big &mdash; so it is drawn and dimensioned like the sink rather than counted.
          The plain hole count on a sink still works and is still priced; it just does not say where anything
          goes.
        </p>
      ) : (
        <div className="space-y-2">
          {faucets.map(cu => {
            const bores = ctFaucetHoles(cu);
            const sink = ctFaucetSink(counter, cu);
            const sinkList = (counter.cutouts || []).filter(z => z.kind === 'Sink');
            const selected = sel && sel.kind === 'cutout' && sel.idx === cu.id;
            return (
              <div key={cu.id} className={`rounded-md border p-2 ${selected ? 'border-[var(--leon-brown)] bg-[var(--leon-cream)]' : 'border-[var(--leon-line)]'}`}>
                <div className="flex items-end gap-2 flex-wrap">
                  <Field label="Configuration" className="w-52">
                    <Select className="!py-1" value={cu.faucetConfig || 'single'} disabled={!editable}
                      onFocus={() => onSel({ counterId: counter.id, kind: 'cutout', idx: cu.id })}
                      onChange={e => {
                        const c0 = CT_FAUCET_CONFIGS.find(x => x.key === e.target.value);
                        edit(cu.id, t => {
                          t.faucetConfig = e.target.value;
                          if (c0) { t.faucetHoles = c0.holes; t.spreadIn = c0.spreadIn; }
                        }, `LEON Countertop — faucet set ${c0 ? c0.label.toLowerCase() : ''}`);
                      }}>
                      {CT_FAUCET_CONFIGS.map(c => <option key={c.key} value={c.key}>{c.label}</option>)}
                    </Select>
                  </Field>
                  <Field label="Bores" className="w-20">
                    <TextInput className="!py-1" disabled={!editable} defaultValue={Math.max(1, ctNum(cu.faucetHoles) || 1)}
                      onBlur={e => edit(cu.id, t => { t.faucetHoles = Math.max(1, ctNum(e.target.value)); }, `LEON Countertop — faucet bores set`)} />
                  </Field>
                  <Field label="Spread, outer to outer" className="w-40">
                    <TextInput className="!py-1" disabled={!editable} defaultValue={ctFmtIn(ctNum(cu.spreadIn), sys)}
                      onBlur={e => { const v = ctParseIn(e.target.value, sys); if (v === null) return;
                        edit(cu.id, t => { t.spreadIn = Math.max(0, v); }, `LEON Countertop — faucet spread set`); }} />
                  </Field>
                  <Field label="Bore diameter" className="w-32">
                    <TextInput className="!py-1" disabled={!editable}
                      defaultValue={ctFmtIn(ctNum(cu.holeDiaIn) || CT_FAUCET_HOLE_DIA_IN, sys)}
                      onBlur={e => { const v = ctParseIn(e.target.value, sys); if (v === null) return;
                        edit(cu.id, t => { t.holeDiaIn = Math.max(0.25, v); }, `LEON Countertop — faucet bore diameter set`); }} />
                  </Field>
                  {editable && <IconAction icon="✕" title="Remove this faucet"
                    onClick={() => withCounter(c => { c.cutouts = (c.cutouts || []).filter(z => z.id !== cu.id); }, `LEON Countertop — faucet removed`)} />}
                </div>
                <div className="flex items-end gap-2 flex-wrap mt-2 pt-2 border-t border-[var(--leon-line)]">
                  <Field label="Set out from" className="w-52">
                    <Select className="!py-1" value={cu.sinkCutoutId || ''} disabled={!editable}
                      onChange={e => edit(cu.id, t => { t.sinkCutoutId = e.target.value || null; }, `LEON Countertop — faucet linked to a sink`)}>
                      <option value="">— not linked to a sink —</option>
                      {sinkList.map(sk => (
                        <option key={sk.id} value={sk.id}>
                          {sk.name || `${ctFmtIn(ctNum(sk.widthIn), sys)} × ${ctFmtIn(ctNum(sk.depthIn), sys)} ${sk.sinkType}`}
                        </option>
                      ))}
                    </Select>
                  </Field>
                  <Field label="Position" className="w-44">
                    <Select className="!py-1" value={cu.faucetAlign || 'center'} disabled={!editable || !sink}
                      onChange={e => edit(cu.id, t => { t.faucetAlign = e.target.value; }, `LEON Countertop — faucet ${e.target.value === 'center' ? 'centred on the sink' : 'offset ' + e.target.value}`)}>
                      {CT_FAUCET_ALIGNS.map(a => <option key={a.key} value={a.key}>{a.label}</option>)}
                    </Select>
                  </Field>
                </div>
                <div className="text-[11px] text-[var(--leon-black)]/50 mt-1">
                  {bores.length} bore{bores.length === 1 ? '' : 's'} of {ctFmtIn(bores[0].dia, sys)}
                  {bores.length > 1 && ` across ${ctFmtIn(ctNum(cu.spreadIn), sys)}`}
                  {' — '}priced as {bores.length} faucet hole{bores.length === 1 ? '' : 's'}, the same as a counted one.
                  {sink ? (
                    <> The bores sit <b>{(cu.faucetAlign || 'center') === 'center'
                      ? 'on the sink centreline'
                      : `${ctFmtIn(ctNum(sink.widthIn) / 4, sys)} ${cu.faucetAlign} of the sink centreline`}</b>,
                      so they follow the bowl if the bowl moves.</>
                  ) : (
                    <b> Not linked to a sink, so it stays where it is put.</b>
                  )}
                </div>
                <CtCutoutDimFields counter={counter} cu={cu} editable={editable}
                  withCounter={withCounter} sys={sys} lockAlong={!!sink} />
              </div>
            );
          })}
        </div>
      )}
    </CtPanel>
  );
}

// The overhang, per side, with the support verdict. It is the gap between the
// CABINET FACE and the edge of the stone — what the installer sets out to —
// and the drawing now shows it as a dashed cabinet line set in from the edge.
function CtOverhangPanel({ counter, editable, withCounter, sys }) {
  if (!counter) return null;
  const runs = ctOverhangRuns(counter);
  const limit = ctSupportLimitIn(counter);
  const dflt = ctIsSet(counter.overhangDefaultIn) ? ctNum(counter.overhangDefaultIn) : CT_DEFAULT_OVERHANG_IN;
  const needing = runs.filter(r => r.needsSupport);
  const carrying = runs.filter(r => r.overhangIn > 0);

  return (
    <CtPanel title={`${counter.name} — overhang`}>
      <div className="flex items-end gap-3 flex-wrap mb-2">
        <Field label="Standard on this counter" className="w-40">
          <TextInput className="!py-1" disabled={!editable} defaultValue={ctFmtIn(dflt, sys)}
            onBlur={e => { const v = ctParseIn(e.target.value, sys); if (v === null) return;
              withCounter(c => { c.overhangDefaultIn = Math.max(0, v); }, `LEON Countertop — standard overhang set on ${counter.name}`); }} />
        </Field>
        <div className="pb-2 text-xs text-[var(--leon-black)]/55">
          {carrying.length} of {runs.length} sides carry one · unsupported limit at {ctFmtIn(limit, sys)}
          {' '}for {ctNum(counter.thicknessCm) || 3} cm stone
        </div>
      </div>

      {needing.length > 0 && (
        <div className="rounded-md border border-[#c98b8b] bg-[#fbf1f1] px-3 py-2 text-xs mb-2">
          <b>{needing.length} side{needing.length === 1 ? '' : 's'} past {ctFmtIn(limit, sys)}</b> &mdash;
          {' '}{needing.map(r => `#${r.i + 1} at ${ctFmtIn(r.overhangIn, sys)}`).join(', ')}.
          {' '}At this thickness that needs corbels or a bracket, and it is a support detail rather than a
          preference. The drawing marks those sides in red.
        </div>
      )}

      <div className="space-y-1">
        {runs.map(r => (
          <div key={r.i} className="flex items-center gap-2 flex-wrap text-sm">
            <span className="w-10 text-xs font-bold">#{r.i + 1}</span>
            <span className="w-24 text-xs text-[var(--leon-black)]/50">{r.seg.kind}</span>
            <span className="w-20 text-xs text-[var(--leon-black)]/45 tabular-nums">{ctFmtIn(r.lenIn, sys)}</span>
            {r.againstWall ? (
              <span className="text-xs text-[var(--leon-black)]/45">
                against a wall &mdash; no overhang
              </span>
            ) : (
              <>
                <TextInput className="!py-1 !w-24" disabled={!editable} placeholder={ctFmtIn(dflt, sys)}
                  defaultValue={r.own ? ctFmtIn(r.overhangIn, sys) : ''}
                  onBlur={e => {
                    const txt = String(e.target.value || '').trim();
                    const v = txt === '' ? null : ctParseIn(txt, sys);
                    if (txt !== '' && v === null) return;
                    withCounter(c => {
                      c.segments[r.i] = Object.assign(ctBlankSegment(), c.segments[r.i] || {}, { overhangIn: v });
                    }, `LEON Countertop — overhang ${v === null ? 'follows the standard' : 'set'} on side ${r.i + 1}`);
                  }} />
                <span className="text-[11px] text-[var(--leon-black)]/40">
                  {r.own ? 'its own' : 'follows the standard'}
                </span>
                {r.needsSupport && <Badge tone="bad">needs support</Badge>}
              </>
            )}
          </div>
        ))}
      </div>

      <p className="text-[11px] text-[var(--leon-black)]/55 mt-2">
        The dashed line inside the outline on the drawing is the <b>cabinet face</b> &mdash; the overhang is the
        gap between it and the stone. An overhang does not change the countertop area: the polygon you drew is
        the finished top and already includes it. Blank a side to follow the standard; a side against a wall has
        none by definition.
      </p>
    </CtPanel>
  );
}


// ============================================================================
// THE COUNTERTOP SHOP DRAWING
// ============================================================================
// Same model as the door sheet, deliberately: a real paper size, a real scale,
// LEON's own lockup, a side panel of information, and the finishes and
// selections along the bottom. A shop drawing set that reads differently from
// one trade to the next is two sets, and the whole point of issuing them
// together is that a reviewer learns one sheet.
//
// The SVG is sized in MILLIMETRES (`width="594mm"` over a `0 0 594 420`
// viewBox), so paper mm = model mm / denominator and what prints is at the
// scale the title block claims — the same rule the door and casework sheets
// follow, and the only thing that makes a printed drawing measurable.
const CT_INK = '#2B2118', CT_BROWN = '#8B5E34', CT_LINE = '#D9D2C7', CT_CREAM = '#F3EFE9';
const CT_FONT = "'Century Gothic Leon', 'Century Gothic', Questrial, sans-serif";
// Line weights carry meaning: the stone outline is what is being made, the
// cabinet line is reference, a dimension is thinner than either.
const CTW = { cut: 0.62, outline: 0.42, detail: 0.26, thin: 0.16 };
// How far outside the outline a dimension string sits, in MODEL inches — so it
// scales with the drawing, which is why the fit calculation has to include it.
const CT_SHEET_DIM_OFFSET_IN = 6;

function ctSheetSize(key) {
  const list = (typeof SHEET_SIZES !== 'undefined' && SHEET_SIZES) || [{ key: 'A2', w: 594, h: 420 }];
  return list.find(x => x.key === key) || list.find(x => x.key === 'A2') || list[0];
}
// The largest scale off the ladder that fits `mm` of model into `paper` mm.
function ctFitDenom(mm, paper, detail) {
  const src = detail
    ? ((typeof SHEET_DETAIL_SCALES !== 'undefined' && SHEET_DETAIL_SCALES) || [{ denom: 5 }])
    : ((typeof SHEET_SCALES !== 'undefined' && SHEET_SCALES) || [{ denom: 20 }]);
  const ladder = src.map(x => x.denom).sort((a, b) => a - b);
  for (const dn of ladder) if (mm / dn <= paper) return dn;
  return ladder[ladder.length - 1];
}
function ctScaleLabel(dn, detail) {
  const src = detail
    ? ((typeof SHEET_DETAIL_SCALES !== 'undefined' && SHEET_DETAIL_SCALES) || [])
    : ((typeof SHEET_SCALES !== 'undefined' && SHEET_SCALES) || []);
  const f = src.find(x => x.denom === dn);
  return f ? f.label : `1:${dn}`;
}

// A dimension string, drawn in PAPER millimetres. The same reason the door and
// casework sheets do it: a dimension is a property of the sheet, not of the
// countertop, so at 1:50 the figure must not shrink with the drawing.
function CtDim({ x1, y1, x2, y2, text, tone, flip }) {
  const dx = x2 - x1, dy = y2 - y1;
  const len = Math.hypot(dx, dy) || 1;
  const nx = -dy / len, ny = dx / len;
  const t = 1.3;
  const mx = (x1 + x2) / 2, my = (y1 + y2) / 2;
  const ang = Math.atan2(dy, dx) * 180 / Math.PI;
  const flipText = ang > 90 || ang < -90;
  const col = tone || CT_BROWN;
  return (
    <g stroke={col} strokeWidth={CTW.thin} fill="none">
      <line x1={x1} y1={y1} x2={x2} y2={y2} />
      <line x1={x1 - nx * t} y1={y1 - ny * t} x2={x1 + nx * t} y2={y1 + ny * t} />
      <line x1={x2 - nx * t} y1={y2 - ny * t} x2={x2 + nx * t} y2={y2 + ny * t} />
      <text x={mx} y={my + (flip ? 3.2 : -1.3)} fontSize="2.5" fill={col} stroke="none"
        fontFamily={CT_FONT} textAnchor="middle"
        transform={`rotate(${flipText ? ang + 180 : ang} ${mx} ${my})`}>{text}</text>
    </g>
  );
}

function CtViewBox({ x, y, w, h, title, scaleNote, children }) {
  return (
    <g>
      <rect x={x} y={y} width={w} height={h} fill="none" stroke={CT_INK}
        strokeWidth={CTW.thin} opacity="0.45" />
      <text x={x + 1.5} y={y + 3.6} fontSize="2.6" fontWeight="bold" fill={CT_BROWN}
        fontFamily={CT_FONT} letterSpacing="0.9">{title}</text>
      {scaleNote && (
        <text x={x + w - 1.5} y={y + 3.6} fontSize="2" fill={CT_INK} opacity="0.5"
          fontFamily={CT_FONT} textAnchor="end">{scaleNote}</text>
      )}
      <line x1={x} y1={y + 5} x2={x + w} y2={y + 5} stroke={CT_INK} strokeWidth={CTW.thin} opacity="0.35" />
      {children}
    </g>
  );
}


// The plan, at a stated scale. Everything inside is PAPER millimetres: model
// inches x 25.4 / denominator. Nothing is re-derived — the outline, the sides,
// the splash, the overhang and the cutouts all come off the same counter
// record the drawing screen edits, so the sheet and the screen cannot
// disagree.
function CtSheetPlan({ area, counter, x, y, w, h, denom, system }) {
  const b = ctBounds([counter]);
  const sp = v => (v * 25.4) / denom;                 // model inches -> paper mm
  const modW = b.maxX - b.minX, modH = b.maxY - b.minY;
  const band = 11;                                    // room for the dimension strings
  const ox = x + (w - sp(modW)) / 2 - sp(b.minX);
  const oy = y + 5 + (h - 5 - sp(modH)) / 2 - sp(b.minY);
  const P = (px, py) => `${ox + sp(px)},${oy + sp(py)}`;
  const fmt = v => ctFmtIn(v, system);
  const segs = ctAllSegments(counter);
  const cab = ctCabinetLine(counter);
  const th = (ctNum(counter.thicknessCm) || 3) / 2.54;

  return (
    <g>
      {/* The stone itself. */}
      <polygon points={(counter.points || []).map(p => P(p.x, p.y)).join(' ')}
        fill="#fdfcfa" stroke={CT_INK} strokeWidth={CTW.cut} />

      {/* The SPLASH as a band of the slab's own thickness against the wall. */}
      {segs.map(sg => {
        const hgt = ctNum(sg.splashHeight) > 0 ? ctNum(sg.splashHeight)
          : (sg.kind === 'Splash' ? ctNum(area && area.splashHeightIn) : 0);
        if (!(hgt > 0)) return null;
        const inx = -sg.nx, iny = -sg.ny;
        return (
          <g key={`sp${sg.i}`}>
            <polygon points={[P(sg.a.x, sg.a.y), P(sg.b.x, sg.b.y),
              P(sg.b.x + inx * th, sg.b.y + iny * th), P(sg.a.x + inx * th, sg.a.y + iny * th)].join(' ')}
              fill={CT_BROWN} fillOpacity="0.2" stroke={CT_BROWN} strokeWidth={CTW.detail} />
            <text x={ox + sp((sg.a.x + sg.b.x) / 2 + inx * th * 0.5)}
              y={oy + sp((sg.a.y + sg.b.y) / 2 + iny * th * 0.5) + 0.8}
              fontSize="2" fill={CT_BROWN} fontFamily={CT_FONT} textAnchor="middle" fontWeight="bold">
              {fmt(hgt)} SPLASH
            </text>
          </g>
        );
      })}

      {/* The CABINET LINE, all the way round, dashed. */}
      {cab.length >= 3 && (
        <polygon points={cab.map(p => P(p.x, p.y)).join(' ')} fill="none"
          stroke={CT_INK} strokeWidth={CTW.detail} strokeDasharray="2.4,1.6" opacity="0.55" />
      )}

      {/* Cutouts. A sink and a cooktop are openings; a faucet is bores. */}
      {(counter.cutouts || []).filter(cu => cu.kind !== 'Outlet').map(cu => {
        if (cu.kind === 'Faucet Hole') {
          const fx = ctFaucetX(counter, cu);
          return (
            <g key={cu.id}>
              {ctFaucetHoles(cu).map((bo, k) => (
                <circle key={k} cx={ox + sp(fx + bo.dx)} cy={oy + sp(ctNum(cu.y))}
                  r={sp(bo.dia / 2)} fill="#fff" stroke={CT_INK} strokeWidth={CTW.outline} />
              ))}
            </g>
          );
        }
        const cw = ctNum(cu.widthIn), ch = ctNum(cu.depthIn);
        if (!(cw > 0) || !(ch > 0)) return null;
        return (
          <g key={cu.id}>
            <rect x={ox + sp(ctNum(cu.x) - cw / 2)} y={oy + sp(ctNum(cu.y) - ch / 2)}
              width={sp(cw)} height={sp(ch)}
              fill="#fff" stroke={CT_INK} strokeWidth={CTW.outline}
              strokeDasharray={cu.kind === 'Cooktop' ? '2,1.4' : undefined} />
            <text x={ox + sp(ctNum(cu.x))} y={oy + sp(ctNum(cu.y)) + 0.8}
              fontSize="2" fill={CT_INK} fontFamily={CT_FONT} textAnchor="middle" opacity="0.7">
              {cu.kind === 'Sink' ? `${fmt(cw)} × ${fmt(ch)}` : cu.kind.toUpperCase()}
            </text>
          </g>
        );
      })}

      {/* Every side, dimensioned, outside the outline. */}
      {segs.map(sg => {
        if (sg.len < 4) return null;
        const off = CT_SHEET_DIM_OFFSET_IN;
        return (
          <CtDim key={`d${sg.i}`}
            x1={ox + sp(sg.a.x + sg.nx * off)} y1={oy + sp(sg.a.y + sg.ny * off)}
            x2={ox + sp(sg.b.x + sg.nx * off)} y2={oy + sp(sg.b.y + sg.ny * off)}
            text={fmt(sg.len)} />
        );
      })}
    </g>
  );
}


// The side panel: LEON's own lockup, the job, and the facts a reviewer checks
// before reading the drawing. Same shape as the door sheet, deliberately.
function CtSheetPanel({ project, ctx, area, counter, pl, x, y, w, h, system, takeoff }) {
  const opt = ctSelectedOption(area);
  const mat = ctMaterialOf(pl, opt);
  const col = ctColorOf(mat, opt);
  const fmt = v => ctFmtIn(v, system);
  const rows = [
    ['AREA', area ? area.name : ''],
    ['COUNTER', counter ? counter.name : ''],
    ['MATERIAL', mat ? mat.name : '— not selected —'],
    ['COLOUR', col ? col.name : (opt && opt.priceGroupId ? 'price group only' : '— not selected —')],
    ['THICKNESS', `${ctNum(counter && counter.thicknessCm) || 3} cm`],
    ['EDGE', (opt && opt.edgeProfile) || ctAreaEdgeProfile(area)],
    ['SQ FT', takeoff ? `${(takeoff.topSqFt || 0).toFixed(2)}` : ''],
    ['PERIMETER', takeoff ? ctFmtIn(takeoff.perimeterIn || 0, system) : ''],
  ];
  const co = (ctx && ctx.companyProfile) || {};
  return (
    <g>
      <rect x={x} y={y} width={w} height={h} fill="#ffffff" stroke={CT_INK}
        strokeWidth={CTW.thin} opacity="0.9" />
      {/* The real lockup, referenced as the brand SVG — no font reproduces the
          lion or the E. */}
      <rect x={x} y={y} width={w} height={40} fill={CT_CREAM} />
      <image href="logo/leon-official.svg" x={x + w / 2 - 15} y={y + 2} width="30" height="36"
        preserveAspectRatio="xMidYMid meet" />
      <line x1={x} y1={y + 40} x2={x + w} y2={y + 40} stroke={CT_BROWN} strokeWidth="0.5" />
      <text x={x + 2} y={y + 45} fontSize="2.6" fill={CT_INK} fontFamily={CT_FONT} fontWeight="bold">
        {String((project && project.name) || '').slice(0, 28)}
      </text>
      <text x={x + 2} y={y + 48.6} fontSize="1.9" fill={CT_INK} fontFamily={CT_FONT} opacity="0.55">
        {(project && project.projectNumber) || ''}
      </text>
      <line x1={x} y1={y + 50.5} x2={x + w} y2={y + 50.5} stroke={CT_LINE} strokeWidth={CTW.thin} />
      {rows.map(([k, v], i) => (
        <g key={k}>
          <text x={x + 2} y={y + 56 + i * 7} fontSize="1.8" fill={CT_INK} fontFamily={CT_FONT}
            opacity="0.45" letterSpacing="0.5">{k}</text>
          <text x={x + 2} y={y + 59.4 + i * 7} fontSize="2.4" fill={CT_INK} fontFamily={CT_FONT}>
            {String(v == null ? '' : v).slice(0, 24)}
          </text>
          <line x1={x + 2} y1={y + 61 + i * 7} x2={x + w - 2} y2={y + 61 + i * 7}
            stroke={CT_LINE} strokeWidth={CTW.thin} opacity="0.6" />
        </g>
      ))}
      {/* The company block sits at the foot of the panel, as on the door sheet. */}
      <text x={x + 2} y={y + h - 7} fontSize="1.8" fill={CT_INK} fontFamily={CT_FONT} opacity="0.45">
        {String(co.name || 'LEON INTEGRA').toUpperCase()}
      </text>
      <text x={x + 2} y={y + h - 3.6} fontSize="1.7" fill={CT_INK} fontFamily={CT_FONT} opacity="0.4">
        {[co.addressLine1, co.phone].filter(Boolean).join(' · ').slice(0, 34)}
      </text>
    </g>
  );
}

// FINISHES & SELECTIONS along the bottom, at a size someone can judge a stone
// at. Every one is resolved from the SUPPLIER LIBRARY through the price list's
// own colour record, so the drawing names the same product the order will —
// there is no second description of the material anywhere.
function CtSheetFinishes({ area, counter, pl, ctx, x, y, w, h, system }) {
  const opt = ctSelectedOption(area);
  const mat = ctMaterialOf(pl, opt);
  const col = ctColorOf(mat, opt);
  const ref = col && col.supplierRef ? col.supplierRef : null;
  const vendor = ref && ref.vendorId && ctx && Array.isArray(ctx.vendors)
    ? (ctx.vendors.find(v => v.id === ref.vendorId) || null) : null;
  const segs = ctAllSegments(counter);
  const profiles = {};
  segs.forEach(sg => {
    if (sg.kind !== 'Finished' && sg.kind !== 'Splash') return;
    const pr = sg.edgeProfile || ctAreaEdgeProfile(area);
    profiles[pr] = (profiles[pr] || 0) + sg.len;
  });
  const cards = [];
  cards.push({
    tag: 'SLAB', name: ref ? ref.name : (col ? col.name : (mat ? mat.name : 'Not selected')),
    sub: ref ? [ref.supplier, ref.code].filter(Boolean).join(' · ')
             : (mat ? mat.type : 'Choose a material and colour'),
    img: ref ? ref.img : '', linked: !!ref,
  });
  Object.keys(profiles).forEach(pr => cards.push({
    tag: 'EDGE', name: pr, sub: `${ctFmtIn(profiles[pr], system)} of edge`, img: '', linked: true,
  }));
  const splash = segs.filter(sg => ctNum(sg.splashHeight) > 0 || sg.kind === 'Splash');
  if (splash.length) {
    const hgt = ctNum(splash[0].splashHeight) || ctNum(area && area.splashHeightIn);
    cards.push({ tag: 'SPLASH', name: `${ctFmtIn(hgt, system)} backsplash`,
      sub: ref ? `${ref.name} — same slab` : 'Same material as the top', img: ref ? ref.img : '', linked: !!ref });
  }
  const sinks = (counter.cutouts || []).filter(c => c.kind === 'Sink');
  sinks.forEach(sk => cards.push({
    tag: 'SINK', name: sk.name || `${sk.sinkType} sink`,
    sub: `${ctFmtIn(ctNum(sk.widthIn), system)} × ${ctFmtIn(ctNum(sk.depthIn), system)} opening`,
    img: '', linked: true,
  }));
  const fau = (counter.cutouts || []).filter(c => c.kind === 'Faucet Hole');
  fau.forEach(f => {
    const cfg = CT_FAUCET_CONFIGS.find(z => z.key === f.faucetConfig);
    cards.push({ tag: 'FAUCET', name: cfg ? cfg.label : 'Faucet',
      sub: `${Math.max(1, ctNum(f.faucetHoles))} × ${ctFmtIn(ctNum(f.holeDiaIn) || CT_FAUCET_HOLE_DIA_IN, system)} bores`,
      img: '', linked: true });
  });

  const n = Math.max(1, cards.length);
  const cw = (w - 2) / Math.min(n, 6);
  const imgH = Math.max(14, h - 20);
  return (
    <g>
      <rect x={x} y={y} width={w} height={h} fill="#ffffff" stroke={CT_INK}
        strokeWidth={CTW.thin} opacity="0.9" />
      <text x={x + 2} y={y + 4} fontSize="2.4" fontWeight="bold" fill={CT_BROWN}
        fontFamily={CT_FONT} letterSpacing="1">FINISHES &amp; SELECTIONS</text>
      <line x1={x} y1={y + 5.6} x2={x + w} y2={y + 5.6} stroke={CT_INK} strokeWidth={CTW.thin} opacity="0.35" />
      {cards.slice(0, 6).map((c, i) => {
        const cx = x + 1 + i * cw;
        return (
          <g key={i}>
            {c.img
              ? <image href={c.img} x={cx + 1} y={y + 7} width={cw - 3} height={imgH}
                  preserveAspectRatio="xMidYMid slice" />
              : <rect x={cx + 1} y={y + 7} width={cw - 3} height={imgH} fill={CT_CREAM}
                  stroke={CT_LINE} strokeWidth={CTW.thin} />}
            <rect x={cx + 1} y={y + 7} width={cw - 3} height={imgH} fill="none"
              stroke={CT_INK} strokeWidth={CTW.thin} opacity="0.4" />
            <text x={cx + 2.5} y={y + 10.5} fontSize="1.8" fill={CT_BROWN} fontFamily={CT_FONT}
              fontWeight="bold" letterSpacing="0.6">{c.tag}</text>
            <text x={cx + 1} y={y + 7 + imgH + 4} fontSize="2.2" fill={CT_INK} fontFamily={CT_FONT}>
              {String(c.name || '').slice(0, Math.floor(cw / 1.15))}
            </text>
            <text x={cx + 1} y={y + 7 + imgH + 7.4} fontSize="1.8" fill={CT_INK} fontFamily={CT_FONT} opacity="0.55">
              {String(c.sub || '').slice(0, Math.floor(cw / 1))}
            </text>
          </g>
        );
      })}
      {!cards[0].linked && (
        <text x={x + w - 2} y={y + 4} fontSize="1.8" fill="#b83b3b" fontFamily={CT_FONT} textAnchor="end">
          Material not linked to the supplier library
        </text>
      )}
    </g>
  );
}



// The EDGE, in section, at a detail scale. A plan cannot show a profile, and
// the profile is the single most visible thing about a finished countertop —
// so a sheet without it is a sheet the client cannot check.
function CtEdgeProfilePath(profile, x, y, w, t) {
  // x,y is the top-outer corner; the stone runs right (inward) and down.
  const r = t / 2;
  switch (profile) {
    case 'Square':
      return `M${x},${y} L${x + w},${y} L${x + w},${y + t} L${x},${y + t} Z`;
    case 'Bevel':
      return `M${x + t * 0.35},${y} L${x + w},${y} L${x + w},${y + t} L${x},${y + t} L${x},${y + t * 0.35} Z`;
    case 'Double Bevel':
      return `M${x + t * 0.3},${y} L${x + w},${y} L${x + w},${y + t} L${x + t * 0.3},${y + t} L${x},${y + t * 0.7} L${x},${y + t * 0.3} Z`;
    case 'Bullnose':
      return `M${x + r},${y} L${x + w},${y} L${x + w},${y + t} L${x + r},${y + t} A${r},${r} 0 0 1 ${x + r},${y} Z`;
    case 'Half Bullnose':
      return `M${x + r},${y} L${x + w},${y} L${x + w},${y + t} L${x},${y + t} L${x},${y + r} A${r},${r} 0 0 1 ${x + r},${y} Z`;
    case 'Demi Bullnose':
      return `M${x + r * 0.6},${y} L${x + w},${y} L${x + w},${y + t} L${x},${y + t} L${x},${y + r * 0.6} A${r * 0.8},${r * 0.8} 0 0 1 ${x + r * 0.6},${y} Z`;
    case 'Cove':
      return `M${x},${y} L${x + w},${y} L${x + w},${y + t} L${x},${y + t} L${x},${y + t * 0.6} A${t * 0.4},${t * 0.4} 0 0 0 ${x + t * 0.4},${y + t * 0.2} Z`;
    case 'Ogee':
      return `M${x + t * 0.5},${y} L${x + w},${y} L${x + w},${y + t} L${x},${y + t}`
        + ` C${x + t * 0.1},${y + t * 0.75} ${x + t * 0.55},${y + t * 0.6} ${x + t * 0.45},${y + t * 0.35}`
        + ` C${x + t * 0.4},${y + t * 0.15} ${x + t * 0.45},${y + t * 0.05} ${x + t * 0.5},${y} Z`;
    case 'DuPont':
      return `M${x + t * 0.45},${y} L${x + w},${y} L${x + w},${y + t} L${x},${y + t} L${x},${y + t * 0.45}`
        + ` A${t * 0.45},${t * 0.45} 0 0 1 ${x + t * 0.45},${y} Z`;
    case 'Eased':
    default:
      return `M${x + t * 0.12},${y} L${x + w},${y} L${x + w},${y + t} L${x},${y + t} L${x},${y + t * 0.12}`
        + ` A${t * 0.12},${t * 0.12} 0 0 1 ${x + t * 0.12},${y} Z`;
  }
}
// The edge profile and the splash, in section, side by side. Two things a plan
// can never say and a fabricator always asks.
function CtSheetSections({ area, counter, x, y, w, h, system }) {
  const th = ((ctNum(counter.thicknessCm) || 3) / 2.54) * 25.4;   // mm of stone
  const segs = ctAllSegments(counter);
  const defProf = ctAreaEdgeProfile(area);
  const prof = (segs.find(sg => sg.kind === 'Finished' && sg.edgeProfile) || {}).edgeProfile || defProf;
  const splashSeg = segs.find(sg => ctNum(sg.splashHeight) > 0 || sg.kind === 'Splash');
  const splashH = splashSeg
    ? (ctNum(splashSeg.splashHeight) || ctNum(area && area.splashHeightIn)) : 0;
  const fmt = v => ctFmtIn(v, system);
  const half = (w - 4) / 2;
  // A section is a DETAIL — its own scale, stated, because at the plan's scale
  // 30 mm of stone is a hairline.
  // A DETAIL SCALE, not the plan's — at 1:5 a 30 mm edge is 6 mm of paper.
  // The drawn group is the stone plus a run of deck (4x thickness) and the
  // dimension to its left, so that whole width is what has to fit.
  // The drawn group is the stone, the cabinet box under it and the overhang
  // dimension below that — about 3.4x the thickness. Fitting on the stone
  // alone is what let the cabinet box hang out of the band.
  const dnE = Math.max(ctFitDenom(th * 5.5, half - 24, true),
                       ctFitDenom(th * 3.6, h - 20, true));
  const sE = mm => mm / dnE;
  const dnS = splashH > 0
    ? Math.max(ctFitDenom(th * 8, half - 24, true),
               ctFitDenom(splashH * 25.4 + th * 2, h - 20, true))
    : dnE;
  const sS = mm => mm / dnS;

  // CENTRED in the half it is given, both ways — a detail pinned to a corner
  // with dead space beside it reads as unfinished.
  const eDrawW = sE(th * 4), eDrawH = sE(th * 3.4);
  const ex = x + (half - eDrawW) / 2, ey = y + 6 + (h - 12 - eDrawH) / 2;
  const runW = sE(th * 3);
  const sDrawW = sS(th * 7), sDrawH = sS(splashH * 25.4 + th);
  const sx = x + half + 4 + (half - sDrawW) / 2 + sS(th);
  const sBase = y + 6 + (h - 12 - sDrawH) / 2 + sS(splashH * 25.4);

  return (
    <g>
      {/* ── the edge ── */}
      <text x={x + 2} y={y + 4} fontSize="2.2" fontWeight="bold" fill={CT_BROWN}
        fontFamily={CT_FONT} letterSpacing="0.7">EDGE — {String(prof).toUpperCase()}</text>
      <text x={x + half - 2} y={y + 4} fontSize="1.8" fill={CT_INK} opacity="0.5"
        fontFamily={CT_FONT} textAnchor="end">DETAIL · {ctScaleLabel(dnE, true)}</text>
      <path d={CtEdgeProfilePath(prof, ex, ey, runW, sE(th))}
        fill="#f0ebe3" stroke={CT_INK} strokeWidth={CTW.cut} />
      <CtDim x1={ex - 4} y1={ey} x2={ex - 4} y2={ey + sE(th)}
        text={`${ctNum(counter.thicknessCm) || 3} cm`} />
      {/* The cabinet under it, set back by the overhang — drawn as reference, so
          the section reads as an assembly and the overhang is visible in
          section as well as in plan. */}
      {(() => {
        const over = ctSegOverhang(segs.find(sg => sg.kind === 'Finished') || segs[0], counter) * 25.4;
        const cabX = ex + sE(over);
        return (
          <g>
            <rect x={cabX} y={ey + sE(th)} width={Math.max(2, ex + runW - cabX)} height={sE(th) * 1.4}
              fill="none" stroke={CT_INK} strokeWidth={CTW.detail} strokeDasharray="2,1.5" opacity="0.6" />
            {over > 0 && (
              <CtDim x1={ex} y1={ey + sE(th) + sE(th) * 1.7} x2={cabX} y2={ey + sE(th) + sE(th) * 1.7}
                text={fmt(over / 25.4)} flip />
            )}
            <text x={ex + runW} y={ey + sE(th) * 2.9} fontSize="1.7" fill={CT_INK} opacity="0.45"
              fontFamily={CT_FONT} textAnchor="end">CABINET BELOW · OVERHANG</text>
          </g>
        );
      })()}

      <line x1={x + half + 2} y1={y + 6} x2={x + half + 2} y2={y + h - 3}
        stroke={CT_LINE} strokeWidth={CTW.thin} />

      {/* ── the splash ── */}
      <text x={x + half + 6} y={y + 4} fontSize="2.2" fontWeight="bold" fill={CT_BROWN}
        fontFamily={CT_FONT} letterSpacing="0.7">
        {splashH > 0 ? `SPLASH — ${fmt(splashH)}` : 'SPLASH — none on this counter'}
      </text>
      {splashH > 0 && (
        <>
          <text x={x + w - 2} y={y + 4} fontSize="1.8" fill={CT_INK} opacity="0.5"
            fontFamily={CT_FONT} textAnchor="end">DETAIL · {ctScaleLabel(dnS, true)}</text>
          {/* the wall */}
          <line x1={sx - sS(th)} y1={sBase - sS(splashH * 25.4) - 6} x2={sx - sS(th)} y2={sBase + 4}
            stroke={CT_INK} strokeWidth={CTW.outline} />
          {/* the splash standing on the deck */}
          <rect x={sx - sS(th)} y={sBase - sS(splashH * 25.4)} width={sS(th)} height={sS(splashH * 25.4)}
            fill="#f0ebe3" stroke={CT_INK} strokeWidth={CTW.cut} />
          {/* the deck it sits on */}
          <rect x={sx - sS(th)} y={sBase} width={sS(th * 6)} height={sS(th)}
            fill="#f0ebe3" stroke={CT_INK} strokeWidth={CTW.cut} />
          <CtDim x1={sx + sS(th) + 5} y1={sBase - sS(splashH * 25.4)} x2={sx + sS(th) + 5} y2={sBase}
            text={fmt(splashH)} />
          <text x={sx + sS(th * 6)} y={sBase + sS(th) + 4} fontSize="1.7" fill={CT_INK} opacity="0.45"
            fontFamily={CT_FONT} textAnchor="end">DECK</text>
        </>
      )}
    </g>
  );
}

// One sheet per counter: the plan at a stated scale, the side panel, and the
// finishes along the bottom.
function CtShopDrawingPage({ project, ctx, area, counter, pl, size, denom, system, sheetNo, rev, autoFit }) {
  const S = ctSheetSize(size);
  const m = 7, panelW = 74, gap = 2.5;
  const drawW = S.w - m * 2 - panelW - gap;
  const top = m + 2;
  const footH = Math.max(42, Math.round(S.h * 0.135));
  const bodyH = S.h - m * 2 - 4 - footH - gap;
  const b = ctBounds([counter]);
  // The plan takes the tall band; the two sections share a strip beneath it.
  // Without them the plan box was nearly the whole sheet for a drawing that
  // needed a third of it, and an edge profile — the most visible thing about a
  // finished top — was nowhere on the sheet at all.
  const modW = (b.maxX - b.minX) * 25.4, modH = (b.maxY - b.minY) * 25.4;
  // The DIMENSION BANDS scale with the drawing, so they belong inside the
  // budget rather than being a flat margin — a flat one under-counts at every
  // step down the ladder and lets a scale be chosen that then runs its own
  // dimensions off the sheet.
  const dimBand = CT_SHEET_DIM_OFFSET_IN * 25.4 * 2 + 60;
  // THE PLAN BAND TAKES WHAT THE DRAWING NEEDS, not a fixed share. A 96"
  // counter at 1:10 is a third of the sheet, and a band fixed at 78% left the
  // plan floating over half a page of white while the sections were squeezed.
  // Choose the scale against the room available, then give the band the height
  // that scale actually asks for and hand the rest to the sections.
  // The SECTIONS are details — they take a detail's worth of the sheet and no
  // more. The plan is the main view and gets everything left over: a plan with
  // room around it reads well, two details floating in half a page do not.
  const secH = Math.min(Math.round(bodyH * 0.30), 120);
  const planH = bodyH - secH - gap;
  const fit = Math.max(ctFitDenom(modW + dimBand, drawW - 6),
                       ctFitDenom(modH + dimBand, planH - 12));
  const dn = autoFit ? fit : denom;

  const takeoff = ctAreaTakeoff({ counters: [counter], splashHeightIn: area && area.splashHeightIn,
    colorOptions: area && area.colorOptions }, {});

  return (
    <svg width={`${S.w}mm`} height={`${S.h}mm`} viewBox={`0 0 ${S.w} ${S.h}`}
      style={{ background: '#fff', maxWidth: '100%', height: 'auto' }}
      role="img" aria-label={`Countertop shop drawing — ${counter.name}`}>
      <rect x="0" y="0" width={S.w} height={S.h} fill="#ffffff" />
      <rect x={m / 2} y={m / 2} width={S.w - m} height={S.h - m}
        fill="none" stroke={CT_INK} strokeWidth="0.5" />

      <CtViewBox x={m} y={top} w={drawW} h={planH}
        title="PLAN — COUNTERTOP"
        scaleNote={`SCALE ${ctScaleLabel(dn)}`}>
        <CtSheetPlan area={area} counter={counter} x={m} y={top} w={drawW} h={planH}
          denom={dn} system={system} />
      </CtViewBox>

      <CtViewBox x={m} y={top + planH + gap} w={drawW} h={secH} title="SECTIONS">
        <CtSheetSections area={area} counter={counter} x={m} y={top + planH + gap + 5}
          w={drawW} h={secH - 5} system={system} />
      </CtViewBox>

      <CtSheetPanel project={project} ctx={ctx} area={area} counter={counter} pl={pl}
        x={m + drawW + gap} y={top} w={panelW} h={bodyH} system={system} takeoff={takeoff} />

      <CtSheetFinishes area={area} counter={counter} pl={pl} ctx={ctx}
        x={m} y={top + bodyH + gap} w={S.w - m * 2} h={footH} system={system} />

      <text x={S.w - m} y={S.h - m / 2 - 1.5} fontSize="2" fill={CT_INK} fontFamily={CT_FONT}
        textAnchor="end" opacity="0.5">
        {sheetNo || ''}{rev ? ` · ${rev}` : ''}
      </text>
    </svg>
  );
}

// The Shop Drawing section. One sheet per counter in the area, paginated by
// the picker rather than crammed onto one page — a countertop sheet is read
// piece by piece.
function CtShopDrawingPanel({ ctx, project, quote, editable }) {
  const areas = (quote && quote.areas) || [];
  const [areaId, setAreaId] = useState(areas[0] ? areas[0].id : '');
  const [counterId, setCounterId] = useState('');
  const [sizeKey, setSizeKey] = useState('A2');
  const [scaleKey, setScaleKey] = useState('fit');
  const ref = useRef(null);
  const area = areas.find(a => a.id === areaId) || areas[0] || null;
  const counters = (area && area.counters) || [];
  const counter = counters.find(c => c.id === counterId) || counters[0] || null;
  const pl = ctPriceListFor(ctx, quote);
  const system = ctQuoteUnits(quote, pl);
  const scales = (typeof SHEET_SCALES !== 'undefined' && SHEET_SCALES) || [];
  const sizes = (typeof SHEET_SIZES !== 'undefined' && SHEET_SIZES) || [];

  if (!areas.length) return <EmptyState text="This quote has no areas yet." />;
  if (!counter) return <EmptyState text="No counter drawn in this area yet. Draw one under Drawing first — the sheet is drawn from the counter, not beside it." />;

  return (
    <div className="space-y-3">
      <div>
        <h3 className="font-bold">📄 Shop Drawing</h3>
        <p className="text-sm text-[var(--leon-black)]/60 max-w-3xl">
          The counter at a stated scale, with LEON's own lockup, the job information down the side, and the
          finishes and selections along the bottom &mdash; the same sheet model the door shop drawings use, so a
          reviewer learns one sheet and not one per trade. Drawn from the counter record itself, so the sheet
          and the drawing screen cannot disagree.
        </p>
      </div>

      <div className="flex items-end gap-3 flex-wrap">
        <Field label="Area">
          <Select className="!w-44" value={area ? area.id : ''} onChange={e => { setAreaId(e.target.value); setCounterId(''); }}>
            {areas.map(a => <option key={a.id} value={a.id}>{a.name}</option>)}
          </Select>
        </Field>
        <Field label="Counter">
          <Select className="!w-44" value={counter ? counter.id : ''} onChange={e => setCounterId(e.target.value)}>
            {counters.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
          </Select>
        </Field>
        <Field label="Sheet size">
          <Select className="!w-48" value={sizeKey} onChange={e => setSizeKey(e.target.value)}>
            {sizes.map(z => <option key={z.key} value={z.key}>{z.label}</option>)}
          </Select>
        </Field>
        <Field label="Scale">
          <Select className="!w-44" value={scaleKey} onChange={e => setScaleKey(e.target.value)}>
            <option value="fit">Fit to the sheet</option>
            {scales.map(z => <option key={z.key} value={z.key}>{z.label}</option>)}
          </Select>
        </Field>
        <div className="ml-auto flex items-end gap-1.5">
          <IconAction icon="🖨" title="Print this sheet"
            onClick={() => printRegion(ref.current, { title: `${project.name} — ${counter.name}`, heading: 'Countertop shop drawing' })} />
        </div>
      </div>

      <div ref={ref} data-print-region="Countertop shop drawing"
        className="overflow-auto rounded-lg border border-[var(--leon-line)] bg-[var(--leon-cream)]/40 p-3">
        <CtShopDrawingPage project={project} ctx={ctx} area={area} counter={counter} pl={pl}
          size={sizeKey} system={system} autoFit={scaleKey === 'fit'}
          denom={(scales.find(z => z.key === scaleKey) || {}).denom || 20}
          sheetNo={`CT-${String(counters.indexOf(counter) + 1).padStart(2, '0')}`}
          rev={`REV ${quote.revision || 0}`} />
      </div>

      <p className="text-[11px] text-[var(--leon-black)]/45 max-w-3xl">
        A browser prints through the page, so the sheet size here sets the DRAWING size &mdash; choose matching
        paper in the print dialog and turn scaling off, or the scale in the header stops being true. That is a
        limitation of printing from a browser, not of the drawing.
      </p>
    </div>
  );
}

// ── Step 4 · Sink & Cooktop ───────────────────────────────────────────────

function CtStepCutouts({ ctx, area, counter, sel, editable, withArea, withCounter, onSel, pl, round, sys }) {
  const [preset, setPreset] = useState('');
  if (!counter) return <EmptyState text="Pick a counter in step 1 first." />;
  const cutouts = (counter.cutouts || []).filter(c => c.kind !== 'Outlet' && c.kind !== 'Faucet Hole');
  const faucets = (counter.cutouts || []).filter(c => c.kind === 'Faucet Hole');
  const shortcuts = ((pl || {}).cutouts || {}).shortcuts || [];

  function place(spec) {
    const b = ctBounds([counter]);
    const w = ctNum(spec.widthIn), d = ctNum(spec.depthIn);
    // A sink is cut at a SETBACK from the front edge, not dropped in the middle
    // of the deck. 4" is the standard and the field beneath is there to change
    // it — the cabinet and the bowl both move it in practice.
    const frontMaxY = ctFrontIsMaxY(counter);
    // x/y is the CENTRE, so the setback is solved back to it.
    const y = (d > 0)
      ? round(frontMaxY ? b.maxY - CT_SINK_SETBACK_IN - d / 2 : b.minY + CT_SINK_SETBACK_IN + d / 2)
      : round((b.minY + b.maxY) / 2);
    withCounter(c => {
      c.cutouts = (c.cutouts || []).concat([makeCtCutout(Object.assign({
        kind: spec.kind || 'Sink', sinkType: spec.sinkType || 'Undermount', name: spec.label || '',
        widthIn: w, depthIn: d, faucetHoles: ctNum(spec.faucetHoles),
        x: round((b.minX + b.maxX) / 2), y,
      }))]);
    }, `LEON Countertop — ${spec.label || spec.kind || 'cutout'} placed on ${counter.name} at a ${CT_SINK_SETBACK_IN}" setback`);
  }

  return (
    <div className="space-y-3">
      <CtPanel title="Place a cutout">
        <div className="flex items-end gap-2 flex-wrap">
          <Field label="Standard sizes" className="grow min-w-[12rem]">
            <Select className="!py-1" value={preset} onChange={e => setPreset(e.target.value)} disabled={!editable}>
              <option value="">— choose —</option>
              <optgroup label="Standard openings">
                {CT_SINK_PRESETS.map((p, i) => <option key={`p${i}`} value={`p${i}`}>{p.label}</option>)}
              </optgroup>
              {shortcuts.length > 0 && (
                <optgroup label="Price-list shortcuts">
                  {shortcuts.map(s => <option key={s.id} value={`s${s.id}`}>{s.label}</option>)}
                </optgroup>
              )}
            </Select>
          </Field>
          <Button size="sm" disabled={!editable || !preset} onClick={() => {
            const spec = preset[0] === 'p' ? CT_SINK_PRESETS[Number(preset.slice(1))] : shortcuts.find(s => s.id === preset.slice(1));
            if (spec) place(spec);
          }}>Place</Button>
          <Button size="sm" variant="outline" disabled={!editable}
            onClick={() => place({ kind: 'Sink', sinkType: 'Undermount', widthIn: 0, depthIn: 0, faucetHoles: 0, label: '' })}>
            Type a size instead
          </Button>
        </div>
        <p className="text-[11px] text-[var(--leon-black)]/50 mt-2">
          These are cutout OPENINGS, not bowl sizes — the opening is what prices the cutout, and its longest
          edge is what picks the size tier.
        </p>
      </CtPanel>

      <CtPanel title={`${counter.name} — cutouts`}>
        {cutouts.length === 0 ? <EmptyState text="No cutouts on this counter yet." /> : (
          <div className="space-y-2">
            {cutouts.map(cu => {
              const selected = sel && sel.kind === 'cutout' && sel.idx === cu.id;
              return (
                <div key={cu.id} className={`rounded-md border p-2 ${selected ? 'border-[var(--leon-brown)] bg-[var(--leon-cream)]' : 'border-[var(--leon-line)]'}`}>
                  <div className="flex items-end gap-2 flex-wrap">
                    <Field label="Kind" className="w-28">
                      <Select className="!py-1" value={cu.kind} disabled={!editable}
                        onFocus={() => onSel({ counterId: counter.id, kind: 'cutout', idx: cu.id })}
                        onChange={e => withCounter(c => { const t = c.cutouts.find(z => z.id === cu.id); if (t) t.kind = e.target.value; }, `LEON Countertop — cutout kind set`)}>
                        {CT_CUTOUT_KINDS.filter(k => k !== 'Outlet' && k !== 'Faucet Hole').map(k => <option key={k}>{k}</option>)}
                      </Select>
                    </Field>
                    {cu.kind === 'Sink' && (
                      <Field label="Sink type" className="w-32">
                        <Select className="!py-1" value={cu.sinkType} disabled={!editable}
                          onChange={e => withCounter(c => { const t = c.cutouts.find(z => z.id === cu.id); if (t) t.sinkType = e.target.value; }, `LEON Countertop — sink type set`)}>
                          {CT_SINK_TYPES.map(k => <option key={k}>{k}</option>)}
                        </Select>
                      </Field>
                    )}
                    <Field label="Width" className="w-24">
                      <TextInput className="!py-1" defaultValue={cu.widthIn ? ctFmtIn(cu.widthIn, sys) : ''} disabled={!editable}
                        onBlur={e => { const v = ctParseIn(e.target.value, sys); withCounter(c => { const t = c.cutouts.find(z => z.id === cu.id); if (t) t.widthIn = v === null ? 0 : v; }, `LEON Countertop — cutout size set`); }} />
                    </Field>
                    <Field label="Depth" className="w-24">
                      <TextInput className="!py-1" defaultValue={cu.depthIn ? ctFmtIn(cu.depthIn, sys) : ''} disabled={!editable}
                        onBlur={e => { const v = ctParseIn(e.target.value, sys); withCounter(c => { const t = c.cutouts.find(z => z.id === cu.id); if (t) t.depthIn = v === null ? 0 : v; }, `LEON Countertop — cutout size set`); }} />
                    </Field>
                    <Field label="Faucet holes" className="w-24">
                      <TextInput className="!py-1" defaultValue={ctNum(cu.faucetHoles)} disabled={!editable}
                        onBlur={e => withCounter(c => { const t = c.cutouts.find(z => z.id === cu.id); if (t) t.faucetHoles = Math.max(0, ctNum(e.target.value)); }, `LEON Countertop — faucet holes set`)} />
                    </Field>
                    <Field label="Rotation" className="w-20">
                      <TextInput className="!py-1" defaultValue={ctNum(cu.rotation)} disabled={!editable}
                        onBlur={e => withCounter(c => { const t = c.cutouts.find(z => z.id === cu.id); if (t) t.rotation = ctNum(e.target.value); }, `LEON Countertop — cutout rotated`)} />
                    </Field>
                    {editable && <IconAction icon="✕" title="Remove this cutout"
                      onClick={() => withCounter(c => { c.cutouts = c.cutouts.filter(z => z.id !== cu.id); }, `LEON Countertop — cutout removed`)} />}
                  </div>
                  <CtCutoutDimFields counter={counter} cu={cu} editable={editable}
                    withCounter={withCounter} sys={sys} />
                </div>
              );
            })}
          </div>
        )}
      </CtPanel>

      <CtFaucetPanel counter={counter} faucets={faucets} editable={editable}
        withCounter={withCounter} sys={sys} sel={sel} onSel={onSel} />

      <CtOutletPanel area={area} counter={counter} editable={editable}
        withArea={withArea} withCounter={withCounter} sys={sys} />

      <CtPanel title="Cutouts and square footage">
        <p className="text-[11px] text-[var(--leon-black)]/60">
          <strong>A cutout never reduces the square footage.</strong> The stone is still bought, still handled
          and still fabricated — the hole is extra work, not less material. Each cutout adds a charge; none of
          them subtracts area. Faucet holes are counted separately from the sink.
        </p>
      </CtPanel>
    </div>
  );
}

// ── Step 5 · Color & Edge, per AREA ───────────────────────────────────────
// An area is a room. A COLOUR OPTION is an alternative the client compares
// side by side — one is selected, the rest are there to price against it.

function CtStepColor({ ctx, project, quote, area, editable, withArea, apply, pl, onArea, sys }) {
  const areas = quote.areas || [];
  if (!area) return <EmptyState text="No areas on this quote yet." />;
  const materials = (pl && pl.materials) || [];
  const options = area.colorOptions || [];

  function addOption() {
    withArea(a => {
      a.colorOptions = (a.colorOptions || []).concat([{
        id: uid('ctopt'), materialId: materials[0] ? materials[0].id : null, colorId: null,
        priceGroupId: null, edgeProfile: 'Eased', edgeProfile2: '', selected: !(a.colorOptions || []).length,
      }]);
    }, `LEON Countertop — colour option added to ${area.name}`);
  }
  function setOpt(id, fields, line) {
    withArea(a => { a.colorOptions = (a.colorOptions || []).map(o => (o.id === id ? Object.assign({}, o, fields) : o)); }, line);
  }
  function moveOpt(id, dir) {
    withArea(a => {
      const list = (a.colorOptions || []).slice();
      const i = list.findIndex(o => o.id === id);
      const j = i + dir;
      if (i < 0 || j < 0 || j >= list.length) return;
      const t = list[i]; list[i] = list[j]; list[j] = t;
      a.colorOptions = list;
    }, `LEON Countertop — colour options re-ordered`);
  }

  return (
    <div className="space-y-3">
      <CtPanel title="Areas" right={editable && (
        <Button size="sm" onClick={() => apply(q => {
          q.areas = (q.areas || []).concat([makeCtArea({ name: 'NEW AREA', splashHeightIn: ctNum(ctSetting('defaultSplashHeightIn', 4)) || 4 })]);
        }, `LEON Countertop — area added to ${quote.name}`)}>+ Add area</Button>
      )}>
        <div className="space-y-1">
          {areas.map((a, i) => (
            <div key={a.id} className={`flex items-center gap-2 rounded-md border px-2 py-1.5 ${a.id === area.id ? 'border-[var(--leon-brown)] bg-[var(--leon-cream)]' : 'border-[var(--leon-line)]'}`}>
              <button className="grow text-left text-sm font-semibold" onClick={() => onArea(a.id)}>{a.name}</button>
              <span className="text-[11px] text-[var(--leon-black)]/45">{(a.counters || []).length} counter{(a.counters || []).length === 1 ? '' : 's'}</span>
              {editable && <IconAction icon="↑" title="Move up" onClick={() => apply(q => {
                const l = q.areas.slice(); if (i === 0) return; const t = l[i]; l[i] = l[i - 1]; l[i - 1] = t; q.areas = l;
              }, 'LEON Countertop — areas re-ordered')} />}
              {editable && <IconAction icon="↓" title="Move down" onClick={() => apply(q => {
                const l = q.areas.slice(); if (i >= l.length - 1) return; const t = l[i]; l[i] = l[i + 1]; l[i + 1] = t; q.areas = l;
              }, 'LEON Countertop — areas re-ordered')} />}
              {editable && areas.length > 1 && <IconAction icon="✕" title="Remove this area"
                onClick={() => apply(q => { q.areas = q.areas.filter(x => x.id !== a.id); }, `LEON Countertop — area removed: ${a.name}`)} />}
            </div>
          ))}
        </div>
        {editable && (
          <Field label="Rename this area" className="mt-2">
            <TextInput defaultValue={area.name} key={area.id}
              onBlur={e => withArea(a => { a.name = e.target.value.toUpperCase(); }, `LEON Countertop — area renamed`)} />
          </Field>
        )}
      </CtPanel>

      <CtPanel title={`${area.name} — colour options`}
        right={editable && <Button size="sm" onClick={addOption}>+ Add color option</Button>}>
        {!materials.length && (
          <div className="rounded-md bg-[#fdfaf2] border border-[#eee0c4] p-2 text-[11px] mb-2">
            The price list has no materials yet. Add them under Price Lists → Materials — including each one's
            slab size, which is what turns a countertop into a slab count.
          </div>
        )}
        {options.length === 0 ? <EmptyState text="No colour option yet. Add one to choose the product, colour and edge." /> : (
          <div className="space-y-2">
            {options.map((o, i) => {
              const mat = ctMaterialOf(pl, o);
              const col = ctColorOf(mat, o);
              const grp = ctGroupOf(mat, o);
              const ref = col && col.supplierRef;
              const vendor = mat && mat.vendorId ? (ctx.vendors || []).find(v => v.id === mat.vendorId) : null;
              return (
                <div key={o.id} className={`rounded-md border p-2 ${o.selected ? 'border-[var(--leon-brown)] bg-[var(--leon-cream)]' : 'border-[var(--leon-line)]'}`}>
                  <div className="flex items-center gap-2 mb-2">
                    <label className="flex items-center gap-1.5 text-xs font-semibold">
                      <input type="radio" checked={!!o.selected} disabled={!editable}
                        onChange={() => withArea(a => { a.colorOptions = (a.colorOptions || []).map(x => Object.assign({}, x, { selected: x.id === o.id })); },
                          `LEON Countertop — colour option selected on ${area.name}`)} />
                      {o.selected ? 'Selected' : 'Alternative'}
                    </label>
                    <div className="grow" />
                    {editable && <IconAction icon="↑" title="Move left" onClick={() => moveOpt(o.id, -1)} />}
                    {editable && <IconAction icon="↓" title="Move right" onClick={() => moveOpt(o.id, 1)} />}
                    {editable && <IconAction icon="✕" title="Remove this option"
                      onClick={() => withArea(a => { a.colorOptions = (a.colorOptions || []).filter(x => x.id !== o.id); }, `LEON Countertop — colour option removed`)} />}
                  </div>
                  <div className="grid grid-cols-2 gap-2">
                    <Field label="Product" hint={vendor ? vendor.name : 'no vendor linked'}>
                      <Select className="!py-1" value={o.materialId || ''} disabled={!editable}
                        onChange={e => setOpt(o.id, { materialId: e.target.value, colorId: null, priceGroupId: null }, `LEON Countertop — product set`)}>
                        <option value="">— none —</option>
                        {materials.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
                      </Select>
                    </Field>
                    <Field label="Colour">
                      <Select className="!py-1" value={o.colorId ? `c:${o.colorId}` : (o.priceGroupId ? `g:${o.priceGroupId}` : '')}
                        disabled={!editable}
                        onChange={e => {
                          const v = e.target.value;
                          setOpt(o.id, v.startsWith('c:') ? { colorId: v.slice(2), priceGroupId: null }
                            : (v.startsWith('g:') ? { colorId: null, priceGroupId: v.slice(2) } : { colorId: null, priceGroupId: null }),
                            `LEON Countertop — colour set`);
                        }}>
                        <option value="">— none —</option>
                        {mat && (mat.priceGroups || []).length > 0 && (
                          <optgroup label="Price group (no colour chosen yet)">
                            {(mat.priceGroups || []).map(g => <option key={g.id} value={`g:${g.id}`}>{g.name}</option>)}
                          </optgroup>
                        )}
                        {mat && (
                          <optgroup label="Colours">
                            {(mat.colors || []).map(c => <option key={c.id} value={`c:${c.id}`}>{c.name}</option>)}
                          </optgroup>
                        )}
                      </Select>
                    </Field>
                    <Field label="Edge profile">
                      <Select className="!py-1" value={o.edgeProfile || 'Eased'} disabled={!editable}
                        onChange={e => setOpt(o.id, { edgeProfile: e.target.value }, `LEON Countertop — edge profile set`)}>
                        {CT_EDGE_PROFILES.map(p => <option key={p}>{p}</option>)}
                      </Select>
                    </Field>
                    <Field label="Second edge profile" hint="A second profile on the same area.">
                      <Select className="!py-1" value={o.edgeProfile2 || ''} disabled={!editable}
                        onChange={e => setOpt(o.id, { edgeProfile2: e.target.value }, `LEON Countertop — second edge profile set`)}>
                        <option value="">— none —</option>
                        {CT_EDGE_PROFILES.map(p => <option key={p}>{p}</option>)}
                      </Select>
                    </Field>
                  </div>
                  {grp && <div className="text-[11px] text-[var(--leon-black)]/55 mt-1">Quoted at the <strong>{grp.name}</strong> price point — a colour has not been chosen yet.</div>}
                  {ref && (
                    <div className="flex items-center gap-2 mt-2">
                      {ref.img ? <img src={ref.img} alt="" className="w-10 h-10 object-cover rounded" /> : null}
                      <div className="text-[11px]">
                        <div className="font-semibold">{ref.name}</div>
                        <div className="text-[var(--leon-black)]/45">{ref.code} · {typeof supplierDisplayName === 'function' ? supplierDisplayName(ref.source, ctx.vendors) : (ref.supLabel || '')}</div>
                      </div>
                    </div>
                  )}
                </div>
              );
            })}
          </div>
        )}
      </CtPanel>

      {options.length > 1 && (
        <CtPanel title="Side by side">
          <p className="text-[11px] text-[var(--leon-black)]/55 mb-2">
            Each option is priced from <strong>its own slab size</strong>. CounterGo calculates every option off
            the leftmost one's slab size, which quietly misprices any comparison between materials whose slabs
            differ — and they differ all the time.
          </p>
          <div className="overflow-x-auto">
            <table className="w-full text-sm">
              <thead><tr className="text-left text-[11px] uppercase tracking-wide text-[var(--leon-black)]/50">
                <th className="py-1">Option</th><th>Slab</th><th>Slabs</th><th>Rate{ctUnitSuffix('sq ft', sys)}</th>
                {ctx.canSeeFin && <th className="text-right">Area subtotal</th>}
              </tr></thead>
              <tbody>
                {options.map(o => {
                  const res = ctPriceArea(area, pl || ctBlankPriceList(), ctx, project.id, o, ctNum(quote.discount), sys);
                  const mat = ctMaterialOf(pl, o), col = ctColorOf(mat, o);
                  return (
                    <tr key={o.id} className="border-t border-[var(--leon-line)]">
                      <td className="py-1.5">{o.selected && <Badge tone="brown">selected</Badge>} {ctOptionLabel(pl, o)}</td>
                      <td className="whitespace-nowrap">{ctSlabSizeText(res.plan.lengthIn, res.plan.widthIn, sys)}</td>
                      <td>{res.plan.count}{res.plan.source === 'estimated' ? ' est.' : ''}</td>
                      <td>{ctPriceText(ctRateToDisplay(ctMaterialRate(mat, col, o), 'sq ft', sys))}</td>
                      {ctx.canSeeFin && <td className="text-right font-semibold">{ctMoneyText(res.subtotal)}{res.unpriced.length ? <div className="text-[10px] text-[#b83b3b] font-semibold">incomplete</div> : null}</td>}
                    </tr>
                  );
                })}
              </tbody>
            </table>
          </div>
        </CtPanel>
      )}

      <Collapsible id={`ct-slabs-${area.id}`} title="Slabs & Layout" defaultOpen>
        <CtSlabsAndLayout ctx={ctx} project={project} area={area} pl={pl} editable={editable} withArea={withArea} sys={sys} />
      </Collapsible>
    </div>
  );
}

// ── Slabs & Layout ────────────────────────────────────────────────────────
// The slab photograph sits behind the layout because veining is the whole
// reason a person places pieces by hand rather than accepting a packing.

function CtSlabsAndLayout({ ctx, project, area, pl, editable, withArea, sys }) {
  const plan = ctAreaSlabPlan(area, pl || ctBlankPriceList(), ctx, project.id);
  const saved = area.slabPlan || {};
  const jobSlabs = ctJobSlabs(ctx, project.id);
  const [drag, setDrag] = useState(null);
  const svgRef = useRef(null);

  function setPlan(fields, line) {
    withArea(a => { a.slabPlan = Object.assign({}, a.slabPlan || {}, fields); }, line);
  }
  // "Lay them out" starts from the automatic packing rather than from an empty
  // slab, because the argument is usually with one piece, not with all of them.
  function seedPlacements() {
    const placements = {};
    plan.packed.forEach(s => s.placements.forEach(p => { placements[p.key] = { slab: s.index, x: p.x, y: p.y, rot: p.rot }; }));
    setPlan({ placements }, `LEON Countertop — slab layout started from the automatic packing on ${area.name}`);
  }
  const placements = saved.placements || null;
  const slabCount = placements ? Math.max(1, ctPlacementSlabCount(placements)) : plan.packed.length;
  const byKey = {};
  plan.pieces.forEach(p => { byKey[p.key] = p; });

  function svgIn(evt, slabIndex) {
    const el = svgRef.current;
    if (!el) return null;
    const r = el.getBoundingClientRect();
    const perSlabH = (plan.widthIn + 8);
    const totalH = perSlabH * slabCount;
    const x = ((evt.clientX - r.left) / r.width) * plan.lengthIn;
    const y = ((evt.clientY - r.top) / r.height) * totalH - slabIndex * perSlabH;
    return { x, y };
  }

  return (
    <div className="space-y-3">
      <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
        <Field label="Slab from LEON Stone" hint="The job's own assigned slabs, with their real size and photo.">
          <Select className="!py-1" value={saved.slabRecordId || ''} disabled={!editable}
            onChange={e => setPlan({ slabRecordId: e.target.value || null }, `LEON Countertop — slab record chosen for ${area.name}`)}>
            <option value="">— use the material's nominal size —</option>
            {jobSlabs.map(s => <option key={s.id} value={s.id}>{s.slabId || s.id} · {s.colour || s.material}</option>)}
          </Select>
        </Field>
        <Field label="Slab length">
          <CtLenInput value={plan.lengthIn} sys={sys} editable={editable}
            className="w-full rounded-md border border-[var(--leon-line)] px-2 py-1 text-sm"
            onValue={v => setPlan({ lengthIn: v }, `LEON Countertop — slab length set`)} />
        </Field>
        <Field label="Slab width">
          <CtLenInput value={plan.widthIn} sys={sys} editable={editable}
            className="w-full rounded-md border border-[var(--leon-line)] px-2 py-1 text-sm"
            onValue={v => setPlan({ widthIn: v }, `LEON Countertop — slab width set`)} />
        </Field>
        <Field label="Kerf" hint="LEON Stone's saw kerf, so both halves of the software agree.">
          <CtLenInput value={plan.kerf} sys={sys} editable={editable}
            className="w-full rounded-md border border-[var(--leon-line)] px-2 py-1 text-sm"
            onValue={v => setPlan({ kerfIn: v }, `LEON Countertop — kerf set`)} />
        </Field>
      </div>

      <div className="rounded-md border border-[var(--leon-line)] p-2.5 text-sm">
        <div className="flex items-center justify-between gap-2 flex-wrap">
          <div>
            <span className="font-bold text-lg">{plan.count}</span> slab{plan.count === 1 ? '' : 's'}
            <Badge tone={plan.source === 'estimated' ? 'yellow' : (plan.source === 'manual' ? 'blue' : 'green')}>{plan.source}</Badge>
          </div>
          <div className="flex items-end gap-2">
            <Field label="Override the count" className="w-32">
              <TextInput className="!py-1" placeholder={String(plan.autoCount)} defaultValue={ctIsSet(saved.count) ? saved.count : ''}
                disabled={!editable}
                onBlur={e => setPlan({ count: e.target.value === '' ? null : ctNum(e.target.value) }, `LEON Countertop — slab count set by hand on ${area.name}`)} />
            </Field>
            {editable && <Button size="sm" variant="outline" onClick={seedPlacements}>Lay the pieces out</Button>}
            {editable && placements && <Button size="sm" variant="ghost"
              onClick={() => setPlan({ placements: null }, `LEON Countertop — laid-out plan cleared`)}>Back to the estimate</Button>}
          </div>
        </div>
        <div className="text-[11px] text-[var(--leon-black)]/55 mt-1.5">
          {ctQtyLabel(plan.demandSqFt, 'sq ft', sys)} of stone needed · {ctQtyLabel(plan.usableSlabSqFt, 'sq ft', sys)} per slab
          ({ctSlabSizeText(plan.lengthIn, plan.widthIn, sys)}, from {plan.sizeSource}) ·
          {plan.pieces.length} piece{plan.pieces.length === 1 ? '' : 's'} · {ctFmtIn(plan.kerf, sys)} kerf
          {plan.splashSlabs > 0 ? ` · +${plan.splashSlabs} slab${plan.splashSlabs === 1 ? '' : 's'} for backsplash` : ''}
        </div>
        {plan.source === 'estimated' && (
          <div className="text-[11px] text-[#a67b1f] mt-1">
            This count is a first-fit packing, not a plan a person has agreed. It is labelled as an estimate on
            the quote line too, so nobody reads it as a fact.
          </div>
        )}
        {plan.oversize > 0 && (
          <div className="text-[11px] text-[#b83b3b] mt-1">
            {plan.oversize} piece{plan.oversize === 1 ? ' does' : 's do'} not fit this slab at all — add a seam in step 1 or use a bigger slab.
          </div>
        )}
      </div>

      <div>
        <div className="text-xs font-semibold mb-1">Pieces</div>
        <p className="text-[11px] text-[var(--leon-black)]/55 mb-1">
          Pieces come from the outline: an L is two runs, not one L-shaped slab. Where a run is longer than the
          usable slab it is split into equal parts — that is the whole rule, and any length here can be changed.
        </p>
        <table className="w-full text-sm">
          <thead><tr className="text-left text-[11px] uppercase tracking-wide text-[var(--leon-black)]/50">
            <th className="py-1">Piece</th><th>Length</th><th>Width</th><th>Slab</th><th></th>
          </tr></thead>
          <tbody>
            {plan.pieces.map(p => {
              const pl2 = placements ? placements[p.key] : null;
              return (
                <tr key={p.key} className="border-t border-[var(--leon-line)]">
                  <td className="py-1">
                    {p.counterName}{p.partCount > 1 ? ` — part ${p.partIndex + 1} of ${p.partCount}` : ''}
                    {p.suggestedSplit && <span className="ml-1 text-[10px] text-[#a67b1f] font-semibold">suggested split</span>}
                    {!p.exact && <span className="ml-1 text-[10px] text-[#b83b3b] font-semibold">bounding box</span>}
                  </td>
                  <td>
                    <TextInput className="!py-0.5 !w-24" disabled={!editable} key={`${p.key}-${Math.round(p.lengthIn * 100)}`}
                      defaultValue={ctFmtIn(p.lengthIn, sys)}
                      onBlur={e => {
                        const v = ctParseIn(e.target.value, sys);
                        if (!v) return;
                        withArea(a => {
                          const sp = Object.assign({}, a.slabPlan || {});
                          const ov = Object.assign({}, sp.pieceOverrides || {});
                          const group = (ov[p.groupKey] && ov[p.groupKey].lengths) ? ov[p.groupKey].lengths.slice()
                            : plan.pieces.filter(x => x.groupKey === p.groupKey).map(x => x.lengthIn);
                          group[p.partIndex] = v;
                          ov[p.groupKey] = { lengths: group };
                          sp.pieceOverrides = ov;
                          a.slabPlan = sp;
                        }, `LEON Countertop — piece length set on ${area.name}`);
                      }} />
                  </td>
                  <td>{ctFmtIn(p.widthIn, sys)}</td>
                  <td>
                    {placements ? (
                      <Select className="!py-0.5 !w-24" value={pl2 ? pl2.slab : 0} disabled={!editable}
                        onChange={e => withArea(a => {
                          const sp = Object.assign({}, a.slabPlan || {});
                          sp.placements = Object.assign({}, sp.placements || {}, { [p.key]: Object.assign({ x: 0, y: 0, rot: 0 }, sp.placements[p.key], { slab: Number(e.target.value) }) });
                          a.slabPlan = sp;
                        }, `LEON Countertop — piece moved to another slab`)}>
                        {Array.from({ length: Math.max(slabCount, (pl2 ? pl2.slab : 0) + 2) }).map((_, i) => <option key={i} value={i}>Slab {i + 1}</option>)}
                      </Select>
                    ) : <span className="text-[var(--leon-black)]/40">—</span>}
                  </td>
                  <td className="text-right">
                    {editable && placements && pl2 && (
                      <IconAction icon="⟳" title="Rotate 90°"
                        onClick={() => withArea(a => {
                          const sp = Object.assign({}, a.slabPlan || {});
                          sp.placements = Object.assign({}, sp.placements || {}, { [p.key]: Object.assign({}, pl2, { rot: pl2.rot ? 0 : 90 }) });
                          a.slabPlan = sp;
                        }, `LEON Countertop — piece rotated`)} />
                    )}
                  </td>
                </tr>
              );
            })}
          </tbody>
        </table>
      </div>

      {plan.lengthIn > 0 && plan.widthIn > 0 && (
        <div>
          <div className="text-xs font-semibold mb-1">Layout</div>
          <svg ref={svgRef} viewBox={`-4 -4 ${plan.lengthIn + 8} ${(plan.widthIn + 8) * slabCount}`}
            width="100%" height={Math.min(520, 130 * slabCount)}
            className="rounded-md border border-[var(--leon-line)] bg-white"
            style={{ touchAction: 'none' }}
            onPointerMove={e => {
              if (!drag) return;
              const p = svgIn(e, drag.slab);
              if (!p) return;
              withArea(a => {
                const sp = Object.assign({}, a.slabPlan || {});
                const cur = (sp.placements || {})[drag.key] || { slab: drag.slab, rot: 0 };
                const piece = byKey[drag.key];
                const w = cur.rot ? piece.widthIn : piece.lengthIn, h = cur.rot ? piece.lengthIn : piece.widthIn;
                sp.placements = Object.assign({}, sp.placements || {}, {
                  [drag.key]: Object.assign({}, cur, {
                    x: Math.max(0, Math.min(plan.lengthIn - w, p.x - drag.dx)),
                    y: Math.max(0, Math.min(plan.widthIn - h, p.y - drag.dy)),
                  }),
                });
                a.slabPlan = sp;
              }, null);
            }}
            onPointerUp={() => setDrag(null)} onPointerLeave={() => setDrag(null)}>
            {Array.from({ length: slabCount }).map((_, si) => {
              const oy = si * (plan.widthIn + 8);
              const rec = plan.slabRecord;
              return (
                <g key={si}>
                  {rec && rec.photoUrl && (
                    <image href={rec.photoUrl} x={0} y={oy} width={plan.lengthIn} height={plan.widthIn}
                      preserveAspectRatio="xMidYMid slice" opacity="0.9" />
                  )}
                  <rect x={0} y={oy} width={plan.lengthIn} height={plan.widthIn}
                    fill={rec && rec.photoUrl ? 'none' : '#f6f3ee'} stroke="var(--leon-black)" strokeWidth={plan.lengthIn * 0.004} />
                  <text x={1.5} y={oy - 1.5} fontSize={plan.lengthIn * 0.028} fill="var(--leon-black)" opacity="0.6">
                    Slab {si + 1} — {ctSlabSizeText(plan.lengthIn, plan.widthIn, sys)}
                  </text>
                  {(placements
                    ? Object.keys(placements).filter(k => placements[k].slab === si).map(k => ({ key: k, p: placements[k] }))
                    : (plan.packed[si] ? plan.packed[si].placements.map(p => ({ key: p.key, p: { x: p.x, y: p.y, rot: p.rot } })) : [])
                  ).map(item => {
                    const piece = byKey[item.key];
                    if (!piece) return null;
                    const w = item.p.rot ? piece.widthIn : piece.lengthIn;
                    const h = item.p.rot ? piece.lengthIn : piece.widthIn;
                    return (
                      <g key={item.key} style={{ cursor: editable && placements ? 'move' : 'default' }}
                        onPointerDown={e => {
                          if (!editable || !placements) return;
                          const p = svgIn(e, si);
                          if (p) setDrag({ key: item.key, slab: si, dx: p.x - item.p.x, dy: p.y - item.p.y });
                        }}>
                        <rect x={item.p.x} y={oy + item.p.y} width={w} height={h}
                          fill="rgba(255,255,255,0.82)" stroke="var(--leon-brown)" strokeWidth={plan.lengthIn * 0.004} />
                        <text x={item.p.x + w / 2} y={oy + item.p.y + h / 2} fontSize={plan.lengthIn * 0.022}
                          textAnchor="middle" fill="var(--leon-black)">
                          {piece.counterName}{piece.partCount > 1 ? ` ${piece.partIndex + 1}` : ''}
                        </text>
                      </g>
                    );
                  })}
                </g>
              );
            })}
          </svg>
          <p className="text-[11px] text-[var(--leon-black)]/50 mt-1">
            {placements ? 'Drag a piece to move it, or send it to another slab in the table above.'
              : 'This is the automatic packing. Press “Lay the pieces out” to take it over and move them by hand.'}
            {plan.slabRecord && plan.slabRecord.photoUrl ? ' The slab photograph is the real one from LEON Stone — veining is why anyone lays pieces out at all.' : ''}
          </p>
        </div>
      )}
    </div>
  );
}

// ── Step 6 · Price Details ────────────────────────────────────────────────

function CtStepPrice({ ctx, project, quote, editable, apply, pl, sys }) {
  const [pushing, setPushing] = useState(false);
  const res = ctPriceQuote(quote, pl, ctx, project.id);
  const form = ctForm(quote);
  const account = (ctx.accounts || []).find(a => a.id === quote.accountId) || null;

  function setQ(fields, line) { apply(q => Object.assign(q, fields), line); }
  function setForm(fields, line) { apply(q => { q.form = Object.assign({}, ctForm(q), fields); }, line || 'LEON Countertop — quote form changed'); }

  return (
    <div className="space-y-3">
      <CtPanel title="Quote">
        <div className="grid grid-cols-2 gap-2">
          <Field label="Price list">
            <Select className="!py-1" value={quote.priceListId || ''} disabled={!editable}
              onChange={e => setQ({ priceListId: e.target.value || null }, `LEON Countertop — price list changed on ${quote.name}`)}>
              <option value="">— none —</option>
              {(ctx.ctPriceLists || []).map(p => <option key={p.id} value={p.id}>{p.name} (Rev. {p.revision})</option>)}
            </Select>
          </Field>
          <Field label="Status">
            <Select className="!py-1" value={quote.status} disabled={!editable}
              onChange={e => setQ({ status: e.target.value }, `LEON Countertop — quote status set ${e.target.value}`)}>
              {CT_QUOTE_STATUSES.map(s => <option key={s}>{s}</option>)}
            </Select>
          </Field>
          <Field label="Units" className="col-span-2"
            hint="Imperial reads in inches, square feet and linear feet; Metric in millimetres, m² and linear metres. Rates are held per square and linear foot either way, so this changes how the quote reads and never what it comes to.">
            <Select className="!py-1" value={quote.unitSystem || ''} disabled={!editable}
              onChange={e => setQ({ unitSystem: e.target.value || null }, `LEON Countertop — quote units set to ${e.target.value || 'follow the price list'}`)}>
              <option value="">Follow the price list ({ctSysOf(pl && pl.units)})</option>
              <option value="Imperial">Imperial — in, sq ft, lin ft</option>
              <option value="Metric">Metric — mm, m², lin m</option>
            </Select>
          </Field>
          <Field label="Tax rate %" hint={pl && ctIsSet(pl.defaultTaxRate) ? `Price list default ${pl.defaultTaxRate}%` : 'No default on the price list'}>
            <TextInput className="!py-1" placeholder={pl && ctIsSet(pl.defaultTaxRate) ? String(pl.defaultTaxRate) : '—'}
              defaultValue={ctIsSet(quote.taxRate) ? quote.taxRate : ''} disabled={!editable}
              onBlur={e => setQ({ taxRate: e.target.value === '' ? null : ctNum(e.target.value) }, `LEON Countertop — tax rate set`)} />
          </Field>
          <Field label="Discount %" hint="Applied to the UNIT price of every line that allows it.">
            <TextInput className="!py-1" defaultValue={ctIsSet(quote.discount) ? quote.discount : ''} disabled={!editable}
              onBlur={e => setQ({ discount: e.target.value === '' ? null : ctNum(e.target.value) }, `LEON Countertop — discount set`)} />
          </Field>
          <Field label="Expires">
            <TextInput className="!py-1" type="date" defaultValue={quote.expirationDate || ''} disabled={!editable}
              onBlur={e => setQ({ expirationDate: e.target.value || null }, `LEON Countertop — expiration set`)} />
          </Field>
          <Field label="Estimate no.">
            <TextInput className="!py-1" defaultValue={quote.estimateNo || ''} disabled={!editable}
              onBlur={e => setQ({ estimateNo: e.target.value }, `LEON Countertop — estimate number set`)} />
          </Field>
          <Field label="Payment terms" className="col-span-2">
            <TextInput className="!py-1" defaultValue={quote.paymentTerms || ''} disabled={!editable}
              onBlur={e => setQ({ paymentTerms: e.target.value }, `LEON Countertop — payment terms set`)} />
          </Field>
          <Field label="Address" className="col-span-2">
            <TextInput className="!py-1" defaultValue={quote.address || ''} disabled={!editable}
              onBlur={e => setQ({ address: e.target.value }, `LEON Countertop — address set`)} />
          </Field>
          <Field label="Notes" className="col-span-2">
            <TextArea rows={2} defaultValue={quote.notes || ''} disabled={!editable}
              onBlur={e => setQ({ notes: e.target.value }, `LEON Countertop — notes set`)} />
          </Field>
        </div>
      </CtPanel>

      <CtPanel title="Form — who is this copy for?">
        <p className="text-[11px] text-[var(--leon-black)]/55 mb-2">
          The customer quote, the internal copy and the shop sheet are the SAME drawing with different things
          shown. That is why there is no separate shop-drawing generator here — a second one would be a second
          thing to keep in step.
        </p>
        <div className="flex gap-1 flex-wrap mb-2">
          {Object.keys(CT_FORM_PRESETS).map(k => (
            <Button key={k} size="sm" variant="outline" disabled={!editable}
              onClick={() => setForm(CT_FORM_PRESETS[k], `LEON Countertop — form set to ${k}`)}>{k}</Button>
          ))}
        </div>
        <div className="grid grid-cols-2 gap-1">
          {CT_FORM_TOGGLES.map(t => (
            <label key={t.k} className="flex items-center gap-1.5 text-xs">
              <input type="checkbox" checked={!!form[t.k]} disabled={!editable}
                onChange={e => setForm({ [t.k]: e.target.checked })} />
              {t.label}
            </label>
          ))}
        </div>
      </CtPanel>

      {!res.complete && (
        <div className="rounded-md border border-[#f0d9d9] bg-[#fdf6f6] p-3 text-sm">
          <div className="font-bold text-[#b83b3b] mb-1">This quote is incomplete — {res.unpriced.length} item{res.unpriced.length === 1 ? '' : 's'} with no price set.</div>
          <div className="text-xs text-[var(--leon-black)]/70">
            An unset price is <strong>not</strong> zero. The total below is what has been priced so far; it is
            not what the job costs, and it must not be sent as though it were. A line counts here only when it
            has <strong>neither</strong> a rate on the company price list <strong>nor</strong> one typed on this
            quote — type a rate against it under <em>prices on this quote</em> below and it stops being listed.
          </div>
          <ul className="text-xs mt-1.5 list-disc pl-5">
            {res.unpriced.map((u, i) => <li key={i}><strong>{u.area}</strong> — {u.label}</li>)}
          </ul>
        </div>
      )}
      {res.taxUnset && (
        <div className="rounded-md border border-[#eee0c4] bg-[#fdfaf2] p-2.5 text-xs">
          No tax rate has been set on this quote or its price list, so no tax is shown. That is a blank, not a
          zero-rated sale.
        </div>
      )}

      {res.areas.map(ar => (
        <CtQuoteRates key={ar.area.id} area={ar.area} lines={ar.lines} editable={editable} sys={sys}
          onChange={(fn, line) => apply(q => { const t = (q.areas || []).find(x => x.id === ar.area.id); if (t) fn(t); }, line)} />
      ))}

      {(quote.areas || []).map(a => (
        <CtExtraItems key={a.id} area={a} pl={pl} editable={editable} sys={sys}
          onChange={(fn, line) => apply(q => { const t = (q.areas || []).find(x => x.id === a.id); if (t) fn(t); }, line)} />
      ))}

      <CtPanel title="The estimate">
        {/* The actions sit INSIDE the print region on purpose: PrintButton
            scopes itself to the nearest [data-print-region] ancestor, so from
            outside it would print the whole page instead of the quote. Buttons
            are stripped from the printed clone anyway. */}
        <CtQuoteDocument ctx={ctx} project={project} quote={quote} res={res} form={form} sys={sys}
          actions={<div className="no-print flex items-center gap-1 justify-end">
            <ShareButton ctx={ctx} subject={`${quote.name} — countertop quote`} projectId={project.id}
              subjectKey={`ctquote:${quote.id}`}
              summary={`${(quote.areas || []).length} area(s), ${res.complete ? ctMoneyText(res.total) : 'incomplete pricing'}`} />
            <DocActions title={`${quote.name} — countertop quote`} heading="Countertop Quote"
              lines={[project.name, account ? account.name : '', quote.address || '']} />
          </div>} />
      </CtPanel>

      <CtPanel title="Hand the pieces to the cut list"
        right={editable && <Button size="sm" onClick={() => setPushing(true)}>Send pieces to LEON Stone</Button>}>
        <p className="text-[11px] text-[var(--leon-black)]/60">
          This is what makes the drawing a take-off rather than a picture: the pieces become real cut-list
          records on this job, in millimetres, and the slab side of LEON Countertop takes it from there.
        </p>
      </CtPanel>
      <CtPushToStoneModal open={pushing} onClose={() => setPushing(false)} ctx={ctx}
        project={project} quote={quote} pl={pl} />
    </div>
  );
}

// ── Prices typed on the quote ─────────────────────────────────────────────
// EVERY PRICED LINE CAN TAKE A RATE TYPED HERE INSTEAD OF THE LIST'S —
// including a line the company list has no rate for at all, which is the whole
// point: a quote goes out while the list is still half-filled, and the quote
// stops reporting itself incomplete the moment a rate is typed.
//
// Two rules the panel is built around:
//   · A typed rate is NOT a discount. It is excluded from the discount total
//     and marked OVR rather than D, because "I gave them 10% off" and "I sold
//     it to them at $62" are different claims.
//   · Reverting never destroys the typed number. It is kept and offered back
//     with one click, and the editor's undo covers it as well.
function CtQuoteRates({ area, lines, editable, sys, onChange }) {
  const [showAll, setShowAll] = useState(false);
  const prev = area.lineOverridesPrev || {};
  const rows = (lines || []).filter(l => showAll || !l.hidden);
  const typed = (lines || []).filter(l => l.overridden).length;
  const missing = (lines || []).filter(l => l.unpriced && (l.qty === null || l.qty > 0)).length;

  return (
    <CtPanel title={`${area.name} — prices on this quote`}
      right={<label className="flex items-center gap-1.5 text-[11px] text-[var(--leon-black)]/55">
        <input type="checkbox" checked={showAll} onChange={e => setShowAll(e.target.checked)} />
        Include lines hidden on the quote
      </label>}>
      <p className="text-[11px] text-[var(--leon-black)]/55 mb-2">
        A rate typed here replaces the company price list <em>for this quote only</em>, and stands in for it
        entirely where the list has none. It is marked <strong>OVR</strong> on the quote and stays out of the
        discount, because a price agreed is not a discount given. Rates read in{' '}
        {sys === 'Metric' ? '$/m² and $/lin m' : '$/sq ft and $/lin ft'}; they are held per square and linear
        foot underneath, so switching units cannot change what a typed rate means.
      </p>
      {rows.length === 0 ? <div className="text-[11px] text-[var(--leon-black)]/45">Nothing priced on this area yet.</div> : (
        <div className="overflow-x-auto">
          <table className="w-full text-sm">
            <thead><tr className="text-left text-[11px] uppercase tracking-wide text-[var(--leon-black)]/50">
              <th className="py-1">Line</th><th>Price list</th><th>Rate on this quote</th><th>Source</th><th></th>
            </tr></thead>
            <tbody>
              {rows.map(l => {
                const kind = l.unit === 'sq ft' || l.unit === 'lin ft' ? l.unit : null;
                const suffix = kind ? ctUnitSuffix(kind, sys) : '';
                const stash = prev[l.key];
                return (
                  <tr key={l.key} className="border-t border-[var(--leon-line)] align-top">
                    <td className="py-1.5">
                      <div>{l.label}</div>
                      {l.hidden && <div className="text-[10px] text-[var(--leon-black)]/40">hidden on the quote</div>}
                    </td>
                    <td className="py-1.5 whitespace-nowrap text-[var(--leon-black)]/60">
                      {ctIsSet(l.listRate)
                        ? ctPriceText(ctRateToDisplay(l.listRate, kind, sys), suffix)
                        : <span className="text-[#b83b3b] font-semibold">-No price-</span>}
                    </td>
                    <td className="py-1.5 whitespace-nowrap">
                      {l.lockedRate ? (
                        <span className="text-[11px] text-[var(--leon-black)]/50">
                          Locked — the price list has <em>Editable price on quote</em> unticked for this row.
                        </span>
                      ) : (
                        <span className="inline-flex items-center gap-1">
                          <CtRateInput value={l.typedRate} unitKind={kind} sys={sys} editable={editable}
                            placeholder={ctIsSet(l.listRate) ? 'use the list' : 'type a rate'}
                            onValue={v => onChange(a => ctSetLineRate(a, l.key, v),
                              v === null ? `LEON Countertop — typed rate cleared on ${l.label}` : `LEON Countertop — rate typed on this quote for ${l.label}`)} />
                          <span className="text-[10px] text-[var(--leon-black)]/40">{suffix}</span>
                        </span>
                      )}
                    </td>
                    <td className="py-1.5 whitespace-nowrap text-[11px]">
                      {l.overridden
                        ? <Badge tone="blue">Typed on this quote</Badge>
                        : (ctIsSet(l.listRate) ? <span className="text-[var(--leon-black)]/55">From price list</span>
                          : <span className="text-[#b83b3b] font-semibold">No rate anywhere</span>)}
                    </td>
                    <td className="py-1.5 text-right whitespace-nowrap">
                      {editable && l.overridden && (
                        <Button size="sm" variant="ghost"
                          title={ctIsSet(l.listRate) ? 'Go back to the company price list rate. The number you typed is kept and can be put back.' : 'Clear the typed rate. The line then has no price at all — and the number you typed is kept.'}
                          onClick={() => onChange(a => ctRevertLineRate(a, l.key), `LEON Countertop — reverted ${l.label} to the price list`)}>
                          {ctIsSet(l.listRate) ? 'Revert to list' : 'Clear typed rate'}
                        </Button>
                      )}
                      {editable && !l.overridden && ctIsSet(stash) && (
                        <Button size="sm" variant="outline"
                          title="Put back the rate that was typed here before it was reverted."
                          onClick={() => onChange(a => ctRestoreLineRate(a, l.key), `LEON Countertop — typed rate restored on ${l.label}`)}>
                          Put back {ctPriceText(ctRateToDisplay(stash, kind, sys), suffix)}
                        </Button>
                      )}
                    </td>
                  </tr>
                );
              })}
            </tbody>
          </table>
        </div>
      )}
      <div className="text-[11px] text-[var(--leon-black)]/55 mt-2">
        {typed} line{typed === 1 ? '' : 's'} priced on this quote ·{' '}
        {missing === 0
          ? <span className="text-[#3a7d44] font-semibold">every line on this area has a rate</span>
          : <span className="text-[#b83b3b] font-semibold">{missing} line{missing === 1 ? '' : 's'} still have neither a list rate nor a typed one</span>}
      </div>
    </CtPanel>
  );
}

// Items an estimator adds by hand — sinks sold as product, other and
// unit-priced items, and the curve rows that are not a property of a corner.
function CtExtraItems({ area, pl, editable, onChange, sys }) {
  const [kind, setKind] = useState('other');
  const [refId, setRefId] = useState('');
  const list = area.extraItems || [];
  const sinks = (pl && pl.sinks) || [];
  const others = (pl && pl.otherItems) || [];
  const curves = CT_CURVE_ROWS.filter(r => !r.corner);

  function add() {
    if (!refId) return;
    onChange(a => {
      a.extraItems = (a.extraItems || []).concat([{ id: uid('ctex'), source: kind, refId, qty: 1, note: '', lengthFt: 0 }]);
    }, `LEON Countertop — item added to ${area.name}`);
    setRefId('');
  }
  return (
    <CtPanel title={`${area.name} — added items`}>
      <div className="flex items-end gap-2 flex-wrap mb-2">
        <Field label="From" className="w-32">
          <Select className="!py-1" value={kind} onChange={e => { setKind(e.target.value); setRefId(''); }} disabled={!editable}>
            <option value="other">Other items</option>
            <option value="sink">Sinks</option>
            <option value="curve">Curves & bumpouts</option>
          </Select>
        </Field>
        <Field label="Item" className="grow min-w-[10rem]">
          <Select className="!py-1" value={refId} onChange={e => setRefId(e.target.value)} disabled={!editable}>
            <option value="">— choose —</option>
            {kind === 'sink' && sinks.map(s => <option key={s.id} value={s.id}>{s.label || s.name}</option>)}
            {kind === 'other' && others.map(o => <option key={o.id} value={o.id}>{o.label}{o.kind === 'unit' ? ` (per ${o.unit})` : ''}</option>)}
            {kind === 'curve' && curves.map(c => <option key={c.key} value={c.key}>{c.label}</option>)}
          </Select>
        </Field>
        <Button size="sm" onClick={add} disabled={!editable || !refId}>Add</Button>
        <Button size="sm" variant="outline" disabled={!editable}
          title="A line typed straight onto this quote — description, quantity, unit and rate — without first editing the company price list."
          onClick={() => onChange(a => { a.extraItems = (a.extraItems || []).concat([ctMakeAdhocItem()]); },
            `LEON Countertop — line typed onto ${area.name}`)}>
          + Type a line
        </Button>
      </div>
      <p className="text-[11px] text-[var(--leon-black)]/50 mb-2">
        <strong>Type a line</strong> is for the thing the company price list has never had a row for. It prices,
        discounts, taxes, prints and shares exactly like every other line — it simply lives on this quote. A
        unit-priced item's quantity is read in the unit the price list names for it, not converted.
      </p>
      {list.length === 0 ? <div className="text-[11px] text-[var(--leon-black)]/45">Nothing added by hand on this area.</div> : (
        <div className="space-y-1">
          {list.map(ex => {
            const unitItem = ex.source === 'other' ? others.find(o => o.id === ex.refId && o.kind === 'unit') : null;
            const curveRow = ex.source === 'curve' ? CT_CURVE_ROWS.find(r => r.key === ex.refId) : null;
            const patch = (fields, line) => onChange(a => {
              const t = (a.extraItems || []).find(x => x.id === ex.id);
              if (t) Object.assign(t, fields);
            }, line);
            if (ex.source === 'adhoc') {
              const kindU = ex.unit === 'sq ft' || ex.unit === 'lin ft' ? ex.unit : null;
              return (
                <div key={ex.id} className="flex items-end gap-2 flex-wrap rounded-md border border-[#cfe0f0] bg-[#f7fafd] p-2">
                  <Field label="Description" className="grow min-w-[12rem]">
                    <TextInput className="!py-0.5" defaultValue={ex.label || ''} disabled={!editable}
                      placeholder="What is being charged for"
                      onBlur={e => patch({ label: e.target.value }, `LEON Countertop — typed line described`)} />
                  </Field>
                  <Field label="Unit" className="w-28">
                    <Select className="!py-0.5" value={ex.unit || 'each'} disabled={!editable}
                      onChange={e => patch({ unit: e.target.value }, `LEON Countertop — typed line unit set`)}>
                      {CT_ADHOC_UNITS.map(u => <option key={u} value={u}>{u === 'each' ? 'each' : ctUnitLabel(u, sys)}</option>)}
                    </Select>
                  </Field>
                  <Field label={`Qty (${kindU ? ctUnitLabel(kindU, sys) : 'each'})`} className="w-28">
                    <input className="w-full rounded-md border border-[var(--leon-line)] px-2 py-1 text-sm text-right"
                      disabled={!editable} key={`q-${ex.id}-${ex.unit}-${sys}`}
                      defaultValue={ctQtyText(ctQtyToDisplay(ctNum(ex.qty), kindU, sys))}
                      onBlur={e => patch({ qty: ctQtyToCanon(ctNum(e.target.value), kindU, sys) }, `LEON Countertop — typed line quantity set`)} />
                  </Field>
                  <Field label={`Rate${kindU ? ctUnitSuffix(kindU, sys) : ' each'}`} className="w-32">
                    <CtRateInput value={ex.price} unitKind={kindU} sys={sys} editable={editable}
                      className="w-full rounded-md border border-[var(--leon-line)] px-2 py-1 text-sm text-right"
                      onValue={v => patch({ price: v }, `LEON Countertop — typed line rate set`)} />
                  </Field>
                  <div className="w-full text-[10px] text-[var(--leon-black)]/45">
                    Typed on this quote — not on the company price list. Leave the rate blank and this line
                    prices as <strong>-No price-</strong> and keeps the quote incomplete, the same as any other.
                  </div>
                  {editable && <IconAction icon="✕" title="Remove"
                    onClick={() => onChange(a => { a.extraItems = (a.extraItems || []).filter(x => x.id !== ex.id); }, `LEON Countertop — typed line removed`)} />}
                </div>
              );
            }
            return (
              <div key={ex.id} className="flex items-end gap-2 flex-wrap rounded-md border border-[var(--leon-line)] p-2">
                <div className="grow text-sm">
                  {ex.source === 'sink' ? ((sinks.find(s => s.id === ex.refId) || {}).label || 'Sink')
                    : ex.source === 'curve' ? (curveRow ? curveRow.label : ex.refId)
                    : ((others.find(o => o.id === ex.refId) || {}).label || 'Item')}
                  {unitItem && <span className="text-[11px] text-[var(--leon-black)]/45"> · base fee + rate per {unitItem.unit}</span>}
                </div>
                <Field label={unitItem ? unitItem.unit : 'Qty'} className="w-24">
                  <TextInput className="!py-0.5" defaultValue={ex.qty} disabled={!editable}
                    onBlur={e => onChange(a => { const t = (a.extraItems || []).find(x => x.id === ex.id); if (t) t.qty = ctNum(e.target.value); }, `LEON Countertop — item quantity set`)} />
                </Field>
                {curveRow && curveRow.twoPrice && (
                  <Field label="Lin ft" className="w-24">
                    <TextInput className="!py-0.5" defaultValue={ex.lengthFt || 0} disabled={!editable}
                      onBlur={e => onChange(a => { const t = (a.extraItems || []).find(x => x.id === ex.id); if (t) t.lengthFt = ctNum(e.target.value); }, `LEON Countertop — length set`)} />
                  </Field>
                )}
                {editable && <IconAction icon="✕" title="Remove"
                  onClick={() => onChange(a => { a.extraItems = (a.extraItems || []).filter(x => x.id !== ex.id); }, `LEON Countertop — item removed`)} />}
              </div>
            );
          })}
        </div>
      )}
    </CtPanel>
  );
}

// ── The quote as a document ───────────────────────────────────────────────
// One region, printed and exported through the app's own machinery, showing
// whatever the FORM says this copy shows.

function CtQuoteDocument({ ctx, project, quote, res, form, actions, sys }) {
  const f = form || ctForm(quote);
  const account = (ctx.accounts || []).find(a => a.id === quote.accountId) || null;
  const seller = (ctx.teamDirectory || []).find(p => p.id === quote.salespersonId) || null;
  const showMoney = f.showPrices && ctx.canSeeFin;
  // The printed quote follows the same unit setting as the screen — including
  // the drawing's own dimensions, which is the whole point of putting the
  // switch on the quote rather than on the price list.
  const U = sys || (res && res.sys) || 'Imperial';

  return (
    <div data-print-region className="space-y-4">
      {actions}
      <div className="text-sm">
        <div className="font-bold text-base">{quote.name}</div>
        <div className="text-[var(--leon-black)]/60">
          {project.name}{account ? ` · ${account.name}` : ''}{quote.address ? ` · ${quote.address}` : ''}
        </div>
        <div className="text-[11px] text-[var(--leon-black)]/50">
          {quote.estimateNo ? `Estimate ${quote.estimateNo} · ` : ''}Revision {quote.revision || 0}
          {quote.expirationDate ? ` · expires ${fmtDate(quote.expirationDate)}` : ''}
          {seller ? ` · ${seller.name}` : ''}
          {quote.paymentTerms ? ` · ${quote.paymentTerms}` : ''}
        </div>
      </div>

      {res.areas.map(a => (
        <div key={a.area.id} className="space-y-2">
          <div className="lp-section-title font-bold text-sm border-b border-[var(--leon-line)] pb-1">
            {a.area.name}{a.takeoff.splashSqFt > 0 ? '' : ' (No Backsplash)'}
          </div>
          <CtDrawing area={a.area} form={f} step="view" sel={null} editable={false} sys={U}
            view={{ zoom: 1, panX: 0, panY: 0 }} height={300} showAll />
          {f.showSlabCounts && (
            <div className="text-[11px] text-[var(--leon-black)]/60">
              {a.plan.count} slab{a.plan.count === 1 ? '' : 's'} of {ctSlabSizeText(a.plan.lengthIn, a.plan.widthIn, U)}
              {a.plan.source === 'estimated' ? ' — estimated, not laid out' : a.plan.source === 'manual' ? ' — set by hand' : ' — laid out'}
            </div>
          )}
          {f.showLineItems && (
            <table className="w-full text-sm">
              <tbody>
                {a.lines.filter(l => !l.hidden).filter(l => f.showZeroLines || l.rate === null || l.amount !== 0).map(l => (
                  <tr key={l.id} className="border-t border-[var(--leon-line)] align-top">
                    <td className="py-1">
                      <div>
                        {l.label}
                        {l.rate !== null && <span className="text-[var(--leon-black)]/50"> @ {ctPriceText(ctRateToDisplay(l.rate, l.unit, U), l.unit === 'each' ? '' : ctUnitSuffix(l.unit, U))}</span>}
                        {l.discounted && <span className="ml-1.5 text-[10px] font-bold text-[var(--leon-brown)]" title="Discounted — the discount is applied to this line's unit price">D</span>}
                        {l.overridden && <span className="ml-1.5 text-[10px] font-bold text-[#2563a8]"
                          title={`Typed on this quote${ctIsSet(l.listRate) ? ` — the price list says ${ctPriceText(ctRateToDisplay(l.listRate, l.unit, U), l.unit === 'each' ? '' : ctUnitSuffix(l.unit, U))}` : ' — the price list has no rate for this line'}`}>OVR</span>}
                      </div>
                      {l.subs.map((s, i) => <div key={i} className="text-[11px] text-[var(--leon-black)]/50 pl-3">{s}</div>)}
                      {l.note && <div className="text-[11px] text-[#b83b3b] pl-3">{l.note}</div>}
                    </td>
                    <td className="py-1 text-right whitespace-nowrap">
                      {l.rate === null
                        ? <span className="font-semibold text-[#b83b3b]">-No price-</span>
                        : (showMoney ? ctMoneyText(l.amount) : '')}
                    </td>
                  </tr>
                ))}
              </tbody>
            </table>
          )}
          {showMoney && (
            <div className="flex justify-end text-sm font-bold border-t border-[var(--leon-black)] pt-1">
              <span className="mr-4">Subtotal:</span>{ctMoneyText(a.subtotal)}
            </div>
          )}
        </div>
      ))}

      {showMoney && (
        <div className="border-t-2 border-[var(--leon-black)] pt-2 text-sm">
          <div className="flex justify-end"><span className="mr-4 text-[var(--leon-black)]/60">Subtotal</span><span className="w-28 text-right">{ctMoneyText(res.subtotal)}</span></div>
          {res.discountPct > 0 && (
            <div className="flex justify-end"><span className="mr-4 text-[var(--leon-black)]/60">Discount {res.discountPct}% (applied to unit prices marked D)</span><span className="w-28 text-right">-{ctMoneyText(res.discount)}</span></div>
          )}
          <div className="flex justify-end"><span className="mr-4 text-[var(--leon-black)]/60">Tax {res.taxRate === null ? '(not set)' : `${res.taxRate}%`}</span><span className="w-28 text-right">{res.tax === null ? '—' : ctMoneyText(res.tax)}</span></div>
          <div className="flex justify-end font-bold text-base"><span className="mr-4">{res.complete ? 'Total' : 'Priced so far'}</span><span className="w-28 text-right">{ctMoneyText(res.total)}</span></div>
          {!res.complete && (
            <div className="text-right text-[11px] font-semibold text-[#b83b3b]">
              Incomplete — {res.unpriced.length} item{res.unpriced.length === 1 ? '' : 's'} still carry no price.
            </div>
          )}
        </div>
      )}

      {res.areas.some(a => a.lines.some(l => l.overridden && !l.hidden)) && (
        <div className="text-[10px] text-[var(--leon-black)]/55">
          <strong>OVR</strong> — the rate on that line was typed on this quote rather than read from the company
          price list. <strong>D</strong> — the quote discount was applied to that line's unit price.
        </div>
      )}
      <div className="text-[10px] text-[var(--leon-black)]/40">
        Quantities and rates are shown in {U === 'Metric' ? 'metric — m², linear metres and millimetres' : 'imperial — square feet, linear feet and inches'}.
        The drawing is held in inches and every rate per square or linear foot, converted only for display, so
        the units shown never change what is owed.
      </div>
      {quote.notes && <div className="text-xs text-[var(--leon-black)]/70 whitespace-pre-wrap">{quote.notes}</div>}
      <div className="text-[10px] text-[var(--leon-black)]/40">
        Emailing this queues it in the Hub's outbox rather than sending it — there is no mail server behind a
        browser. Nothing here records whether a client opened it, and there is no e-signature, payment capture
        or accounting posting: those need a backend this app does not have.
      </div>
    </div>
  );
}

// ── Hand-off to the cut list ──────────────────────────────────────────────

function CtPushToStoneModal({ open, onClose, ctx, project, quote, pl }) {
  const [scopeId, setScopeId] = useState('');
  const [done, setDone] = useState(0);
  const scopes = (project.scopes || []).filter(s => s.active !== false);
  const list = (quote.areas || []).map(a => {
    const plan = ctAreaSlabPlan(a, pl || ctBlankPriceList(), ctx, project.id);
    const opt = ctSelectedOption(a);
    const mat = ctMaterialOf(pl, opt), col = ctColorOf(mat, opt);
    return { area: a, plan, material: [mat ? mat.name : '', col ? col.name : ''].filter(Boolean).join(' ') || 'Unspecified' };
  });
  const total = list.reduce((n, x) => n + x.plan.pieces.length, 0);
  const canPush = typeof stoneMakePiece === 'function' && typeof ctx.updateProject === 'function';

  function push() {
    if (!canPush) return;
    const pieces = [];
    list.forEach(x => {
      const counters = x.area.counters || [];
      x.plan.pieces.forEach(p => {
        const c = counters.find(z => z.id === p.counterId);
        const opt = ctSelectedOption(x.area);
        pieces.push(stoneMakePiece({
          scopeId: scopeId || null,
          label: `${x.area.name} — ${p.counterName}${p.partCount > 1 ? ` (${p.partIndex + 1}/${p.partCount})` : ''}`,
          material: x.material,
          // The one conversion in this module: it draws in inches, every
          // record in the Hub is millimetres.
          thicknessMm: ctNum(c ? c.thicknessCm : 3) * 10,
          lengthMm: p.lengthIn * MM_PER_INCH,
          widthMm: p.widthIn * MM_PER_INCH,
          qty: 1,
          edgeProfile: CT_STONE_EDGE_MAP[ctAreaEdgeProfile(x.area, opt)] || ctAreaEdgeProfile(x.area, opt),
          notes: `From countertop quote ${quote.name}${p.suggestedSplit ? ' · suggested split, not an agreed seam' : ''}${p.exact ? '' : ' · bounding box, outline is not rectilinear'}`,
        }, ctx.currentUserName || ''));
      });
    });
    ctx.updateProject(project.id, draft => {
      draft.stoneCutList = (draft.stoneCutList || []).concat(pieces);
      if (typeof ctx.logAction === 'function') {
        ctx.logAction(draft, `LEON Countertop — ${pieces.length} piece${pieces.length === 1 ? '' : 's'} sent to the cut list from ${quote.name}`);
      }
    });
    setDone(pieces.length);
  }

  return (
    <Modal wide open={open} onClose={() => { setDone(0); onClose(); }} title="Send pieces to the cut list"
      footer={<>
        <Button variant="ghost" onClick={() => { setDone(0); onClose(); }}>Close</Button>
        <Button onClick={push} disabled={!canPush || !total || !!done}>Send {total} piece{total === 1 ? '' : 's'}</Button>
      </>}>
      {!canPush ? (
        <div className="rounded-md border border-[#f0d9d9] bg-[#fdf6f6] p-3 text-sm">
          The slab side of LEON Countertop is not loaded, so there is nowhere to send these pieces. Nothing has
          been written.
        </div>
      ) : done ? (
        <div className="rounded-md border border-[#d8e9db] bg-[#f5faf6] p-3 text-sm">
          <div className="font-bold mb-1">{done} piece{done === 1 ? '' : 's'} written to this job's cut list.</div>
          They are in the <strong>Cut List</strong> tab of this same software, and from there in <strong>Slab
          Layout</strong> — the slab half of LEON Countertop, one tab along.
        </div>
      ) : (
        <div className="space-y-3">
          <Field label="File them against a scope" hint="Optional. A piece with no scope still lands on the job.">
            <Select value={scopeId} onChange={e => setScopeId(e.target.value)}>
              <option value="">— no scope —</option>
              {scopes.map(s => <option key={s.id} value={s.id}>{s.name || s.family}</option>)}
            </Select>
          </Field>
          <table className="w-full text-sm">
            <thead><tr className="text-left text-[11px] uppercase tracking-wide text-[var(--leon-black)]/50">
              <th className="py-1">Area</th><th>Material</th><th>Pieces</th><th>Note</th>
            </tr></thead>
            <tbody>
              {list.map(x => (
                <tr key={x.area.id} className="border-t border-[var(--leon-line)]">
                  <td className="py-1.5 font-semibold">{x.area.name}</td>
                  <td>{x.material}</td>
                  <td>{x.plan.pieces.length}</td>
                  <td className="text-[11px] text-[var(--leon-black)]/55">
                    {x.plan.pieces.some(p => p.suggestedSplit) ? 'Contains a suggested split — a seam is still the fabricator’s call. ' : ''}
                    {x.plan.anyInexact ? 'One outline is not rectilinear; its piece is the bounding box.' : ''}
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
          <p className="text-[11px] text-[var(--leon-black)]/50">
            Sizes convert to millimetres on the way in, because that is what every record in this app is in.
            Sending again adds a second set rather than replacing the first — cut-list pieces are removed in the
            Cut List tab, deliberately, so nothing quietly disappears from under a fabricator.
          </p>
        </div>
      )}
    </Modal>
  );
}

// ═══════════════════════════════════════════════════ price lists
// SHIPPED EMPTY OF PRICES, COMPLETE IN STRUCTURE. Every row a quote can
// reference exists from the first day with nothing in it, because the team is
// typing their own numbers in and a seeded rate is a wrong answer nobody
// notices. `-No price-` is the shipped state and it is not $0.00.

function ctWritePl(ctx, id, fn) {
  if (typeof ctx.setCtPriceLists !== 'function') return;
  ctx.setCtPriceLists(prev => (prev || []).map(p => {
    if (p.id !== id) return p;
    const n = cloneDeep(p);
    fn(n);
    n.modifiedBy = ctx.currentUserName || '';
    n.modifiedDate = todayISO();
    return n;
  }));
}

const CT_PL_SECTIONS = [
  { key: 'settings', label: 'Settings' },
  { key: 'materials', label: 'Materials' },
  { key: 'splash', label: 'Splash' },
  { key: 'fabinstall', label: 'Fabrication & Installation' },
  { key: 'miter', label: 'Mitered Edges & Waterfalls' },
  { key: 'edges', label: 'Finished Edges' },
  { key: 'applianceEdges', label: 'Appliance Edges' },
  { key: 'curves', label: 'Curves & Bumpouts' },
  { key: 'cutouts', label: 'Cutouts' },
  { key: 'sinks', label: 'Sinks' },
  { key: 'other', label: 'Other Items' },
];

function CtPriceListsPanel({ ctx, editable }) {
  const lists = ctx.ctPriceLists || [];
  const [id, setId] = useState(() => (lists[0] ? lists[0].id : ''));
  const [section, setSection] = useState('settings');
  const pl = lists.find(p => p.id === id) || lists[0] || null;

  function create() {
    const fresh = ctBlankPriceList('New Price List', ctx.currentUserName || '');
    ctx.setCtPriceLists(prev => (prev || []).concat([fresh]));
    setId(fresh.id);
  }
  function duplicate() {
    if (!pl) return;
    const copy = cloneDeep(pl);
    copy.id = uid('ctpl'); copy.name = `${pl.name} (copy)`; copy.revision = 1;
    copy.createdBy = ctx.currentUserName || ''; copy.createdDate = todayISO();
    ctx.setCtPriceLists(prev => (prev || []).concat([copy]));
    setId(copy.id);
  }
  // A revision here is a version stamp on a live list, not a frozen copy: the
  // frozen copy is Duplicate, and conflating the two is how a price list gets
  // edited under a quote that was built on it.
  function revise() { if (pl) ctWritePl(ctx, pl.id, p => { p.revision = (p.revision || 1) + 1; }); }

  return (
    <div className="space-y-3">
      <div className="flex items-end gap-2 flex-wrap">
        <Field label="Price list">
          <Select className="!w-64" value={pl ? pl.id : ''} onChange={e => setId(e.target.value)}>
            {lists.map(p => <option key={p.id} value={p.id}>{p.name} — Rev. {p.revision} ({p.status})</option>)}
          </Select>
        </Field>
        {editable && <Button onClick={create}>+ New price list</Button>}
        {editable && pl && <Button variant="outline" onClick={duplicate}>Duplicate</Button>}
        {editable && pl && <Button variant="outline" onClick={revise} title="Stamp a new revision number on this list.">Revise</Button>}
      </div>

      {!pl ? (
        <EmptyState text="No price list yet. Create one — it arrives with every row present and no prices, ready to type into." />
      ) : (
        <>
          <div className="flex gap-1 flex-wrap">
            {CT_PL_SECTIONS.map(s => (
              <button key={s.key} onClick={() => setSection(s.key)}
                className={`px-2.5 py-1 rounded-md text-xs font-semibold border ${section === s.key ? 'bg-[var(--leon-black)] text-white border-[var(--leon-black)]' : 'bg-white border-[var(--leon-line)] text-[var(--leon-black)]/65 hover:border-[var(--leon-brown-light)]'}`}>
                {s.label}
              </button>
            ))}
          </div>
          <div className="rounded-md bg-[var(--leon-cream)] px-3 py-2 text-[11px] text-[var(--leon-black)]/70">
            <strong>-No price-</strong> means nobody has set that rate yet. <strong>$0.00</strong> means it is
            genuinely free. A quote that uses an unpriced row reports itself incomplete rather than totalling as
            though the rate were zero. Tab moves straight to the next price field; the four controls are skipped
            on purpose so a whole column can be typed without touching the mouse.
          </div>
          {section === 'settings' && <CtPlSettings ctx={ctx} pl={pl} editable={editable} />}
          {section === 'materials' && <CtPlMaterials ctx={ctx} pl={pl} editable={editable} />}
          {section === 'splash' && <CtPlSplash ctx={ctx} pl={pl} editable={editable} />}
          {section === 'fabinstall' && (
            <CtPlSection ctx={ctx} pl={pl} editable={editable} title="Fabrication & Installation" unit="/sq ft"
              rows={[
                { key: 'fabrication', label: 'Fabrication', get: p => p.fabricationPerSqFt, set: (p, v) => { p.fabricationPerSqFt = v; } },
                { key: 'installation', label: 'Installation', get: p => p.installationPerSqFt, set: (p, v) => { p.installationPerSqFt = v; } },
              ]}
              note="Charged on the drawn countertop area, not on the slab." />
          )}
          {section === 'miter' && (
            <CtPlSection ctx={ctx} pl={pl} editable={editable} title="Mitered Edges & Waterfalls"
              rows={[
                { key: 'miter', label: 'Mitered cuts', unit: '/lin ft', get: p => p.miterPerLinFt, set: (p, v) => { p.miterPerLinFt = v; } },
                { key: 'waterfall', label: 'Waterfall installation', unit: ' each', get: p => p.waterfallInstallCharge, set: (p, v) => { p.waterfallInstallCharge = v; } },
              ]}
              note="A miter is cut on BOTH pieces, so a miter on a 24 inch edge bills 4 linear feet, not 2. The estimate already doubles it — this rate is per linear foot of billed miter." />
          )}
          {section === 'edges' && <CtPlEdges ctx={ctx} pl={pl} editable={editable} />}
          {section === 'applianceEdges' && (
            <CtPlSection ctx={ctx} pl={pl} editable={editable} title="Appliance Edges" unit="/lin ft"
              rows={[{ key: 'applianceEdge', label: 'Appliance edge', get: p => p.applianceEdgePerLinFt, set: (p, v) => { p.applianceEdgePerLinFt = v; } }]}
              note="A real flat cut and polish where the counter meets an appliance — not a synonym for no edge." />
          )}
          {section === 'curves' && <CtPlCurves ctx={ctx} pl={pl} editable={editable} />}
          {section === 'cutouts' && <CtPlCutouts ctx={ctx} pl={pl} editable={editable} />}
          {section === 'sinks' && <CtPlSinks ctx={ctx} pl={pl} editable={editable} />}
          {section === 'other' && <CtPlOtherItems ctx={ctx} pl={pl} editable={editable} />}
        </>
      )}
    </div>
  );
}

// ── Two inputs that hold canonical numbers and show display ones ──────────
// A RATE IS STORED PER SQUARE FOOT OR PER LINEAR FOOT, ALWAYS. Under Metric
// the same rate is SHOWN as $/m² or $/lin m and typed back the same way — so a
// rate typed under one system cannot change meaning when the system is
// switched, because the stored number never moved. `unitKind` null means the
// rate is per item and reads the same in both systems.
function CtRateInput({ value, unitKind, sys, editable, onValue, className, placeholder }) {
  const shown = ctRateToDisplay(value, unitKind, sys);
  const initial = ctIsSet(shown) ? String(shown) : '';
  return (
    <input
      className={className || `w-24 rounded-md border px-2 py-1 text-sm text-right ${ctIsSet(value) ? 'border-[var(--leon-line)]' : 'border-[#e6d6c2] bg-[#fdfaf2]'}`}
      disabled={!editable} placeholder={placeholder || '-No price-'}
      key={`${unitKind || 'each'}-${sys}-${ctIsSet(value) ? value : ''}`}
      defaultValue={initial}
      // BLUR WITHOUT AN EDIT MUST WRITE NOTHING. Converting out and back in is
      // lossy by a rounding step, so tabbing down a metric price column would
      // otherwise nudge every rate it passed. Unchanged text, no write.
      onBlur={e => {
        if (e.target.value.trim() === initial) return;
        onValue(e.target.value.trim() === '' ? null : ctRateToCanon(ctNum(e.target.value), unitKind, sys));
      }} />
  );
}
// A LENGTH IS STORED IN INCHES, ALWAYS. Either system is accepted on the way
// in; only the way out follows the setting.
function CtLenInput({ value, sys, editable, onValue, className, placeholder, allowBlank }) {
  const initial = ctIsSet(value) ? ctFmtIn(Number(value), sys) : '';
  return (
    <input
      className={className || 'w-20 rounded-md border border-[var(--leon-line)] px-2 py-1 text-sm text-right'}
      disabled={!editable} placeholder={placeholder || ''}
      key={`${sys}-${ctIsSet(value) ? value : ''}`}
      defaultValue={initial}
      // Same rule as the rate input: an untouched field writes nothing, so a
      // stored 36" is never quietly rewritten as 35.98" by the round trip
      // through millimetres.
      onBlur={e => {
        const txt = e.target.value.trim();
        if (txt === initial) return;
        if (txt === '' && allowBlank !== false) { onValue(null); return; }
        onValue(ctParseIn(txt, sys));
      }} />
  );
}

// One priced row: the number, and the four controls that hang off every priced
// row in this app. The checkboxes are deliberately OUT of the tab order so a
// whole column of prices can be typed straight down.
function CtPriceRow({ ctx, pl, editable, label, sub, value, onValue, metaKey, unit, indent }) {
  const [openMat, setOpenMat] = useState(false);
  const meta = ctMeta(pl, metaKey);
  const setMeta = fields => ctWritePl(ctx, pl.id, p => {
    p.itemMeta = Object.assign({}, p.itemMeta || {});
    p.itemMeta[metaKey] = Object.assign({}, p.itemMeta[metaKey] || {}, fields);
  });
  const cb = (k, lbl, def) => (
    <label className="inline-flex items-center gap-1 text-[10px] text-[var(--leon-black)]/55" title={lbl}>
      <input type="checkbox" tabIndex={-1} disabled={!editable}
        checked={meta[k] === undefined ? def : !!meta[k]}
        onChange={e => setMeta({ [k]: e.target.checked })} />
      {lbl}
    </label>
  );
  const perMat = meta.perMaterial || {};
  const nOverrides = Object.keys(perMat).filter(k => ctIsSet(perMat[k])).length;
  const sys = ctListUnits(pl);
  const kind = ctRateUnitKind(unit);
  const unitText = kind ? ctUnitSuffix(kind, sys) : (unit || '');

  return (
    <div className={`border-t border-[var(--leon-line)] py-1.5 ${indent ? 'pl-4' : ''}`}>
      <div className="flex items-center gap-2 flex-wrap">
        <div className="grow min-w-[10rem] text-sm">
          {label}
          {sub && <div className="text-[11px] text-[var(--leon-black)]/45">{sub}</div>}
        </div>
        <div className="flex items-center gap-1">
          <CtRateInput value={value} unitKind={kind} sys={sys} editable={editable} onValue={onValue} />
          <span className="text-[11px] text-[var(--leon-black)]/45 w-14">{unitText}</span>
        </div>
        <div className="flex items-center gap-2 flex-wrap">
          {cb('hideOnQuote', 'Hide', false)}
          {cb('allowDiscount', 'Disc.', true)}
          {cb('editableOnQuote', 'Editable', true)}
          <input className="w-20 rounded-md border border-[var(--leon-line)] px-1.5 py-0.5 text-[11px]" tabIndex={-1}
            placeholder="Tax code" disabled={!editable} defaultValue={meta.taxCode || ''}
            onBlur={e => setMeta({ taxCode: e.target.value })} />
          <button className="text-[11px] text-[var(--leon-brown)] font-semibold" tabIndex={-1}
            onClick={() => setOpenMat(v => !v)} title="Price for a specific material, replacing the default above">
            Price for: {nOverrides ? `${nOverrides} material${nOverrides === 1 ? '' : 's'}` : 'Default all materials'}
          </button>
        </div>
      </div>
      {openMat && (
        <div className="mt-1 pl-3 border-l-2 border-[var(--leon-line)]">
          <div className="text-[11px] text-[var(--leon-black)]/50 mb-1">
            A price here replaces the default for that material only. Blank keeps the default.
          </div>
          {((pl.materials || []).length === 0) ? <div className="text-[11px] text-[var(--leon-black)]/40">No materials on this list yet.</div> : null}
          {(pl.materials || []).map(m => (
            <div key={m.id} className="flex items-center gap-2 py-0.5">
              <span className="text-xs grow">{m.name || 'Untitled material'}</span>
              <CtRateInput value={perMat[m.id]} unitKind={kind} sys={sys} editable={editable}
                className="w-24 rounded-md border border-[var(--leon-line)] px-2 py-0.5 text-xs text-right"
                onValue={v => setMeta({ perMaterial: Object.assign({}, perMat, { [m.id]: v }) })} />
              <span className="text-[10px] text-[var(--leon-black)]/40 w-12">{unitText}</span>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

// A whole section of simple rows, with Fill down — because typing a list from
// scratch is the stated path and a column of identical rates is common.
function CtPlSection({ ctx, pl, editable, title, rows, unit, note, extra }) {
  function fillDown() {
    const first = rows.map(r => r.get(pl)).find(v => ctIsSet(v));
    if (!ctIsSet(first)) return;
    ctWritePl(ctx, pl.id, p => { rows.forEach(r => { if (!ctIsSet(r.get(p))) r.set(p, Number(first)); }); });
  }
  return (
    <div className="rounded-lg border border-[var(--leon-line)] bg-white p-3">
      <div className="flex items-center justify-between gap-2 mb-1">
        <h4 className="font-bold text-sm lp-section-title">{title}</h4>
        {editable && rows.length > 1 && (
          <Button size="sm" variant="ghost" onClick={fillDown}
            title="Copy the first price that IS set down into every row that still has none.">Fill down</Button>
        )}
      </div>
      {note && <p className="text-[11px] text-[var(--leon-black)]/55 mb-1">{note}</p>}
      {rows.map(r => (
        <CtPriceRow key={r.key} ctx={ctx} pl={pl} editable={editable} label={r.label} sub={r.sub}
          value={r.get(pl)} unit={r.unit || unit} metaKey={r.key}
          onValue={v => ctWritePl(ctx, pl.id, p => r.set(p, v))} />
      ))}
      {extra}
    </div>
  );
}

function CtPlSettings({ ctx, pl, editable }) {
  const set = (k, v) => ctWritePl(ctx, pl.id, p => { p[k] = v; });
  const accounts = ctx.accounts || [];
  return (
    <div className="rounded-lg border border-[var(--leon-line)] bg-white p-3 space-y-3">
      <div className="grid gap-2 sm:grid-cols-3">
        <Field label="Name"><TextInput defaultValue={pl.name} disabled={!editable} onBlur={e => set('name', e.target.value)} /></Field>
        <Field label="Status">
          <Select value={pl.status} disabled={!editable} onChange={e => set('status', e.target.value)}>
            {['Active', 'Draft', 'Retired'].map(s => <option key={s}>{s}</option>)}
          </Select>
        </Field>
        <Field label="Units" hint="How this list READS. Rates are held per sq ft / lin ft either way.">
          <Select value={pl.units || 'inches'} disabled={!editable} onChange={e => set('units', e.target.value)}>
            <option value="inches">Imperial — in, sq ft, lin ft</option>
            <option value="millimeters">Metric — mm, m², lin m</option>
          </Select>
        </Field>
        <Field label="Default tax rate %" hint="Blank is a blank, not a zero-rated sale.">
          <TextInput placeholder="-Not set-" defaultValue={ctIsSet(pl.defaultTaxRate) ? pl.defaultTaxRate : ''} disabled={!editable}
            onBlur={e => set('defaultTaxRate', e.target.value === '' ? null : ctNum(e.target.value))} />
        </Field>
        <Field label="Default payment terms">
          <TextInput defaultValue={pl.defaultPaymentTerms || ''} disabled={!editable} onBlur={e => set('defaultPaymentTerms', e.target.value)} />
        </Field>
        <Field label="Quote expires after (days)">
          <TextInput defaultValue={ctIsSet(pl.expirationDays) ? pl.expirationDays : ''} disabled={!editable}
            onBlur={e => set('expirationDays', e.target.value === '' ? null : ctNum(e.target.value))} />
        </Field>
        <Field label="Round quote lines to" hint="Money. 0.01 is to the cent.">
          <TextInput defaultValue={pl.roundLinesTo} disabled={!editable} onBlur={e => set('roundLinesTo', ctNum(e.target.value))} />
        </Field>
        <Field label="Round material to" hint="Square feet. 0.1 is the nearest tenth, 1 is the next whole foot.">
          <Select value={String(pl.roundMaterialTo)} disabled={!editable} onChange={e => set('roundMaterialTo', Number(e.target.value))}>
            <option value="0.1">Nearest 0.1 sq ft</option>
            <option value="1">Next whole sq ft</option>
          </Select>
        </Field>
      </div>
      <div className="rounded-md bg-[var(--leon-cream)] px-3 py-2 text-[11px]">
        <strong>Units are a way of reading this list, not a second list.</strong> Every rate is stored per square
        foot or per linear foot and every dimension in inches; switching to Metric shows the same rates as
        $/m² and $/lin m and the same sizes in millimetres, converted on the way in and out. Nothing on a quote
        already sent can move, and there is no need for a second "Retail (mm)" list. A quote may also override
        this for itself, under Step 6.
      </div>
      <div className="rounded-md bg-[var(--leon-cream)] px-3 py-2 text-[11px]">
        {CT_ROUNDING_RULE} There is deliberately <strong>no waste percentage</strong> on this list: waste is
        already absorbed by pricing material by the whole slab and by rounding up, and a third allowance would
        charge the same waste three times.
      </div>
      <Collapsible id={`ct-pl-access-${pl.id}`} title="Which accounts may be quoted on this list" count={(pl.accountIds || []).length}>
        <div className="grid gap-1 sm:grid-cols-2">
          {accounts.map(a => (
            <label key={a.id} className="flex items-center gap-1.5 text-xs">
              <input type="checkbox" disabled={!editable} checked={(pl.accountIds || []).includes(a.id)}
                onChange={e => ctWritePl(ctx, pl.id, p => {
                  const s = new Set(p.accountIds || []);
                  if (e.target.checked) s.add(a.id); else s.delete(a.id);
                  p.accountIds = Array.from(s);
                })} />
              {a.name}
            </label>
          ))}
        </div>
        <p className="text-[11px] text-[var(--leon-black)]/45 mt-1">Nothing ticked means the list is open to every account.</p>
      </Collapsible>
    </div>
  );
}

// ── Materials → price groups → colours ────────────────────────────────────
// The slab size lives here and on each colour, because it is what turns a
// countertop into a slab count. A colour can point at LEON's real supplier
// finish catalog rather than being retyped — thousands of them are already in
// the app, with the supplier's own codes and photographs.

function CtPlMaterials({ ctx, pl, editable }) {
  const [openId, setOpenId] = useState('');
  const [finishFor, setFinishFor] = useState(null);
  const materials = pl.materials || [];
  const sys = ctListUnits(pl);

  const setMat = (mid, fn) => ctWritePl(ctx, pl.id, p => { const m = (p.materials || []).find(x => x.id === mid); if (m) fn(m); });

  return (
    <div className="rounded-lg border border-[var(--leon-line)] bg-white p-3 space-y-2">
      <div className="flex items-center justify-between">
        <h4 className="font-bold text-sm lp-section-title">Materials</h4>
        {editable && <Button size="sm" onClick={() => ctWritePl(ctx, pl.id, p => {
          p.materials = (p.materials || []).concat([makeCtMaterial({ name: 'New material' })]);
        })}>+ Material</Button>}
      </div>
      {materials.length === 0 && <EmptyState text="No materials yet. Add one — name, type, slab size, and the vendor LEON buys it from." />}
      {materials.map(m => (
        <div key={m.id} className="rounded-md border border-[var(--leon-line)]">
          <button className="w-full flex items-center gap-2 px-2 py-1.5 text-left" onClick={() => setOpenId(openId === m.id ? '' : m.id)}>
            <span className="font-semibold text-sm grow">{m.name || 'Untitled material'}</span>
            <span className="text-[11px] text-[var(--leon-black)]/45">
              {(m.colors || []).length} colour{(m.colors || []).length === 1 ? '' : 's'} · {(m.priceGroups || []).length} group{(m.priceGroups || []).length === 1 ? '' : 's'} ·
              {' '}{ctSlabSizeText(m.slabLengthIn, m.slabWidthIn, sys)}
            </span>
            <span className="text-xs">{openId === m.id ? '▾' : '▸'}</span>
          </button>
          {openId === m.id && (
            <div className="border-t border-[var(--leon-line)] p-2 space-y-3">
              <div className="grid gap-2 sm:grid-cols-3">
                <Field label="Name"><TextInput className="!py-1" defaultValue={m.name} disabled={!editable} onBlur={e => setMat(m.id, x => { x.name = e.target.value; })} /></Field>
                <Field label="Type">
                  <Select className="!py-1" value={m.type} disabled={!editable} onChange={e => setMat(m.id, x => { x.type = e.target.value; })}>
                    {(typeof STONE_MATERIALS !== 'undefined' ? STONE_MATERIALS : ['Quartz', 'Granite', 'Marble', 'Porcelain']).map(t => <option key={t}>{t}</option>)}
                  </Select>
                </Field>
                <Field label="Bought from" hint="LEON's own vendor record.">
                  <Select className="!py-1" value={m.vendorId || ''} disabled={!editable} onChange={e => setMat(m.id, x => { x.vendorId = e.target.value || null; })}>
                    <option value="">— none —</option>
                    {(ctx.vendors || []).map(v => <option key={v.id} value={v.id}>{v.name}</option>)}
                  </Select>
                </Field>
                <Field label="Default slab length" hint={sys === 'Metric' ? 'mm' : 'inches'}>
                  <CtLenInput value={m.slabLengthIn} sys={sys} editable={editable}
                    className="w-full rounded-md border border-[var(--leon-line)] px-2 py-1 text-sm"
                    onValue={v => setMat(m.id, x => { x.slabLengthIn = v === null ? 0 : v; })} />
                </Field>
                <Field label="Default slab width" hint={sys === 'Metric' ? 'mm' : 'inches'}>
                  <CtLenInput value={m.slabWidthIn} sys={sys} editable={editable}
                    className="w-full rounded-md border border-[var(--leon-line)] px-2 py-1 text-sm"
                    onValue={v => setMat(m.id, x => { x.slabWidthIn = v === null ? 0 : v; })} />
                </Field>
                <Field label="Tax code"><TextInput className="!py-1" defaultValue={m.taxCode || ''} disabled={!editable} onBlur={e => setMat(m.id, x => { x.taxCode = e.target.value; })} /></Field>
              </div>
              <div className="flex gap-3 flex-wrap text-[11px]">
                {[['allowOtherColor', 'Allow other colours'], ['allowDiscount', 'Allow discount'], ['editableOnQuote', 'Editable price on quote']].map(c => (
                  <label key={c[0]} className="inline-flex items-center gap-1">
                    <input type="checkbox" tabIndex={-1} disabled={!editable} checked={m[c[0]] !== false}
                      onChange={e => setMat(m.id, x => { x[c[0]] = e.target.checked; })} />{c[1]}
                  </label>
                ))}
              </div>

              <div>
                <div className="flex items-center justify-between mb-1">
                  <div className="text-xs font-bold">Price groups</div>
                  {editable && <Button size="sm" variant="ghost" onClick={() => setMat(m.id, x => {
                    x.priceGroups = (x.priceGroups || []).concat([{ id: uid('ctpg'), name: `Group ${(x.priceGroups || []).length + 1}`, pricePerSqFt: null }]);
                  })}>+ Group</Button>}
                </div>
                <p className="text-[11px] text-[var(--leon-black)]/50 mb-1">
                  A group is selectable AS a colour on a quote — that is how a showroom quote starts when the
                  client knows their price point but has not chosen a slab.
                </p>
                {(m.priceGroups || []).map(g => (
                  <div key={g.id} className="flex items-center gap-2 py-0.5">
                    <TextInput className="!py-0.5 grow" defaultValue={g.name} disabled={!editable}
                      onBlur={e => setMat(m.id, x => { const t = x.priceGroups.find(z => z.id === g.id); if (t) t.name = e.target.value; })} />
                    <CtRateInput value={g.pricePerSqFt} unitKind="sq ft" sys={sys} editable={editable}
                      onValue={v => setMat(m.id, x => { const t = x.priceGroups.find(z => z.id === g.id); if (t) t.pricePerSqFt = v; })} />
                    <span className="text-[11px] text-[var(--leon-black)]/45 w-12">{ctUnitSuffix('sq ft', sys)}</span>
                    {editable && <IconAction icon="✕" title="Remove this group"
                      onClick={() => setMat(m.id, x => { x.priceGroups = x.priceGroups.filter(z => z.id !== g.id); })} />}
                  </div>
                ))}
              </div>

              <div>
                <div className="flex items-center justify-between mb-1">
                  <div className="text-xs font-bold">Colours</div>
                  {editable && <Button size="sm" variant="ghost" onClick={() => setMat(m.id, x => {
                    x.colors = (x.colors || []).concat([{ id: uid('ctcol'), name: 'New colour', priceGroupId: null, pricePerSqFt: null, slabLengthIn: null, slabWidthIn: null, supplierRef: null }]);
                  })}>+ Colour</Button>}
                </div>
                {(m.colors || []).map(c => (
                  <div key={c.id} className="flex items-center gap-2 py-0.5 flex-wrap">
                    {c.supplierRef && c.supplierRef.img
                      ? <img src={c.supplierRef.img} alt="" className="w-8 h-8 object-cover rounded" />
                      : <span className="w-8 h-8 rounded bg-[var(--leon-line)]" />}
                    <TextInput className="!py-0.5 w-40" defaultValue={c.name} disabled={!editable}
                      onBlur={e => setMat(m.id, x => { const t = x.colors.find(z => z.id === c.id); if (t) t.name = e.target.value; })} />
                    <Select className="!py-0.5 w-32" value={c.priceGroupId || ''} disabled={!editable}
                      onChange={e => setMat(m.id, x => { const t = x.colors.find(z => z.id === c.id); if (t) t.priceGroupId = e.target.value || null; })}>
                      <option value="">no group</option>
                      {(m.priceGroups || []).map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
                    </Select>
                    <CtRateInput value={c.pricePerSqFt} unitKind="sq ft" sys={sys} editable={editable}
                      onValue={v => setMat(m.id, x => { const t = x.colors.find(z => z.id === c.id); if (t) t.pricePerSqFt = v; })} />
                    <span className="text-[10px] text-[var(--leon-black)]/40">{ctUnitSuffix('sq ft', sys)}</span>
                    <CtLenInput value={c.slabLengthIn} sys={sys} editable={editable} placeholder="slab L"
                      className="w-20 rounded-md border border-[var(--leon-line)] px-2 py-1 text-xs text-right"
                      onValue={v => setMat(m.id, x => { const t = x.colors.find(z => z.id === c.id); if (t) t.slabLengthIn = v; })} />
                    <CtLenInput value={c.slabWidthIn} sys={sys} editable={editable} placeholder="slab W"
                      className="w-20 rounded-md border border-[var(--leon-line)] px-2 py-1 text-xs text-right"
                      onValue={v => setMat(m.id, x => { const t = x.colors.find(z => z.id === c.id); if (t) t.slabWidthIn = v; })} />
                    {editable && <Button size="sm" variant="ghost" onClick={() => setFinishFor({ materialId: m.id, colorId: c.id })}
                      title="Link this colour to LEON's supplier finish catalog instead of retyping it.">
                      {c.supplierRef ? 'Change finish' : 'Link finish'}
                    </Button>}
                    {editable && <IconAction icon="✕" title="Remove this colour"
                      onClick={() => setMat(m.id, x => { x.colors = x.colors.filter(z => z.id !== c.id); })} />}
                  </div>
                ))}
                <p className="text-[11px] text-[var(--leon-black)]/45 mt-1">
                  A colour's own slab size overrides the material's. Each colour option on a quote is priced from
                  its OWN slab size, so comparing two materials whose slabs differ gives the right answer.
                </p>
              </div>

              {editable && (
                <Button size="sm" variant="ghost" onClick={() => ctWritePl(ctx, pl.id, p => { p.materials = (p.materials || []).filter(x => x.id !== m.id); })}>
                  Remove this material
                </Button>
              )}
            </div>
          )}
        </div>
      ))}
      <CtFinishPickerModal open={!!finishFor} onClose={() => setFinishFor(null)} ctx={ctx}
        onPick={rec => {
          if (!finishFor) return;
          ctWritePl(ctx, pl.id, p => {
            const m = (p.materials || []).find(x => x.id === finishFor.materialId);
            if (!m) return;
            const c = (m.colors || []).find(x => x.id === finishFor.colorId);
            if (!c) return;
            c.supplierRef = typeof makeSupplierFinishRef === 'function' ? makeSupplierFinishRef(rec) : null;
            if (!c.name || c.name === 'New colour') c.name = rec.name || c.name;
          });
          setFinishFor(null);
        }} />
    </div>
  );
}

function CtFinishPickerModal({ open, onClose, ctx, onPick }) {
  const [q, setQ] = useState('');
  const [sup, setSup] = useState('');
  const results = (open && typeof searchSupplierFinishes === 'function') ? searchSupplierFinishes(sup || null, null, q, 40) : [];
  const suppliers = (typeof SUPPLIER_CATALOGS !== 'undefined') ? SUPPLIER_CATALOGS : [];
  return (
    <Modal wide open={open} onClose={onClose} title="Link a supplier finish"
      footer={<Button variant="ghost" onClick={onClose}>Close</Button>}>
      <div className="flex items-end gap-2 mb-3">
        <Field label="Supplier" className="w-52">
          <Select value={sup} onChange={e => setSup(e.target.value)}>
            <option value="">All suppliers</option>
            {suppliers.map(s => <option key={s.key} value={s.key}>{typeof supplierDisplayName === 'function' ? supplierDisplayName(s.key, ctx.vendors) : s.label}</option>)}
          </Select>
        </Field>
        <Field label="Search" className="grow"><TextInput value={q} onChange={e => setQ(e.target.value)} placeholder="name or supplier code" /></Field>
      </div>
      {results.length === 0 ? <EmptyState text="Nothing matched. LEON's catalogs hold thousands of finishes — try a colour name or a supplier code." /> : (
        <div className="grid gap-2 sm:grid-cols-3 lg:grid-cols-4 max-h-96 overflow-y-auto">
          {results.map(r => (
            <button key={`${r.sup}:${r.id}`} onClick={() => onPick(r)}
              className="text-left rounded-lg border border-[var(--leon-line)] overflow-hidden hover:border-[var(--leon-brown)]">
              {r.img ? <img src={r.img} alt="" className="w-full h-20 object-cover" /> : <div className="w-full h-20 bg-[var(--leon-line)]" />}
              <div className="p-1.5">
                <div className="text-xs font-semibold truncate">{r.name}</div>
                <div className="text-[10px] text-[var(--leon-black)]/45 truncate">{r.code} · {r.supLabel}</div>
              </div>
            </button>
          ))}
        </div>
      )}
    </Modal>
  );
}

// ── Splash ────────────────────────────────────────────────────────────────

function CtPlSplash({ ctx, pl, editable }) {
  const sp = pl.splash || {};
  const basis = ctSplashBasis(pl);
  const sys = ctListUnits(pl);
  const setSp = fields => ctWritePl(ctx, pl.id, p => { p.splash = Object.assign({}, p.splash || {}, fields); });
  return (
    <div className="rounded-lg border border-[var(--leon-line)] bg-white p-3 space-y-2">
      <h4 className="font-bold text-sm lp-section-title">Splash</h4>
      <p className="text-[11px] text-[var(--leon-black)]/60">
        Backsplash is priced three genuinely different ways, and they are not variations of one rule. The AREA
        is the same number in all three — each side's splash run times its own height, read straight off the
        drawing. Only the <strong>rate</strong> changes.
      </p>
      <Field label="Charged" className="max-w-md">
        <Select value={basis} disabled={!editable} onChange={e => setSp({ mode: e.target.value })}>
          {CT_SPLASH_BASES.map(b => <option key={b.k} value={b.k}>{b.label}</option>)}
        </Select>
      </Field>
      <p className="text-[11px] text-[var(--leon-black)]/60">
        {basis === 'linearFt'
          ? 'Per linear foot makes the splash HEIGHT irrelevant to the price — only the run counts.'
          : basis === 'sqft'
            ? 'The splash’s OWN rate per square foot, banded by height — so a 4" splash and a full-height one can carry different rates on the same drawing. Its stone is understood to be inside that rate, so it is NOT added to the slab count.'
            : 'Square feet at the MATERIAL’s own rate. The splash is then cut from the same stone, so its area joins the slab demand and is paid for through the slab line rather than a line of its own.'}
      </p>
      {basis === 'linearFt' && (
        <CtPriceRow ctx={ctx} pl={pl} editable={editable} label="Splash" unit="/lin ft" metaKey="splash"
          value={sp.pricePerLinFt} onValue={v => setSp({ pricePerLinFt: v })} />
      )}
      {basis === 'sqft' && (
        <>
          <CtPriceRow ctx={ctx} pl={pl} editable={editable} label="Splash — any height" unit="/sq ft" metaKey="splash"
            sub="Used where no height band below covers the run. Left blank, that run prices as -No price- and the quote reports itself incomplete."
            value={sp.pricePerSqFt} onValue={v => setSp({ pricePerSqFt: v })} />
          <div className="flex items-center justify-between mt-2">
            <div className="text-xs font-bold">By height</div>
            {editable && <Button size="sm" variant="ghost" onClick={() => setSp({ byHeight: (sp.byHeight || []).concat([makeCtPriceItem({ label: 'Up to …', unit: 'sq ft', upToIn: null })]) })}>+ Height band</Button>}
          </div>
          <p className="text-[11px] text-[var(--leon-black)]/50">
            Each splash RUN is banded by its own height, not by the area's default — a drawing carrying both a
            4" run and a full-height one produces two priced lines, each at its own rate.
          </p>
          {(sp.byHeight || []).map(r => (
            <div key={r.id} className="flex items-center gap-2 py-0.5">
              <span className="text-xs">Up to</span>
              <CtLenInput value={r.upToIn} sys={sys} editable={editable} placeholder="[Any]"
                onValue={v => setSp({ byHeight: (sp.byHeight || []).map(x => x.id === r.id ? Object.assign({}, x, { upToIn: v }) : x) })} />
              <span className="text-xs">high</span>
              <CtRateInput value={r.price} unitKind="sq ft" sys={sys} editable={editable}
                onValue={v => setSp({ byHeight: (sp.byHeight || []).map(x => x.id === r.id ? Object.assign({}, x, { price: v }) : x) })} />
              <span className="text-[11px] text-[var(--leon-black)]/45">{ctUnitSuffix('sq ft', sys)}</span>
              {editable && <IconAction icon="✕" title="Remove" onClick={() => setSp({ byHeight: (sp.byHeight || []).filter(x => x.id !== r.id) })} />}
            </div>
          ))}
        </>
      )}
      <p className="text-[10px] text-[var(--leon-black)]/45">
        A list saved before this existed carries no basis, or the old spelling <code>linearFoot</code>. Both are
        read as they always behaved — an absent basis is the material rate — so nothing on an existing quote moves.
      </p>
    </div>
  );
}

// ── Finished edges ────────────────────────────────────────────────────────

function CtPlEdges({ ctx, pl, editable }) {
  const groups = pl.edgeGroups || [];
  return (
    <div className="space-y-2">
      <CtPlSection ctx={ctx} pl={pl} editable={editable} title="Finished Edges" unit="/lin ft"
        note="Only a FINISHED side is billable edge — a run against a wall is not edge at all. Miter and Waterfall are priced under Mitered Edges & Waterfalls, on the both-pieces rule, so they are not repeated here."
        rows={CT_FINISHED_EDGE_PROFILES.map(prof => ({
          key: `edge:${prof}`, label: prof,
          sub: (pl.edgeGroupOf || {})[prof] ? `Falls back to ${(groups.find(g => g.id === pl.edgeGroupOf[prof]) || {}).name || 'a group'} when blank` : '',
          get: p => (p.finishedEdges || {})[prof],
          set: (p, v) => { p.finishedEdges = Object.assign({}, p.finishedEdges || {}); p.finishedEdges[prof] = v; },
        }))}
        extra={
          <div className="mt-3 pt-2 border-t border-[var(--leon-line)]">
            <div className="flex items-center justify-between mb-1">
              <div className="text-xs font-bold">Edge price groups</div>
              {editable && <Button size="sm" variant="ghost" onClick={() => ctWritePl(ctx, pl.id, p => {
                p.edgeGroups = (p.edgeGroups || []).concat([{ id: uid('cteg'), name: `Edge group ${(p.edgeGroups || []).length + 1}`, pricePerLinFt: null }]);
              })}>+ Edge group</Button>}
            </div>
            <p className="text-[11px] text-[var(--leon-black)]/50 mb-1">One rate several profiles share. A profile's own price always wins where it is set.</p>
            {groups.map(g => (
              <div key={g.id} className="flex items-center gap-2 py-0.5">
                <TextInput className="!py-0.5 w-40" defaultValue={g.name} disabled={!editable}
                  onBlur={e => ctWritePl(ctx, pl.id, p => { const t = (p.edgeGroups || []).find(z => z.id === g.id); if (t) t.name = e.target.value; })} />
                <CtRateInput value={g.pricePerLinFt} unitKind="lin ft" sys={ctListUnits(pl)} editable={editable}
                  onValue={v => ctWritePl(ctx, pl.id, p => { const t = (p.edgeGroups || []).find(z => z.id === g.id); if (t) t.pricePerLinFt = v; })} />
                <span className="text-[11px] text-[var(--leon-black)]/45">{ctUnitSuffix('lin ft', ctListUnits(pl))}</span>
                {editable && <IconAction icon="✕" title="Remove" onClick={() => ctWritePl(ctx, pl.id, p => { p.edgeGroups = (p.edgeGroups || []).filter(z => z.id !== g.id); })} />}
              </div>
            ))}
            {groups.length > 0 && (
              <div className="mt-2 grid gap-1 sm:grid-cols-2">
                {CT_FINISHED_EDGE_PROFILES.map(prof => (
                  <div key={prof} className="flex items-center gap-2 text-xs">
                    <span className="w-28">{prof}</span>
                    <Select className="!py-0.5 grow" value={(pl.edgeGroupOf || {})[prof] || ''} disabled={!editable}
                      onChange={e => ctWritePl(ctx, pl.id, p => { p.edgeGroupOf = Object.assign({}, p.edgeGroupOf || {}); p.edgeGroupOf[prof] = e.target.value || null; })}>
                      <option value="">no group</option>
                      {groups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
                    </Select>
                  </div>
                ))}
              </div>
            )}
          </div>
        } />
    </div>
  );
}

// ── Curves & bumpouts ─────────────────────────────────────────────────────

function CtPlCurves({ ctx, pl, editable }) {
  const simple = CT_CURVE_ROWS.filter(r => !r.twoPrice);
  const two = CT_CURVE_ROWS.filter(r => r.twoPrice);
  return (
    <div className="space-y-2">
      <CtPlSection ctx={ctx} pl={pl} editable={editable} title="Curves & Bumpouts" unit=" each"
        note="A treated corner is a countable, priced line on the estimate. Bump Outs, Bump Ins and Full Radius Edges are not properties of a corner, so they are added by hand on the quote."
        rows={simple.map(r => ({
          key: `corner:${r.key}`, label: r.label,
          get: p => (p.corners || {})[r.key],
          set: (p, v) => { p.corners = Object.assign({}, p.corners || {}); p.corners[r.key] = v; },
        }))} />
      {two.map(r => {
        const cur = (pl.corners || {})[r.key] || { flat: null, linear: null };
        const setPart = (k, v) => ctWritePl(ctx, pl.id, p => {
          p.corners = Object.assign({}, p.corners || {});
          p.corners[r.key] = Object.assign({ flat: null, linear: null }, p.corners[r.key], { [k]: v });
        });
        return (
          <div key={r.key} className="rounded-lg border border-[var(--leon-line)] bg-white p-3">
            <h4 className="font-bold text-sm lp-section-title mb-1">{r.label}</h4>
            <p className="text-[11px] text-[var(--leon-black)]/55 mb-1">
              Two prices, because this one is charged both ways: a flat charge per feature and a rate per
              linear foot. Both are shown on the quote line so neither is silently dropped.
            </p>
            <CtPriceRow ctx={ctx} pl={pl} editable={editable} label="Flat edge price" unit=" each"
              metaKey={`corner:${r.key}`} value={cur.flat} onValue={v => setPart('flat', v)} />
            <CtPriceRow ctx={ctx} pl={pl} editable={editable} label="Linear price" unit="/lin ft"
              metaKey={`corner:${r.key}:linear`} value={cur.linear} onValue={v => setPart('linear', v)} />
          </div>
        );
      })}
    </div>
  );
}

// ── Cutouts ───────────────────────────────────────────────────────────────
// Keyed by sink TYPE first, then one of three bases. A sink record here holds
// CUTOUT dimensions — the opening, not the bowl — because the opening is what
// is fabricated and therefore what is priced.

function CtPlCutouts({ ctx, pl, editable }) {
  const cut = pl.cutouts || {};
  const sys = ctListUnits(pl);
  const setCut = fields => ctWritePl(ctx, pl.id, p => { p.cutouts = Object.assign({}, p.cutouts || {}, fields); });

  return (
    <div className="space-y-2">
      {CT_SINK_BANDS.map(band => {
        const basis = (cut.sinkBasis || {})[band.type] || 'tier';
        const rows = cut[band.key] || [];
        return (
          <div key={band.key} className="rounded-lg border border-[var(--leon-line)] bg-white p-3">
            <div className="flex items-center justify-between gap-2 mb-1 flex-wrap">
              <h4 className="font-bold text-sm lp-section-title">{band.label}</h4>
              <Field label="Priced" className="w-56">
                <Select className="!py-1" value={basis} disabled={!editable}
                  onChange={e => setCut({ sinkBasis: Object.assign({}, cut.sinkBasis || {}, { [band.type]: e.target.value }) })}>
                  {CT_SINK_BASES.map(b => <option key={b.k} value={b.k}>{b.label}</option>)}
                </Select>
              </Field>
            </div>
            {basis === 'flat' && (
              <CtPriceRow ctx={ctx} pl={pl} editable={editable} label={`${band.type} sink cutout — flat`} unit=" each"
                metaKey={`sinkflat:${band.type}`} value={(cut.sinkFlat || {})[band.type]}
                onValue={v => setCut({ sinkFlat: Object.assign({}, cut.sinkFlat || {}, { [band.type]: v }) })} />
            )}
            {basis === 'material' && (
              <CtPriceRow ctx={ctx} pl={pl} editable={editable} label={`${band.type} sink cutout — by material`} unit=" each"
                metaKey={`sinkmat:${band.type}`} value={null} onValue={() => {}}
                sub="Set the price against each material under “Price for” — there is no default on this basis, by design." />
            )}
            {basis === 'tier' && (
              <>
                <p className="text-[11px] text-[var(--leon-black)]/55 mb-1">
                  The tier is chosen by the sink's <strong>longest edge</strong>. <code>[Any Size]</code> is the
                  catch-all and is used when no sized tier covers the opening.
                </p>
                {rows.map(r => (
                  <div key={r.id} className="flex items-center gap-2 py-0.5 flex-wrap">
                    <TextInput className="!py-0.5 w-40" defaultValue={r.label} disabled={!editable}
                      onBlur={e => setCut({ [band.key]: rows.map(x => x.id === r.id ? Object.assign({}, x, { label: e.target.value }) : x) })} />
                    <span className="text-xs">up to</span>
                    <CtLenInput value={r.upToIn} sys={sys} editable={editable} placeholder="[Any]"
                      onValue={v => setCut({ [band.key]: rows.map(x => x.id === r.id ? Object.assign({}, x, { upToIn: v }) : x) })} />
                    <input className={`w-24 rounded-md border px-2 py-1 text-sm text-right ${ctIsSet(r.price) ? 'border-[var(--leon-line)]' : 'border-[#e6d6c2] bg-[#fdfaf2]'}`}
                      placeholder="-No price-" disabled={!editable} defaultValue={ctIsSet(r.price) ? r.price : ''}
                      onBlur={e => setCut({ [band.key]: rows.map(x => x.id === r.id ? Object.assign({}, x, { price: e.target.value === '' ? null : ctNum(e.target.value) }) : x) })} />
                    {editable && rows.length > 1 && <IconAction icon="✕" title="Remove this tier"
                      onClick={() => setCut({ [band.key]: rows.filter(x => x.id !== r.id) })} />}
                  </div>
                ))}
                {editable && <Button size="sm" variant="ghost"
                  onClick={() => setCut({ [band.key]: rows.concat([makeCtPriceItem({ label: 'Up to …', unit: 'each', upToIn: null })]) })}>+ Size tier</Button>}
              </>
            )}
          </div>
        );
      })}

      <CtPlSection ctx={ctx} pl={pl} editable={editable} title="Other cutouts" unit=" each"
        note="A cutout never reduces the square footage — the stone is still bought and still fabricated. Faucet holes are counted separately from the sink; outlets are entered as a count because their position does not change the cost."
        rows={[
          { key: 'faucetHole', label: 'Faucet hole', get: p => (p.cutouts || {}).faucetHole, set: (p, v) => { p.cutouts = Object.assign({}, p.cutouts || {}, { faucetHole: v }); } },
          { key: 'cooktop', label: 'Cooktop cutout', get: p => (p.cutouts || {}).cooktop, set: (p, v) => { p.cutouts = Object.assign({}, p.cutouts || {}, { cooktop: v }); } },
          { key: 'outlet', label: 'Outlet cutout', get: p => (p.cutouts || {}).outlet, set: (p, v) => { p.cutouts = Object.assign({}, p.cutouts || {}, { outlet: v }); } },
          { key: 'otherCutout', label: 'Other cutout', get: p => (p.cutouts || {}).other, set: (p, v) => { p.cutouts = Object.assign({}, p.cutouts || {}, { other: v }); } },
        ]} />

      <div className="rounded-lg border border-[var(--leon-line)] bg-white p-3">
        <div className="flex items-center justify-between mb-1">
          <h4 className="font-bold text-sm lp-section-title">Sink Cutout Shortcuts</h4>
          {editable && <Button size="sm" variant="ghost" onClick={() => setCut({
            shortcuts: (cut.shortcuts || []).concat([{ id: uid('ctsc'), label: 'New shortcut', sinkType: 'Undermount', widthIn: 0, depthIn: 0, faucetHoles: 3 }]),
          })}>+ Shortcut</Button>}
        </div>
        <p className="text-[11px] text-[var(--leon-black)]/55 mb-1">
          Named openings that place in one click in step 4. Sizes only — the price still comes from the tiers above.
        </p>
        {(cut.shortcuts || []).length === 0 && <div className="text-[11px] text-[var(--leon-black)]/40">None yet. Step 4 also offers a list of standard openings.</div>}
        {(cut.shortcuts || []).map(s => (
          <div key={s.id} className="flex items-center gap-2 py-0.5 flex-wrap">
            <TextInput className="!py-0.5 w-44" defaultValue={s.label} disabled={!editable}
              onBlur={e => setCut({ shortcuts: cut.shortcuts.map(x => x.id === s.id ? Object.assign({}, x, { label: e.target.value }) : x) })} />
            <Select className="!py-0.5 w-32" value={s.sinkType} disabled={!editable}
              onChange={e => setCut({ shortcuts: cut.shortcuts.map(x => x.id === s.id ? Object.assign({}, x, { sinkType: e.target.value }) : x) })}>
              {CT_SINK_TYPES.map(t => <option key={t}>{t}</option>)}
            </Select>
            {[['widthIn', 'W'], ['depthIn', 'D']].map(f => (
              <span key={f[0]} className="inline-flex items-center gap-1 text-xs">
                {f[1]}
                <CtLenInput value={s[f[0]]} sys={sys} editable={editable}
                  className="w-20 rounded-md border border-[var(--leon-line)] px-1.5 py-1 text-xs text-right"
                  onValue={v => setCut({ shortcuts: cut.shortcuts.map(x => x.id === s.id ? Object.assign({}, x, { [f[0]]: v === null ? 0 : v }) : x) })} />
              </span>
            ))}
            <span className="inline-flex items-center gap-1 text-xs">
              Holes
              <input className="w-16 rounded-md border border-[var(--leon-line)] px-1.5 py-1 text-xs text-right" disabled={!editable}
                defaultValue={s.faucetHoles}
                onBlur={e => setCut({ shortcuts: cut.shortcuts.map(x => x.id === s.id ? Object.assign({}, x, { faucetHoles: ctNum(e.target.value) }) : x) })} />
            </span>
            {editable && <IconAction icon="✕" title="Remove" onClick={() => setCut({ shortcuts: cut.shortcuts.filter(x => x.id !== s.id) })} />}
          </div>
        ))}
      </div>
    </div>
  );
}

// ── Sinks sold as product ─────────────────────────────────────────────────

function CtPlSinks({ ctx, pl, editable }) {
  const sinks = pl.sinks || [];
  const setSinks = list => ctWritePl(ctx, pl.id, p => { p.sinks = list; });
  return (
    <div className="rounded-lg border border-[var(--leon-line)] bg-white p-3 space-y-1">
      <div className="flex items-center justify-between mb-1">
        <h4 className="font-bold text-sm lp-section-title">Sinks</h4>
        {editable && <Button size="sm" variant="ghost" onClick={() => setSinks(sinks.concat([makeCtPriceItem({ label: 'New sink', unit: 'each', type: 'Undermount', sizeIn: '' })]))}>+ Sink</Button>}
      </div>
      <label className="inline-flex items-center gap-1.5 text-[11px] mb-1">
        <input type="checkbox" tabIndex={-1} disabled={!editable} checked={pl.allowOtherSinks !== false}
          onChange={e => ctWritePl(ctx, pl.id, p => { p.allowOtherSinks = e.target.checked; })} />
        Allow other sinks to be typed on a quote
      </label>
      {sinks.length === 0 && <EmptyState text="No sinks yet. These are sinks LEON SELLS — the cutout for a client-supplied sink is priced under Cutouts." />}
      {sinks.map(s => (
        <div key={s.id} className="flex items-center gap-2 py-0.5 flex-wrap">
          <TextInput className="!py-0.5 w-48" defaultValue={s.label} disabled={!editable}
            onBlur={e => setSinks(sinks.map(x => x.id === s.id ? Object.assign({}, x, { label: e.target.value }) : x))} />
          <Select className="!py-0.5 w-32" value={s.type || 'Undermount'} disabled={!editable}
            onChange={e => setSinks(sinks.map(x => x.id === s.id ? Object.assign({}, x, { type: e.target.value }) : x))}>
            {CT_SINK_TYPES.map(t => <option key={t}>{t}</option>)}
          </Select>
          <TextInput className="!py-0.5 w-28" placeholder="cutout size" defaultValue={s.sizeIn || ''} disabled={!editable}
            onBlur={e => setSinks(sinks.map(x => x.id === s.id ? Object.assign({}, x, { sizeIn: e.target.value }) : x))} />
          <input className={`w-24 rounded-md border px-2 py-1 text-sm text-right ${ctIsSet(s.price) ? 'border-[var(--leon-line)]' : 'border-[#e6d6c2] bg-[#fdfaf2]'}`}
            placeholder="-No price-" disabled={!editable} defaultValue={ctIsSet(s.price) ? s.price : ''}
            onBlur={e => setSinks(sinks.map(x => x.id === s.id ? Object.assign({}, x, { price: e.target.value === '' ? null : ctNum(e.target.value) }) : x))} />
          <span className="text-[11px] text-[var(--leon-black)]/45">each</span>
          {[['hideOnQuote', 'Hide', false], ['allowDiscount', 'Disc.', true], ['editableOnQuote', 'Editable', true]].map(c => (
            <label key={c[0]} className="inline-flex items-center gap-1 text-[10px] text-[var(--leon-black)]/55">
              <input type="checkbox" tabIndex={-1} disabled={!editable} checked={s[c[0]] === undefined ? c[2] : !!s[c[0]]}
                onChange={e => setSinks(sinks.map(x => x.id === s.id ? Object.assign({}, x, { [c[0]]: e.target.checked }) : x))} />{c[1]}
            </label>
          ))}
          <input className="w-20 rounded-md border border-[var(--leon-line)] px-1.5 py-0.5 text-[11px]" tabIndex={-1}
            placeholder="Tax code" disabled={!editable} defaultValue={s.taxCode || ''}
            onBlur={e => setSinks(sinks.map(x => x.id === s.id ? Object.assign({}, x, { taxCode: e.target.value }) : x))} />
          {editable && <IconAction icon="✕" title="Remove" onClick={() => setSinks(sinks.filter(x => x.id !== s.id))} />}
        </div>
      ))}
    </div>
  );
}

// ── Other items, including unit-priced items ──────────────────────────────
// Tear-out, travel and delivery are quoted this way in practice: a unit, and
// size ranges each carrying BOTH a base fee and a per-unit rate. There is no
// separate tear-out entity and no minimum-square-footage field — a minimum is
// the base fee on the lowest range, which is the same number said honestly.

function CtPlOtherItems({ ctx, pl, editable }) {
  const items = pl.otherItems || [];
  const setItems = list => ctWritePl(ctx, pl.id, p => { p.otherItems = list; });
  const patch = (id, fields) => setItems(items.map(x => (x.id === id ? Object.assign({}, x, fields) : x)));

  return (
    <div className="rounded-lg border border-[var(--leon-line)] bg-white p-3 space-y-2">
      <div className="flex items-center justify-between">
        <h4 className="font-bold text-sm lp-section-title">Other Items</h4>
        {editable && (
          <div className="flex gap-1">
            <Button size="sm" variant="ghost" onClick={() => setItems(items.concat([makeCtPriceItem({ label: 'New item', unit: 'each', kind: 'flat' })]))}>+ Flat item</Button>
            <Button size="sm" variant="ghost" onClick={() => setItems(items.concat([ctMakeUnitItem({ label: 'New unit-priced item' })]))}>+ Unit-priced item</Button>
          </div>
        )}
      </div>
      <label className="inline-flex items-center gap-1.5 text-[11px]">
        <input type="checkbox" tabIndex={-1} disabled={!editable} checked={pl.allowOtherItems !== false}
          onChange={e => ctWritePl(ctx, pl.id, p => { p.allowOtherItems = e.target.checked; })} />
        Allow other items to be typed on a quote
      </label>
      {items.length === 0 && <EmptyState text="Nothing here yet. Tear-out, travel and delivery belong here as unit-priced items." />}
      {items.map(it => it.kind === 'unit' ? (
        <div key={it.id} className="rounded-md border border-[var(--leon-line)] p-2">
          <div className="flex items-center gap-2 flex-wrap mb-1">
            <TextInput className="!py-0.5 w-56" defaultValue={it.label} disabled={!editable}
              onBlur={e => patch(it.id, { label: e.target.value })} />
            <Select className="!py-0.5 w-28" value={it.unit} disabled={!editable} onChange={e => patch(it.id, { unit: e.target.value })}>
              {CT_UNIT_ITEM_UNITS.map(u => <option key={u}>{u}</option>)}
            </Select>
            <span className="text-[11px] text-[var(--leon-black)]/45">base fee + rate per {it.unit}</span>
            <div className="grow" />
            {editable && <IconAction icon="✕" title="Remove" onClick={() => setItems(items.filter(x => x.id !== it.id))} />}
          </div>
          <table className="w-full text-xs">
            <thead><tr className="text-left text-[10px] uppercase tracking-wide text-[var(--leon-black)]/50">
              <th className="py-1">Up to</th><th>Base fee</th><th>Per {it.unit}</th><th></th>
            </tr></thead>
            <tbody>
              {(it.ranges || []).map(r => (
                <tr key={r.id} className="border-t border-[var(--leon-line)]">
                  <td className="py-1">
                    <input className="w-20 rounded-md border border-[var(--leon-line)] px-2 py-1 text-right" disabled={!editable}
                      placeholder="[Any]" defaultValue={ctIsSet(r.upTo) ? r.upTo : ''}
                      onBlur={e => patch(it.id, { ranges: it.ranges.map(x => x.id === r.id ? Object.assign({}, x, { upTo: e.target.value === '' ? null : ctNum(e.target.value) }) : x) })} />
                  </td>
                  <td>
                    <input className={`w-24 rounded-md border px-2 py-1 text-right ${ctIsSet(r.baseFee) ? 'border-[var(--leon-line)]' : 'border-[#e6d6c2] bg-[#fdfaf2]'}`}
                      placeholder="-No price-" disabled={!editable} defaultValue={ctIsSet(r.baseFee) ? r.baseFee : ''}
                      onBlur={e => patch(it.id, { ranges: it.ranges.map(x => x.id === r.id ? Object.assign({}, x, { baseFee: e.target.value === '' ? null : ctNum(e.target.value) }) : x) })} />
                  </td>
                  <td>
                    <input className={`w-24 rounded-md border px-2 py-1 text-right ${ctIsSet(r.perUnit) ? 'border-[var(--leon-line)]' : 'border-[#e6d6c2] bg-[#fdfaf2]'}`}
                      placeholder="-No price-" disabled={!editable} defaultValue={ctIsSet(r.perUnit) ? r.perUnit : ''}
                      onBlur={e => patch(it.id, { ranges: it.ranges.map(x => x.id === r.id ? Object.assign({}, x, { perUnit: e.target.value === '' ? null : ctNum(e.target.value) }) : x) })} />
                  </td>
                  <td className="text-right">
                    {editable && (it.ranges || []).length > 1 && <IconAction icon="✕" title="Remove this range"
                      onClick={() => patch(it.id, { ranges: it.ranges.filter(x => x.id !== r.id) })} />}
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
          {editable && <Button size="sm" variant="ghost"
            onClick={() => patch(it.id, { ranges: (it.ranges || []).concat([{ id: uid('ctrng'), upTo: null, baseFee: null, perUnit: null }]) })}>+ Size range</Button>}
          <div className="flex gap-3 flex-wrap text-[10px] mt-1">
            {[['hideOnQuote', 'Hide', false], ['allowDiscount', 'Disc.', true], ['editableOnQuote', 'Editable', true]].map(c => (
              <label key={c[0]} className="inline-flex items-center gap-1 text-[var(--leon-black)]/55">
                <input type="checkbox" tabIndex={-1} disabled={!editable} checked={it[c[0]] === undefined ? c[2] : !!it[c[0]]}
                  onChange={e => patch(it.id, { [c[0]]: e.target.checked })} />{c[1]}
              </label>
            ))}
            <input className="w-20 rounded-md border border-[var(--leon-line)] px-1.5 py-0.5" tabIndex={-1}
              placeholder="Tax code" disabled={!editable} defaultValue={it.taxCode || ''}
              onBlur={e => patch(it.id, { taxCode: e.target.value })} />
          </div>
        </div>
      ) : (
        <div key={it.id} className="flex items-center gap-2 py-0.5 flex-wrap">
          <TextInput className="!py-0.5 w-56" defaultValue={it.label} disabled={!editable}
            onBlur={e => patch(it.id, { label: e.target.value })} />
          <input className={`w-24 rounded-md border px-2 py-1 text-sm text-right ${ctIsSet(it.price) ? 'border-[var(--leon-line)]' : 'border-[#e6d6c2] bg-[#fdfaf2]'}`}
            placeholder="-No price-" disabled={!editable} defaultValue={ctIsSet(it.price) ? it.price : ''}
            onBlur={e => patch(it.id, { price: e.target.value === '' ? null : ctNum(e.target.value) })} />
          <span className="text-[11px] text-[var(--leon-black)]/45">each</span>
          {[['hideOnQuote', 'Hide', false], ['allowDiscount', 'Disc.', true], ['editableOnQuote', 'Editable', true]].map(c => (
            <label key={c[0]} className="inline-flex items-center gap-1 text-[10px] text-[var(--leon-black)]/55">
              <input type="checkbox" tabIndex={-1} disabled={!editable} checked={it[c[0]] === undefined ? c[2] : !!it[c[0]]}
                onChange={e => patch(it.id, { [c[0]]: e.target.checked })} />{c[1]}
            </label>
          ))}
          <input className="w-20 rounded-md border border-[var(--leon-line)] px-1.5 py-0.5 text-[11px]" tabIndex={-1}
            placeholder="Tax code" disabled={!editable} defaultValue={it.taxCode || ''}
            onBlur={e => patch(it.id, { taxCode: e.target.value })} />
          {editable && <IconAction icon="✕" title="Remove" onClick={() => setItems(items.filter(x => x.id !== it.id))} />}
        </div>
      ))}
    </div>
  );
}
