// ═══════════════════════════════════════════════ LEON Image & Render Studio
//
// One sentence: change the material on a surface in a render and keep the
// lighting that was already there.
//
// The Hub has no backend and no image model, so nothing here is AI. It does not
// need to be. Architectural visualisers replaced materials in finished renders
// for twenty years before segmentation models existed, using the one fact that
// makes it work: THE LIGHTING IS ALREADY IN THE PIXELS. A rendered cabinet door
// is its material multiplied by its shading — every shadow, highlight, ambient
// occlusion gradient and bounce is sitting in the luminance of those pixels. If
// you keep the luminance and swap only the colour under it, the surface changes
// material and the room stays lit. That is the whole engine (studioComposite),
// and it is why this module ships something real instead of a wired-up button.
//
// Two structural rules the rest of the file is built around:
//
//  1. The original is never touched. Everything is stored as operations —
//     masks, assignments, adjustments — and re-composited from the source
//     pixels on every render. That is also why Option C never sits on top of
//     Option B: an option is a set of assignments against the ORIGINAL, not a
//     stack of edits (studioResolveAssignments).
//
//  2. An edited image never changes technical project data. Painting a new
//     cabinet finish here does not move the approved Casework selection. The
//     design modules stay the source of truth; this module can only PROPOSE
//     (studioMakeProposal) and the screen says so.

const STUDIO_SECTIONS = [
  { key: 'editor', label: 'Editor', icon: '🖌️' },
  { key: 'documents', label: 'Documents', icon: '📄' },
  { key: 'options', label: 'Options & Compare', icon: '⚖️' },
  { key: 'board', label: 'Finish Board', icon: '📋' },
  { key: 'materials', label: 'Materials', icon: '🎨' },
  { key: 'about', label: 'About', icon: 'ℹ️' },
];

const STUDIO_DOC_STATUSES = ['Draft', 'Internal Review', 'Client Review', 'Approved', 'Superseded', 'Archived'];

// A mask is named for what it IS, not for the tool that drew it, because the
// same outline gets reused for every material change on that surface forever.
const STUDIO_MASK_KINDS = ['Cabinet Front', 'Countertop', 'Floor', 'Wall', 'Backsplash', 'Door', 'Ceiling', 'Other'];

// One act changes a whole group. "Change the cabinetry" is what a client says;
// "change these eleven door polygons" is what the software used to make you do.
const STUDIO_MASK_GROUPS = [
  { key: 'cabinetry', label: 'Cabinetry', parts: ['Doors', 'Drawer fronts', 'End panels', 'Toe kick'] },
  { key: 'countertops', label: 'Countertops', parts: ['Island', 'Perimeter', 'Backsplash'] },
  { key: 'flooring', label: 'Flooring', parts: ['Per room'] },
  { key: 'walls', label: 'Walls & ceilings', parts: ['Per wall'] },
  { key: 'other', label: 'Other', parts: [] },
];

// An exclusion is the foreground. Glass and Mirror are called out separately
// because they are never a surface you lay an opaque texture across — a texture
// over a window reads as a wall and destroys the image instantly.
const STUDIO_EXCLUSION_KINDS = ['Object', 'Furniture', 'Rug', 'Appliance', 'Glass', 'Mirror', 'Plant', 'Other'];
const STUDIO_ALWAYS_PROTECTED = ['Glass', 'Mirror'];

// Where the texture actually came from. A client is entitled to know whether
// they are looking at the manufacturer's own scan or a photo somebody took.
const STUDIO_TEXTURE_SOURCES = [
  'Official Manufacturer Texture',
  'Scanned Physical Sample',
  'LEON Photograph',
  'Vendor Image',
  'User Uploaded',
  'Unknown',
];
const STUDIO_ACCURACY_LEVELS = ['Manufacturer Reference', 'Sample-Based', 'Approximate', 'Concept Only'];
// The honest default for each source. Overridable — someone may know a vendor
// image is a marketing shot rather than a texture — but never silently better
// than what the source can support.
const STUDIO_SOURCE_ACCURACY = {
  'Official Manufacturer Texture': 'Manufacturer Reference',
  'Scanned Physical Sample': 'Sample-Based',
  'LEON Photograph': 'Sample-Based',
  'Vendor Image': 'Approximate',
  'User Uploaded': 'Approximate',
  'Unknown': 'Concept Only',
};
const STUDIO_ACCURACY_NOTE = {
  'Manufacturer Reference': 'Texture supplied by the manufacturer for this exact product code.',
  'Sample-Based': 'Built from a physical sample we hold. Colour is close; sheen and depth are not.',
  'Approximate': 'A representative image, not a calibrated texture. Treat colour as indicative.',
  'Concept Only': 'Placeholder for the idea, not the product. Do not present as the finish.',
};

// The line that goes on every presented image. It is not legal boilerplate —
// screen colour genuinely is not finish approval, and a render that lets a
// client believe it is will be argued about on site.
const STUDIO_APPROVAL_LINE = 'This is a digital visualisation for design reference. Final finish approval is against the approved physical sample — screen colour is not finish approval.';

// What was actually asked for. The taxonomy exists so the fourth case can be
// refused properly instead of faked.
const STUDIO_CHANGE_TYPES = [
  { key: 'Colour Only', hint: 'Same geometry, same format — a different colour of the same product.' },
  { key: 'Material', hint: 'A different material on the same surface. The outline does not move.' },
  { key: 'Format', hint: 'A new tile or plank size. The pattern regenerates at the new dimensions.' },
  { key: 'Geometry', hint: 'A real design change — the shape, size or layout of built work moves.' },
];
const STUDIO_CR_MODULES = ['LEON Casework', 'LEON Stone (countertops)', 'LEON Doors', 'LEON Fenestration', 'LEON Surfaces (flooring / tile)', 'Project scope — other'];

const STUDIO_TILE_PATTERNS = ['Straight', 'Half offset (1/2)', 'Third offset (1/3)', 'Herringbone'];
const STUDIO_MATERIAL_MODES = [
  { key: 'tile', label: 'Tile / sheet' },
  { key: 'plank', label: 'Plank' },
  { key: 'slab', label: 'Slab (single piece)' },
  { key: 'colour', label: 'Flat colour / paint' },
];
const STUDIO_OPTION_LABELS = ['Option A', 'Option B', 'Option C', 'Option D'];

// Documents live in localStorage with everything else, so a 12 MP render cannot
// be stored as supplied — one of them would fill the whole quota on its own.
const STUDIO_MAX_EDGE = 1600;

// ─────────────────────────────────────────────────────── AI provider seam
// Named operations, no provider. Every one of these needs an image model the
// Hub has no connection to, so the buttons exist, are disabled, and say what
// they would do. A real provider is registered here and nothing else changes —
// which is the point of writing the seam rather than leaving a gap.
const STUDIO_AI_OPERATIONS = [
  { key: 'segment', label: 'Auto-select this surface', would: 'find the cabinet fronts and draw the mask for you, instead of you tracing it.' },
  { key: 'inpaint', label: 'Remove this object', would: 'delete the object and invent believable pixels behind it.' },
  { key: 'generate', label: 'Generate a variation', would: 'produce a new image of this room from a description.' },
  { key: 'upscale', label: 'Upscale', would: 'add resolution this image does not contain.' },
  { key: 'removeBackground', label: 'Remove background', would: 'cut the subject out from what is behind it.' },
  { key: 'relight', label: 'Relight the scene', would: 'change the time of day or the light fittings and re-derive every shadow.' },
];
// The interface a provider must satisfy. Kept as a shape rather than a class so
// registering one is a single assignment from wherever it is wired up.
function studioAiProvider() {
  // No provider is registered, and none can be from inside a browser page with
  // no backend and no key it could safely hold. Returning null is the honest
  // answer; the UI reads it and disables, rather than stubbing a fake result.
  return null;
}

// ───────────────────────────────────────────────────── small numeric helpers
function studioNum(v, fallback) {
  const n = Number(v);
  return Number.isFinite(n) ? n : (fallback || 0);
}
function studioClamp(v, lo, hi) { return v < lo ? lo : v > hi ? hi : v; }
function studioId(p) { return uid(p); }

function studioBBox(points) {
  if (!points || !points.length) return { x0: 0, y0: 0, x1: 0, y1: 0 };
  let x0 = points[0].x, y0 = points[0].y, x1 = points[0].x, y1 = points[0].y;
  points.forEach(p => {
    if (p.x < x0) x0 = p.x; if (p.x > x1) x1 = p.x;
    if (p.y < y0) y0 = p.y; if (p.y > y1) y1 = p.y;
  });
  return { x0, y0, x1, y1 };
}
function studioCentroid(points) {
  if (!points || !points.length) return { x: 0.5, y: 0.5 };
  let sx = 0, sy = 0;
  points.forEach(p => { sx += p.x; sy += p.y; });
  return { x: sx / points.length, y: sy / points.length };
}

// ───────────────────────────────────────────────────────────── homography
// Canvas 2D has no perspective transform — setTransform is affine, which cannot
// make a rectangle into a trapezoid. The standard answer, and the one every
// texture-mapper used before WebGL, is: solve the 3x3 projective transform that
// takes the texture rectangle to the four corners the user placed, then draw the
// texture as many small triangles, each with its own affine approximation. Small
// enough cells and the error falls below a pixel.
//
// A homography has 8 unknowns (h33 is fixed at 1). Each corner pair gives two
// equations:
//   x' = (h11 u + h12 v + h13) / (h31 u + h32 v + 1)
//   y' = (h21 u + h22 v + h23) / (h31 u + h32 v + 1)
// Multiplied out, that is linear in the 8 unknowns, so four corners give an 8x8
// system solved by plain Gaussian elimination with partial pivoting.
function studioSolve8(A, b) {
  const n = 8;
  const M = A.map((row, i) => row.concat([b[i]]));
  for (let c = 0; c < n; c++) {
    let piv = c;
    for (let r = c + 1; r < n; r++) if (Math.abs(M[r][c]) > Math.abs(M[piv][c])) piv = r;
    if (Math.abs(M[piv][c]) < 1e-12) return null;      // degenerate quad
    const t = M[c]; M[c] = M[piv]; M[piv] = t;
    for (let r = 0; r < n; r++) {
      if (r === c) continue;
      const f = M[r][c] / M[c][c];
      for (let k = c; k <= n; k++) M[r][k] -= f * M[c][k];
    }
  }
  // Eliminated above AND below the pivot, so M is diagonal and each unknown is
  // simply its own row's right-hand side over its own pivot.
  const x = [];
  for (let i = 0; i < n; i++) x.push(M[i][n] / M[i][i]);
  return x;
}
function studioHomography(src, dst) {
  if (!src || !dst || src.length < 4 || dst.length < 4) return null;
  const A = [], b = [];
  for (let i = 0; i < 4; i++) {
    const u = src[i].x, v = src[i].y, x = dst[i].x, y = dst[i].y;
    A.push([u, v, 1, 0, 0, 0, -u * x, -v * x]); b.push(x);
    A.push([0, 0, 0, u, v, 1, -u * y, -v * y]); b.push(y);
  }
  const h = studioSolve8(A, b);
  if (!h || h.some(v => !Number.isFinite(v))) return null;
  return [h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7], 1];
}
function studioApplyH(H, x, y) {
  const w = H[6] * x + H[7] * y + H[8];
  if (!w) return { x: 0, y: 0 };
  return { x: (H[0] * x + H[1] * y + H[2]) / w, y: (H[3] * x + H[4] * y + H[5]) / w };
}

// One triangle of the warp. Given the triangle's corners in TEXTURE pixels and
// the same corners projected into IMAGE pixels, derive the affine that maps one
// to the other and draw the whole texture through it, clipped to the triangle.
// Cramer's rule on the 3-point affine system; den is twice the signed area of
// the source triangle, so a degenerate cell is simply skipped.
function studioDrawTriangle(g, tex, s0, s1, s2, d0, d1, d2, grow) {
  const den = s0.x * (s2.y - s1.y) - s1.x * (s2.y - s0.y) + s2.x * (s1.y - s0.y);
  if (!den) return;
  const m11 = (d0.x * (s2.y - s1.y) - d1.x * (s2.y - s0.y) + d2.x * (s1.y - s0.y)) / den;
  const m12 = (d0.y * (s2.y - s1.y) - d1.y * (s2.y - s0.y) + d2.y * (s1.y - s0.y)) / den;
  const m21 = -(d0.x * (s2.x - s1.x) - d1.x * (s2.x - s0.x) + d2.x * (s1.x - s0.x)) / den;
  const m22 = -(d0.y * (s2.x - s1.x) - d1.y * (s2.x - s0.x) + d2.y * (s1.x - s0.x)) / den;
  const dx = (d0.x * (s2.x * s1.y - s1.x * s2.y) + d1.x * (s0.x * s2.y - s2.x * s0.y) + d2.x * (s1.x * s0.y - s0.x * s1.y)) / den;
  const dy = (d0.y * (s2.x * s1.y - s1.x * s2.y) + d1.y * (s0.x * s2.y - s2.x * s0.y) + d2.y * (s1.x * s0.y - s0.x * s1.y)) / den;
  // Cells are clipped to their own edges, so adjacent cells meet on a shared
  // line and antialiasing leaves a hairline seam. Growing each source triangle
  // a fraction about its centroid makes them overlap instead.
  const k = grow || 1.004;
  const cx = (s0.x + s1.x + s2.x) / 3, cy = (s0.y + s1.y + s2.y) / 3;
  const e = p => ({ x: cx + (p.x - cx) * k, y: cy + (p.y - cy) * k });
  const g0 = e(s0), g1 = e(s1), g2 = e(s2);
  g.save();
  g.setTransform(m11, m12, m21, m22, dx, dy);
  g.beginPath();
  g.moveTo(g0.x, g0.y); g.lineTo(g1.x, g1.y); g.lineTo(g2.x, g2.y); g.closePath();
  g.clip();
  g.drawImage(tex, 0, 0);
  g.restore();
}

// Warp a flat "plane space" canvas (the surface laid out at real-world scale)
// onto the four corners the user placed on the photograph.
function studioDrawWarped(g, tex, corners, cells) {
  const H = studioHomography(
    [{ x: 0, y: 0 }, { x: tex.width, y: 0 }, { x: tex.width, y: tex.height }, { x: 0, y: tex.height }],
    corners);
  if (!H) return false;
  const n = Math.max(4, Math.min(64, cells || 24));
  for (let r = 0; r < n; r++) {
    for (let c = 0; c < n; c++) {
      const u0 = (c / n) * tex.width, u1 = ((c + 1) / n) * tex.width;
      const v0 = (r / n) * tex.height, v1 = ((r + 1) / n) * tex.height;
      const s = [{ x: u0, y: v0 }, { x: u1, y: v0 }, { x: u1, y: v1 }, { x: u0, y: v1 }];
      const d = s.map(p => studioApplyH(H, p.x, p.y));
      studioDrawTriangle(g, tex, s[0], s[1], s[2], d[0], d[1], d[2]);
      studioDrawTriangle(g, tex, s[0], s[2], s[3], d[0], d[2], d[3]);
    }
  }
  return true;
}

// ────────────────────────────────────────────────────────── image loading
const STUDIO_IMG_CACHE = new Map();
function studioLoadImage(url) {
  if (!url) return Promise.resolve(null);
  if (STUDIO_IMG_CACHE.has(url)) return STUDIO_IMG_CACHE.get(url);
  const p = new Promise(resolve => {
    const img = new Image();
    // Same-origin files (finishes/, renders/) and data URIs need nothing. An
    // imported finish pointing at a supplier's own server will taint the canvas
    // and getImageData throws — asking for CORS at least gives it a chance.
    img.crossOrigin = 'anonymous';
    img.onload = () => resolve(img);
    img.onerror = () => resolve(null);
    img.src = url;
  });
  STUDIO_IMG_CACHE.set(url, p);
  return p;
}

// Store big images downscaled. Says what it did rather than silently degrading
// somebody's 12 MP render.
async function studioShrink(file) {
  const raw = await readFileAsDataURL(file);
  const img = await studioLoadImage(raw);
  if (!img) return null;
  const long = Math.max(img.width, img.height);
  if (long <= STUDIO_MAX_EDGE && raw.length < 1200000) {
    return { dataUrl: raw, width: img.width, height: img.height, note: `Stored as supplied — ${img.width} × ${img.height}.` };
  }
  const k = Math.min(1, STUDIO_MAX_EDGE / long);
  const w = Math.max(1, Math.round(img.width * k));
  const h = Math.max(1, Math.round(img.height * k));
  const cv = document.createElement('canvas');
  cv.width = w; cv.height = h;
  const g = cv.getContext('2d');
  g.imageSmoothingQuality = 'high';
  g.drawImage(img, 0, 0, w, h);
  const out = cv.toDataURL('image/jpeg', 0.9);
  return {
    dataUrl: out, width: w, height: h,
    note: `Downscaled from ${img.width} × ${img.height} to ${w} × ${h} and re-encoded as JPEG. The Hub keeps its data in this browser's own storage, and a full-size render would fill it on its own — the original file on your disk is untouched.`,
  };
}

// ───────────────────────────────────────────────── plane-space texture build
// The surface is first laid out FLAT, at real-world scale, in its own canvas —
// "plane space". Everything about pattern, tile size, grout and stagger is
// decided here, in millimetres, where it is simple. Perspective is applied
// afterwards by warping this canvas. Doing it the other way round (tiling in
// perspective) is what produces the stretched, wrong-sized tiles that make an
// edited render look edited.
function studioTexturePlane(assign, texImg, planeW, planeH, ppm) {
  const cv = document.createElement('canvas');
  cv.width = Math.max(2, Math.round(planeW));
  cv.height = Math.max(2, Math.round(planeH));
  const g = cv.getContext('2d');
  const mode = assign.mode || 'tile';

  if (mode === 'colour' || !texImg) {
    g.fillStyle = assign.colour || '#cccccc';
    g.fillRect(0, 0, cv.width, cv.height);
    if (!texImg) return cv;
    if (mode === 'colour') return cv;
  }

  if (mode === 'slab') {
    // One piece, placed. A slab is not tiled — the veining IS the product, and
    // repeating it gives away the trick immediately.
    const s = assign.slab || {};
    const scale = studioNum(s.scale, 1) || 1;
    const rot = (studioNum(s.rotate, 0) + studioNum(s.veinDeg, 0)) * Math.PI / 180;
    // Cover the plane at scale 1, then let the user scale from there.
    const cover = Math.max(cv.width / texImg.width, cv.height / texImg.height) * scale;
    const dw = texImg.width * cover, dh = texImg.height * cover;
    g.save();
    g.translate(cv.width / 2 + studioNum(s.x, 0) * cv.width, cv.height / 2 + studioNum(s.y, 0) * cv.height);
    g.rotate(rot);
    g.scale(s.flipX ? -1 : 1, s.flipY ? -1 : 1);
    g.drawImage(texImg, -dw / 2, -dh / 2, dw, dh);
    g.restore();
    return cv;
  }

  const groutPx = Math.max(0, studioNum(assign.grout, 0) * ppm);
  g.fillStyle = assign.groutColor || '#d9d5cf';
  g.fillRect(0, 0, cv.width, cv.height);

  if (mode === 'plank') {
    const pw = Math.max(4, studioNum(assign.plankW, 190) * ppm);   // plank WIDTH across the board
    const pl = Math.max(4, studioNum(assign.plankL, 1220) * ppm);  // plank LENGTH along the board
    const stagger = studioClamp(studioNum(assign.stagger, 0.33), 0, 0.9);
    const dir = studioNum(assign.direction, 0) * Math.PI / 180;
    // Rotating the whole field is how a floor gets laid on the diagonal; the
    // canvas is over-drawn so the rotated field still covers every corner.
    const span = Math.hypot(cv.width, cv.height);
    g.save();
    g.translate(cv.width / 2, cv.height / 2);
    g.rotate(dir);
    g.translate(-span / 2, -span / 2);
    let row = 0;
    for (let y = -pw; y < span + pw; y += pw + groutPx) {
      const shift = ((row * stagger) % 1) * pl;
      for (let x = -pl - shift; x < span + pl; x += pl + groutPx) {
        g.drawImage(texImg, x + shift, y, pl, pw);
      }
      row++;
    }
    g.restore();
    return cv;
  }

  // Tile / sheet
  const tw = Math.max(4, studioNum(assign.tileW, 600) * ppm);
  const th = Math.max(4, studioNum(assign.tileH, 600) * ppm);
  const pattern = assign.pattern || 'Straight';
  const offset = pattern.indexOf('1/2') >= 0 ? 0.5 : pattern.indexOf('1/3') >= 0 ? 1 / 3 : 0;

  if (pattern === 'Herringbone') {
    // Herringbone is pairs of rectangles at ±45°, stepping on the diagonal.
    // Drawn over a rotated field for the same reason as the plank direction.
    const span = Math.hypot(cv.width, cv.height);
    const L = Math.max(8, tw), Wd = Math.max(4, th);
    g.save();
    g.translate(cv.width / 2, cv.height / 2);
    g.translate(-span / 2, -span / 2);
    const step = (L + Wd) / Math.SQRT2;
    for (let r = -1; r * step < span + L; r++) {
      for (let c = -1; c * step < span + L; c++) {
        const bx = c * step, by = r * step;
        [[45, 0, 0], [-45, L / Math.SQRT2, -Wd / Math.SQRT2]].forEach(([deg, ox, oy]) => {
          g.save();
          g.translate(bx + ox, by + oy);
          g.rotate(deg * Math.PI / 180);
          g.drawImage(texImg, 0, 0, Math.max(1, L - groutPx), Math.max(1, Wd - groutPx));
          g.restore();
        });
      }
    }
    g.restore();
    return cv;
  }

  let row = 0;
  for (let y = -th; y < cv.height + th; y += th + groutPx) {
    const shift = offset ? ((row * offset) % 1) * tw : 0;
    for (let x = -tw - shift; x < cv.width + tw; x += tw + groutPx) {
      g.drawImage(texImg, x + shift, y, tw - groutPx, th - groutPx);
    }
    row++;
  }
  return cv;
}

