// VELOUR dashboard — Wardrobe pillar = Amber. "Shop your capsule first."
// Step 1 — foundation: Wardrobe Intelligence strip + photo-built closet grid,
// cost-per-wear, Capsule ROI score, and a brand-voice insight.
// Closet state persists to localStorage so it survives reload (testable).
const { Card, Badge, Button, Switch } = window.VelourDesignSystem_380ed4;

const FRESH = !!window.VELOUR_FRESH;
const CLOSET_KEY = FRESH ? "velour_fresh_closet_v1" : "velour_closet_v1";

const SEED_CLOSET = [
  { id: "c1", name: "Black wool blazer", category: "Outerwear", icon: "coat-hanger", price: 240, wears: 53, lastWorn: 2, photo: null },
  { id: "c2", name: "White cotton shirt", category: "Tops", icon: "t-shirt", price: 68, wears: 41, lastWorn: 1, photo: null },
  { id: "c3", name: "Straight-leg denim", category: "Denim", icon: "pants", price: 110, wears: 60, lastWorn: 3, photo: null },
  { id: "c4", name: "Cashmere crewneck", category: "Knits", icon: "coat-hanger", price: 180, wears: 22, lastWorn: 9, photo: null },
  { id: "c5", name: "Beige trench coat", category: "Outerwear", icon: "coat-hanger", price: 240, wears: 1, lastWorn: 94, photo: null },
  { id: "c6", name: "Silk slip dress", category: "Dresses", icon: "dress", price: 195, wears: 4, lastWorn: 121, photo: null },
  { id: "c7", name: "Leather loafers", category: "Shoes", icon: "boot", price: 220, wears: 38, lastWorn: 4, photo: null },
  { id: "c8", name: "Linen overshirt", category: "Tops", icon: "t-shirt", price: 95, wears: 16, lastWorn: 38, photo: null },
  { id: "c9", name: "Leather tote bag", category: "Accessories", icon: "handbag", price: 320, wears: 88, lastWorn: 1, photo: null },
  { id: "c10", name: "White leather sneakers", category: "Shoes", icon: "boot", price: 130, wears: 70, lastWorn: 2, photo: null },
  { id: "c11", name: "Merino turtleneck", category: "Knits", icon: "coat-hanger", price: 120, wears: 31, lastWorn: 6, photo: null },
  { id: "c12", name: "Quilted puffer jacket", category: "Outerwear", icon: "coat-hanger", price: 185, wears: 2, lastWorn: 140, photo: null },
];

// "Trousers & skirts" added 13 Sep 2026. Denim was the only place a bottom could
// go, so tailored trousers and skirts had no honest home and Evening Out could only
// ever be a dress. Additive: existing rows keep their category, nothing migrates.
const CATEGORIES = ["All", "Outerwear", "Tops", "Knits", "Denim", "Trousers & skirts", "Dresses", "Shoes", "Accessories"];
const CATEGORY_ICON = { Outerwear: "coat-hanger", Tops: "t-shirt", Knits: "coat-hanger", Denim: "pants", "Trousers & skirts": "pants", Dresses: "dress", Shoes: "boot", Accessories: "handbag", Unsorted: "coat-hanger" };

// What a category holds, shown under the picker while a piece is being filed. The
// capsule formulas ask for categories by name, so a member who files by instinct
// meets a gap they cannot explain: reported 15 Sep 2026 by a tester whose closet was
// all Tops, who read Smart-Casual's standing request for a knit as a filing mistake
// of hers rather than a piece she genuinely did not own. Knits is the line that
// earns this — every other category reads the way it sounds.
// MUST MATCH `WB_CAT_MEANING` IN src/ui_kits/app/WardrobeScreen.jsx. One filing
// opinion, two surfaces, and a member meets both.
const CATEGORY_MEANING = {
  Outerwear: "Blazers, coats and jackets.",
  Tops: "Shirts, blouses and tees.",
  Knits: "Jumpers and cardigans. A jersey tee belongs in Tops.",
  Denim: "Jeans, and denim skirts.",
  "Trousers & skirts": "Tailored trousers and skirts, anything that isn't denim.",
  Dresses: "Dresses, and jumpsuits.",
  Shoes: "Every pair.",
  Accessories: "Bags, belts, scarves, jewellery.",
  Unsorted: "File it later. Cost-per-wear counts it either way.",
};

// Friendly default names per category (so the user doesn't have to type one).
const NAME_DEFAULT = { Outerwear: "Outerwear", Tops: "Top", Knits: "Knit", Denim: "Denim", "Trousers & skirts": "Trousers", Dresses: "Dress", Shoes: "Shoes", Accessories: "Bag", Unsorted: "New piece" };

// Filename-keyword detection. This used to be the whole story; it is now only the
// fallback for when /api/vision is unreachable — which is what local development
// is, since api/ exists only on the deployed site. It rarely fires on a genuine
// upload (real photos are IMG_4821.jpg), and that is fine: an unmatched filename
// lands on "Unsorted", which is the same honest empty state a failed vision call
// produces.
const DETECT_RULES = [
  { cat: "Accessories", kw: ["purse", "bag", "tote", "clutch", "crossbody", "handbag", "backpack", "satchel", "wallet", "belt", "scarf"] },
  { cat: "Denim", kw: ["jean", "denim"] },
  // After Denim, so a denim skirt stays Denim. Bodysuits are tops, not dresses;
  // jumpsuits and rompers are one-piece, so they sit with dresses.
  { cat: "Trousers & skirts", kw: ["trouser", "pants", "skirt", "chino", "slacks", "culotte", "legging"] },
  { cat: "Dresses", kw: ["dress", "gown", "frock", "jumpsuit", "romper", "playsuit"] },
  { cat: "Shoes", kw: ["shoe", "boot", "sneaker", "trainer", "loafer", "heel", "sandal", "pump"] },
  { cat: "Outerwear", kw: ["coat", "jacket", "blazer", "trench", "parka", "overcoat", "outer"] },
  { cat: "Knits", kw: ["knit", "sweater", "jumper", "cardigan", "cashmere", "wool"] },
  { cat: "Tops", kw: ["top", "shirt", "tee", "t-shirt", "blouse", "tank", "polo", "henley", "bodysuit"] },
];
function detectCategory(filename) {
  const n = (filename || "").toLowerCase();
  for (const r of DETECT_RULES) { if (r.kw.some((k) => n.includes(k))) return { category: r.cat, icon: CATEGORY_ICON[r.cat], detected: true }; }
  return { category: "Unsorted", icon: "coat-hanger", detected: false };
}

// Ask the server what the photo actually shows. Returns null when the endpoint is
// unreachable or refuses, and the caller then keeps whatever the filename guess
// produced rather than overwriting it with nothing.
async function identifyPhoto(dataUrl) {
  try {
    // velourAiFetch attaches the session and refuses early when there is none,
    // so the demo site falls straight through to the filename guess instead of
    // spending a request to be told 401.
    return await window.velourAiFetch("/api/vision", { image: dataUrl });
  } catch (e) {
    return null;
  }
}

// ---- helpers ----------------------------------------------------------------
function cpw(item) { return item.wears > 0 ? item.price / item.wears : null; }
function cpwLabel(item) { const v = cpw(item); return v == null ? "—" : window.velourMoney(v); }