// ────────────────────────────────────────────────────────────── mask alpha
// The mask's own shape, feathered, with the foreground punched back out.
//
// Order is load-bearing: FEATHER FIRST, THEN PUNCH. Feathering after the punch
// blurs the material back over the island edge, which is exactly the artefact
// occlusion exists to prevent — an object in front of a surface has a hard edge,
// the outline of the surface itself does not.
function studioMaskAlpha(mask, exclusions, W, H) {
  const cv = document.createElement('canvas');
  cv.width = W; cv.height = H;
  const g = cv.getContext('2d');
  const pts = (mask.points || []).map(p => ({ x: p.x * W, y: p.y * H }));
  if (pts.length < 3) return cv;

  const shape = document.createElement('canvas');
  shape.width = W; shape.height = H;
  const sg = shape.getContext('2d');
  sg.fillStyle = '#fff';
  sg.beginPath();
  pts.forEach((p, i) => (i ? sg.lineTo(p.x, p.y) : sg.moveTo(p.x, p.y)));
  sg.closePath();
  sg.fill();

  const feather = Math.max(0, studioNum(mask.feather, 2));
  if (feather > 0 && typeof g.filter !== 'undefined') g.filter = `blur(${feather}px)`;
  g.drawImage(shape, 0, 0);
  g.filter = 'none';

  // Glass and mirrors are protected from EVERY mask whether or not anyone
  // remembered to tick them, because an opaque texture across a window is never
  // the intended result.
  const cut = (exclusions || []).filter(x =>
    (mask.excludeIds || []).indexOf(x.id) >= 0 || STUDIO_ALWAYS_PROTECTED.indexOf(x.kind) >= 0);
  if (cut.length) {
    const punch = document.createElement('canvas');
    punch.width = W; punch.height = H;
    const pg = punch.getContext('2d');
    pg.fillStyle = '#fff';
    cut.forEach(x => {
      const q = (x.points || []).map(p => ({ x: p.x * W, y: p.y * H }));
      if (q.length < 3) return;
      pg.beginPath();
      q.forEach((p, i) => (i ? pg.lineTo(p.x, p.y) : pg.moveTo(p.x, p.y)));
      pg.closePath();
      pg.fill();
    });
    g.globalCompositeOperation = 'destination-out';
    if (typeof g.filter !== 'undefined') g.filter = 'blur(0.6px)';   // just enough to kill the jaggies
    g.drawImage(punch, 0, 0);
    g.filter = 'none';
    g.globalCompositeOperation = 'source-over';
  }
  return cv;
}

// ───────────────────────────────────────────────────────────── adjustments
// Six controls, one pixel pass, applied on render and never baked in. CSS
// filters would cover four of them; temperature, highlights and shadows are not
// CSS filters at all, so doing all six here keeps one code path instead of two.
function studioApplyAdjust(data, adj) {
  if (!adj) return;
  const ex = Math.pow(2, studioNum(adj.exposure, 0));
  const c = studioNum(adj.contrast, 0);
  const cf = (259 * (c + 255)) / (255 * (259 - c));
  const sat = 1 + studioNum(adj.saturation, 0) / 100;
  const temp = studioNum(adj.temperature, 0) / 300;
  const hi = studioNum(adj.highlights, 0) / 100;
  const sh = studioNum(adj.shadows, 0) / 100;
  if (ex === 1 && c === 0 && sat === 1 && !temp && !hi && !sh) return;
  const d = data.data;
  for (let i = 0; i < d.length; i += 4) {
    let r = d[i] * ex, gg = d[i + 1] * ex, b = d[i + 2] * ex;
    if (temp) { r *= 1 + temp; b *= 1 - temp; }
    if (c) { r = cf * (r - 128) + 128; gg = cf * (gg - 128) + 128; b = cf * (b - 128) + 128; }
    const L = 0.2126 * r + 0.7152 * gg + 0.0722 * b;
    if (sat !== 1) { r = L + (r - L) * sat; gg = L + (gg - L) * sat; b = L + (b - L) * sat; }
    if (hi || sh) {
      const n = studioClamp(L / 255, 0, 1);
      // Weighted so highlights only touch the bright end and shadows the dark
      // end; a flat lift would just be exposure again.
      const wH = n * n, wS = (1 - n) * (1 - n);
      const kH = hi * wH, kS = sh * wS;
      r += kH * (255 - r) + kS * r * 0.9;
      gg += kH * (255 - gg) + kS * gg * 0.9;
      b += kH * (255 - b) + kS * b * 0.9;
    }
    d[i] = studioClamp(r, 0, 255);
    d[i + 1] = studioClamp(gg, 0, 255);
    d[i + 2] = studioClamp(b, 0, 255);
  }
}

// ══════════════════════════════════════════════════════ THE COMPOSITE ENGINE
//
// Luminance-preserving material replacement, in five steps:
//
//   1. Start from the ORIGINAL pixels. Never from a previous render.
//   2. For each masked surface, pull the shading out of those original pixels:
//        L  = (0.2126 R + 0.7152 G + 0.0722 B) / 255
//      That single number per pixel is the entire lighting solution the renderer
//      computed — shadow, highlight, ambient occlusion, bounce, reflection.
//   3. Lay the new material into the same region, tiled at its REAL size and
//      warped by the plane's homography so it sits in perspective.
//   4. Multiply:
//        L' = lo + (1 - lo) * L^gamma          a levels lift, so a deep shadow
//                                              darkens the material instead of
//                                              crushing it to black
//        out = texture * L' * gain
//   5. Blend through the feathered mask alpha, with the foreground punched out.
//
// Because step 2 always reads the untouched source, two masks that overlap or
// sit side by side cannot contaminate each other: the second surface is lit by
// the render, not by the material the first surface was just given.
async function studioComposite(doc, assignments, adjust, opts) {
  opts = opts || {};
  const W = doc.width || 1200, H = doc.height || 800;
  const cv = document.createElement('canvas');
  cv.width = W; cv.height = H;
  const g = cv.getContext('2d');
  const src = await studioLoadImage(doc.sourceImage);
  if (!src) return { canvas: cv, error: 'The source image could not be loaded.' };
  g.drawImage(src, 0, 0, W, H);

  let original, work;
  try {
    original = g.getImageData(0, 0, W, H);
    work = g.getImageData(0, 0, W, H);
  } catch (e) {
    // A cross-origin texture or source taints the canvas and the browser refuses
    // to hand the pixels back. There is no way round it from a page; say so.
    return { canvas: cv, error: 'This browser will not let the Hub read the pixels of this image back, which the material engine needs. That happens when an image is loaded from another site rather than uploaded. Download it and upload the file here.' };
  }

  const masks = doc.masks || [];
  const exclusions = doc.exclusions || [];
  const applied = [];

  for (let mi = 0; mi < masks.length; mi++) {
    const mask = masks[mi];
    const assign = assignments[mask.id];
    // A layer hidden in the Layers panel is skipped here, so the picture and
    // the option always agree about what is specified.
    if (assign && assign.hidden) continue;
    if (!assign) continue;
    if (opts.onlyMaskId && opts.onlyMaskId !== mask.id) continue;

    const texUrl = assign.mode === 'colour' ? null : (assign.textureUrl || (assign.finishRef && assign.finishRef.img) || null);
    const texImg = texUrl ? await studioLoadImage(texUrl) : null;
    if (!texImg && assign.mode !== 'colour') { applied.push({ mask, assign, skipped: 'no texture' }); continue; }

    const pts = (mask.points || []).map(p => ({ x: p.x * W, y: p.y * H }));
    if (pts.length < 3) continue;
    const bb = studioBBox(pts);
    const plane = (mask.plane && mask.plane.length === 4) ? mask.plane.map(p => ({ x: p.x * W, y: p.y * H })) : null;

    // Real-world scale. The user states the true size of something in the image
    // and everything is tiled against that — no "scale until it looks right",
    // which is how a 600 mm tile ends up rendered at 900.
    const realW = Math.max(1, studioNum(mask.realMmW, 2400));
    const realH = Math.max(1, studioNum(mask.realMmH, 900));

    const texCv = document.createElement('canvas');
    texCv.width = W; texCv.height = H;
    const tg = texCv.getContext('2d');

    if (plane) {
      // Resolution matched to how big the plane actually lands on screen, so a
      // distant wall does not build a 4000 px texture nobody will ever see.
      const edge = Math.max(Math.hypot(plane[1].x - plane[0].x, plane[1].y - plane[0].y),
                            Math.hypot(plane[2].x - plane[3].x, plane[2].y - plane[3].y));
      const ppm = studioClamp(edge / realW, 0.02, 4);
      const planeCv = studioTexturePlane(assign, texImg, studioClamp(realW * ppm, 16, 3000), studioClamp(realH * ppm, 16, 3000), ppm);
      studioDrawWarped(tg, planeCv, plane, opts.cells || 24);
    } else {
      // No plane placed: the surface is treated as flat-on. Correct for a wall
      // shot square, wrong for anything receding — which is why the editor
      // nudges you to place the four corners.
      const bw = Math.max(2, bb.x1 - bb.x0), bh = Math.max(2, bb.y1 - bb.y0);
      const ppm = studioClamp(bw / realW, 0.02, 4);
      const planeCv = studioTexturePlane(assign, texImg, bw, bh, ppm);
      tg.drawImage(planeCv, bb.x0, bb.y0);
    }

    const alphaCv = studioMaskAlpha(mask, exclusions, W, H);
    let texData, maskData;
    try {
      texData = tg.getImageData(0, 0, W, H);
      maskData = alphaCv.getContext('2d').getImageData(0, 0, W, H);
    } catch (e) {
      return { canvas: cv, error: 'That texture is hosted on the supplier’s own site, and the browser will not let the Hub read its pixels. Upload the image file here instead of referencing it by link.' };
    }

    const lo = studioClamp(studioNum(assign.shadowLift, 0.12), 0, 0.6);
    const gamma = studioClamp(studioNum(assign.gamma, 0.9), 0.4, 2.2);
    const gain = studioClamp(studioNum(assign.gain, 1.12), 0.4, 2.5);
    const alpha = studioClamp(studioNum(assign.opacity, 1), 0, 1);

    const x0 = Math.max(0, Math.floor(bb.x0) - 4), x1 = Math.min(W, Math.ceil(bb.x1) + 4);
    const y0 = Math.max(0, Math.floor(bb.y0) - 4), y1 = Math.min(H, Math.ceil(bb.y1) + 4);
    const od = original.data, wd = work.data, td = texData.data, md = maskData.data;
    for (let y = y0; y < y1; y++) {
      for (let x = x0; x < x1; x++) {
        const i = (y * W + x) * 4;
        const ma = (md[i + 3] / 255) * alpha;
        if (ma <= 0.003) continue;
        const ta = td[i + 3] / 255;
        if (ta <= 0.003) continue;
        const L = (0.2126 * od[i] + 0.7152 * od[i + 1] + 0.0722 * od[i + 2]) / 255;
        const shade = (lo + (1 - lo) * Math.pow(L, gamma)) * gain;
        const k = ma * ta;
        wd[i] = wd[i] * (1 - k) + studioClamp(td[i] * shade, 0, 255) * k;
        wd[i + 1] = wd[i + 1] * (1 - k) + studioClamp(td[i + 1] * shade, 0, 255) * k;
        wd[i + 2] = wd[i + 2] * (1 - k) + studioClamp(td[i + 2] * shade, 0, 255) * k;
      }
    }
    applied.push({ mask, assign });
  }

  studioApplyAdjust(work, adjust);
  g.putImageData(work, 0, 0);
  return { canvas: cv, applied, error: null };
}

// Before/after with the changed regions called out. A reviewer should not have
// to hunt for what moved; a per-pixel difference answers it exactly.
function studioDifferenceCanvas(aCv, bCv, threshold) {
  const W = aCv.width, H = aCv.height;
  const out = document.createElement('canvas');
  out.width = W; out.height = H;
  const g = out.getContext('2d');
  g.drawImage(bCv, 0, 0);
  let ad, bd;
  try {
    ad = aCv.getContext('2d').getImageData(0, 0, W, H).data;
    bd = bCv.getContext('2d').getImageData(0, 0, W, H).data;
  } catch (e) { return out; }
  const tint = g.getImageData(0, 0, W, H);
  const t = studioNum(threshold, 10);
  const td = tint.data;
  let changed = 0;
  for (let i = 0; i < td.length; i += 4) {
    const d = Math.abs(ad[i] - bd[i]) + Math.abs(ad[i + 1] - bd[i + 1]) + Math.abs(ad[i + 2] - bd[i + 2]);
    if (d > t * 3) {
      changed++;
      td[i] = td[i] * 0.55 + 232 * 0.45;
      td[i + 1] = td[i + 1] * 0.55 + 62 * 0.45;
      td[i + 2] = td[i + 2] * 0.55 + 168 * 0.45;
    }
  }
  g.putImageData(tint, 0, 0);
  out.__changedPct = W * H ? (changed / (W * H)) * 100 : 0;
  return out;
}

// ════════════════════════════════════════════════════════════ data model
function studioMakeExclusion(data, by) {
  return {
    id: studioId('stdex'),
    name: data.name || 'Foreground object',
    kind: data.kind || 'Object',
    points: data.points || [],
    createdBy: by || '', createdDate: todayISO(),
  };
}
function studioMakeMask(data, by) {
  return {
    id: studioId('stdmask'),
    name: data.name || 'Surface',
    kind: data.kind || 'Other',
    groupKey: data.groupKey || 'other',
    points: data.points || [],
    // Four corners of the real-world rectangle this surface occupies. Optional:
    // a wall shot square-on does not need one, everything receding does.
    plane: data.plane || null,
    feather: studioNum(data.feather, 2),
    // Foreground shapes this surface must stay behind. Drawn once, honoured by
    // every material ever applied here.
    excludeIds: data.excludeIds || [],
    // The real size of the surface, which is what makes a 600 mm tile 600 mm.
    realMmW: studioNum(data.realMmW, 2400),
    realMmH: studioNum(data.realMmH, 900),
    // Link back to a project selection. Never read to CHANGE the render — only
    // to notice the selection has moved on and flag it.
    selectionLink: data.selectionLink || null,
    createdBy: by || '', createdDate: todayISO(),
  };
}
function studioMakeAssignment(data) {
  const source = data.source || 'Unknown';
  return {
    maskId: data.maskId,
    finishRef: data.finishRef || null,          // makeSupplierFinishRef — the real product
    renderItemId: data.renderItemId || null,    // or a LEON Render Library item
    textureUrl: data.textureUrl || null,
    label: data.label || '',
    source,
    accuracy: data.accuracy || STUDIO_SOURCE_ACCURACY[source] || 'Concept Only',
    changeType: data.changeType || 'Material',
    mode: data.mode || 'tile',
    tileW: studioNum(data.tileW, 600), tileH: studioNum(data.tileH, 600),
    grout: studioNum(data.grout, 3), groutColor: data.groutColor || '#d9d5cf',
    pattern: data.pattern || 'Straight',
    plankW: studioNum(data.plankW, 190), plankL: studioNum(data.plankL, 1220),
    direction: studioNum(data.direction, 0), stagger: studioNum(data.stagger, 0.33),
    slab: data.slab || { x: 0, y: 0, scale: 1, rotate: 0, flipX: false, flipY: false, veinDeg: 0 },
    colour: data.colour || '#cfcfcf',
    shadowLift: studioNum(data.shadowLift, 0.12),
    gamma: studioNum(data.gamma, 0.9),
    gain: studioNum(data.gain, 1.12),
    opacity: studioNum(data.opacity, 1),
  };
}
function studioMakeOption(data, by) {
  return {
    id: studioId('stdopt'),
    label: data.label || 'Option A',
    // WHICH LOCKED IMAGE THIS BRANCHES FROM. Null = the original source. An
    // option never points at another option, which is the whole reason C cannot
    // end up built on top of B.
    baseRevisionId: data.baseRevisionId || null,
    assignments: data.assignments || {},
    adjust: data.adjust || { exposure: 0, contrast: 0, saturation: 0, temperature: 0, highlights: 0, shadows: 0 },
    notes: data.notes || '',
    createdBy: by || '', createdDate: todayISO(),
  };
}
function studioMakeRevision(data, by) {
  return {
    id: studioId('stdrev'),
    n: studioNum(data.n, 1),
    date: todayISO(),
    by: by || '',
    note: data.note || '',
    // Only a revision marked as the approved base may be branched from. A
    // revision is a SNAPSHOT OF ASSIGNMENTS, not a flattened picture — that is
    // what lets every option re-composite from the original pixels.
    approvedBase: !!data.approvedBase,
    assignments: data.assignments || {},
    adjust: data.adjust || null,
    optionId: data.optionId || null,
  };
}
function studioMakeDocument(data, by) {
  return {
    id: studioId('stddoc'),
    name: data.name || 'Untitled image',
    projectId: data.projectId || null,
    roomId: data.roomId || '',
    scopeId: data.scopeId || null,
    sourceImage: data.sourceImage || null,
    sourceName: data.sourceName || '',
    width: studioNum(data.width, 1200),
    height: studioNum(data.height, 800),
    masks: [],
    exclusions: [],
    layers: [],          // reserved: named operation stacks beyond the option set
    options: [studioMakeOption({ label: 'Option A' }, by)],
    revisions: [],
    callouts: [],
    status: 'Draft',
    links: [],
    proposals: [],
    accuracyNote: data.accuracyNote || '',
    storageNote: data.storageNote || '',
    createdBy: by || '', createdDate: todayISO(),
  };
}
function studioMakeTemplate(doc, data, by) {
  return {
    id: studioId('stdtpl'),
    name: data.name || doc.name,
    unitType: data.unitType || '',
    roomType: data.roomType || '',
    // Masks and exclusions only — never the materials. A template is "where the
    // surfaces are in a Type A kitchen", which is what repeats; what goes on
    // them is chosen per unit.
    masks: cloneDeep(doc.masks || []),
    exclusions: cloneDeep(doc.exclusions || []),
    sourceRef: doc.id,
    createdBy: by || '', createdDate: todayISO(),
  };
}
function studioMakeProposal(data, by) {
  return {
    id: studioId('stdprop'),
    kind: data.kind || 'selection',            // 'selection' | 'design'
    docId: data.docId || null,
    maskId: data.maskId || null,
    scopeId: data.scopeId || null,
    categoryId: data.categoryId || null,
    module: data.module || '',
    finishRef: data.finishRef || null,
    referenceImage: data.referenceImage || null,
    note: data.note || '',
    status: 'Proposed',
    raisedBy: by || '', raisedDate: todayISO(),
  };
}