// "Today" honoring the global day offset, plus date-based recency so "last worn"
// and "idle" age correctly as real days pass. lastWornDate (ISO) is the source of
// truth; falls back to the legacy numeric lastWorn for seeded/old items.
function wToday() { const d = new Date(); const off = Number(window.VELOUR_DAY_OFFSET) || 0; if (off) d.setDate(d.getDate() + off); return d; }
function wTodayISO() { return window.velourDateISO(wToday()); }
function daysIdle(item) {
  if (item.lastWornDate) return Math.max(0, Math.round((new Date(wTodayISO() + "T00:00:00") - new Date(item.lastWornDate + "T00:00:00")) / 86400000));
  if (item.wears === 0) return 999;
  return item.lastWorn != null ? item.lastWorn : 999;
}
function wornToday(item) { return item.lastWornDate === wTodayISO(); }
// Record a wear on `day` (ISO). Bumps the count and keeps lastWornDate at the most recent day worn.
function markWorn(it, day) {
  const already = it.lastWornDate === day;
  const newDate = (!it.lastWornDate || day > it.lastWornDate) ? day : it.lastWornDate;
  return { ...it, wears: already ? it.wears : it.wears + 1, lastWornDate: newDate };
}

function isIdle(item) { return item.wears === 0 || daysIdle(item) >= 90; }
function lastWornLabel(item) {
  if (item.wears === 0 && !item.lastWornDate) return "Never worn";
  const d = daysIdle(item);
  if (d === 0) return "Worn today";
  if (d === 1) return "Worn yesterday";
  if (d >= 90) return `${d} days idle`;
  return `${d} days ago`;
}
// Capsule ROI: share of pieces actively earning their keep (worn in last 30d AND healthy cpw)
function roiScore(items) {
  if (!items.length) return 0;
  const active = items.filter((it) => it.wears > 0 && daysIdle(it) < 30 && (cpw(it) ?? 99) < 12).length;
  return Math.round((active / items.length) * 100);
}

// ---- Photo storage: IndexedDB ----------------------------------------------
// localStorage is a shared ~5MB bucket — too small for many photos. So item
// metadata lives in localStorage and the actual photo data URLs live in
// IndexedDB, keyed by item id. This removes the ceiling; all photos persist.
const PHOTO_DB = "velour_photos", PHOTO_STORE = "photos";

// PHOTOS ARE KEYED BY ACCOUNT, and that is a data-safety rule rather than a
// tidiness one. They are the only thing in VELOUR with no server copy: metadata
// syncs, pictures do not, so an image exists in exactly one browser and nowhere
// else. Everything else this app stores locally is a cache that reconcile can
// rebuild; a photograph that is deleted is gone.
//
// The store used to be keyed by bare item id, which meant one shared namespace
// for every account on a device. That forced a bad choice — leave one member's
// pictures visible to the next, or wipe the store at sign-out — and sign-out
// chose to wipe, destroying the owner's only copy to protect against a stranger
// who probably was not there. It cost a real wardrobe twice.
//
// A prefix removes the choice. Another account cannot read these keys, so nothing
// has to be destroyed to keep them apart, and signing back in finds them intact.
// Must stay character-identical to wbPhotoKey() in the mobile app's
// WardrobeScreen.jsx — same origin, same database, same store.
function photoAccount() {
  try {
    const u = window.velourAuth && window.velourAuth.user();
    if (u && u.id) return u.id;
  } catch (e) {}
  // Signed out — the demo and the not-yet-signed-in fresh flow both land here, and
  // they are one bucket on purpose: a device with no account has one anonymous
  // owner, and naming it keeps those photos out of every real account's namespace.
  return "local";
}
function photoKey(id) { return photoAccount() + "::" + id; }

function idbOpen() {
  return new Promise((resolve, reject) => {
    let req; try { req = indexedDB.open(PHOTO_DB, 1); } catch (e) { reject(e); return; }
    req.onupgradeneeded = () => { req.result.createObjectStore(PHOTO_STORE); };
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}
async function idbPutPhoto(id, dataUrl) {
  try { const db = await idbOpen(); await new Promise((res, rej) => { const t = db.transaction(PHOTO_STORE, "readwrite"); t.objectStore(PHOTO_STORE).put(dataUrl, photoKey(id)); t.oncomplete = res; t.onerror = () => rej(t.error); }); } catch (e) {}
  // The device's own copy is written first and unconditionally. Upload is a
  // best-effort second step that does nothing unless the member turned sync on,
  // so a failure here can never cost them the photograph.
  try { if (window.velourPhotoSync) window.velourPhotoSync.upload(id, dataUrl); } catch (e) {}
}
// Returns { itemId: dataUrl } for THIS account only, prefix stripped so callers
// still speak in item ids.
//
// Un-prefixed keys are pictures written before this change, when the store had one
// namespace. They belong to somebody, and the only evidence of who is whether this
// account's closet contains that item id — ids are "c" plus six random characters,
// so a match is the owner's. Matching ones are adopted (re-keyed, original removed);
// the rest are left untouched rather than guessed at. Adoption is what stops the
// change from looking like a second deletion to anyone who had photos already.
async function idbAllPhotos(knownIds) {
  const mine = photoAccount() + "::";
  const known = knownIds instanceof Set ? knownIds : new Set(knownIds || []);
  try {
    const db = await idbOpen();
    const { out, adopt } = await new Promise((res) => {
      const out = {}, adopt = [];
      const t = db.transaction(PHOTO_STORE, "readonly");
      const cur = t.objectStore(PHOTO_STORE).openCursor();
      cur.onsuccess = (e) => {
        const c = e.target.result;
        if (!c) return res({ out, adopt });
        const k = String(c.key);
        if (k.indexOf(mine) === 0) out[k.slice(mine.length)] = c.value;
        else if (k.indexOf("::") === -1 && known.has(k)) { out[k] = c.value; adopt.push([k, c.value]); }
        c.continue();
      };
      cur.onerror = () => res({ out, adopt });
    });
    if (adopt.length) {
      await new Promise((res) => {
        const t = db.transaction(PHOTO_STORE, "readwrite");
        const st = t.objectStore(PHOTO_STORE);
        adopt.forEach(([k, v]) => { st.put(v, mine + k); st.delete(k); });
        t.oncomplete = res; t.onerror = res;
      });
    }
    return out;
  } catch (e) { return {}; }
}
// Deletes both shapes: the member may be removing a piece whose photo is still
// sitting under a pre-prefix key that this account has adopted or not yet seen.
async function idbDelPhoto(id) {
  // Removing a piece removes its server copy too, when there is one.
  try { if (window.velourPhotoSync) window.velourPhotoSync.remove(id); } catch (e) {}
  try { const db = await idbOpen(); await new Promise((res) => { const t = db.transaction(PHOTO_STORE, "readwrite"); const st = t.objectStore(PHOTO_STORE); st.delete(photoKey(id)); st.delete(id); t.oncomplete = res; t.onerror = res; }); } catch (e) {}
}
// Move any base64 photos on these items into IDB (keyed by id).
function persistPhotos(items) { (items || []).forEach((it) => { if (it && it.id && typeof it.photo === "string" && it.photo.indexOf("data:") === 0) idbPutPhoto(it.id, it.photo); }); }

function loadCloset() {
  try { const raw = localStorage.getItem(CLOSET_KEY); if (raw) return JSON.parse(raw); } catch (e) {}
  return FRESH ? [] : SEED_CLOSET;
}
// Save metadata only — photos are stripped out and kept in IDB, so localStorage stays tiny.
function saveCloset(items) {
  try { localStorage.setItem(CLOSET_KEY, JSON.stringify((items || []).map((it) => (it && it.photo ? { ...it, photo: null } : it)))); return true; } catch (e) { return false; }
}
// Demo/dev helper: read the FREE account's closet so the paid account can import it
// in one click (same browser only). Remove or hide before public launch.
function loadFreshCloset() {
  try { const raw = localStorage.getItem("velour_fresh_closet_v1"); if (raw) return JSON.parse(raw) || []; } catch (e) {}
  return [];
}

// Shrink an uploaded photo to a small JPEG data URL so the closet fits in
// localStorage (~5MB cap; full-res camera photos are several MB each). Returns a
// compressed JPEG capped at ~500KB, or null if the browser can't decode the image
// (e.g. iPhone HEIC in Chrome) — we never store the giant original, which both
// blew the quota and rendered as a broken thumbnail.
const PHOTO_MAX_BYTES = 500000; // ~0.5MB encoded — fits hundreds of pieces under the cap
function compressImage(file, maxDim, quality) {
  return new Promise((resolve) => {
    const reader = new FileReader();
    reader.onload = () => {
      const img = new Image();
      img.onload = () => {
        try {
          let scale = Math.min(1, maxDim / Math.max(img.width, img.height));
          let q = quality, out = null;
          for (let i = 0; i < 5; i++) {
            const w = Math.max(1, Math.round(img.width * scale)), h = Math.max(1, Math.round(img.height * scale));
            const canvas = document.createElement("canvas");
            canvas.width = w; canvas.height = h;
            canvas.getContext("2d").drawImage(img, 0, 0, w, h);
            out = canvas.toDataURL("image/jpeg", q);
            if (out.length <= PHOTO_MAX_BYTES) break; // small enough
            scale *= 0.8; q = Math.max(0.5, q - 0.08); // shrink & re-encode
          }
          resolve(out && out.indexOf("data:image/jpeg") === 0 ? out : null);
        } catch (e) { resolve(null); }
      };
      img.onerror = () => resolve(null); // undecodable format (e.g. HEIC) — skip the photo, don't store a huge blob
      img.src = reader.result;
    };
    reader.onerror = () => resolve(null);
    reader.readAsDataURL(file);
  });
}

// ---- intelligence strip -----------------------------------------------------
function StatTile({ label, value, sub, tint, children }) {
  return (
    <Card padding={20} style={{ display: "flex", flexDirection: "column", minHeight: 132 }}>
      <div style={{ fontSize: 12, color: "var(--text-tertiary)", marginBottom: "auto" }}>{label}</div>
      {children || (
        <>
          <div style={{ fontFamily: "var(--font-serif)", fontSize: 30, color: tint || "var(--text-primary)", letterSpacing: "-0.01em", marginTop: 14, whiteSpace: "nowrap" }}>{value}</div>
          <div style={{ fontSize: 12.5, color: "var(--text-tertiary)", marginTop: 5, lineHeight: 1.4 }}>{sub}</div>
        </>
      )}
    </Card>
  );
}

function RoiTile({ score }) {
  const R = 26, C = 2 * Math.PI * R, off = C * (1 - score / 100);
  const band = score >= 70 ? "Working hard" : score >= 45 ? "Room to earn more" : "Underused";
  return (
    <Card padding={20} style={{ display: "flex", alignItems: "center", gap: 16, minHeight: 132 }}>
      <div style={{ position: "relative", width: 64, height: 64, flexShrink: 0 }}>
        <svg width="64" height="64" viewBox="0 0 64 64" style={{ transform: "rotate(-90deg)" }}>
          <circle cx="32" cy="32" r={R} fill="none" stroke="var(--border)" strokeWidth="6" />
          <circle cx="32" cy="32" r={R} fill="none" stroke="var(--amber)" strokeWidth="6" strokeLinecap="round" strokeDasharray={C} strokeDashoffset={off} />
        </svg>
        <div style={{ position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "var(--font-serif)", fontSize: 20, color: "var(--text-primary)" }}>{score}</div>
      </div>
      <div>
        <div style={{ fontSize: 12, color: "var(--text-tertiary)" }}>Capsule ROI score</div>
        <div style={{ fontFamily: "var(--font-serif)", fontSize: 19, color: "var(--amber-text)", letterSpacing: "-0.01em", marginTop: 3 }}>{band}</div>
        <div style={{ fontSize: 12, color: "var(--text-tertiary)", marginTop: 4 }}>Know what to keep</div>
      </div>
    </Card>
  );
}

// ---- closet item card -------------------------------------------------------
function ItemThumb({ item, size = "100%" }) {
  return (
    <div style={{ width: size, aspectRatio: "1 / 1", borderRadius: 12, overflow: "hidden", background: isIdle(item) ? "var(--surface-sunken)" : "var(--amber-fill)", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
      {item.photo
        ? <img src={item.photo} alt={item.name} style={{ width: "100%", height: "100%", objectFit: "cover" }} />
        : <i className={`ph ph-${item.icon || "coat-hanger"}`} style={{ fontSize: 38, color: isIdle(item) ? "var(--text-tertiary)" : "var(--amber-text)" }}></i>}
    </div>
  );
}

function ClosetCard({ item, onWear, onRemove, onEdit }) {
  const idle = isIdle(item);
  const dateRef = React.useRef(null);
  const wt = wornToday(item);
  return (
    <div className="vr-item" style={{ position: "relative", background: "var(--surface-card)", border: "var(--border-width) solid var(--border)", borderRadius: 16, padding: 12, boxShadow: "var(--shadow-sm)" }}>
      <div style={{ position: "relative" }}>
        <ItemThumb item={item} />
        {idle && <span style={{ position: "absolute", top: 8, left: 8, fontSize: 10.5, fontWeight: 500, letterSpacing: "0.04em", textTransform: "uppercase", color: "var(--amber-text)", background: "rgba(254,243,224,0.94)", borderRadius: 999, padding: "3px 9px" }}>Idle</span>}
        <div className="vr-item-actions" style={{ position: "absolute", top: 8, right: 8, display: "flex", gap: 6 }}>
          <button onClick={() => onEdit(item)} aria-label="Edit item" style={{ width: 28, height: 28, borderRadius: "50%", border: "none", background: "rgba(255,255,255,0.92)", color: "var(--text-secondary)", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", boxShadow: "var(--shadow-sm)" }}><i className="ph ph-pencil-simple" style={{ fontSize: 13 }}></i></button>
          <button onClick={() => onRemove(item.id)} aria-label="Remove item" style={{ width: 28, height: 28, borderRadius: "50%", border: "none", background: "rgba(255,255,255,0.92)", color: "var(--text-secondary)", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", boxShadow: "var(--shadow-sm)" }}><i className="ph ph-x" style={{ fontSize: 13 }}></i></button>
        </div>
      </div>
      <div style={{ padding: "12px 4px 4px" }}>
        <div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", gap: 8 }}>
          <span style={{ fontSize: 14, color: "var(--text-primary)", fontWeight: 500, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{item.name}</span>
          <span style={{ fontFamily: "var(--font-serif)", fontSize: 16, color: idle ? "var(--text-tertiary)" : "var(--amber-text)", whiteSpace: "nowrap" }}>{cpwLabel(item)}</span>
        </div>
        <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8, marginTop: 4 }}>
          <span style={{ fontSize: 12, color: "var(--text-tertiary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", minWidth: 0 }}>{item.category} · {item.wears} {item.wears === 1 ? "wear" : "wears"}</span>
          <span style={{ fontSize: 11.5, color: idle ? "var(--amber-text)" : "var(--text-tertiary)", whiteSpace: "nowrap", flexShrink: 0 }}>{lastWornLabel(item)}</span>
        </div>
        <div style={{ display: "flex", gap: 8, marginTop: 11 }}>
          <button onClick={() => onWear(item.id)} style={{ flex: 1, display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 7, background: wt ? "var(--amber-fill)" : "var(--surface-sunken)", color: wt ? "var(--amber-text)" : "var(--text-secondary)", border: "none", borderRadius: 10, padding: "9px", fontFamily: "var(--font-sans)", fontSize: 13, fontWeight: 500, cursor: "pointer" }}>
            <i className={`ph ph-${wt ? "check" : "plus"}`} style={{ fontSize: 14 }}></i>{wt ? "Worn today" : "Wore it today"}
          </button>
          <button onClick={() => { const el = dateRef.current; if (el) { el.showPicker ? el.showPicker() : el.click(); } }} title="Log a different day" style={{ width: 38, flexShrink: 0, display: "inline-flex", alignItems: "center", justifyContent: "center", background: "var(--surface-card)", color: "var(--text-secondary)", border: "var(--border-width) solid var(--border)", borderRadius: 10, cursor: "pointer" }}>
            <i className="ph ph-calendar-plus" style={{ fontSize: 15 }}></i>
          </button>
          <input ref={dateRef} type="date" max={wTodayISO()} onChange={(e) => { if (e.target.value) { onWear(item.id, e.target.value); e.target.value = ""; } }} style={{ position: "absolute", width: 1, height: 1, opacity: 0, pointerEvents: "none" }} />
        </div>
      </div>
    </div>
  );
}

// ---- add-from-photo tile ----------------------------------------------------
// TWO DOORS, NOT ONE. Until 2026-09-02 the only way to add a piece was the camera: the
// tile opened a file picker with capture="environment" and there was no other affordance.
// That blocks anyone cataloguing a closet from a desk, and it is the first thing a new
// member meets — an empty wardrobe whose only instruction is "go and photograph forty
// things". The photograph is still the better path and stays the primary one; this only
// stops it being the only one.
//
// ItemEditor already handled a photoless draft — it checks `draft.photo` before waiting on
// /api/vision and opens straight into its normal state without one. The capability was
// there the whole time and nothing could reach it.
function AddPhotoTile({ onPick, onBlank }) {
  const inputRef = React.useRef(null);
  return (
    <div style={{ border: "1.5px dashed var(--border-strong)", borderRadius: 16, padding: 12, display: "flex", flexDirection: "column" }}>
      <button onClick={() => inputRef.current && inputRef.current.click()} style={{ flex: 1, width: "100%", aspectRatio: "1 / 1", borderRadius: 12, border: "none", background: "var(--amber-fill)", color: "var(--amber-text)", cursor: "pointer", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 10, fontFamily: "var(--font-sans)" }}>
        <i className="ph ph-camera" style={{ fontSize: 32 }}></i>
        <span style={{ fontSize: 13, fontWeight: 500 }}>Add from photo</span>
      </button>
      <div style={{ padding: "12px 4px 4px", textAlign: "center" }}>
        <div style={{ fontSize: 13, color: "var(--text-primary)", fontWeight: 500 }}>Snap your closet</div>
        <div style={{ fontSize: 12, color: "var(--text-tertiary)", marginTop: 3, lineHeight: 1.45 }}>One piece per photo, laid flat. Any phone is enough.</div>
      </div>
      <button onClick={onBlank} style={{ margin: "2px 4px 4px", padding: "7px 4px", background: "none", border: "none", borderTop: "var(--border-width) solid var(--divider)", color: "var(--text-secondary)", fontFamily: "var(--font-sans)", fontSize: 12.5, cursor: "pointer", textAlign: "center" }}>
        Add without a photo
      </button>
      <input ref={inputRef} type="file" accept="image/*" capture="environment" style={{ display: "none" }} onChange={(e) => { const f = e.target.files && e.target.files[0]; if (f) { compressImage(f, 640, 0.72).then((url) => onPick(url, f.name)); } e.target.value = ""; }} />
    </div>
  );
}

// ---- item editor (new + edit) ----------------------------------------------
function ItemEditor({ draft, onSave, onCancel }) {
  const [d, setD] = React.useState(draft);
  // "Reading your photo…" is now a real wait on /api/vision rather than a timer.
  // No photo means nothing to read — an undecodable format resolves to null upstream —
  // so the editor opens straight into its normal state instead of feigning a pause.
  const [reading, setReading] = React.useState(!!draft._isNew && !!draft.photo);
  React.useEffect(() => {
    if (!draft._isNew || !draft.photo) return;
    let cancelled = false;
    identifyPhoto(draft.photo).then((seen) => {
      if (cancelled) return; // editor closed mid-flight
      // Only override the filename guess when the model is actually confident.
      // An unsure answer leaves the draft alone rather than replacing a guess with
      // a different guess.
      if (seen && seen.detected) {
        setD((p) => ({
          ...p,
          category: seen.category,
          icon: CATEGORY_ICON[seen.category] || "coat-hanger",
          name: p.name || seen.name || "",
          _detected: true,
        }));
      }
      setReading(false);
    });
    return () => { cancelled = true; };
  }, []);
  const set = (k, v) => setD((p) => ({ ...p, [k]: v }));
  const setCat = (c) => setD((p) => ({ ...p, category: c, icon: CATEGORY_ICON[c] || "coat-hanger" }));
  const cats = CATEGORIES.filter((c) => c !== "All").concat("Unsorted");
  const field = { width: "100%", padding: "10px 12px", borderRadius: 10, border: "var(--border-width) solid var(--border)", background: "var(--surface-card)", fontFamily: "var(--font-sans)", fontSize: 14, color: "var(--text-primary)", outline: "none", boxSizing: "border-box" };
  const lbl = { fontSize: 11.5, letterSpacing: "0.04em", color: "var(--text-tertiary)", marginBottom: 6, display: "block" };

  const save = () => onSave({ ...d, name: (d.name || "").trim() || NAME_DEFAULT[d.category] || "New piece", icon: d.icon || CATEGORY_ICON[d.category] || "coat-hanger" });

  let badgeText = d._isNew ? (reading ? "Reading your photo…" : (d._detected ? "Detected" : "New piece")) : "Edit piece";
  // THREE STATES, NOT TWO. "We couldn't tell from this one" is an apology for a
  // photograph that did not read — and it was being shown to members who never
  // offered one, because "Add without a photo" lands here with _detected false
  // and photo null. Apologising for a picture nobody took, then advising them to
  // lay it flat, reads as a bug in the very door that exists to skip the camera.
  let helper = d._isNew
    ? (reading
        ? "One moment — figuring out what this is."
        : (d._detected
            ? `Looks like ${d.category.toLowerCase()}. Tap to change it if we got it wrong, then add a price.`
            : (d.photo
                ? "We couldn't tell from this one. Flat, on a plain surface, usually reads better — or pick a category below."
                : "Pick a category and add a price. You can photograph it later, and cost-per-wear starts either way.")))
    : "A few details let VELOUR track cost-per-wear and style you from it.";

  return (
    <div style={{ position: "fixed", inset: 0, background: "rgba(28,28,26,0.42)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 50, padding: 24 }} onClick={onCancel}>
      <div onClick={(e) => e.stopPropagation()} style={{ width: 420, maxWidth: "100%", background: "var(--surface-card)", borderRadius: 20, padding: 26, boxShadow: "var(--shadow-xl)" }}>
        <div style={{ display: "flex", gap: 16, marginBottom: 20 }}>
          <ItemThumb item={d} size={88} />
          <div style={{ flex: 1 }}>
            <span style={{ display: "inline-flex", alignItems: "center", gap: 7, fontSize: 11, letterSpacing: "0.14em", textTransform: "uppercase", color: "var(--amber-text)", background: "var(--amber-fill)", borderRadius: 999, padding: "5px 12px", fontWeight: 500 }}>
              <i className={`ph ph-${reading ? "sparkle" : (d._isNew && d._detected ? "magic-wand" : "coat-hanger")}`} style={{ fontSize: 13 }}></i>{badgeText}
            </span>
            <div style={{ fontSize: 12.5, color: "var(--text-tertiary)", marginTop: 10, lineHeight: 1.5 }}>{helper}</div>
          </div>
        </div>

        {reading ? (
          <div style={{ display: "flex", flexDirection: "column", gap: 10, padding: "8px 0 18px" }}>
            {[80, 60].map((w, i) => <div key={i} style={{ height: 12, width: `${w}%`, borderRadius: 6, background: "var(--surface-sunken)" }} />)}
          </div>
        ) : (
          <React.Fragment>
            <label style={lbl}>{d._isNew && d._detected ? "Category · detected" : "Category"}</label>
            <div style={{ display: "flex", flexWrap: "wrap", gap: 7, marginBottom: 10 }}>
              {cats.map((c) => {
                const on = d.category === c;
                return (
                  <button key={c} onClick={() => setCat(c)} style={{ display: "inline-flex", alignItems: "center", gap: 6, padding: "7px 13px", borderRadius: 999, cursor: "pointer", fontFamily: "var(--font-sans)", fontSize: 13, fontWeight: 500, border: "var(--border-width) solid " + (on ? "var(--amber)" : "var(--border)"), background: on ? "var(--amber-fill)" : "var(--surface-card)", color: on ? "var(--amber-text)" : "var(--text-secondary)" }}>
                    {on && d._isNew && d._detected && <i className="ph ph-check" style={{ fontSize: 12 }}></i>}{c}
                  </button>
                );
              })}
            </div>
            {CATEGORY_MEANING[d.category] && (
              <div style={{ fontSize: 12, lineHeight: 1.45, color: "var(--text-tertiary)", marginBottom: 20 }}>{CATEGORY_MEANING[d.category]}</div>
            )}

            <label style={lbl}>Estimated price</label>
            <div style={{ position: "relative", marginBottom: 18 }}>
              <span style={{ position: "absolute", left: 14, top: "50%", transform: "translateY(-50%)", color: "var(--text-tertiary)", fontSize: 18 }}>$</span>
              <input autoFocus={d._isNew} type="number" min="0" value={d.price || ""} placeholder="0" onChange={(e) => set("price", Number(e.target.value))} onKeyDown={(e) => { if (e.key === "Enter") save(); }} style={{ ...field, paddingLeft: 28, fontSize: 22, fontFamily: "var(--font-serif)", padding: "12px 12px 12px 28px" }} />
            </div>

            <label style={lbl}>Name (optional)</label>
            <input value={d.name} onChange={(e) => set("name", e.target.value)} placeholder={NAME_DEFAULT[d.category] || "e.g. Black wool blazer"} style={{ ...field, marginBottom: 22 }} />

            <div style={{ display: "flex", gap: 10, justifyContent: "flex-end" }}>
              <button onClick={onCancel} style={{ padding: "11px 18px", borderRadius: 999, border: "var(--border-width) solid var(--border)", background: "var(--surface-card)", color: "var(--text-secondary)", fontFamily: "var(--font-sans)", fontSize: 14, fontWeight: 500, cursor: "pointer" }}>Cancel</button>
              {/* Labels must match WardrobeScreen.jsx's editor — see the note there. */}
              <button onClick={save} style={{ padding: "11px 20px", borderRadius: 999, border: "none", background: "var(--ink)", color: "var(--parchment)", fontFamily: "var(--font-sans)", fontSize: 14, fontWeight: 500, cursor: "pointer" }}>{d._isNew ? "Add to closet" : "Save"}</button>
            </div>
          </React.Fragment>
        )}
      </div>
    </div>
  );
}

// ---- insight ----------------------------------------------------------------
function WardrobeInsight({ items }) {
  const idleExpensive = items.filter(isIdle).sort((a, b) => b.price - a.price)[0];
  const best = items.filter((it) => it.wears > 0).sort((a, b) => cpw(a) - cpw(b))[0];
  if (!items.length) {
    return (
      <Card variant="featured" pillar="wardrobe" padding={26}>
        <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 13 }}>
          <i className="ph ph-camera" style={{ fontSize: 18, color: "var(--amber)" }}></i>
          <span style={{ fontSize: 11, letterSpacing: "0.14em", textTransform: "uppercase", color: "var(--amber-text)", fontWeight: 500 }}>Start your closet</span>
        </div>
        <div style={{ fontFamily: "var(--font-serif)", fontSize: 22, lineHeight: 1.32, color: "var(--text-primary)", letterSpacing: "-0.01em" }}>Photograph a few pieces to begin.</div>
        <p style={{ fontSize: 14.5, color: "var(--text-secondary)", marginTop: 12, lineHeight: 1.6 }}>As you add what you own, VELOUR tracks cost-per-wear, flags pieces going idle, and unlocks outfit formulas, your stylist, and budget planning.</p>
        {/* Three rules, in the order the evidence says they matter — see docs/WARDROBE_PHOTO_STANDARD.md */}
        <div style={{ marginTop: 18, paddingTop: 16, borderTop: "var(--border-width) solid var(--divider)" }}>
          <div style={{ fontSize: 11, letterSpacing: "0.14em", textTransform: "uppercase", color: "var(--text-tertiary)", fontWeight: 500, marginBottom: 10 }}>Photographing a piece</div>
          <ul style={{ margin: 0, paddingLeft: 18, display: "flex", flexDirection: "column", gap: 6 }}>
            <li style={{ fontSize: 14, color: "var(--text-secondary)", lineHeight: 1.55 }}>Lay it flat and the right way up, on a plain surface.</li>
            <li style={{ fontSize: 14, color: "var(--text-secondary)", lineHeight: 1.55 }}>One piece per photo — glance at the edges of the frame.</li>
            <li style={{ fontSize: 14, color: "var(--text-secondary)", lineHeight: 1.55 }}>Any phone camera is enough. VELOUR resizes everything itself.</li>
            {/* The browser has to decode the file before compressImage() can resize it,
                and Chrome, Firefox and Android cannot decode HEIC — the piece still
                saves, the photo is what goes missing. Said here rather than only in the
                toast afterwards. */}
            <li style={{ fontSize: 14, color: "var(--text-secondary)", lineHeight: 1.55 }}>JPG and PNG always work. An iPhone HEIC converts itself when you upload from the iPhone, but on a computer or an Android phone it may not read — export it as JPG first.</li>
          </ul>
          <p style={{ fontSize: 14, color: "var(--text-secondary)", marginTop: 12, lineHeight: 1.6 }}>Thirty to forty pieces is a capsule. Start with what you actually reach for.</p>
        </div>
      </Card>
    );
  }
  return (
    <window.VelourInsight pillar="wardrobe" cite="wear-nine-months"
      headline={idleExpensive
        ? `Your ${idleExpensive.name.toLowerCase()} hasn't been worn in ${daysIdle(idleExpensive)} days. At ${window.velourMoney(idleExpensive.price)} paid, it's sitting at ${window.velourMoney(cpw(idleExpensive) == null ? idleExpensive.price : cpw(idleExpensive))} cost-per-wear.`
        : "Everything in your capsule is earning its place right now."}
      support={best ? `Your hardest-working piece is the ${best.name.toLowerCase()} — ${best.wears} wears, just ${cpwLabel(best)} each. That's the shape of a smart buy.` : null} />
  );
}

// ---- page -------------------------------------------------------------------
// ---------------------------------------------------------------------------
// The photo-sync ask.
//
// Shown once per account per device, to a signed-in member who has photographs
// and has not already decided. Both answers are recorded — saying no is a
// decision, not a postponement, and asking again would make it one.
//
// THE WORDING IS LOAD-BEARING. It claims only what is true: photos are not
// STORED on the server unless this is on. It deliberately does not say they never
// leave the device, because api/vision.js sends a newly added photo to identify
// the garment. Storage and transmission are two different promises and the copy
// keeps them apart. See docs/PHOTO_STORAGE.md.
// ---------------------------------------------------------------------------
// The standing photo-sync control, web side. Parity with PhotoSyncRow() in the
// mobile ProfileScreen: the ask is asked once, this is where it can be changed
// and where it is disclosed to anyone who never saw the ask.
function PhotoSyncSetting() {
  const ps = window.velourPhotoSync;
  const [on, setOn] = React.useState(() => !!(ps && ps.isEnabled()));
  const [busy, setBusy] = React.useState(false);
  const [note, setNote] = React.useState(null);
  // The switch does not move until the member has read what it does. `pending`
  // is the value they asked for and have not yet confirmed.
  const [pending, setPending] = React.useState(null);
  const [count, setCount] = React.useState(null);

  React.useEffect(() => {
    if (!ps) return;
    let alive = true;
    (ps.known() ? Promise.resolve(ps.isEnabled()) : ps.refresh()).then((v) => { if (alive) setOn(!!v); });
    return () => { alive = false; };
  }, []);

  if (!ps || !window.velourAuth || !window.velourAuth.session()) return null;

  const ask = (next) => {
    setNote(null);
    setPending(next);
    setCount(null);
    // Quote a number rather than "your photographs" — a member can weigh a count.
    ps.countLocal().then((n) => setCount(n)).catch(() => setCount(null));
  };

  const confirm = async () => {
    const next = pending;
    setBusy(true); setNote(null);
    try {
      const r = await ps.setEnabled(next);
      setOn(r.enabled);
      setPending(null);
      if (!r.enabled) {
        setNote(typeof r.removed === "number"
          ? (r.removed === 1 ? "One photograph removed from our servers." : `${r.removed} photographs removed from our servers.`)
          : "Photo sync is off.");
      } else {
        setNote("Photographs on this device are being copied to your account.");
      }
    } catch (e) {
      setNote((e && e.message) || "That setting could not be saved just now.");
    } finally { setBusy(false); }
  };

  const n = typeof count === "number" ? count : null;
  const photos = n === null ? "your closet photographs" : (n === 1 ? "1 photograph" : `${n} photographs`);

  return (
    <Card padding={20}>
      <Switch checked={pending === null ? on : pending} onChange={ask} pillar="wardrobe"
        label="Keep closet photographs with my account" disabled={busy || pending !== null} />

      {pending === null ? (
        <div style={{ fontSize: 13.5, color: "var(--text-tertiary)", lineHeight: 1.6, marginTop: 10, maxWidth: 640 }}>
          {on
            ? "Your photographs are stored with your account, so they reach every device you sign in on. Turn this off and every copy we hold is deleted."
            : "Your closet photographs are never stored on our servers unless you turn this on. They live on this device alone \u2014 which means clearing it loses them."}
        </div>
      ) : (
        <div style={{ marginTop: 14, padding: 16, borderRadius: 10, background: "var(--amber-fill)", border: "var(--border-width) solid var(--amber)", maxWidth: 660 }}>
          <div style={{ fontSize: 14.5, lineHeight: 1.65, color: "var(--text-primary)" }}>
            {pending
              ? `Turning this on uploads ${photos} from this device to your account. After that, a piece you photograph anywhere reaches every device you sign in on.`
              : `Turning this off deletes every copy we hold. The ${photos} on this device stay exactly where they are \u2014 we only remove ours.`}
          </div>
          <div style={{ fontSize: 13.5, lineHeight: 1.6, color: "var(--text-secondary)", marginTop: 8 }}>
            {pending
              ? "You can turn it off again whenever you like, and everything we hold is deleted."
              : "If you turn it back on later, those photographs are uploaded again \u2014 so switching back and forth re-uploads them each time."}
          </div>
          <div style={{ display: "flex", gap: 10, marginTop: 14, flexWrap: "wrap" }}>
            <Button pillar="wardrobe" onClick={confirm} disabled={busy}>
              {busy ? "Saving\u2026" : (pending ? "Upload and keep them with my account" : "Delete the copies you hold")}
            </Button>
            <Button variant="secondary" pillar="wardrobe" onClick={() => setPending(null)} disabled={busy}>
              Leave it as it is
            </Button>
          </div>
        </div>
      )}
      {note && <div style={{ fontSize: 13.5, color: "var(--text-secondary)", marginTop: 10 }}>{note}</div>}
    </Card>
  );
}

function PhotoSyncAsk({ onDecided }) {
  const [busy, setBusy] = React.useState(false);
  const [error, setError] = React.useState(null);
  const ps = window.velourPhotoSync;

  const decide = async (yes) => {
    if (!ps || busy) return;
    setBusy(true); setError(null);
    try {
      if (yes) await ps.setEnabled(true);
      else ps.markAsked();
      onDecided(yes);
    } catch (e) {
      setError((e && e.message) || "That could not be saved just now.");
    } finally { setBusy(false); }
  };

  return (
    <Card padding={22} style={{ borderColor: "var(--amber)", background: "var(--amber-fill)" }}>
      <div style={{ display: "flex", alignItems: "flex-start", gap: 14, flexWrap: "wrap" }}>
        <i className="ph ph-images" style={{ fontSize: 20, color: "var(--amber-text)", marginTop: 2 }}></i>
        <div style={{ flex: 1, minWidth: 260 }}>
          <div style={{ fontFamily: "var(--font-serif)", fontSize: 20, color: "var(--text-primary)", marginBottom: 8 }}>
            Should your closet photographs travel with you?
          </div>
          <p style={{ fontSize: 14.5, lineHeight: 1.65, color: "var(--text-secondary)", margin: "0 0 6px" }}>
            Your closet photographs are never stored on our servers unless you turn this on.
            They live on this device alone — which also means that if it is cleared or lost,
            they are gone, and they are the only part of VELOUR we cannot bring back.
          </p>
          <p style={{ fontSize: 14.5, lineHeight: 1.65, color: "var(--text-secondary)", margin: "0 0 16px" }}>
            Turn it on and your photographs are kept with your account, so the piece you
            photograph on your phone is there in the browser too. Turn it off later and every
            copy we hold is deleted.
          </p>
          {error && <div style={{ fontSize: 13.5, color: "var(--terracotta)", marginBottom: 12 }}>{error}</div>}
          <div style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>
            <Button pillar="wardrobe" onClick={() => decide(true)} disabled={busy}>
              {busy ? "Saving\u2026" : "Keep them with my account"}
            </Button>
            <Button variant="secondary" pillar="wardrobe" onClick={() => decide(false)} disabled={busy}>
              Keep them on this device
            </Button>
          </div>
        </div>
      </div>
    </Card>
  );
}

function DashWardrobe({ onNavigate }) {
  const [items, setItems] = React.useState(loadCloset);
  const [filter, setFilter] = React.useState("All");
  const [editing, setEditing] = React.useState(null); // draft item or null
  const [toast, setToast] = React.useState(null); // { kind: "ok" | "nophoto" | "full" | "imported" | "nofree", n? }
  const [freshCount, setFreshCount] = React.useState(() => (FRESH ? 0 : loadFreshCloset().length));
  // Whether to put the photo-sync question in front of this member right now.
  const [askPhotos, setAskPhotos] = React.useState(false);
  React.useEffect(() => {
    const ps = window.velourPhotoSync;
    if (!ps) return;
    let alive = true;
    // The account's setting is fetched on sign-in; wait for it rather than
    // guessing, so the question never appears to someone who already said yes.
    const check = () => { if (alive) setAskPhotos(ps.shouldAsk()); };
    (ps.known() ? Promise.resolve() : ps.refresh()).then(check);
    // A photo arriving from another device means the render path should re-read
    // IndexedDB — otherwise the piece shows a placeholder until the next reload.
    const onHydrated = () => { if (alive) setItems((prev) => prev.slice()); };
    window.addEventListener("velour:photos-hydrated", onHydrated);
    return () => { alive = false; window.removeEventListener("velour:photos-hydrated", onHydrated); };
  }, []);
  React.useEffect(() => { saveCloset(items); }, [items]);
  // On mount: move any base64 photos still in localStorage into IDB, then hydrate
  // every item's photo from IDB (photos live there now, not in localStorage).
  React.useEffect(() => {
    let alive = true;
    (async () => {
      persistPhotos(items);
      // The ids are what lets a pre-prefix photo be recognised as this account's.
      const map = await idbAllPhotos(items.map((it) => it.id));
      if (!alive) return;
      setItems((cur) => cur.map((it) => (map[it.id] ? { ...it, photo: map[it.id] } : it)));
    })();
    return () => { alive = false; };
  }, []);
  // The account's closet lands after first paint on a second device. Re-attach
  // photos from IDB as well as re-reading: the server payload carries photo:null
  // for every piece by design, so applying it verbatim would blank the pictures
  // on the very device that holds them until the next reload.
  if (window.useVelourSynced) window.useVelourSynced("wardrobe", () => {
    const next = loadCloset();
    setItems(next);
    idbAllPhotos(next.map((it) => it.id)).then((map) => {
      setItems((cur) => cur.map((it) => (map[it.id] ? { ...it, photo: map[it.id] } : it)));
    }).catch(() => {});
  });
  const flashToast = (kind, n) => { setToast({ kind, n }); setTimeout(() => setToast(null), 3200); };

  // One-click: merge the free account's closet into this (paid) account. Dev/demo only.
  const importFresh = async () => {
    const fresh = loadFreshCloset();
    if (!fresh.length) { flashToast("nofree"); return; }
    // Move the free-account photos into IDB (keyed by id) so all of them persist.
    await Promise.all(fresh.map((it) => (it.photo ? idbPutPhoto(it.id, it.photo) : Promise.resolve())));
    try { localStorage.setItem("velour_fresh_closet_v1", JSON.stringify(fresh.map((it) => (it.photo ? { ...it, photo: null } : it)))); } catch (e) {}
    const byId = {};
    [...items, ...fresh].forEach((it) => { byId[it.id] = it; });
    const merged = Object.values(byId);
    const added = merged.length - items.length;
    setItems(merged);
    saveCloset(merged);
    setFreshCount(0);
    flashToast("imported", added);
  };

  const update = (next) => setItems(next);
  const wear = (id, dateISO) => { const day = dateISO || wTodayISO(); update(items.map((it) => (it.id === id ? markWorn(it, day) : it))); };
  const wearMany = (ids) => { const day = wTodayISO(); update(items.map((it) => (ids.includes(it.id) ? markWorn(it, day) : it))); };
  const remove = (id) => { idbDelPhoto(id); update(items.filter((it) => it.id !== id)); };
  const saveDraft = (d) => {
    const { _isNew, _detected, ...clean } = d;
    const savedId = clean.id || ("c" + Math.random().toString(36).slice(2, 8));
    if (_isNew) clean.id = savedId;
    // Photo goes to IDB; localStorage keeps only metadata, so it can't hit quota.
    if (typeof clean.photo === "string" && clean.photo.indexOf("data:") === 0) idbPutPhoto(savedId, clean.photo);
    const next = _isNew ? [clean, ...items] : items.map((it) => (it.id === savedId ? clean : it));
    setItems(next);
    saveCloset(next);
    flashToast("ok");
    setEditing(null);
  };
  const addPhoto = (dataUrl, filename) => {
    const det = detectCategory(filename);
    setEditing({ _isNew: true, _detected: det.detected, name: "", category: det.category, price: 0, wears: 0, lastWornDate: null, photo: dataUrl, icon: det.icon });
  };
  // No photo, so nothing to detect from and nothing to read: the piece starts Unsorted and
  // the member says what it is. `_detected: false` is what keeps the editor from claiming a
  // guess it never made. Everything downstream already tolerates photo: null — saveCloset
  // stores it, the grid sorts photographed pieces first, and the cost-per-wear maths never
  // looked at the picture.
  const addBlank = () => {
    setEditing({ _isNew: true, _detected: false, name: "", category: "Unsorted", price: 0, wears: 0, lastWornDate: null, photo: null, icon: "coat-hanger" });
  };

  // Photographed pieces first, so real (uploaded) items lead over icon-only ones.
  const shown = (filter === "All" ? items : items.filter((it) => it.category === filter)).slice().sort((a, b) => (b.photo ? 1 : 0) - (a.photo ? 1 : 0));
  const best = items.filter((it) => it.wears > 0).sort((a, b) => cpw(a) - cpw(b))[0];
  const unworn = items.filter(isIdle).length;
  const totalValue = items.reduce((s, it) => s + it.price, 0);
  const score = roiScore(items);

  return (
    <main style={{ maxWidth: 1240, margin: "0 auto", padding: "36px 32px 64px", display: "flex", flexDirection: "column", gap: 24, fontFamily: "var(--font-sans)" }}>
      <style>{`
        /* HOVER IS NOT AVAILABLE ON A PHONE, and asking for it costs a tap.
           Safari and Chrome answer the first tap on an element with :hover rules
           by applying the hover state instead of dispatching the click, so a
           control revealed by hover needs tapping twice. Hover styling is gated
           to pointers that can actually hover; touch gets the revealed state
           outright. :focus-visible stays ungated — keyboard focus is unrelated. */
        .vr-item-actions { opacity: 0; transition: opacity .15s var(--ease-out); }
        @media (hover: hover) and (pointer: fine) {
          .vr-item:hover .vr-item-actions { opacity: 1; }
        }
        @media (hover: none) { .vr-item-actions { opacity: 1; } }
        .vr-cat { transition: all .15s var(--ease-out); }
        @keyframes vrspin { to { transform: rotate(360deg); } }
        .vr-spin { animation: vrspin 0.7s linear infinite; }
      `}</style>

      {/* Header */}
      <div style={{ display: "flex", alignItems: "flex-end", justifyContent: "space-between", flexWrap: "wrap", gap: 24 }}>
        <div style={{ maxWidth: 580 }}>
          <span style={{ display: "inline-flex", alignItems: "center", gap: 9, fontSize: 11.5, letterSpacing: "0.16em", textTransform: "uppercase", fontWeight: 500, color: "var(--amber-text)", background: "var(--amber-fill)", borderRadius: 999, padding: "6px 14px" }}><i className="ph ph-coat-hanger" style={{ fontSize: 14 }}></i>Wardrobe</span>
          <h1 style={{ fontFamily: "var(--font-serif)", fontWeight: 500, fontSize: 40, lineHeight: 1.08, letterSpacing: "-0.02em", color: "var(--text-primary)", margin: "18px 0 0", textWrap: "balance" }}>Shop your capsule first.</h1>
          <p style={{ fontSize: 16, lineHeight: 1.65, color: "var(--text-secondary)", margin: "14px 0 0" }}>Photograph what you own and VELOUR tracks how hard each piece works — so you style from your capsule before you ever buy, and every new purchase is a decision, not an impulse.</p>
        </div>
        {!FRESH && freshCount > 0 && (
          <button onClick={importFresh} title="Merge the free-account closet into this account (demo tool — remove before launch)"
            style={{ display: "inline-flex", alignItems: "center", gap: 8, alignSelf: "flex-end", background: "var(--amber-fill)", color: "var(--amber-text)", border: "var(--border-width) solid var(--amber)", borderRadius: 999, padding: "9px 16px", fontFamily: "var(--font-sans)", fontSize: 13.5, fontWeight: 500, cursor: "pointer", whiteSpace: "nowrap" }}>
            <i className="ph ph-download-simple" style={{ fontSize: 15 }}></i>Import free-account closet ({freshCount})
          </button>
        )}
      </div>

      {/* The photo-sync question, asked where the photographs are. Only for a
          signed-in member who has some, and only until they answer. */}
      {askPhotos && items.some((it) => it && it.photo) && (
        <PhotoSyncAsk onDecided={() => setAskPhotos(false)} />
      )}

      {/* Insight */}
      <WardrobeInsight items={items} />

      {/* Intelligence strip */}
      <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 170px), 1fr))", gap: 16 }}>
        <StatTile label="Best value piece" value={best ? cpwLabel(best) : "—"} sub={best ? `cost per wear · ${best.name.toLowerCase()}` : "add a few pieces"} tint="var(--amber-text)" />
        <StatTile label="Going unworn" value={String(unworn)} sub="idle 90+ days" tint={unworn ? "var(--amber-text)" : "var(--text-primary)"} />
        <RoiTile score={score} />
        <StatTile label="Capsule value" value={window.velourMoney(totalValue)} sub={`${items.length} piece${items.length === 1 ? "" : "s"} tracked`} />
      </div>

      {/* Closet */}
      <div>
        <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: 12, margin: "8px 2px 16px" }}>
          <h2 style={{ fontFamily: "var(--font-serif)", fontWeight: 500, fontSize: 24, color: "var(--text-primary)", margin: 0, letterSpacing: "-0.01em" }}>Your closet</h2>
          <div style={{ display: "flex", gap: 7, flexWrap: "wrap" }}>
            {CATEGORIES.map((c) => {
              const active = filter === c;
              return <button key={c} className="vr-cat" onClick={() => setFilter(c)} style={{ padding: "7px 15px", borderRadius: 999, border: "var(--border-width) solid " + (active ? "transparent" : "var(--border)"), background: active ? "var(--ink)" : "var(--surface-card)", color: active ? "var(--parchment)" : "var(--text-secondary)", fontFamily: "var(--font-sans)", fontSize: 13, fontWeight: 500, cursor: "pointer" }}>{c}</button>;
            })}
          </div>
        </div>
        {/* auto-FILL, not auto-FIT. auto-fit collapses the empty tracks and lets
            what is left stretch across the whole row — so an empty closet, which
            holds exactly one tile, rendered that tile at the full grid width and
            its aspect-ratio:1/1 photo button grew to match: a ~1150px square as
            the first thing a new member meets. auto-fill keeps the empty tracks,
            so the tile stays one column wide whether the closet holds one piece
            or forty. */}
        <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(min(100%, 170px), 1fr))", gap: 16 }}>
          <AddPhotoTile onPick={addPhoto} onBlank={addBlank} />
          {shown.map((it) => <ClosetCard key={it.id} item={it} onWear={wear} onRemove={remove} onEdit={(item) => setEditing({ ...item, _isNew: false })} />)}
        </div>
        {/* "All" is not an adjective. Unfiltered and empty, this read "No all
            pieces yet." — the one state a brand-new member is guaranteed to see. */}
        {shown.length === 0 && (
          <div style={{ padding: "20px 4px", color: "var(--text-tertiary)", fontSize: 14 }}>
            {filter === "All" ? "Nothing in your closet yet." : `No ${filter.toLowerCase()} pieces yet.`}
          </div>
        )}
      </div>

      {items.length > 0 ? (
        <React.Fragment>
          {/* Shop your capsule — outfit formulas */}
          <window.WardrobeOutfits items={items} onWearMany={wearMany} />

          {/* AI Stylist */}
          <window.WardrobeStylist items={items} onWearMany={wearMany} onNavigate={onNavigate} />

          {/* Events & Budget → smart purchases */}
          <window.WardrobeBudget items={items} onNavigate={onNavigate} />
        </React.Fragment>
      ) : (
        <div style={{ display: "flex", gap: 13, alignItems: "flex-start", background: "var(--surface-card)", border: "var(--border-width) dashed var(--border-strong)", borderRadius: 16, padding: "22px 24px" }}>
          <i className="ph ph-lock-simple" style={{ fontSize: 20, color: "var(--text-tertiary)", marginTop: 1 }}></i>
          <div>
            <div style={{ fontSize: 15.5, color: "var(--text-primary)", fontWeight: 500 }}>Outfit formulas, your stylist & budget planning</div>
            <div style={{ fontSize: 14, color: "var(--text-secondary)", marginTop: 4, lineHeight: 1.55 }}>Add your first piece above, and the rest of the wardrobe follows.</div>
          </div>
        </div>
      )}

      {editing && <ItemEditor draft={editing} onSave={saveDraft} onCancel={() => setEditing(null)} />}

      {toast && (
        <div style={{ position: "fixed", left: "50%", bottom: 28, transform: "translateX(-50%)", zIndex: 60, maxWidth: "90vw", display: "inline-flex", alignItems: "center", gap: 10, padding: "13px 20px", borderRadius: 999, fontFamily: "var(--font-sans)", fontSize: 14, fontWeight: 500, boxShadow: "var(--shadow-lg)", background: toast.kind === "full" ? "var(--terracotta)" : "var(--ink)", color: toast.kind === "full" ? "#fff" : "var(--parchment)" }}>
          <i className={`ph ph-${toast.kind === "ok" || toast.kind === "imported" ? "check-circle" : toast.kind === "nophoto" ? "image-broken" : toast.kind === "nofree" ? "info" : "warning-circle"}`} style={{ fontSize: 17 }}></i>
          {toast.kind === "ok" ? "Saved to your closet"
            : toast.kind === "imported" ? (toast.n > 0 ? `Imported ${toast.n} piece${toast.n === 1 ? "" : "s"} from your free account` : "Those pieces are already in your capsule")
            : toast.kind === "nofree" ? "No free-account closet found in this browser."
            : toast.kind === "nophoto" ? "Saved — but that photo couldn't be stored (try a JPG/PNG)."
            : "Couldn't save — storage is full. Remove a few pieces and retry."}
        </div>
      )}

      {/* Where the photo-sync answer can be changed, and disclosed to anyone who
          never saw the ask. Parity with PhotoSyncRow() on the mobile Profile. */}
      <PhotoSyncSetting />
    </main>
  );
}

window.DashWardrobe = DashWardrobe;