// ───────────────────────────────────────────────── reads and derived state
function studioDocs(project) { return (project && project.studioDocuments) || []; }
function studioAllDocs(projects) {
  const out = [];
  (projects || []).forEach(p => studioDocs(p).forEach(d => out.push({ doc: d, project: p })));
  return out;
}
function studioTemplates(projects) {
  const out = [];
  (projects || []).forEach(p => (p.studioTemplates || []).forEach(t => out.push({ tpl: t, project: p })));
  return out;
}
function studioApprovedBases(doc) {
  return (doc.revisions || []).filter(r => r.approvedBase);
}
function studioRevision(doc, id) { return (doc.revisions || []).find(r => r.id === id) || null; }

// THE BRANCH RULE, in one function.
// An option's effective assignments are the locked base's assignments with the
// option's own laid over the top — and the composite that uses them always
// starts from the original source pixels. Nothing is ever composited on top of
// another option's output, so an image cannot degrade through a chain of edits
// and Option C is not Option B plus something.
function studioResolveAssignments(doc, option) {
  if (!option) return {};
  const base = option.baseRevisionId ? studioRevision(doc, option.baseRevisionId) : null;
  const out = {};
  if (base && base.assignments) Object.keys(base.assignments).forEach(k => { out[k] = base.assignments[k]; });
  Object.keys(option.assignments || {}).forEach(k => {
    if (option.assignments[k] === null) delete out[k];      // an explicit "not on this option"
    else out[k] = option.assignments[k];
  });
  return out;
}
function studioResolveAdjust(doc, option) {
  const base = option && option.baseRevisionId ? studioRevision(doc, option.baseRevisionId) : null;
  return Object.assign({ exposure: 0, contrast: 0, saturation: 0, temperature: 0, highlights: 0, shadows: 0 },
    (base && base.adjust) || {}, (option && option.adjust) || {});
}

// The weakest accuracy in the image is the accuracy of the image. One
// Concept-Only placeholder in a set of manufacturer textures makes the whole
// visualisation concept-only, and saying otherwise would be the lie.
function studioDocAccuracy(doc, option) {
  const assigns = studioResolveAssignments(doc, option);
  const used = Object.keys(assigns).map(k => assigns[k].accuracy).filter(Boolean);
  if (!used.length) return null;
  let worst = 0;
  used.forEach(a => { const i = STUDIO_ACCURACY_LEVELS.indexOf(a); if (i > worst) worst = i; });
  return STUDIO_ACCURACY_LEVELS[worst];
}

// The selection this mask is linked to, as it stands in the project RIGHT NOW.
function studioCurrentSelectionFinish(project, link) {
  if (!project || !link || !link.scopeId || !link.categoryId) return null;
  const scope = (project.scopes || []).find(s => s.id === link.scopeId);
  if (!scope) return null;
  if (link.areaId && link.areaId !== 'main') {
    const area = (scope.selectionAreas || []).find(a => a.id === link.areaId);
    return (area && area.supplierFinishes && area.supplierFinishes[link.categoryId]) || null;
  }
  return (scope.supplierFinishes && scope.supplierFinishes[link.categoryId]) || null;
}
// Flag, never fix. If the selection has moved on, the render is stale and the
// team decides what to do about it — regenerating silently would hand a client
// a picture nobody has looked at.
function studioLinkOutdated(project, link) {
  if (!link) return false;
  const now = studioCurrentSelectionFinish(project, link);
  const then = link.finishSnapshot;
  if (!then) return false;
  if (!now) return true;
  return `${now.source}:${now.id}` !== `${then.source}:${then.id}`;
}
function studioDocOutdatedMasks(project, doc) {
  return (doc.masks || []).filter(m => m.selectionLink && studioLinkOutdated(project, m.selectionLink));
}
function studioOutdatedAcrossProjects(projects) {
  const out = [];
  (projects || []).forEach(p => studioDocs(p).forEach(d => {
    const stale = studioDocOutdatedMasks(p, d);
    if (stale.length) out.push({ project: p, doc: d, masks: stale });
  }));
  return out;
}

// The LEON Render Library, as a texture source. Items carry their own name,
// brand and finish, so a board callout reads off the real record.
function studioRenderItems() { return typeof RENDER_ITEMS === 'undefined' ? [] : RENDER_ITEMS; }
function studioRenderItem(id) { return studioRenderItems().find(r => r.id === id) || null; }

// Stone slabs already assigned to this job. If LEON Stone holds the slab that
// will actually be fabricated, that slab's photograph is the right texture for
// the countertop — so the render and the fabrication show the same veining
// rather than an arbitrary catalogue crop.
function studioProjectSlabs(ctx, project) {
  if (!project) return [];
  return (ctx.slabs || []).filter(s => s.projectId === project.id && s.photoUrl);
}
function studioScopeName(project, scopeId) {
  const s = (project && (project.scopes || []).find(x => x.id === scopeId)) || null;
  return s ? s.name : '';
}
function studioSuggestedMode(kind) {
  if (kind === 'Floor') return 'plank';
  if (kind === 'Countertop') return 'slab';
  if (kind === 'Cabinet Front' || kind === 'Door') return 'slab';
  if (kind === 'Wall' || kind === 'Ceiling') return 'colour';
  return 'tile';
}

// ═══════════════════════════════════════════════════════════ write helpers
// Every write goes through ctx.updateProject and records itself with
// ctx.logAction — both live inside App() and are only reachable on ctx.
function studioUpdateDoc(ctx, project, docId, fn, logText) {
  ctx.updateProject(project.id, draft => {
    if (!Array.isArray(draft.studioDocuments)) draft.studioDocuments = [];
    const d = draft.studioDocuments.find(x => x.id === docId);
    if (!d) return;
    fn(d, draft);
    if (logText) ctx.logAction(draft, logText);
  });
}

// ═══════════════════════════════════════════════════════════ shared notices
// The two lines this module is required to keep saying. They are components so
// they cannot drift into six slightly different wordings.
function StudioApprovalNotice({ accuracy, className }) {
  return (
    <div className={`rounded-md border border-[var(--leon-line)] bg-white/70 px-3 py-2 text-[11px] leading-snug text-[var(--leon-black)]/65 ${className || ''}`}>
      <span className="font-semibold text-[var(--leon-black)]/80">Design reference only. </span>
      {STUDIO_APPROVAL_LINE.replace('This is a digital visualisation for design reference. ', '')}
      {accuracy && <span className="block mt-1">Accuracy of this image: <b>{accuracy}</b> — {STUDIO_ACCURACY_NOTE[accuracy]}</span>}
    </div>
  );
}
function StudioTruthNotice({ className }) {
  return (
    <div className={`rounded-md border border-[#e6dcc6] bg-[#fbf6e9] px-3 py-2 text-[11px] leading-snug text-[#6b5726] ${className || ''}`}>
      <b>An edited image never changes project data.</b> Changing a cabinet finish here does not change the
      approved Casework selection, the countertop scope or anything in Procurement. This module can only
      propose — the design modules stay the source of truth.
    </div>
  );
}

// An AI control that says what it would do and does not pretend. The Hub still
// has no image model — it cannot run one from a page with no backend — but it
// CAN write the request properly and open the assistant that can answer it, and
// that is a real step rather than a disabled button.
function StudioAiButton({ op, className }) {
  const provider = studioAiProvider();
  const live = provider && typeof provider[op.key] === 'function';
  const brief = buildAiHandoffBrief({
    task: 'I need help with this: ' + op.label + '.',
    detail: 'The intent is to ' + op.would,
    context: [{ label: 'Raised from', value: 'LEON Image & Render Studio' }],
    files: ['The image you want changed, exported from the Studio'],
  });
  return (
    <div className={`rounded-md border border-dashed border-[var(--leon-line)] px-3 py-2 ${className || ''}`}>
      <div className="flex items-center gap-2">
        {live
          ? <Button size="sm" variant="ghost" onClick={() => {}}>✨ {op.label}</Button>
          : <AiHandoffButton label={op.label} brief={brief} title={op.label} />}
      </div>
      <p className="text-[11px] text-[var(--leon-black)]/50 mt-1 leading-snug">
        Would {op.would} The Hub has no image model of its own, so this writes the request and opens
        Claude or ChatGPT — you export the image, attach it there and bring the result back.
      </p>
    </div>
  );
}

// ═════════════════════════════════════════════════════════════ the renderer
// Composites are produced as a data URL and shown in an <img>, not painted into
// a live <canvas> on the page. cloneNode does not copy a canvas's bitmap, so a
// canvas prints and exports to PDF as a blank rectangle — the <img> survives
// both, which is what makes the Finish Board printable at all.
function useStudioRender(doc, assignments, adjust) {
  const [state, setState] = useState({ url: null, error: null, busy: true, canvas: null });
  const sig = useMemo(() => JSON.stringify({
    d: doc ? doc.id : null,
    w: doc ? doc.width : 0, h: doc ? doc.height : 0,
    m: doc ? doc.masks : null, x: doc ? doc.exclusions : null,
    a: assignments, j: adjust,
  }), [doc, assignments, adjust]);

  useEffect(() => {
    let live = true;
    if (!doc || !doc.sourceImage) { setState({ url: null, error: null, busy: false, canvas: null }); return; }
    setState(s => ({ ...s, busy: true }));
    studioComposite(doc, assignments || {}, adjust).then(res => {
      if (!live) return;
      let url = null;
      try { url = res.canvas.toDataURL('image/jpeg', 0.92); } catch (e) { url = null; }
      setState({ url, error: res.error || null, busy: false, canvas: res.canvas });
    });
    return () => { live = false; };
  }, [sig]);

  return state;
}

// ═══════════════════════════════════════════════════════════════ the stage
// The composite underneath, an SVG on top for the shapes. Shapes are stored
// normalised 0..1 so a document can be re-rendered at any size — and so a
// template's masks can be dropped onto a differently sized photograph of the
// same unit type.
function StudioStage({ doc, url, error, busy, tool, draft, selMaskId, selExId,
                       onCanvasPoint, onMovePoint, onSelectMask, onSelectEx, showShapes, height }) {
  const wrapRef = useRef(null);
  const [drag, setDrag] = useState(null);
  const W = doc.width || 1200, H = doc.height || 800;

  function norm(e) {
    const r = wrapRef.current.getBoundingClientRect();
    return {
      x: studioClamp((e.clientX - r.left) / r.width, 0, 1),
      y: studioClamp((e.clientY - r.top) / r.height, 0, 1),
    };
  }
  function onDown(e) {
    if (!tool || tool === 'none') return;
    onCanvasPoint(norm(e));
  }
  function onMove(e) {
    if (!drag) return;
    onMovePoint(drag, norm(e));
  }

  const shapeStroke = { vectorEffect: 'non-scaling-stroke' };
  const r = Math.max(3, W * 0.006);

  return (
    <div ref={wrapRef} className="relative rounded-lg overflow-hidden border border-[var(--leon-line)] bg-[var(--leon-cream)] select-none"
      style={{ maxHeight: height || undefined }}
      onPointerDown={onDown} onPointerMove={onMove}
      onPointerUp={() => setDrag(null)} onPointerLeave={() => setDrag(null)}>
      {url
        ? <img src={url} alt={doc.name} className="block w-full h-auto" draggable={false} />
        : <div className="w-full" style={{ paddingBottom: `${(H / W) * 100}%` }} />}

      {showShapes !== false && (
        <svg viewBox={`0 0 ${W} ${H}`} className="absolute inset-0 w-full h-full"
          style={{ cursor: tool && tool !== 'none' ? 'crosshair' : 'default' }}>
          {(doc.exclusions || []).map(x => {
            const pts = (x.points || []).map(p => `${p.x * W},${p.y * H}`).join(' ');
            const on = selExId === x.id;
            const protectedKind = STUDIO_ALWAYS_PROTECTED.indexOf(x.kind) >= 0;
            return (
              <g key={x.id}>
                <polygon points={pts} onClick={() => onSelectEx && onSelectEx(x.id)}
                  fill={protectedKind ? 'rgba(56,140,220,0.16)' : 'rgba(20,20,20,0.16)'}
                  stroke={protectedKind ? '#2f7fd0' : '#333'} strokeWidth={on ? 3 : 1.5}
                  strokeDasharray="7 5" style={shapeStroke} />
                {on && (x.points || []).map((p, i) => (
                  <circle key={i} cx={p.x * W} cy={p.y * H} r={r} fill="#333" stroke="#fff" strokeWidth="1.5"
                    style={{ ...shapeStroke, cursor: 'move' }}
                    onPointerDown={ev => { ev.stopPropagation(); setDrag({ type: 'ex', id: x.id, idx: i }); }} />
                ))}
              </g>
            );
          })}

          {(doc.masks || []).map(m => {
            const pts = (m.points || []).map(p => `${p.x * W},${p.y * H}`).join(' ');
            const on = selMaskId === m.id;
            return (
              <g key={m.id}>
                <polygon points={pts} onClick={() => onSelectMask && onSelectMask(m.id)}
                  fill={on ? 'rgba(168,85,247,0.16)' : 'rgba(168,85,247,0.05)'}
                  stroke="#A855F7" strokeWidth={on ? 3 : 1.5} style={shapeStroke} />
                {on && (m.points || []).map((p, i) => (
                  <circle key={i} cx={p.x * W} cy={p.y * H} r={r} fill="#A855F7" stroke="#fff" strokeWidth="1.5"
                    style={{ ...shapeStroke, cursor: 'move' }}
                    onPointerDown={ev => { ev.stopPropagation(); setDrag({ type: 'mask', id: m.id, idx: i }); }} />
                ))}
                {on && m.plane && m.plane.length === 4 && (
                  <g>
                    <polygon points={m.plane.map(p => `${p.x * W},${p.y * H}`).join(' ')}
                      fill="none" stroke="#0ea5e9" strokeWidth="2" strokeDasharray="6 4" style={shapeStroke} />
                    {m.plane.map((p, i) => (
                      <g key={i}>
                        <rect x={p.x * W - r} y={p.y * H - r} width={r * 2} height={r * 2}
                          fill="#0ea5e9" stroke="#fff" strokeWidth="1.5"
                          style={{ ...shapeStroke, cursor: 'move' }}
                          onPointerDown={ev => { ev.stopPropagation(); setDrag({ type: 'plane', id: m.id, idx: i }); }} />
                        <text x={p.x * W + r * 2} y={p.y * H} fontSize={r * 3} fill="#0ea5e9">{i + 1}</text>
                      </g>
                    ))}
                  </g>
                )}
              </g>
            );
          })}

          {draft && draft.points && draft.points.length > 0 && (
            <g>
              <polyline points={draft.points.map(p => `${p.x * W},${p.y * H}`).join(' ')}
                fill="rgba(232,62,168,0.12)" stroke="#e83ea8" strokeWidth="2" strokeDasharray="5 4" style={shapeStroke} />
              {draft.points.map((p, i) => (
                <circle key={i} cx={p.x * W} cy={p.y * H} r={r} fill="#e83ea8" stroke="#fff" strokeWidth="1.5" style={shapeStroke} />
              ))}
            </g>
          )}
        </svg>
      )}

      {busy && (
        <div className="absolute top-2 right-2 rounded-md bg-black/65 text-white text-[11px] px-2 py-1">Compositing…</div>
      )}
      {error && (
        <div className="absolute inset-x-2 bottom-2 rounded-md bg-[#fbe7e7] border border-[#f0c9c9] text-[#8f2f2f] text-[11px] px-3 py-2">{error}</div>
      )}
    </div>
  );
}

// ═════════════════════════════════════════════════════════════ finish picker
// Four honest sources, and each one stamps where the texture came from, because
// the client is shown that on the finished image.
function StudioTexturePicker({ ctx, project, onPick, onClose }) {
  const [tab, setTab] = useState('supplier');
  const groups = useMemo(() => (typeof supplierGroups === 'function' ? supplierGroups() : []), []);
  const [pick, setPick] = useState('');
  const [q, setQ] = useState('');
  const parts = pick ? pick.split('||') : ['', ''];
  const sup = parts[0], cat = parts[1];
  const results = useMemo(() => (cat && typeof searchSupplierFinishes === 'function' ? searchSupplierFinishes(sup, cat, q, 40) : []), [sup, cat, q]);
  const [rq, setRq] = useState('');
  const renderHits = useMemo(() => {
    const t = rq.trim().toLowerCase();
    const all = studioRenderItems();
    return (t ? all.filter(r => `${r.name} ${r.cat} ${r.style} ${r.finish} ${r.brand}`.toLowerCase().indexOf(t) >= 0) : all).slice(0, 60);
  }, [rq]);
  const slabs = studioProjectSlabs(ctx, project);
  const fileRef = useRef(null);

  async function onUpload(e) {
    const f = e.target.files && e.target.files[0];
    if (!f) return;
    const shrunk = await studioShrink(f);
    e.target.value = '';
    if (!shrunk) return;
    onPick({
      textureUrl: shrunk.dataUrl, label: f.name.replace(/\.[^.]+$/, ''),
      source: 'User Uploaded',
    });
  }

  const tabs = [
    { key: 'supplier', label: 'Supplier finishes' },
    { key: 'render', label: 'LEON Render Library' },
    { key: 'slab', label: `Slabs on this job (${slabs.length})` },
    { key: 'upload', label: 'Upload a texture' },
  ];

  return (
    <div className="border border-[var(--leon-brown)] rounded-lg bg-white p-3 space-y-3">
      <div className="flex items-center gap-1 border-b border-[var(--leon-line)] pb-1">
        {tabs.map(t => (
          <button key={t.key} onClick={() => setTab(t.key)}
            className={`px-2 py-1 text-xs font-semibold rounded ${tab === t.key ? 'bg-[var(--leon-cream)] text-[var(--leon-brown)]' : 'text-[var(--leon-black)]/50'}`}>
            {t.label}
          </button>
        ))}
        <span className="ml-auto"><IconBtn title="Close" onClick={onClose}>✕</IconBtn></span>
      </div>

      {tab === 'supplier' && (
        <div className="space-y-2">
          <div className="flex gap-2">
            <Select value={pick} onChange={e => { setPick(e.target.value); setQ(''); }} className="!py-1 !text-xs !w-64">
              <option value="">Vendor &amp; construction…</option>
              {groups.map(g => (
                <optgroup key={g.key} label={typeof supplierDisplayName === 'function' ? supplierDisplayName(g.key, ctx.vendors) : g.label}>
                  {g.cats.map(c => <option key={c.sup + c.cat} value={`${c.sup}||${c.cat}`}>{c.cat} ({c.count})</option>)}
                </optgroup>
              ))}
            </Select>
            <TextInput value={q} onChange={e => setQ(e.target.value)} disabled={!cat}
              placeholder={cat ? 'Search name or supplier code…' : 'Pick a vendor & construction first'}
              className="!py-1 !text-xs flex-1" />
          </div>
          <div className="grid grid-cols-3 sm:grid-cols-5 gap-2 max-h-64 overflow-y-auto">
            {results.map(r => (
              <button key={`${r.sup}:${r.id}`} className="text-left border border-[var(--leon-line)] rounded-md overflow-hidden hover:border-[var(--leon-brown)]"
                onClick={() => onPick({
                  finishRef: makeSupplierFinishRef(r), textureUrl: r.img, label: r.name,
                  // A catalogue image is the vendor's picture of the product. It
                  // is only a manufacturer TEXTURE when the manufacturer says so,
                  // which is a judgement the user makes, not a default.
                  source: r.sup === 'leon' ? 'LEON Photograph' : 'Vendor Image',
                })}>
                {r.img ? <img src={r.img} alt="" loading="lazy" className="w-full h-16 object-cover" />
                  : <div className="w-full h-16 bg-[var(--leon-cream)]" />}
                <div className="px-1.5 py-1">
                  <div className="text-[10px] font-semibold truncate">{r.name}</div>
                  <div className="text-[9px] text-[var(--leon-black)]/45 truncate">{r.code}</div>
                </div>
              </button>
            ))}
            {cat && !results.length && <p className="col-span-full text-xs text-[var(--leon-black)]/45">Nothing in {cat} matches that.</p>}
          </div>
        </div>
      )}

      {tab === 'render' && (
        <div className="space-y-2">
          <TextInput value={rq} onChange={e => setRq(e.target.value)} placeholder="Search the render library…" className="!py-1 !text-xs" />
          <div className="grid grid-cols-3 sm:grid-cols-5 gap-2 max-h-64 overflow-y-auto">
            {renderHits.map(r => (
              <button key={r.id} className="text-left border border-[var(--leon-line)] rounded-md overflow-hidden hover:border-[var(--leon-brown)]"
                onClick={() => onPick({ renderItemId: r.id, textureUrl: r.img, label: r.name, source: 'LEON Photograph' })}>
                <img src={r.img} alt="" loading="lazy" className="w-full h-16 object-cover" />
                <div className="px-1.5 py-1">
                  <div className="text-[10px] font-semibold truncate">{r.name}</div>
                  <div className="text-[9px] text-[var(--leon-black)]/45 truncate">{r.finish || r.style || r.cat}</div>
                </div>
              </button>
            ))}
          </div>
        </div>
      )}

      {tab === 'slab' && (
        <div className="space-y-2">
          <p className="text-[11px] text-[var(--leon-black)]/55 leading-snug">
            Slabs recorded against this job in LEON Stone. Using the slab that will actually be fabricated means
            the render and the fabrication show the same veining. LEON Stone does not compute a nesting layout,
            so this is the slab photograph, not the exact cut piece — place it with the slab controls.
          </p>
          {!slabs.length && <EmptyState text="No slabs with a photograph are assigned to this project in LEON Stone." />}
          <div className="grid grid-cols-3 sm:grid-cols-5 gap-2 max-h-64 overflow-y-auto">
            {slabs.map(s => (
              <button key={s.id} className="text-left border border-[var(--leon-line)] rounded-md overflow-hidden hover:border-[var(--leon-brown)]"
                onClick={() => onPick({
                  textureUrl: s.photoUrl, label: `${s.material}${s.slabId ? ` · ${s.slabId}` : ''}`,
                  source: 'LEON Photograph', mode: 'slab',
                  slab: { x: 0, y: 0, scale: 1, rotate: 0, flipX: false, flipY: false, veinDeg: 0 },
                })}>
                <img src={s.photoUrl} alt="" loading="lazy" className="w-full h-16 object-cover" />
                <div className="px-1.5 py-1">
                  <div className="text-[10px] font-semibold truncate">{s.material}</div>
                  <div className="text-[9px] text-[var(--leon-black)]/45 truncate">{s.slabId} · lot {s.lot || '—'}</div>
                </div>
              </button>
            ))}
          </div>
        </div>
      )}

      {tab === 'upload' && (
        <div className="space-y-2">
          <p className="text-[11px] text-[var(--leon-black)]/55 leading-snug">
            A photograph or scan of a physical sample. It is stored in this browser with everything else, so it is
            downscaled on the way in. Set the source to <b>Scanned Physical Sample</b> afterwards if that is what it is —
            it comes in as User Uploaded, which is the weaker claim.
          </p>
          <Button size="sm" onClick={() => fileRef.current && fileRef.current.click()}>Choose an image…</Button>
          <input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={onUpload} />
        </div>
      )}
    </div>
  );
}

// ═════════════════════════════════════════════════════ material control panel
function StudioMaterialPanel({ ctx, project, doc, mask, assign, editable, onChange, onClear }) {
  const [picking, setPicking] = useState(false);
  const mode = (assign && assign.mode) || studioSuggestedMode(mask.kind);

  function set(fields) {
    onChange(studioMakeAssignment(Object.assign({}, assign || { maskId: mask.id }, fields, { maskId: mask.id })));
  }

  if (!assign) {
    return (
      <div className="space-y-2">
        {picking
          ? <StudioTexturePicker ctx={ctx} project={project} onClose={() => setPicking(false)}
              onPick={p => { setPicking(false); set(Object.assign({ mode: p.mode || studioSuggestedMode(mask.kind), accuracy: STUDIO_SOURCE_ACCURACY[p.source] }, p)); }} />
          : <Button size="sm" disabled={!editable} onClick={() => setPicking(true)}>+ Apply a material to “{mask.name}”</Button>}
        <p className="text-[11px] text-[var(--leon-black)]/45">This surface currently shows whatever is in the original image.</p>
      </div>
    );
  }

  const changeHint = (STUDIO_CHANGE_TYPES.find(c => c.key === assign.changeType) || {}).hint;

  return (
    <div className="space-y-3">
      <div className="flex items-start gap-2">
        {assign.textureUrl && <img src={assign.textureUrl} alt="" className="w-14 h-14 object-cover rounded border border-[var(--leon-line)]" />}
        <div className="min-w-0 flex-1">
          <div className="text-sm font-bold truncate">{(assign.finishRef && assign.finishRef.name) || assign.label || 'Material'}</div>
          <div className="text-[11px] text-[var(--leon-black)]/50 truncate">
            {assign.finishRef ? `${assign.finishRef.code || ''}${assign.finishRef.supLabel ? ` · ${assign.finishRef.supLabel}` : ''}` :
              assign.renderItemId ? 'LEON Render Library' : 'Uploaded texture'}
          </div>
        </div>
        {editable && <>
          <Button size="sm" variant="ghost" onClick={() => setPicking(v => !v)}>Change</Button>
          <IconBtn title="Remove this material" onClick={onClear}>✕</IconBtn>
        </>}
      </div>

      {picking && (
        <StudioTexturePicker ctx={ctx} project={project} onClose={() => setPicking(false)}
          onPick={p => { setPicking(false); set(Object.assign({ accuracy: STUDIO_SOURCE_ACCURACY[p.source] }, p)); }} />
      )}

      <div className="grid grid-cols-2 gap-2">
        <Field label="Texture source">
          <Select className="!py-1 !text-xs" value={assign.source} disabled={!editable}
            onChange={e => set({ source: e.target.value, accuracy: STUDIO_SOURCE_ACCURACY[e.target.value] })}>
            {STUDIO_TEXTURE_SOURCES.map(s => <option key={s}>{s}</option>)}
          </Select>
        </Field>
        <Field label="Accuracy shown to the client">
          <Select className="!py-1 !text-xs" value={assign.accuracy} disabled={!editable}
            onChange={e => set({ accuracy: e.target.value })}>
            {STUDIO_ACCURACY_LEVELS.map(s => <option key={s}>{s}</option>)}
          </Select>
        </Field>
      </div>
      <p className="text-[11px] text-[var(--leon-black)]/50">{STUDIO_ACCURACY_NOTE[assign.accuracy]}</p>

      <Field label="What kind of change is this?" hint={changeHint}>
        <Select className="!py-1 !text-xs" value={assign.changeType} disabled={!editable}
          onChange={e => set({ changeType: e.target.value })}>
          {STUDIO_CHANGE_TYPES.filter(c => c.key !== 'Geometry').map(c => <option key={c.key}>{c.key}</option>)}
        </Select>
      </Field>
      <p className="text-[11px] text-[var(--leon-black)]/45">
        Geometry changes are not in this list on purpose — they are not something an image edit can honestly do.
        Use <b>Request a design change</b> below.
      </p>

      <div className="grid grid-cols-2 gap-2">
        <Field label="How it is laid">
          <Select className="!py-1 !text-xs" value={mode} disabled={!editable} onChange={e => set({ mode: e.target.value })}>
            {STUDIO_MATERIAL_MODES.map(m => <option key={m.key} value={m.key}>{m.label}</option>)}
          </Select>
        </Field>
        <Field label="Opacity">
          <input type="range" min="0.2" max="1" step="0.02" value={assign.opacity} disabled={!editable}
            onChange={e => set({ opacity: Number(e.target.value) })} className="w-full" />
        </Field>
      </div>

      {mode === 'tile' && (
        <div className="grid grid-cols-2 gap-2">
          <Field label="Tile width (mm)"><TextInput className="!py-1 !text-xs" value={assign.tileW} disabled={!editable} onChange={e => set({ tileW: e.target.value })} /></Field>
          <Field label="Tile height (mm)"><TextInput className="!py-1 !text-xs" value={assign.tileH} disabled={!editable} onChange={e => set({ tileH: e.target.value })} /></Field>
          <Field label="Grout (mm)"><TextInput className="!py-1 !text-xs" value={assign.grout} disabled={!editable} onChange={e => set({ grout: e.target.value })} /></Field>
          <Field label="Grout colour">
            <input type="color" value={assign.groutColor} disabled={!editable} onChange={e => set({ groutColor: e.target.value })}
              className="w-full h-8 rounded border border-[var(--leon-line)]" />
          </Field>
          <Field label="Pattern" className="col-span-2">
            <Select className="!py-1 !text-xs" value={assign.pattern} disabled={!editable} onChange={e => set({ pattern: e.target.value })}>
              {STUDIO_TILE_PATTERNS.map(p => <option key={p}>{p}</option>)}
            </Select>
          </Field>
        </div>
      )}

      {mode === 'plank' && (
        <div className="grid grid-cols-2 gap-2">
          <Field label="Plank width (mm)"><TextInput className="!py-1 !text-xs" value={assign.plankW} disabled={!editable} onChange={e => set({ plankW: e.target.value })} /></Field>
          <Field label="Plank length (mm)"><TextInput className="!py-1 !text-xs" value={assign.plankL} disabled={!editable} onChange={e => set({ plankL: e.target.value })} /></Field>
          <Field label="Direction (°)"><TextInput className="!py-1 !text-xs" value={assign.direction} disabled={!editable} onChange={e => set({ direction: e.target.value })} /></Field>
          <Field label="Stagger (0–0.9)"><TextInput className="!py-1 !text-xs" value={assign.stagger} disabled={!editable} onChange={e => set({ stagger: e.target.value })} /></Field>
          <Field label="Board gap (mm)" className="col-span-2"><TextInput className="!py-1 !text-xs" value={assign.grout} disabled={!editable} onChange={e => set({ grout: e.target.value })} /></Field>
        </div>
      )}

      {mode === 'slab' && (
        <div className="grid grid-cols-2 gap-2">
          <Field label="Scale"><input type="range" min="0.3" max="4" step="0.05" value={assign.slab.scale} disabled={!editable}
            onChange={e => set({ slab: Object.assign({}, assign.slab, { scale: Number(e.target.value) }) })} className="w-full" /></Field>
          <Field label="Rotate (°)"><TextInput className="!py-1 !text-xs" value={assign.slab.rotate} disabled={!editable}
            onChange={e => set({ slab: Object.assign({}, assign.slab, { rotate: studioNum(e.target.value, 0) }) })} /></Field>
          <Field label="Move across"><input type="range" min="-0.5" max="0.5" step="0.01" value={assign.slab.x} disabled={!editable}
            onChange={e => set({ slab: Object.assign({}, assign.slab, { x: Number(e.target.value) }) })} className="w-full" /></Field>
          <Field label="Move up / down"><input type="range" min="-0.5" max="0.5" step="0.01" value={assign.slab.y} disabled={!editable}
            onChange={e => set({ slab: Object.assign({}, assign.slab, { y: Number(e.target.value) }) })} className="w-full" /></Field>
          <Field label="Vein direction (°)" hint="Which way the movement runs">
            <TextInput className="!py-1 !text-xs" value={assign.slab.veinDeg} disabled={!editable}
              onChange={e => set({ slab: Object.assign({}, assign.slab, { veinDeg: studioNum(e.target.value, 0) }) })} />
          </Field>
          <div className="flex items-end gap-2">
            <Button size="sm" variant="ghost" disabled={!editable}
              onClick={() => set({ slab: Object.assign({}, assign.slab, { flipX: !assign.slab.flipX }) })}>↔ Flip</Button>
            <Button size="sm" variant="ghost" disabled={!editable}
              onClick={() => set({ slab: Object.assign({}, assign.slab, { flipY: !assign.slab.flipY }) })}>↕ Flip</Button>
          </div>
        </div>
      )}

      {mode === 'colour' && (
        <Field label="Colour" hint="A painted or lacquered surface. The lighting still comes from the original pixels.">
          <input type="color" value={assign.colour} disabled={!editable} onChange={e => set({ colour: e.target.value })}
            className="w-full h-9 rounded border border-[var(--leon-line)]" />
        </Field>
      )}

      <Collapsible title="Lighting response" id={`studio-light-${mask.id}`}>
        <p className="text-[11px] text-[var(--leon-black)]/55 mb-2 leading-snug">
          The shading comes from the original render and is multiplied through the new material. These three decide how
          far that goes: the lift stops deep shadow crushing the material to black, gamma bends the shading curve, and
          gain sets overall brightness.
        </p>
        <div className="grid grid-cols-3 gap-2">
          <Field label={`Shadow lift ${assign.shadowLift}`}>
            <input type="range" min="0" max="0.6" step="0.01" value={assign.shadowLift} disabled={!editable}
              onChange={e => set({ shadowLift: Number(e.target.value) })} className="w-full" />
          </Field>
          <Field label={`Gamma ${assign.gamma}`}>
            <input type="range" min="0.4" max="2.2" step="0.02" value={assign.gamma} disabled={!editable}
              onChange={e => set({ gamma: Number(e.target.value) })} className="w-full" />
          </Field>
          <Field label={`Gain ${assign.gain}`}>
            <input type="range" min="0.4" max="2.5" step="0.02" value={assign.gain} disabled={!editable}
              onChange={e => set({ gain: Number(e.target.value) })} className="w-full" />
          </Field>
        </div>
      </Collapsible>
    </div>
  );
}

// ══════════════════════════════════════════════════════════ selection link
// Links a mask to a real project selection so the render can be flagged when
// that selection moves. It is a WATCH, not a feed: nothing here writes back.
function StudioSelectionLink({ ctx, project, doc, mask, editable, onLink }) {
  const [open, setOpen] = useState(false);
  const scopes = (project && project.scopes) || [];
  const [scopeId, setScopeId] = useState(mask.selectionLink ? mask.selectionLink.scopeId : '');
  const [areaId, setAreaId] = useState(mask.selectionLink ? (mask.selectionLink.areaId || 'main') : 'main');
  const [catId, setCatId] = useState(mask.selectionLink ? mask.selectionLink.categoryId : '');
  const scope = scopes.find(s => s.id === scopeId) || null;
  const family = scope ? (ctx.scopeLibrary || []).find(f => f.name === scope.familyName) : null;
  const cats = (family && family.categories) || [];
  const link = mask.selectionLink;
  const stale = link && studioLinkOutdated(project, link);
  const current = link ? studioCurrentSelectionFinish(project, link) : null;

  if (link && !open) {
    return (
      <div className={`rounded-md border px-2.5 py-2 text-[11px] ${stale ? 'border-[#f0c9c9] bg-[#fbe7e7]' : 'border-[var(--leon-line)] bg-white'}`}>
        <div className="font-semibold">
          {stale ? '⚠ May be outdated' : '🔗 Linked to a project selection'}
        </div>
        <div className="text-[var(--leon-black)]/60 mt-0.5">
          {studioScopeName(project, link.scopeId)} · {link.categoryName || 'selection'}
          {link.areaName ? ` · ${link.areaName}` : ''}
        </div>
        <div className="text-[var(--leon-black)]/60 mt-0.5">
          Shown here: <b>{(link.finishSnapshot && link.finishSnapshot.name) || '—'}</b>
          {stale && <> · now selected: <b>{(current && current.name) || 'nothing'}</b></>}
        </div>
        {stale && <p className="mt-1 text-[#8f2f2f]">The render was not changed. Re-apply the material and re-link when you want it to match.</p>}
        {editable && (
          <div className="mt-1.5 flex gap-2">
            <Button size="sm" variant="ghost" onClick={() => setOpen(true)}>Re-link</Button>
            <Button size="sm" variant="ghost" onClick={() => onLink(null)}>Unlink</Button>
          </div>
        )}
      </div>
    );
  }
  if (!editable) return null;
  if (!open) return <Button size="sm" variant="ghost" onClick={() => setOpen(true)}>🔗 Link to a project selection</Button>;

  return (
    <div className="rounded-md border border-[var(--leon-brown)] bg-white p-2 space-y-2">
      <Field label="Scope">
        <Select className="!py-1 !text-xs" value={scopeId} onChange={e => { setScopeId(e.target.value); setCatId(''); }}>
          <option value="">— scope —</option>
          {scopes.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
        </Select>
      </Field>
      {scope && (
        <Field label="Application area">
          <Select className="!py-1 !text-xs" value={areaId} onChange={e => setAreaId(e.target.value)}>
            <option value="main">{scope.mainAreaName || 'Main area'}</option>
            {(scope.selectionAreas || []).map(a => <option key={a.id} value={a.id}>{a.name}</option>)}
          </Select>
        </Field>
      )}
      <Field label="Selection category">
        <Select className="!py-1 !text-xs" value={catId} onChange={e => setCatId(e.target.value)} disabled={!scope}>
          <option value="">— category —</option>
          {cats.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
        </Select>
      </Field>
      <div className="flex gap-2">
        <Button size="sm" disabled={!scopeId || !catId} onClick={() => {
          const cat = cats.find(c => c.id === catId);
          const area = areaId !== 'main' ? (scope.selectionAreas || []).find(a => a.id === areaId) : null;
          const snap = studioCurrentSelectionFinish(project, { scopeId, areaId, categoryId: catId });
          onLink({
            scopeId, areaId, categoryId: catId,
            categoryName: cat ? cat.name : '',
            areaName: area ? area.name : (scope.mainAreaName || ''),
            // The snapshot is what makes "outdated" answerable. Without it there
            // is nothing to compare the current selection against.
            finishSnapshot: snap ? { source: snap.source, id: snap.id, name: snap.name, code: snap.code } : null,
            linkedDate: todayISO(),
          });
          setOpen(false);
        }}>Link</Button>
        <Button size="sm" variant="ghost" onClick={() => setOpen(false)}>Cancel</Button>
      </div>
      <p className="text-[10px] text-[var(--leon-black)]/45 leading-snug">
        Linking only lets the Hub notice when the selection changes. It never edits the render and never edits the selection.
      </p>
    </div>
  );
}

// ══════════════════════════════════════════════════ propose, never overwrite
function StudioProposeModal({ ctx, project, doc, mask, assign, onClose }) {
  const [note, setNote] = useState('');
  const link = mask && mask.selectionLink;
  return (
    <Modal open onClose={onClose} wide title="Propose a selection change">
      <div className="space-y-3">
        <StudioTruthNotice />
        <p className="text-sm">
          This records a proposal against the render. It does <b>not</b> change the selection, the scope or anything in
          Procurement — someone with the authority to move a selection does that in the Selection Hub, where the lock and
          the revision history live.
        </p>
        <div className="rounded-md border border-[var(--leon-line)] bg-white p-2.5 text-xs">
          <div><b>Surface:</b> {mask ? mask.name : '—'}</div>
          <div><b>Proposed finish:</b> {(assign && ((assign.finishRef && assign.finishRef.name) || assign.label)) || '—'}</div>
          <div><b>Against:</b> {link ? `${studioScopeName(project, link.scopeId)} · ${link.categoryName}` : 'no linked selection — link one first for this to reach the right place'}</div>
        </div>
        <Field label="Why">
          <TextArea rows={3} value={note} onChange={e => setNote(e.target.value)} placeholder="What the client asked for, and what changed on the image." />
        </Field>
      </div>
      <div className="mt-4 flex justify-end gap-2">
        <Button variant="ghost" onClick={onClose}>Cancel</Button>
        <Button disabled={!note.trim()} onClick={() => {
          studioUpdateDoc(ctx, project, doc.id, d => {
            if (!Array.isArray(d.proposals)) d.proposals = [];
            d.proposals.push(studioMakeProposal({
              kind: 'selection', docId: doc.id, maskId: mask.id,
              scopeId: link ? link.scopeId : null, categoryId: link ? link.categoryId : null,
              finishRef: assign ? assign.finishRef : null, note: note.trim(),
            }, ctx.currentUserName));
          }, `Studio: proposed a selection change from "${doc.name}" for ${mask.name}.`);
          onClose();
        }}>Record the proposal</Button>
      </div>
    </Modal>
  );
}

// A geometry change is a real design change. The software refuses to fake it and
// routes it to the module that owns the geometry instead.
function StudioDesignRequestModal({ ctx, project, doc, referenceImage, onClose }) {
  const [module, setModule] = useState(STUDIO_CR_MODULES[0]);
  const [scopeId, setScopeId] = useState('');
  const [note, setNote] = useState('');
  return (
    <Modal open onClose={onClose} wide title="Request a design change">
      <div className="space-y-3">
        <div className="rounded-md border border-[#e6dcc6] bg-[#fbf6e9] px-3 py-2 text-xs text-[#6b5726] leading-snug">
          <b>This is not something an image edit can do honestly.</b> “Make the island 300 mm longer” or “give the vanity
          curved corners” changes casework and countertop geometry — dimensions, panel sizes, edge development, the cut
          list. Stretching pixels would produce a picture that disagrees with the shop drawings, which is worse than not
          producing it. This records the request against the module that owns the geometry.
        </div>
        <Field label="Which module owns this">
          <Select value={module} onChange={e => setModule(e.target.value)}>
            {STUDIO_CR_MODULES.map(m => <option key={m}>{m}</option>)}
          </Select>
        </Field>
        <Field label="Scope (optional)">
          <Select value={scopeId} onChange={e => setScopeId(e.target.value)}>
            <option value="">— not scope specific —</option>
            {((project && project.scopes) || []).map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
          </Select>
        </Field>
        <Field label="What is being asked for">
          <TextArea rows={4} value={note} onChange={e => setNote(e.target.value)}
            placeholder="In the client's words, plus the dimension or shape involved if it is known." />
        </Field>
        {referenceImage && (
          <div>
            <span className="block text-xs font-semibold text-[var(--leon-black)]/60 uppercase tracking-wide mb-1">Reference image attached</span>
            <img src={referenceImage} alt="" className="w-40 rounded border border-[var(--leon-line)]" />
          </div>
        )}
      </div>
      <div className="mt-4 flex justify-end gap-2">
        <Button variant="ghost" onClick={onClose}>Cancel</Button>
        <Button disabled={!note.trim()} onClick={() => {
          ctx.updateProject(project.id, draft => {
            if (!Array.isArray(draft.studioChangeRequests)) draft.studioChangeRequests = [];
            draft.studioChangeRequests.push({
              id: studioId('stdcr'), module, scopeId: scopeId || null, docId: doc ? doc.id : null,
              docName: doc ? doc.name : '', note: note.trim(), referenceImage: referenceImage || null,
              status: 'Raised', raisedBy: ctx.currentUserName, raisedDate: todayISO(),
            });
            ctx.logAction(draft, `Studio: design change request raised against ${module}${scopeId ? ` for ${studioScopeName(draft, scopeId)}` : ''}.`);
          });
          onClose();
        }}>Record the request</Button>
      </div>
    </Modal>
  );
}

// ═══════════════════════════════════════════════════════ mask group actions
// "Change the cabinetry" in one act. The group is a property of each mask, so
// nothing has to be kept in sync — the group is simply everything wearing that
// label.
function StudioGroupPanel({ ctx, project, doc, option, editable, onAssign }) {
  const [open, setOpen] = useState(null);
  const groups = STUDIO_MASK_GROUPS.map(g => ({
    g, masks: (doc.masks || []).filter(m => (m.groupKey || 'other') === g.key),
  })).filter(r => r.masks.length);
  if (!groups.length) return null;
  return (
    <div className="space-y-2">
      {groups.map(({ g, masks }) => (
        <div key={g.key} className="rounded-md border border-[var(--leon-line)] bg-white p-2.5">
          <div className="flex items-center gap-2">
            <div className="min-w-0">
              <div className="text-xs font-bold">{g.label}</div>
              <div className="text-[10px] text-[var(--leon-black)]/45 truncate">
                {masks.length} surface{masks.length === 1 ? '' : 's'}: {masks.map(m => m.name).join(', ')}
              </div>
            </div>
            {editable && (
              <Button size="sm" variant="ghost" className="ml-auto" onClick={() => setOpen(open === g.key ? null : g.key)}>
                Change all
              </Button>
            )}
          </div>
          {open === g.key && (
            <div className="mt-2">
              <StudioTexturePicker ctx={ctx} project={project} onClose={() => setOpen(null)}
                onPick={p => {
                  setOpen(null);
                  masks.forEach(m => onAssign(m, studioMakeAssignment(Object.assign(
                    { maskId: m.id, mode: p.mode || studioSuggestedMode(m.kind), accuracy: STUDIO_SOURCE_ACCURACY[p.source] }, p))));
                }} />
            </div>
          )}
        </div>
      ))}
    </div>
  );
}

// ═══════════════════════════════════════════════════════ render templates
// The masks and the perspective planes, saved as a reusable set. This is the
// multifamily win: the second Type A kitchen is already masked.
//
// Templates hang off the project rather than a new global collection, because a
// unit type belongs to a building — and because a softwares module may not add
// top-level state. They are still readable across projects, since every project
// the user can see is in ctx.projects.
function StudioTemplatesPanel({ ctx, project, doc, editable, onApplied }) {
  const [name, setName] = useState('');
  const [unitType, setUnitType] = useState('');
  const [roomType, setRoomType] = useState('');
  const all = studioTemplates(ctx.deptProjects(ctx.projects || []));

  function save() {
    ctx.updateProject(project.id, draft => {
      if (!Array.isArray(draft.studioTemplates)) draft.studioTemplates = [];
      draft.studioTemplates.push(studioMakeTemplate(doc, { name: name.trim() || doc.name, unitType, roomType }, ctx.currentUserName));
      ctx.logAction(draft, `Studio: saved render template "${name.trim() || doc.name}" (${(doc.masks || []).length} surfaces).`);
    });
    setName('');
  }
  function apply(tpl) {
    studioUpdateDoc(ctx, project, doc.id, d => {
      // New ids on the way in — the same template applied to twelve documents
      // must not give twelve documents the same mask id.
      const exMap = {};
      (tpl.exclusions || []).forEach(x => {
        const copy = cloneDeep(x); copy.id = studioId('stdex'); exMap[x.id] = copy.id;
        d.exclusions = (d.exclusions || []).concat([copy]);
      });
      (tpl.masks || []).forEach(m => {
        const copy = cloneDeep(m);
        copy.id = studioId('stdmask');
        copy.excludeIds = (m.excludeIds || []).map(i => exMap[i]).filter(Boolean);
        // The link is to a selection on ANOTHER job's scope. Carrying it over
        // would silently attach this render to work it has nothing to do with.
        copy.selectionLink = null;
        d.masks = (d.masks || []).concat([copy]);
      });
    }, `Studio: applied render template "${tpl.name}" to ${doc.name}.`);
    if (onApplied) onApplied();
  }

  return (
    <div className="space-y-3">
      <p className="text-[11px] text-[var(--leon-black)]/55 leading-snug">
        A template saves the surfaces and their perspective planes — not the materials. The second kitchen of the same
        unit type needs no re-masking; what goes on those surfaces is still chosen per unit. Shapes are stored
        proportionally, so a template applies to a differently sized photograph of the same room, but it will need
        nudging if the camera moved.
      </p>
      {editable && (
        <div className="rounded-md border border-[var(--leon-line)] bg-white p-2.5 grid sm:grid-cols-4 gap-2 items-end">
          <Field label="Template name"><TextInput className="!py-1 !text-xs" value={name} onChange={e => setName(e.target.value)} placeholder={doc.name} /></Field>
          <Field label="Unit type"><TextInput className="!py-1 !text-xs" value={unitType} onChange={e => setUnitType(e.target.value)} placeholder="Type A" /></Field>
          <Field label="Room type"><TextInput className="!py-1 !text-xs" value={roomType} onChange={e => setRoomType(e.target.value)} placeholder="Kitchen" /></Field>
          <Button size="sm" disabled={!(doc.masks || []).length} onClick={save}>Save this document as a template</Button>
        </div>
      )}
      {!all.length ? <EmptyState text="No render templates saved yet." /> : (
        <div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-2">
          {all.map(({ tpl, project: p }) => (
            <div key={tpl.id} className="rounded-md border border-[var(--leon-line)] bg-white p-2.5">
              <div className="text-xs font-bold">{tpl.name}</div>
              <div className="text-[10px] text-[var(--leon-black)]/45">
                {[tpl.unitType, tpl.roomType].filter(Boolean).join(' · ') || 'no type set'} · {p.name}
              </div>
              <div className="text-[10px] text-[var(--leon-black)]/45 mt-0.5">
                {(tpl.masks || []).length} surfaces · {(tpl.exclusions || []).length} foreground shapes
              </div>
              {editable && <Button size="sm" variant="ghost" className="mt-1.5" onClick={() => apply(tpl)}>Apply to this document</Button>}
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

// ═══════════════════════════════════════════════════════════════ THE EDITOR

// ── The Layers panel ────────────────────────────────────────────────────────
// Photoshop's, over the stack Studio ALREADY composites. This is not a new
// feature bolted on: `studioComposite` walks the masks in order, blending each
// assigned material through its own feathered alpha with the foreground
// exclusions punched out. That IS a layer stack — it simply had no panel, so
// the one place a Photoshop user looks to understand a composite was missing.
//
// What each row is:
//   • the source photograph as the locked Background, because it is,
//   • one row per SURFACE that carries a material — the layers proper,
//   • the foreground exclusions as a single row at the top, because that is
//     where they sit in the composite: punched out of everything below.
//
// Visibility is a real edit, not a view toggle: hiding a layer removes that
// material from the option, which is what "hidden" has to mean when the option
// is also the specification. So it is written as `hidden` on the assignment and
// `studioComposite` skips it — the picture and the record cannot disagree.
function StudioLayersPanel({ doc, option, assignments, masks, selMaskId, onSelect, onSetAssign, editable }) {
  const withMaterial = (masks || []).filter(m => assignments[m.id]);
  const without = (masks || []).filter(m => !assignments[m.id]);
  const exclusions = doc.exclusions || [];

  function row(key, opts) {
    const { label, sub, swatch, onClick, active, right, muted } = opts;
    return (
      <button key={key} type="button" onClick={onClick} disabled={!onClick}
        className={`w-full flex items-center gap-2 px-2 py-1.5 text-left border-b border-[var(--leon-line)] last:border-0
          ${active ? 'bg-[var(--leon-cream)]' : 'hover:bg-[var(--leon-cream)]/60'} ${muted ? 'opacity-55' : ''}`}>
        <span className="w-8 h-8 rounded border border-[var(--leon-line)] shrink-0 overflow-hidden grid place-items-center bg-white">
          {swatch}
        </span>
        <span className="min-w-0 flex-1">
          <span className="block text-[12px] font-semibold truncate">{label}</span>
          {sub && <span className="block text-[10px] text-[var(--leon-black)]/45 truncate">{sub}</span>}
        </span>
        {right}
      </button>
    );
  }

  return (
    <div className="rounded-lg border border-[var(--leon-line)] bg-white overflow-hidden">
      <div className="px-2 py-1.5 border-b border-[var(--leon-line)] bg-[var(--leon-cream)]/50 flex items-baseline gap-2">
        <span className="text-[10px] font-bold uppercase tracking-[0.16em] text-[var(--leon-black)]/45">Layers</span>
        <span className="text-[10px] text-[var(--leon-black)]/35">
          {option ? option.label : 'no option'} · top of the stack first
        </span>
      </div>

      {!!exclusions.length && row('__ex', {
        label: `Foreground — ${exclusions.length} shape${exclusions.length === 1 ? '' : 's'}`,
        sub: 'Punched out of every surface below',
        swatch: <span className="text-[13px]" aria-hidden="true">✂️</span>,
        muted: true,
      })}

      {withMaterial.slice().reverse().map(m => {
        const a = assignments[m.id];
        const hidden = !!a.hidden;
        return row(m.id, {
          label: m.name,
          sub: (a.finishRef && a.finishRef.name) || a.label || a.kind || 'material',
          active: m.id === selMaskId,
          onClick: () => onSelect(m.id),
          swatch: a.finishRef && a.finishRef.img
            ? <img src={a.finishRef.img} alt="" className="w-full h-full object-cover" />
            : <span className="w-full h-full" style={{ background: a.colour || '#cfcfcf' }} />,
          muted: hidden,
          right: (
            <span className="flex items-center gap-1.5 shrink-0" onClick={e => e.stopPropagation()}>
              <span className="text-[10px] tabular-nums text-[var(--leon-black)]/45 w-8 text-right">
                {Math.round(studioNum(a.opacity, 1) * 100)}%
              </span>
              {/* The eye. In Photoshop it hides a layer; here it also takes the
                  material off the option, because this picture doubles as the
                  specification and a hidden layer that still reads as specified
                  is exactly the disagreement to avoid. */}
              <span role="button" tabIndex={0} aria-label={hidden ? 'Show this layer' : 'Hide this layer'}
                title={hidden ? 'Show — puts the material back on the option' : 'Hide — takes the material off the option'}
                onClick={() => editable && onSetAssign(m, Object.assign({}, a, { hidden: !hidden }))}
                onKeyDown={e => { if (e.key === 'Enter' && editable) onSetAssign(m, Object.assign({}, a, { hidden: !hidden })); }}
                className={`text-[13px] leading-none px-1 ${editable ? 'cursor-pointer hover:opacity-100' : 'cursor-default'} ${hidden ? 'opacity-30' : 'opacity-80'}`}>
                {hidden ? '◌' : '👁'}
              </span>
            </span>
          ),
        });
      })}

      {without.map(m => row(m.id, {
        label: m.name,
        sub: 'No material yet',
        active: m.id === selMaskId,
        onClick: () => onSelect(m.id),
        swatch: <span className="w-full h-full"
          style={{ background: 'repeating-linear-gradient(45deg,#fff,#fff 3px,#eee 3px,#eee 6px)' }} />,
        muted: true,
      }))}

      {row('__bg', {
        label: 'Background',
        sub: doc.sourceName || 'the photograph',
        swatch: doc.sourceImage
          ? <img src={doc.sourceImage} alt="" className="w-full h-full object-cover" />
          : <span className="text-[11px]" aria-hidden="true">🖼</span>,
        right: <span className="text-[11px] text-[var(--leon-black)]/30 pr-1" title="The source photograph is never altered">🔒</span>,
      })}

      {!masks.length && (
        <p className="px-2 py-3 text-[11px] text-[var(--leon-black)]/45">
          No surfaces drawn yet. Draw one on the image and it becomes a layer here.
        </p>
      )}
    </div>
  );
}

function StudioEditor({ ctx, project, doc, editable, optionId, setOptionId }) {
  const [tool, setTool] = useState('none');
  const [draft, setDraft] = useState(null);
  const [selMaskId, setSelMaskId] = useState((doc.masks || [])[0] ? doc.masks[0].id : null);
  const [selExId, setSelExId] = useState(null);
  const [proposing, setProposing] = useState(false);
  const [requesting, setRequesting] = useState(false);
  const [showShapes, setShowShapes] = useState(true);

  const option = (doc.options || []).find(o => o.id === optionId) || (doc.options || [])[0] || null;
  const assignments = studioResolveAssignments(doc, option);
  const adjust = studioResolveAdjust(doc, option);
  const render = useStudioRender(doc, assignments, adjust);
  const mask = (doc.masks || []).find(m => m.id === selMaskId) || null;
  const assign = mask ? assignments[mask.id] : null;
  const accuracy = studioDocAccuracy(doc, option);
  const bases = studioApprovedBases(doc);

  function writeMask(maskId, fields, log) {
    studioUpdateDoc(ctx, project, doc.id, d => {
      const m = (d.masks || []).find(x => x.id === maskId);
      if (m) Object.assign(m, fields);
    }, log);
  }
  function setAssignment(m, a) {
    studioUpdateDoc(ctx, project, doc.id, d => {
      const o = (d.options || []).find(x => x.id === (option && option.id));
      if (!o) return;
      if (!o.assignments) o.assignments = {};
      if (a) o.assignments[m.id] = a; else delete o.assignments[m.id];
    }, a
      ? `Studio: ${(a.finishRef && a.finishRef.name) || a.label || 'material'} applied to ${m.name} on ${doc.name} (${option ? option.label : 'option'}).`
      : `Studio: material removed from ${m.name} on ${doc.name}.`);
  }
  function setAdjust(fields) {
    studioUpdateDoc(ctx, project, doc.id, d => {
      const o = (d.options || []).find(x => x.id === (option && option.id));
      if (!o) return;
      o.adjust = Object.assign({}, o.adjust || {}, fields);
    });
  }

  // ── shape drawing ───────────────────────────────────────────────────────
  function onCanvasPoint(p) {
    if (tool === 'plane') {
      if (!mask) return;
      const next = (draft && draft.points ? draft.points : []).concat([p]);
      if (next.length === 4) {
        writeMask(mask.id, { plane: next }, `Studio: perspective plane set on ${mask.name}.`);
        setDraft(null); setTool('none');
      } else setDraft({ kind: 'plane', points: next });
      return;
    }
    if (tool === 'mask' || tool === 'exclusion') {
      setDraft({ kind: tool, points: (draft && draft.points ? draft.points : []).concat([p]) });
    }
  }
  function onMovePoint(d, p) {
    if (d.type === 'mask') {
      studioUpdateDoc(ctx, project, doc.id, doc2 => {
        const m = (doc2.masks || []).find(x => x.id === d.id);
        if (m && m.points[d.idx]) m.points[d.idx] = p;
      });
    } else if (d.type === 'plane') {
      studioUpdateDoc(ctx, project, doc.id, doc2 => {
        const m = (doc2.masks || []).find(x => x.id === d.id);
        if (m && m.plane && m.plane[d.idx]) m.plane[d.idx] = p;
      });
    } else if (d.type === 'ex') {
      studioUpdateDoc(ctx, project, doc.id, doc2 => {
        const x = (doc2.exclusions || []).find(y => y.id === d.id);
        if (x && x.points[d.idx]) x.points[d.idx] = p;
      });
    }
  }
  function finishDraft() {
    if (!draft || draft.points.length < 3) { setDraft(null); setTool('none'); return; }
    if (draft.kind === 'mask') {
      const m = studioMakeMask({ name: `Surface ${(doc.masks || []).length + 1}`, points: draft.points }, ctx.currentUserName);
      studioUpdateDoc(ctx, project, doc.id, d => { d.masks = (d.masks || []).concat([m]); },
        `Studio: surface "${m.name}" outlined on ${doc.name}.`);
      setSelMaskId(m.id);
    } else {
      const x = studioMakeExclusion({ name: `Foreground ${(doc.exclusions || []).length + 1}`, points: draft.points }, ctx.currentUserName);
      studioUpdateDoc(ctx, project, doc.id, d => {
        d.exclusions = (d.exclusions || []).concat([x]);
        // A newly drawn foreground shape protects every surface by default. The
        // common case is an island standing in front of the floor, and having to
        // remember to tick it on each mask is how a texture ends up painted
        // across the furniture.
        (d.masks || []).forEach(m => { m.excludeIds = (m.excludeIds || []).concat([x.id]); });
      }, `Studio: foreground shape "${x.name}" drawn on ${doc.name} — every surface now stays behind it.`);
      setSelExId(x.id);
    }
    setDraft(null); setTool('none');
  }

  const busyTool = tool !== 'none';

  return (
    <div className="space-y-3" data-print-region>
      <div className="flex items-center gap-2 flex-wrap">
        <div className="min-w-0">
          <div className="font-bold">{doc.name}</div>
          <div className="text-[11px] text-[var(--leon-black)]/50">
            {doc.width} × {doc.height} · {(doc.masks || []).length} surfaces · {(doc.exclusions || []).length} foreground shapes · {doc.status}
          </div>
        </div>
        <div className="ml-auto flex items-center gap-2 flex-wrap no-print">
          <Select className="!py-1 !text-xs !w-40" value={option ? option.id : ''} onChange={e => setOptionId(e.target.value)}>
            {(doc.options || []).map(o => <option key={o.id} value={o.id}>{o.label}</option>)}
          </Select>
          <Button size="sm" variant="ghost" onClick={() => setShowShapes(v => !v)}>{showShapes ? 'Hide outlines' : 'Show outlines'}</Button>
          <DocActions title={doc.name} heading={doc.name} lines={[project.name, `Option: ${option ? option.label : '—'}`]} />
        </div>
      </div>

      {option && option.baseRevisionId && (
        <div className="rounded-md border border-[var(--leon-line)] bg-white px-3 py-2 text-[11px]">
          <b>{option.label}</b> branches from the locked base{' '}
          <b>Rev {(studioRevision(doc, option.baseRevisionId) || {}).n}</b>. It re-composites from the original pixels
          every time — it is not built on top of any other option.
        </div>
      )}

      <div className="grid lg:grid-cols-[minmax(0,1fr)_360px] gap-4">
        <div className="space-y-2">
          <StudioStage doc={doc} url={render.url} error={render.error} busy={render.busy}
            tool={tool} draft={draft} selMaskId={selMaskId} selExId={selExId} showShapes={showShapes}
            onCanvasPoint={onCanvasPoint} onMovePoint={onMovePoint}
            onSelectMask={id => { setSelMaskId(id); setSelExId(null); }}
            onSelectEx={id => { setSelExId(id); }} />

          <div className="flex items-center gap-2 flex-wrap no-print">
            {editable && <>
              <Button size="sm" variant={tool === 'mask' ? 'primary' : 'outline'}
                onClick={() => { setTool(tool === 'mask' ? 'none' : 'mask'); setDraft(null); }}>
                ✏️ Draw a surface
              </Button>
              <Button size="sm" variant={tool === 'exclusion' ? 'primary' : 'outline'}
                onClick={() => { setTool(tool === 'exclusion' ? 'none' : 'exclusion'); setDraft(null); }}>
                🚧 Draw a foreground object
              </Button>
              <Button size="sm" variant={tool === 'plane' ? 'primary' : 'outline'} disabled={!mask}
                onClick={() => { setTool(tool === 'plane' ? 'none' : 'plane'); setDraft(null); }}>
                🔲 Place 4 corners
              </Button>
              {busyTool && draft && draft.points.length >= 3 && tool !== 'plane' && (
                <Button size="sm" onClick={finishDraft}>Finish shape ({draft.points.length} points)</Button>
              )}
              {busyTool && (
                <Button size="sm" variant="ghost" onClick={() => { setDraft(null); setTool('none'); }}>Cancel</Button>
              )}
            </>}
          </div>
          {busyTool && (
            <p className="text-[11px] text-[var(--leon-black)]/55 no-print">
              {tool === 'plane'
                ? `Click the four corners of the real-world rectangle this surface occupies, clockwise from its top-left: ${(draft && draft.points.length) || 0} of 4.`
                : 'Click round the outline. Three points minimum, then Finish shape. Drag any point afterwards to correct it.'}
            </p>
          )}
          <StudioApprovalNotice accuracy={accuracy} />
        </div>

        <div className="space-y-3">
          {/* The Layers panel sits ABOVE the surface list, because that is the
              order a Photoshop user reads a composite in: what is stacked on
              what, then the detail of the one you clicked. The list below stays
              — it is where a surface is drawn, renamed and measured, which is
              editing the shape rather than reading the stack. */}
          <StudioLayersPanel doc={doc} option={option} assignments={assignments}
            masks={doc.masks || []} selMaskId={selMaskId} onSelect={setSelMaskId}
            onSetAssign={setAssignment} editable={editable} />
          <Collapsible title="Surfaces" id={`studio-masks-${doc.id}`} defaultOpen count={(doc.masks || []).length}>
            {!(doc.masks || []).length ? (
              <EmptyState text="No surfaces yet. Draw one — a surface is defined once and reused for every material change afterwards." />
            ) : (
              <div className="space-y-1">
                {(doc.masks || []).map(m => {
                  const a = assignments[m.id];
                  const stale = m.selectionLink && studioLinkOutdated(project, m.selectionLink);
                  return (
                    <button key={m.id} onClick={() => { setSelMaskId(m.id); setSelExId(null); }}
                      className={`w-full text-left rounded-md border px-2.5 py-1.5 flex items-center gap-2 ${selMaskId === m.id ? 'border-[var(--leon-brown)] bg-[var(--leon-cream)]' : 'border-[var(--leon-line)] bg-white'}`}>
                      {a && a.textureUrl
                        ? <img src={a.textureUrl} alt="" className="w-7 h-7 rounded object-cover border border-[var(--leon-line)]" />
                        : <span className="w-7 h-7 rounded border border-dashed border-[var(--leon-line)]" />}
                      <span className="min-w-0 flex-1">
                        <span className="block text-xs font-semibold truncate">{m.name}</span>
                        <span className="block text-[10px] text-[var(--leon-black)]/45 truncate">
                          {m.kind} · {m.plane ? 'perspective set' : 'flat-on'} · {(m.excludeIds || []).length} exclusions
                        </span>
                      </span>
                      {stale && <Badge tone="red">outdated</Badge>}
                    </button>
                  );
                })}
              </div>
            )}
          </Collapsible>

          {mask && (
            <Collapsible title={`Surface — ${mask.name}`} id={`studio-mask-detail-${doc.id}`} defaultOpen>
              <div className="space-y-2.5">
                <div className="grid grid-cols-2 gap-2">
                  <Field label="Name"><TextInput className="!py-1 !text-xs" value={mask.name} disabled={!editable}
                    onChange={e => writeMask(mask.id, { name: e.target.value })} /></Field>
                  <Field label="Kind">
                    <Select className="!py-1 !text-xs" value={mask.kind} disabled={!editable}
                      onChange={e => writeMask(mask.id, { kind: e.target.value })}>
                      {STUDIO_MASK_KINDS.map(k => <option key={k}>{k}</option>)}
                    </Select>
                  </Field>
                  <Field label="Group">
                    <Select className="!py-1 !text-xs" value={mask.groupKey || 'other'} disabled={!editable}
                      onChange={e => writeMask(mask.id, { groupKey: e.target.value })}>
                      {STUDIO_MASK_GROUPS.map(g => <option key={g.key} value={g.key}>{g.label}</option>)}
                    </Select>
                  </Field>
                  <Field label={`Edge feather ${mask.feather}px`}>
                    <input type="range" min="0" max="12" step="0.5" value={mask.feather} disabled={!editable}
                      onChange={e => writeMask(mask.id, { feather: Number(e.target.value) })} className="w-full" />
                  </Field>
                </div>

                <div className="rounded-md border border-[var(--leon-line)] bg-white p-2.5">
                  <div className="text-xs font-bold mb-1">Real-world size</div>
                  <p className="text-[11px] text-[var(--leon-black)]/55 mb-2 leading-snug">
                    State what this surface actually measures and the material is tiled against it. This is what stops a
                    600 mm tile being drawn at whatever size happened to look right.
                  </p>
                  <div className="grid grid-cols-2 gap-2">
                    <Field label="Across (mm)"><TextInput className="!py-1 !text-xs" value={mask.realMmW} disabled={!editable}
                      onChange={e => writeMask(mask.id, { realMmW: studioNum(e.target.value, 2400) })} /></Field>
                    <Field label="Up / back (mm)"><TextInput className="!py-1 !text-xs" value={mask.realMmH} disabled={!editable}
                      onChange={e => writeMask(mask.id, { realMmH: studioNum(e.target.value, 900) })} /></Field>
                  </div>
                  <div className="mt-2 flex items-center gap-2">
                    <span className="text-[11px] text-[var(--leon-black)]/55">
                      {mask.plane ? 'Perspective plane placed — the texture is warped onto it.' : 'No perspective plane. The material is laid flat-on, which is only right for a surface square to the camera.'}
                    </span>
                    {editable && mask.plane && <Button size="sm" variant="ghost" onClick={() => writeMask(mask.id, { plane: null }, `Studio: perspective plane cleared on ${mask.name}.`)}>Clear</Button>}
                  </div>
                </div>

                <div className="rounded-md border border-[var(--leon-line)] bg-white p-2.5">
                  <div className="text-xs font-bold mb-1">Stays behind</div>
                  <p className="text-[11px] text-[var(--leon-black)]/55 mb-2 leading-snug">
                    There is no automatic depth estimation here — nothing in the browser knows what is in front of what.
                    So the foreground is drawn once, by hand, and every material ever applied to this surface is clipped
                    against it. Glass and mirrors are protected from every surface whether ticked or not.
                  </p>
                  {!(doc.exclusions || []).length ? (
                    <p className="text-[11px] text-[var(--leon-black)]/40 italic">No foreground shapes drawn yet.</p>
                  ) : (doc.exclusions || []).map(x => {
                    const always = STUDIO_ALWAYS_PROTECTED.indexOf(x.kind) >= 0;
                    const on = always || (mask.excludeIds || []).indexOf(x.id) >= 0;
                    return (
                      <label key={x.id} className="flex items-center gap-2 text-xs py-0.5">
                        <input type="checkbox" checked={on} disabled={!editable || always}
                          onChange={e => writeMask(mask.id, {
                            excludeIds: e.target.checked
                              ? (mask.excludeIds || []).concat([x.id])
                              : (mask.excludeIds || []).filter(i => i !== x.id),
                          })} />
                        <span>{x.name} <span className="text-[var(--leon-black)]/40">({x.kind}{always ? ' — always protected' : ''})</span></span>
                      </label>
                    );
                  })}
                </div>

                {/* keyed on the mask: the picker holds its own draft state, and
                    without a key it keeps the previous surface's scope. */}
                <StudioSelectionLink key={mask.id} ctx={ctx} project={project} doc={doc} mask={mask} editable={editable}
                  onLink={link => writeMask(mask.id, { selectionLink: link },
                    link ? `Studio: ${mask.name} linked to a selection on ${studioScopeName(project, link.scopeId)}.`
                         : `Studio: ${mask.name} unlinked from its selection.`)} />

                {editable && (
                  <div className="flex gap-2 flex-wrap">
                    <Button size="sm" variant="ghost" onClick={() => setProposing(true)} disabled={!assign}>Propose selection change</Button>
                    <Button size="sm" variant="ghost" onClick={() => setRequesting(true)}>Request a design change</Button>
                    <Button size="sm" variant="ghost" onClick={() => {
                      studioUpdateDoc(ctx, project, doc.id, d => { d.masks = (d.masks || []).filter(m => m.id !== mask.id); },
                        `Studio: surface "${mask.name}" removed from ${doc.name}.`);
                      setSelMaskId(null);
                    }}>Delete surface</Button>
                  </div>
                )}
              </div>
            </Collapsible>
          )}

          {mask && (
            <Collapsible title="Material" id={`studio-material-${doc.id}`} defaultOpen>
              <StudioMaterialPanel key={mask.id} ctx={ctx} project={project} doc={doc} mask={mask} assign={assign} editable={editable}
                onChange={a => setAssignment(mask, a)} onClear={() => setAssignment(mask, null)} />
            </Collapsible>
          )}

          <Collapsible title="Change a whole group" id={`studio-groups-${doc.id}`}>
            <StudioGroupPanel ctx={ctx} project={project} doc={doc} option={option} editable={editable}
              onAssign={(m, a) => setAssignment(m, a)} />
          </Collapsible>

          <Collapsible title="Adjustments" id={`studio-adjust-${doc.id}`}>
            <p className="text-[11px] text-[var(--leon-black)]/55 mb-2 leading-snug">
              Stored as part of the option and re-applied on every render. Nothing is baked into the image.
            </p>
            {[['exposure', 'Exposure (stops)', -2, 2, 0.05], ['contrast', 'Contrast', -80, 80, 1],
              ['saturation', 'Saturation', -100, 100, 1], ['temperature', 'Temperature', -100, 100, 1],
              ['highlights', 'Highlights', -100, 100, 1], ['shadows', 'Shadows', -100, 100, 1]].map(([k, label, lo, hi, step]) => (
              <Field key={k} label={`${label} — ${adjust[k]}`}>
                <input type="range" min={lo} max={hi} step={step} value={adjust[k]} disabled={!editable}
                  onChange={e => setAdjust({ [k]: Number(e.target.value) })} className="w-full" />
              </Field>
            ))}
            {editable && <Button size="sm" variant="ghost" onClick={() => setAdjust({ exposure: 0, contrast: 0, saturation: 0, temperature: 0, highlights: 0, shadows: 0 })}>Reset</Button>}
          </Collapsible>

          <Collapsible title="Foreground shapes" id={`studio-ex-${doc.id}`} count={(doc.exclusions || []).length}>
            {!(doc.exclusions || []).length ? <EmptyState text="Nothing drawn yet." /> : (doc.exclusions || []).map(x => (
              <div key={x.id} className={`rounded-md border px-2.5 py-1.5 mb-1 ${selExId === x.id ? 'border-[var(--leon-brown)] bg-[var(--leon-cream)]' : 'border-[var(--leon-line)] bg-white'}`}
                onClick={() => setSelExId(x.id)}>
                <div className="flex items-center gap-2">
                  <TextInput className="!py-0.5 !text-xs flex-1" value={x.name} disabled={!editable}
                    onChange={e => studioUpdateDoc(ctx, project, doc.id, d => {
                      const t = (d.exclusions || []).find(y => y.id === x.id); if (t) t.name = e.target.value;
                    })} />
                  <Select className="!py-0.5 !text-xs !w-28" value={x.kind} disabled={!editable}
                    onChange={e => studioUpdateDoc(ctx, project, doc.id, d => {
                      const t = (d.exclusions || []).find(y => y.id === x.id); if (t) t.kind = e.target.value;
                    })}>
                    {STUDIO_EXCLUSION_KINDS.map(k => <option key={k}>{k}</option>)}
                  </Select>
                  {editable && <IconBtn title="Delete" onClick={() => studioUpdateDoc(ctx, project, doc.id, d => {
                    d.exclusions = (d.exclusions || []).filter(y => y.id !== x.id);
                    (d.masks || []).forEach(m => { m.excludeIds = (m.excludeIds || []).filter(i => i !== x.id); });
                  }, `Studio: foreground shape "${x.name}" removed.`)}>✕</IconBtn>}
                </div>
              </div>
            ))}
          </Collapsible>

          <Collapsible title="What the Hub cannot do here" id={`studio-ai-${doc.id}`}>
            <p className="text-[11px] text-[var(--leon-black)]/55 mb-2 leading-snug">
              These are the things people look for in an image editor and will not find here, with the reason. None of
              them is a broken button — there is no image model connected to the Hub, and there is no honest way to fake
              one.
            </p>
            <div className="space-y-1.5">
              {STUDIO_AI_OPERATIONS.map(op => <StudioAiButton key={op.key} op={op} />)}
            </div>
          </Collapsible>

          <Collapsible title="Render templates" id={`studio-tpl-${doc.id}`}>
            <StudioTemplatesPanel ctx={ctx} project={project} doc={doc} editable={editable} />
          </Collapsible>
        </div>
      </div>

      {proposing && <StudioProposeModal ctx={ctx} project={project} doc={doc} mask={mask} assign={assign} onClose={() => setProposing(false)} />}
      {requesting && <StudioDesignRequestModal ctx={ctx} project={project} doc={doc} referenceImage={render.url} onClose={() => setRequesting(false)} />}
    </div>
  );
}

// ══════════════════════════════════════════════════════════════ new document
function StudioNewDocumentModal({ ctx, project, onClose, onCreated }) {
  const [name, setName] = useState('');
  const [roomId, setRoomId] = useState('');
  const [scopeId, setScopeId] = useState('');
  const [img, setImg] = useState(null);
  const [busy, setBusy] = useState(false);
  const fileRef = useRef(null);

  async function onFile(e) {
    const f = e.target.files && e.target.files[0];
    e.target.value = '';
    if (!f) return;
    setBusy(true);
    const shrunk = await studioShrink(f);
    setBusy(false);
    if (!shrunk) return;
    setImg(Object.assign({ fileName: f.name }, shrunk));
    if (!name.trim()) setName(f.name.replace(/\.[^.]+$/, ''));
  }

  return (
    <Modal open onClose={onClose} wide title="New image document">
      <div className="space-y-3">
        <Field label="Name"><TextInput value={name} onChange={e => setName(e.target.value)} placeholder="Kitchen — view from dining" /></Field>
        <div className="grid sm:grid-cols-2 gap-3">
          <Field label="Room / area" hint="Free text — how the team refers to it"><TextInput value={roomId} onChange={e => setRoomId(e.target.value)} placeholder="Kitchen" /></Field>
          <Field label="Scope (optional)">
            <Select value={scopeId} onChange={e => setScopeId(e.target.value)}>
              <option value="">— not scope specific —</option>
              {((project && project.scopes) || []).map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
            </Select>
          </Field>
        </div>
        <div>
          <span className="block text-xs font-semibold text-[var(--leon-black)]/60 uppercase tracking-wide mb-1">Source image</span>
          <Button size="sm" variant="outline" disabled={busy} onClick={() => fileRef.current && fileRef.current.click()}>
            {busy ? 'Reading…' : img ? 'Choose a different image' : 'Choose an image…'}
          </Button>
          <input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={onFile} />
          {img && (
            <div className="mt-2 flex gap-3">
              <img src={img.dataUrl} alt="" className="w-40 rounded border border-[var(--leon-line)]" />
              <p className="text-[11px] text-[var(--leon-black)]/55 leading-snug flex-1">{img.note}</p>
            </div>
          )}
        </div>
        <div className="rounded-md border border-[var(--leon-line)] bg-white px-3 py-2 text-[11px] text-[var(--leon-black)]/60 leading-snug">
          The image you upload is the original and is never modified. Everything done to it afterwards is stored as
          operations and re-applied on render, which is why an option can always be taken back to the source.
        </div>
      </div>
      <div className="mt-4 flex justify-end gap-2">
        <Button variant="ghost" onClick={onClose}>Cancel</Button>
        <Button disabled={!img || !name.trim()} onClick={() => {
          const doc = studioMakeDocument({
            name: name.trim(), projectId: project.id, roomId, scopeId: scopeId || null,
            sourceImage: img.dataUrl, sourceName: img.fileName,
            width: img.width, height: img.height, storageNote: img.note,
          }, ctx.currentUserName);
          ctx.updateProject(project.id, draft => {
            if (!Array.isArray(draft.studioDocuments)) draft.studioDocuments = [];
            draft.studioDocuments.push(doc);
            ctx.logAction(draft, `Studio: image document "${doc.name}" created from ${img.fileName}.`);
          });
          onCreated(doc.id);
          onClose();
        }}>Create</Button>
      </div>
    </Modal>
  );
}

// ═══════════════════════════════════════════════════════ documents section
function StudioDocuments({ ctx, project, projects, editable, onOpen }) {
  const [adding, setAdding] = useState(false);
  const [q, setQ] = useState('');
  const docs = studioDocs(project).filter(d => !q.trim() || `${d.name} ${d.roomId}`.toLowerCase().indexOf(q.trim().toLowerCase()) >= 0);
  const outdated = studioOutdatedAcrossProjects(projects);

  return (
    <div className="space-y-4" data-print-region>
      <StudioTruthNotice />

      <Collapsible title="Selections that have moved on" id="studio-outdated" defaultOpen count={outdated.length}>
        <p className="text-[11px] text-[var(--leon-black)]/55 mb-2 leading-snug">
          Every render whose linked selection has since changed. <b>Nothing is regenerated automatically</b> — a render is
          something a person has looked at and often already sent, so the Hub flags it and you decide which ones to redo.
        </p>
        {!outdated.length ? <EmptyState text="No render is showing a material that has since been reselected." /> : (
          <div className="overflow-x-auto">
            <table className="w-full text-xs">
              <thead>
                <tr className="text-left text-[10px] uppercase tracking-wide text-[var(--leon-black)]/45 border-b border-[var(--leon-line)]">
                  <th className="px-2 py-2">Project</th><th className="px-2 py-2">Document</th>
                  <th className="px-2 py-2">Surface</th><th className="px-2 py-2">Shown</th>
                  <th className="px-2 py-2">Now selected</th><th className="px-2 py-2"></th>
                </tr>
              </thead>
              <tbody className="divide-y divide-[var(--leon-line)]">
                {outdated.map(row => row.masks.map(m => {
                  const now = studioCurrentSelectionFinish(row.project, m.selectionLink);
                  return (
                    <tr key={`${row.doc.id}-${m.id}`}>
                      <td className="px-2 py-1.5">{row.project.name}</td>
                      <td className="px-2 py-1.5 font-semibold">{row.doc.name}</td>
                      <td className="px-2 py-1.5">{m.name}</td>
                      <td className="px-2 py-1.5">{(m.selectionLink.finishSnapshot || {}).name || '—'}</td>
                      <td className="px-2 py-1.5">{(now && now.name) || <span className="italic text-[var(--leon-black)]/40">nothing selected</span>}</td>
                      <td className="px-2 py-1.5 text-right">
                        {row.project.id === project.id
                          ? <Button size="sm" variant="ghost" onClick={() => onOpen(row.doc.id)}>Open</Button>
                          : <span className="text-[10px] text-[var(--leon-black)]/40">switch project</span>}
                      </td>
                    </tr>
                  );
                }))}
              </tbody>
            </table>
          </div>
        )}
      </Collapsible>

      <div className="flex items-center gap-2 flex-wrap">
        <TextInput className="!w-64" value={q} onChange={e => setQ(e.target.value)} placeholder="Search documents…" />
        <span className="text-xs text-[var(--leon-black)]/50">{docs.length} document{docs.length === 1 ? '' : 's'}</span>
        {editable && <Button className="ml-auto" size="sm" onClick={() => setAdding(true)}>+ New image document</Button>}
      </div>

      {!docs.length ? (
        <EmptyState text="No image documents on this project yet. Upload a render and outline the surfaces you expect to be asked to change." />
      ) : (
        <div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3">
          {docs.map(d => {
            const stale = studioDocOutdatedMasks(project, d).length;
            return (
              <div key={d.id} className="rounded-lg border border-[var(--leon-line)] bg-white overflow-hidden">
                <button className="block w-full" onClick={() => onOpen(d.id)}>
                  <img src={d.sourceImage} alt="" className="w-full h-32 object-cover" />
                </button>
                <div className="p-2.5">
                  <div className="flex items-start gap-2">
                    <div className="min-w-0 flex-1">
                      <button className="text-left" onClick={() => onOpen(d.id)}>
                        <div className="text-sm font-bold truncate">{d.name}</div>
                      </button>
                      <div className="text-[10px] text-[var(--leon-black)]/45 truncate">
                        {[d.roomId, studioScopeName(project, d.scopeId)].filter(Boolean).join(' · ') || 'no room set'}
                      </div>
                    </div>
                    <Badge tone={d.status === 'Approved' ? 'green' : d.status === 'Client Review' ? 'blue' : d.status === 'Archived' || d.status === 'Superseded' ? 'neutral' : 'yellow'}>{d.status}</Badge>
                  </div>
                  <div className="text-[10px] text-[var(--leon-black)]/45 mt-1">
                    {(d.masks || []).length} surfaces · {(d.options || []).length} options · {(d.revisions || []).length} revisions
                    {studioApprovedBases(d).length ? ` · ${studioApprovedBases(d).length} locked base` : ''}
                  </div>
                  {!!stale && <div className="mt-1"><Badge tone="red">{stale} may be outdated</Badge></div>}
                  {editable && (
                    <div className="mt-2 flex items-center gap-2">
                      <Select className="!py-0.5 !text-[11px] !w-36" value={d.status}
                        onChange={e => studioUpdateDoc(ctx, project, d.id, x => { x.status = e.target.value; },
                          `Studio: "${d.name}" set to ${e.target.value}.`)}>
                        {STUDIO_DOC_STATUSES.map(s => <option key={s}>{s}</option>)}
                      </Select>
                      <Button size="sm" variant="ghost" onClick={() => onOpen(d.id)}>Open</Button>
                    </div>
                  )}
                  {d.storageNote && <p className="text-[10px] text-[var(--leon-black)]/35 mt-1.5 leading-snug">{d.storageNote}</p>}
                </div>
              </div>
            );
          })}
        </div>
      )}

      {adding && <StudioNewDocumentModal ctx={ctx} project={project} onClose={() => setAdding(false)} onCreated={onOpen} />}
    </div>
  );
}

// ══════════════════════════════════════════════ options, compare, revisions
// One rendered pane. Kept separate so the compare views can mount two or three
// of them without each one re-deriving the composite logic.
function StudioPane({ doc, option, label, height }) {
  const assignments = studioResolveAssignments(doc, option);
  const adjust = studioResolveAdjust(doc, option);
  const r = useStudioRender(doc, assignments, adjust);
  return (
    <div>
      <div className="text-xs font-bold mb-1">{label || (option && option.label)}</div>
      <div className="rounded-lg overflow-hidden border border-[var(--leon-line)] bg-[var(--leon-cream)] relative">
        {r.url ? <img src={r.url} alt="" className="block w-full h-auto" /> : <div style={{ height: height || 200 }} />}
        {r.busy && <div className="absolute top-2 right-2 rounded bg-black/60 text-white text-[10px] px-2 py-0.5">Compositing…</div>}
      </div>
    </div>
  );
}

// Two composites, one on top of the other, revealed by a handle. Both are
// rendered from the ORIGINAL — the slider is a way of looking, not a way of
// stacking edits.
function StudioSlider({ doc, left, right }) {
  const [pos, setPos] = useState(50);
  const a = useStudioRender(doc, studioResolveAssignments(doc, left), studioResolveAdjust(doc, left));
  const b = useStudioRender(doc, studioResolveAssignments(doc, right), studioResolveAdjust(doc, right));
  return (
    <div>
      <div className="relative rounded-lg overflow-hidden border border-[var(--leon-line)] bg-[var(--leon-cream)]">
        {a.url ? <img src={a.url} alt="" className="block w-full h-auto" /> : <div style={{ paddingBottom: '60%' }} />}
        {/* clip-path rather than a narrowing wrapper: the overlay stays exactly
            the same size as the image underneath, so the two halves line up at
            any slider position instead of needing the container width in JS. */}
        {b.url && (
          <img src={b.url} alt="" className="absolute inset-0 block w-full h-full"
            style={{ clipPath: `inset(0 ${100 - pos}% 0 0)` }} />
        )}
        <div className="absolute top-0 bottom-0 w-0.5 bg-white shadow" style={{ left: `${pos}%` }} />
        <div className="absolute top-2 left-2 text-[10px] font-semibold bg-black/60 text-white rounded px-1.5 py-0.5">{right ? right.label : 'Original'}</div>
        <div className="absolute top-2 right-2 text-[10px] font-semibold bg-black/60 text-white rounded px-1.5 py-0.5">{left ? left.label : 'Original'}</div>
      </div>
      <input type="range" min="0" max="100" value={pos} onChange={e => setPos(Number(e.target.value))} className="w-full mt-2 no-print" />
    </div>
  );
}

// What actually moved between two composites, per pixel. A reviewer should not
// have to hunt for the change.
function StudioDifference({ doc, a, b }) {
  const ra = useStudioRender(doc, studioResolveAssignments(doc, a), studioResolveAdjust(doc, a));
  const rb = useStudioRender(doc, studioResolveAssignments(doc, b), studioResolveAdjust(doc, b));
  const [out, setOut] = useState({ url: null, pct: 0 });
  useEffect(() => {
    if (!ra.canvas || !rb.canvas) return;
    const cv = studioDifferenceCanvas(ra.canvas, rb.canvas, 10);
    let url = null;
    try { url = cv.toDataURL('image/jpeg', 0.9); } catch (e) { url = null; }
    setOut({ url, pct: cv.__changedPct || 0 });
  }, [ra.url, rb.url]);
  return (
    <div>
      <div className="text-xs font-bold mb-1">Difference — {a ? a.label : 'Original'} → {b ? b.label : 'Original'}</div>
      <div className="rounded-lg overflow-hidden border border-[var(--leon-line)] bg-[var(--leon-cream)]">
        {out.url ? <img src={out.url} alt="" className="block w-full h-auto" /> : <div style={{ paddingBottom: '60%' }} />}
      </div>
      <p className="text-[11px] text-[var(--leon-black)]/55 mt-1">
        {out.pct.toFixed(1)}% of the image differs. Highlighted pink. Changed pixels are found by comparing the two
        composites directly — nothing is inferred.
      </p>
    </div>
  );
}

function StudioOptions({ ctx, project, doc, editable, optionId, setOptionId }) {
  const [mode, setMode] = useState('side');
  const [aId, setAId] = useState((doc.options || [])[0] ? doc.options[0].id : '');
  const [bId, setBId] = useState((doc.options || [])[1] ? doc.options[1].id : '');
  const [lockNote, setLockNote] = useState('');
  const options = doc.options || [];
  const bases = studioApprovedBases(doc);
  const A = options.find(o => o.id === aId) || options[0] || null;
  const B = options.find(o => o.id === bId) || null;

  function addOption() {
    const label = STUDIO_OPTION_LABELS[options.length] || `Option ${options.length + 1}`;
    // A new option branches from the newest LOCKED base, or from the original
    // if nothing has been locked. It never branches from another option — that
    // is what stops C being built on top of B.
    const base = bases.length ? bases[bases.length - 1] : null;
    studioUpdateDoc(ctx, project, doc.id, d => {
      d.options = (d.options || []).concat([studioMakeOption({ label, baseRevisionId: base ? base.id : null }, ctx.currentUserName)]);
    }, `Studio: ${label} added on "${doc.name}"${base ? `, branched from locked Rev ${base.n}` : ' from the original image'}.`);
  }

  function lockBase(option) {
    const n = (doc.revisions || []).length + 1;
    studioUpdateDoc(ctx, project, doc.id, d => {
      if (!Array.isArray(d.revisions)) d.revisions = [];
      const o = (d.options || []).find(x => x.id === option.id);
      d.revisions.push(studioMakeRevision({
        n, note: lockNote.trim(), approvedBase: true, optionId: option.id,
        // The snapshot is the resolved ASSIGNMENTS, not a flattened picture.
        // Storing a picture would be the start of the chain this design exists
        // to avoid — every later option would inherit its compression, its
        // adjustments and its mistakes.
        assignments: cloneDeep(studioResolveAssignments(d, o)),
        adjust: cloneDeep(studioResolveAdjust(d, o)),
      }, ctx.currentUserName));
    }, `Studio: "${doc.name}" ${option.label} locked as approved base Rev ${n}.`);
    setLockNote('');
  }

  return (
    <div className="space-y-4" data-print-region>
      <div className="rounded-md border border-[var(--leon-line)] bg-white px-3 py-2 text-[11px] leading-snug">
        <b>Options branch, they do not chain.</b> Every option re-composites from the original pixels plus its own
        material assignments — Option C is never Option B with something added, so nothing degrades through a run of
        edits. An option may branch from a revision that has been locked as an <b>approved base</b>; the base is stored
        as its assignments, not as a flattened picture, for exactly the same reason.
      </div>

      <div className="flex items-center gap-2 flex-wrap no-print">
        <Select className="!py-1 !text-xs !w-40" value={aId} onChange={e => setAId(e.target.value)}>
          {options.map(o => <option key={o.id} value={o.id}>{o.label}</option>)}
        </Select>
        <span className="text-xs text-[var(--leon-black)]/40">vs</span>
        <Select className="!py-1 !text-xs !w-40" value={bId} onChange={e => setBId(e.target.value)}>
          <option value="">Original image</option>
          {options.map(o => <option key={o.id} value={o.id}>{o.label}</option>)}
        </Select>
        <div className="flex gap-1 ml-2">
          {[['side', 'Side by side'], ['slider', 'Slider'], ['diff', 'Difference'], ['all', 'All options']].map(([k, l]) => (
            <Button key={k} size="sm" variant={mode === k ? 'primary' : 'outline'} onClick={() => setMode(k)}>{l}</Button>
          ))}
        </div>
        {editable && <Button size="sm" className="ml-auto" onClick={addOption} disabled={options.length >= 4}>+ Add option</Button>}
        <DocActions title={`${doc.name} — options`} heading={doc.name} lines={[project.name]} />
      </div>

      {mode === 'side' && (
        <div className="grid md:grid-cols-2 gap-3">
          <StudioPane doc={doc} option={A} />
          <StudioPane doc={doc} option={B} label={B ? B.label : 'Original image'} />
        </div>
      )}
      {mode === 'slider' && <StudioSlider doc={doc} left={A} right={B} />}
      {mode === 'diff' && <StudioDifference doc={doc} a={B} b={A} />}
      {mode === 'all' && (
        <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-3">
          {options.map(o => <StudioPane key={o.id} doc={doc} option={o} />)}
        </div>
      )}

      <StudioApprovalNotice accuracy={studioDocAccuracy(doc, A)} />

      <Collapsible title="Options" id={`studio-optlist-${doc.id}`} defaultOpen count={options.length}>
        <div className="space-y-2">
          {options.map(o => {
            const base = o.baseRevisionId ? studioRevision(doc, o.baseRevisionId) : null;
            const n = Object.keys(studioResolveAssignments(doc, o)).length;
            return (
              <div key={o.id} className="rounded-md border border-[var(--leon-line)] bg-white p-2.5">
                <div className="flex items-center gap-2 flex-wrap">
                  <TextInput className="!py-0.5 !text-xs !w-32" value={o.label} disabled={!editable}
                    onChange={e => studioUpdateDoc(ctx, project, doc.id, d => {
                      const x = (d.options || []).find(y => y.id === o.id); if (x) x.label = e.target.value;
                    })} />
                  <span className="text-[11px] text-[var(--leon-black)]/50">
                    {n} material{n === 1 ? '' : 's'} · branches from {base ? `locked Rev ${base.n}` : 'the original image'}
                  </span>
                  {editable && (
                    <Select className="!py-0.5 !text-[11px] !w-44 ml-auto" value={o.baseRevisionId || ''}
                      onChange={e => studioUpdateDoc(ctx, project, doc.id, d => {
                        const x = (d.options || []).find(y => y.id === o.id); if (x) x.baseRevisionId = e.target.value || null;
                      }, `Studio: ${o.label} re-based on ${e.target.value ? 'a locked revision' : 'the original image'}.`)}>
                      <option value="">Branch from the original</option>
                      {bases.map(r => <option key={r.id} value={r.id}>Locked Rev {r.n} — {fmtDate(r.date)}</option>)}
                    </Select>
                  )}
                  <Button size="sm" variant="ghost" onClick={() => setOptionId(o.id)}>Edit in the editor</Button>
                  {editable && <Button size="sm" variant="ghost" onClick={() => lockBase(o)}>Lock as approved base</Button>}
                </div>
              </div>
            );
          })}
        </div>
        {editable && (
          <Field label="Note on the next lock" className="mt-2">
            <TextInput className="!py-1 !text-xs" value={lockNote} onChange={e => setLockNote(e.target.value)}
              placeholder="What the client approved" />
          </Field>
        )}
      </Collapsible>

      <Collapsible title="Revisions" id={`studio-revs-${doc.id}`} count={(doc.revisions || []).length}>
        {!(doc.revisions || []).length ? <EmptyState text="Nothing locked yet." /> : (
          <table className="w-full text-xs">
            <thead>
              <tr className="text-left text-[10px] uppercase tracking-wide text-[var(--leon-black)]/45 border-b border-[var(--leon-line)]">
                <th className="px-2 py-2">Rev</th><th className="px-2 py-2">Date</th><th className="px-2 py-2">By</th>
                <th className="px-2 py-2">From</th><th className="px-2 py-2">Note</th><th className="px-2 py-2">Base</th>
              </tr>
            </thead>
            <tbody className="divide-y divide-[var(--leon-line)]">
              {(doc.revisions || []).map(r => (
                <tr key={r.id}>
                  <td className="px-2 py-1.5 font-semibold">{r.n}</td>
                  <td className="px-2 py-1.5">{fmtDate(r.date)}</td>
                  <td className="px-2 py-1.5">{r.by}</td>
                  <td className="px-2 py-1.5">{(options.find(o => o.id === r.optionId) || {}).label || '—'}</td>
                  <td className="px-2 py-1.5">{r.note || '—'}</td>
                  <td className="px-2 py-1.5">{r.approvedBase ? <Badge tone="green">approved base</Badge> : '—'}</td>
                </tr>
              ))}
            </tbody>
          </table>
        )}
      </Collapsible>

      <Collapsible title="Proposals raised from this image" id={`studio-props-${doc.id}`} count={(doc.proposals || []).length}>
        <StudioTruthNotice className="mb-2" />
        {!(doc.proposals || []).length ? <EmptyState text="None." /> : (doc.proposals || []).map(p => (
          <div key={p.id} className="rounded-md border border-[var(--leon-line)] bg-white p-2.5 mb-1.5 text-xs">
            <div className="font-semibold">{(p.finishRef && p.finishRef.name) || 'Selection change'} — {p.status}</div>
            <div className="text-[var(--leon-black)]/55">{studioScopeName(project, p.scopeId) || 'no scope linked'} · {p.raisedBy} · {fmtDate(p.raisedDate)}</div>
            <div className="mt-0.5">{p.note}</div>
          </div>
        ))}
      </Collapsible>
    </div>
  );
}

// ═════════════════════════════════════════════════════════════ finish board
// The render, and the numbered list of what is actually on it — read off the
// real library records, never retyped.
function StudioBoard({ ctx, project, doc, optionId }) {
  const option = (doc.options || []).find(o => o.id === optionId) || (doc.options || [])[0] || null;
  const assignments = studioResolveAssignments(doc, option);
  const adjust = studioResolveAdjust(doc, option);
  const r = useStudioRender(doc, assignments, adjust);
  const accuracy = studioDocAccuracy(doc, option);
  const W = doc.width || 1200, H = doc.height || 800;

  const rows = (doc.masks || []).map(m => assignments[m.id] ? { mask: m, assign: assignments[m.id] } : null)
    .filter(Boolean)
    .map((row, i) => {
      const a = row.assign;
      const item = a.renderItemId ? studioRenderItem(a.renderItemId) : null;
      return Object.assign(row, {
        n: String(i + 1).padStart(2, '0'),
        code: (a.finishRef && a.finishRef.code) || (item && item.id) || '',
        name: (a.finishRef && a.finishRef.name) || (item && item.name) || a.label || 'Material',
        supplier: (a.finishRef && a.finishRef.supLabel) || (item && item.brand) || '',
        cat: (a.finishRef && a.finishRef.cat) || (item && item.cat) || '',
        centroid: studioCentroid(row.mask.points || []),
      });
    });

  return (
    <div className="space-y-3" data-print-region>
      <div className="flex items-center gap-2 flex-wrap no-print">
        <div className="font-bold">{doc.name} — finish board</div>
        <span className="text-xs text-[var(--leon-black)]/50">{option ? option.label : ''}</span>
        <DocActions className="ml-auto" title={`${doc.name} finish board`} heading="Finish Board"
          lines={[project.name, doc.name, option ? option.label : '']} />
      </div>

      <div className="relative rounded-lg overflow-hidden border border-[var(--leon-line)] bg-white">
        {r.url ? <img src={r.url} alt={doc.name} className="block w-full h-auto" /> : <div style={{ paddingBottom: `${(H / W) * 100}%` }} />}
        <svg viewBox={`0 0 ${W} ${H}`} className="absolute inset-0 w-full h-full">
          {rows.map(row => (
            <g key={row.mask.id}>
              <circle cx={row.centroid.x * W} cy={row.centroid.y * H} r={W * 0.022} fill="#161311" opacity="0.85" />
              <text x={row.centroid.x * W} y={row.centroid.y * H + W * 0.008} textAnchor="middle"
                fontSize={W * 0.024} fill="#fff" fontWeight="700">{row.n}</text>
            </g>
          ))}
        </svg>
      </div>

      {!rows.length ? <EmptyState text="No materials applied on this option yet — there is nothing to call out." /> : (
        <div className="rounded-lg border border-[var(--leon-line)] bg-white overflow-x-auto">
          <table className="w-full text-xs">
            <thead>
              <tr className="text-left text-[10px] uppercase tracking-wide text-[var(--leon-black)]/45 border-b border-[var(--leon-line)]">
                <th className="px-3 py-2 w-10">#</th><th className="px-3 py-2 w-14"></th>
                <th className="px-3 py-2">Surface</th><th className="px-3 py-2">Code</th>
                <th className="px-3 py-2">Finish</th><th className="px-3 py-2">Supplier</th>
                <th className="px-3 py-2">Format</th><th className="px-3 py-2">Source</th>
                <th className="px-3 py-2">Accuracy</th>
              </tr>
            </thead>
            <tbody className="divide-y divide-[var(--leon-line)]">
              {rows.map(row => (
                <tr key={row.mask.id}>
                  <td className="px-3 py-2 font-bold">{row.n}</td>
                  <td className="px-3 py-2">{row.assign.textureUrl && <img src={row.assign.textureUrl} alt="" className="w-9 h-9 object-cover rounded border border-[var(--leon-line)]" />}</td>
                  <td className="px-3 py-2">{row.mask.name}<div className="text-[10px] text-[var(--leon-black)]/45">{row.mask.kind}</div></td>
                  <td className="px-3 py-2">{row.code || '—'}</td>
                  <td className="px-3 py-2 font-semibold">{row.name}</td>
                  <td className="px-3 py-2">{row.supplier || '—'}</td>
                  <td className="px-3 py-2">
                    {row.assign.mode === 'tile' ? `${row.assign.tileW} × ${row.assign.tileH} mm · ${row.assign.pattern}`
                      : row.assign.mode === 'plank' ? `${row.assign.plankW} × ${row.assign.plankL} mm plank`
                      : row.assign.mode === 'slab' ? 'Slab'
                      : 'Flat colour'}
                  </td>
                  <td className="px-3 py-2">{row.assign.source}</td>
                  <td className="px-3 py-2">{row.assign.accuracy}</td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      )}

      <StudioApprovalNotice accuracy={accuracy} />
      <StudioTruthNotice />
    </div>
  );
}

// ═══════════════════════════════════════════════════════════════ materials
// The library this module draws from, with source quality made visible. There
// is no parallel material list here — a material is a supplier catalog record,
// a LEON Render Library item, or a slab we physically hold.
function StudioMaterials({ ctx, project, projects }) {
  const cat = useMemo(() => (typeof supplierCatalog === 'function' ? supplierCatalog() : []), []);
  const items = studioRenderItems();
  const slabs = project ? studioProjectSlabs(ctx, project) : [];
  const usage = {};
  studioAllDocs(projects).forEach(({ doc }) => {
    (doc.options || []).forEach(o => {
      Object.keys(o.assignments || {}).forEach(k => {
        const a = o.assignments[k];
        const key = a.finishRef ? `${a.finishRef.source}:${a.finishRef.id}` : a.renderItemId ? `render:${a.renderItemId}` : `upload:${a.label}`;
        if (!usage[key]) usage[key] = { key, name: (a.finishRef && a.finishRef.name) || a.label, source: a.source, accuracy: a.accuracy, img: a.textureUrl, count: 0 };
        usage[key].count++;
      });
    });
  });
  const used = Object.keys(usage).map(k => usage[k]).sort((a, b) => b.count - a.count);

  return (
    <div className="space-y-4" data-print-region>
      <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
        {[['Supplier finishes available', cat.length], ['LEON Render Library items', items.length],
          ['Slabs on this job', slabs.length], ['Materials used in renders', used.length]].map(([k, v]) => (
          <div key={k} className="rounded-lg border border-[var(--leon-line)] bg-white p-3">
            <div className="text-[10px] uppercase tracking-wide text-[var(--leon-black)]/40">{k}</div>
            <div className="text-2xl font-bold text-[var(--leon-brown)]">{v}</div>
          </div>
        ))}
      </div>

      <Collapsible title="Where a texture can come from, and what it is worth" id="studio-sources" defaultOpen>
        <div className="grid sm:grid-cols-2 gap-2">
          {STUDIO_TEXTURE_SOURCES.map(s => (
            <div key={s} className="rounded-md border border-[var(--leon-line)] bg-white p-2.5">
              <div className="text-xs font-bold">{s}</div>
              <div className="text-[11px] text-[var(--leon-black)]/55 mt-0.5">
                Defaults to <b>{STUDIO_SOURCE_ACCURACY[s]}</b> — {STUDIO_ACCURACY_NOTE[STUDIO_SOURCE_ACCURACY[s]]}
              </div>
            </div>
          ))}
        </div>
        <p className="text-[11px] text-[var(--leon-black)]/55 mt-2 leading-snug">
          Every visualisation carries the weakest accuracy of the materials on it. One placeholder in a set of
          manufacturer textures makes the whole image concept-only, because that is what it is.
        </p>
      </Collapsible>

      <Collapsible title="Materials in use across renders" id="studio-usage" count={used.length}>
        {!used.length ? <EmptyState text="No materials applied in any render yet." /> : (
          <div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-2">
            {used.map(u => (
              <div key={u.key} className="rounded-md border border-[var(--leon-line)] bg-white overflow-hidden">
                {u.img ? <img src={u.img} alt="" className="w-full h-20 object-cover" /> : <div className="w-full h-20 bg-[var(--leon-cream)]" />}
                <div className="p-2">
                  <div className="text-xs font-semibold truncate">{u.name}</div>
                  <div className="text-[10px] text-[var(--leon-black)]/45">{u.source} · {u.accuracy}</div>
                  <div className="text-[10px] text-[var(--leon-black)]/45">used on {u.count} option{u.count === 1 ? '' : 's'}</div>
                </div>
              </div>
            ))}
          </div>
        )}
      </Collapsible>

      <Collapsible title="Countertops — the slab that will actually be cut" id="studio-slabs" count={slabs.length}>
        <p className="text-[11px] text-[var(--leon-black)]/55 mb-2 leading-snug">
          Slabs recorded against this job in LEON Stone. Using the slab photograph rather than a catalogue crop means the
          render and the fabrication show the same veining. LEON Stone computes no nesting layout, so this is the whole
          slab, not the exact cut piece — position it with the slab controls in the editor.
        </p>
        {!slabs.length ? <EmptyState text="No photographed slab is assigned to this project in LEON Stone." /> : (
          <div className="grid sm:grid-cols-3 lg:grid-cols-5 gap-2">
            {slabs.map(s => (
              <div key={s.id} className="rounded-md border border-[var(--leon-line)] bg-white overflow-hidden">
                <img src={s.photoUrl} alt="" className="w-full h-20 object-cover" />
                <div className="p-2">
                  <div className="text-xs font-semibold truncate">{s.material}</div>
                  <div className="text-[10px] text-[var(--leon-black)]/45">{s.slabId} · lot {s.lot || '—'} · {s.status}</div>
                </div>
              </div>
            ))}
          </div>
        )}
      </Collapsible>
    </div>
  );
}

// ═══════════════════════════════════════════════════════════════════ about
function StudioAbout({ ctx, project }) {
  const crs = (project && project.studioChangeRequests) || [];
  return (
    <div className="space-y-4 max-w-4xl" data-print-region>
      <div className="rounded-lg border border-[var(--leon-line)] bg-white p-4 space-y-2 text-sm leading-relaxed">
        <h3 className="font-bold text-base">How the material replacement actually works</h3>
        <p>
          The lighting in a render is already in its pixels. A cabinet door is its material multiplied by its shading, so
          if you keep the shading and change only what is under it, the surface changes material and the room stays lit.
        </p>
        <ol className="list-decimal ml-5 space-y-1 text-[13px]">
          <li>You outline the surface once, and place four corners of the real-world rectangle it occupies.</li>
          <li>The shading is read out of the <b>original</b> pixels as luminance — every shadow, highlight, ambient
            occlusion gradient and reflection the renderer computed.</li>
          <li>The new material is laid out flat at its true size in millimetres, then warped onto those four corners by a
            perspective transform, so a 600 mm tile is 600 mm and gets smaller as it recedes.</li>
          <li>The two are multiplied, with a levels lift so deep shadow darkens the material rather than crushing it.</li>
          <li>The mask edge is feathered, and any foreground you have drawn is punched back out.</li>
        </ol>
        <p className="text-[13px]">
          No model is involved at any point, and none is needed. This is the technique architectural visualisers used for
          twenty years before AI existed.
        </p>
      </div>

      <div className="rounded-lg border border-[var(--leon-line)] bg-white p-4 space-y-2">
        <h3 className="font-bold text-base">What this module does not do, and why</h3>
        <div className="space-y-2 text-[13px]">
          {[
            ['AI selection, inpainting, generation, object removal, relighting, upscaling',
             'All of these need an image model. The Hub is a page in a browser with no backend and no key it could safely hold, so there is no provider and none can be registered from here. The buttons exist, are disabled, and say what they would do — a real provider drops into the same seam later without any of this being rewritten.'],
            ['Automatic depth — knowing the island is in front of the floor',
             'Nothing in the browser can estimate depth from a photograph. So the foreground is drawn once by hand, as an exclusion, and every material ever applied to that surface is clipped against it. Glass and mirrors are protected from every surface automatically, because an opaque texture across a window is never what anyone meant.'],
            ['Geometry changes — "make the island 300 mm longer", "curve the vanity corners"',
             'That changes casework and countertop geometry: dimensions, panel sizes, edge development, the cut list. An image that shows a longer island than the shop drawings is worse than no image. Use Request a design change; it records the ask against the module that owns the geometry.'],
            ['Blend modes beyond multiply and normal, curves, healing and clone',
             'Multiply is what preserves lighting; the rest are retouching tools, and this is not a retoucher. Take a render that needs retouching back to whoever rendered it.'],
            ['Drag-in object libraries, outpainting, upscaling, marketing layout mode',
             'Placing furniture into a photograph convincingly needs a 3D scene, not a 2D paste. Outpainting and upscaling both need a model to invent pixels. A marketing layout tool is a different product.'],
            ['Sending the image anywhere',
             'The Hub queues email, it does not send it. Use the Share button on the project or the report; it says the same thing there.'],
          ].map(([k, v]) => (
            <div key={k}>
              <div className="font-semibold">{k}</div>
              <div className="text-[var(--leon-black)]/60">{v}</div>
            </div>
          ))}
        </div>
      </div>

      <StudioTruthNotice />

      <Collapsible title="Design change requests raised here" id="studio-crs" count={crs.length} defaultOpen>
        {!crs.length ? <EmptyState text="None raised on this project." /> : crs.map(c => (
          <div key={c.id} className="rounded-md border border-[var(--leon-line)] bg-white p-2.5 mb-1.5 text-xs">
            <div className="font-semibold">{c.module} — {c.status}</div>
            <div className="text-[var(--leon-black)]/55">
              {c.docName ? `from "${c.docName}" · ` : ''}{c.raisedBy} · {fmtDate(c.raisedDate)}
              {c.scopeId ? ` · ${studioScopeName(project, c.scopeId)}` : ''}
            </div>
            <div className="mt-0.5">{c.note}</div>
            {c.referenceImage && <img src={c.referenceImage} alt="" className="mt-1.5 w-40 rounded border border-[var(--leon-line)]" />}
          </div>
        ))}
      </Collapsible>

      <div className="rounded-lg border border-[var(--leon-line)] bg-white p-4">
        <h3 className="font-bold text-base mb-2">The AI seam</h3>
        <p className="text-[13px] text-[var(--leon-black)]/65 mb-3">
          Six named operations, no provider registered. This is the shape a real image model would plug into.
        </p>
        <div className="grid sm:grid-cols-2 gap-2">
          {STUDIO_AI_OPERATIONS.map(op => <StudioAiButton key={op.key} op={op} />)}
        </div>
      </div>
    </div>
  );
}

// ═══════════════════════════════════════════════════════════════ the module
function StudioSoftware({ ctx }) {
  const [section, setSection] = useState('documents');
  const [projectId, setProjectId] = useState('');
  const [docId, setDocId] = useState('');
  const [optionId, setOptionId] = useState('');

  // ctx.deptProjects is a FUNCTION — it takes the list and returns the ones in
  // the active department. Treating it as an array blanks the whole app.
  // Real jobs, plus this person's own unassigned workspace. Work often
  // starts before there is a job to attach it to — and sometimes never
  // becomes one — so no tool should demand a project before it will open.
  const allForTools = typeof ctx.toolProjects === 'function' ? ctx.toolProjects() : (ctx.projects || []);
  const projects = ctx.deptProjects(allForTools);
  const scratchIds = new Set((ctx.scratchProjects || []).map(p => p.id));
  const project = projects.find(p => p.id === projectId) || null;
  const editable = ctx.canEdit('softwares');
  const doc = project ? studioDocs(project).find(d => d.id === docId) || null : null;

  // A freshly opened document has no option chosen yet, and an option deleted
  // elsewhere must not leave a dangling id behind.
  useEffect(() => {
    if (!doc) { if (optionId) setOptionId(''); return; }
    const ok = (doc.options || []).some(o => o.id === optionId);
    if (!ok) setOptionId((doc.options || [])[0] ? doc.options[0].id : '');
  }, [doc && doc.id, doc && (doc.options || []).length]);

  const needsProject = ['editor', 'documents', 'options', 'board'].includes(section);
  const needsDoc = ['editor', 'options', 'board'].includes(section);

  function openDoc(id) { setDocId(id); setSection('editor'); }

  return (
    <div className="space-y-4">
      <div className="flex items-start justify-between gap-4 flex-wrap">
        <div>
          <h2 className="text-xl font-bold">🖼️ Image &amp; Render Studio</h2>
          <p className="text-sm text-[var(--leon-black)]/60 max-w-2xl">
            Change the material on a surface in a render and keep the lighting that is already there. The materials are
            the real ones from LEON&rsquo;s catalogs, tiled at their real size — and the original image is never modified.
          </p>
        </div>
        <div className="flex items-end gap-2 flex-wrap">
          <Field label="Project">
            <Select className="!w-56" value={scratchIds.has(projectId) ? '__scratch' : projectId}
              onChange={e => { const v = e.target.value; const id = v === '__scratch' && typeof ctx.myScratchProject === 'function' ? ctx.myScratchProject().id : v; setProjectId(id); setDocId(''); }}>
              <option value="">— select a project —</option>
              {projects.filter(p => !scratchIds.has(p.id)).map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
              <option value="__scratch">— Not linked to a job (my workspace) —</option>
            </Select>
          </Field>
          {project && (
            <Field label="Document">
              <Select className="!w-56" value={docId} onChange={e => setDocId(e.target.value)}>
                <option value="">— select an image —</option>
                {studioDocs(project).map(d => <option key={d.id} value={d.id}>{d.name}</option>)}
              </Select>
            </Field>
          )}
        </div>
      </div>


      <SoftwareRail swKey="studio" sections={STUDIO_SECTIONS} active={section}
        onSelect={setSection}>
      {needsProject && !project ? (
        <div className="rounded-lg border border-dashed border-[var(--leon-line)] p-8 text-center">
          <div className="text-3xl mb-2">🖼️</div>
          <div className="font-semibold mb-1">Pick a project</div>
          <div className="text-sm text-[var(--leon-black)]/55">
            An image document belongs to a job — it links to that job&rsquo;s scopes and selections. Materials and About
            can be read without one.
          </div>
        </div>
      ) : needsDoc && !doc ? (
        <div className="rounded-lg border border-dashed border-[var(--leon-line)] p-8 text-center">
          <div className="text-3xl mb-2">🗂️</div>
          <div className="font-semibold mb-1">Pick an image</div>
          <div className="text-sm text-[var(--leon-black)]/55">
            Choose one above, or create one under <b>Documents</b>.
          </div>
          <Button size="sm" className="mt-3" onClick={() => setSection('documents')}>Go to Documents</Button>
        </div>
      ) : (
        <>
          {section === 'documents' && <StudioDocuments ctx={ctx} project={project} projects={projects} editable={editable} onOpen={openDoc} />}
          {section === 'editor' && <StudioEditor ctx={ctx} project={project} doc={doc} editable={editable} optionId={optionId} setOptionId={setOptionId} />}
          {section === 'options' && <StudioOptions ctx={ctx} project={project} doc={doc} editable={editable} optionId={optionId} setOptionId={id => { setOptionId(id); setSection('editor'); }} />}
          {section === 'board' && <StudioBoard ctx={ctx} project={project} doc={doc} optionId={optionId} />}
          {section === 'materials' && <StudioMaterials ctx={ctx} project={project} projects={projects} />}
          {section === 'about' && <StudioAbout ctx={ctx} project={project} />}
        </>
      )}
      </SoftwareRail>
    </div>
  );
}
