/* global React, ReactDOM */
// The Library — Debra's responsive portal for browsing, downloading, and
// editing the CCC campaign creatives.
//
// Loads the existing campaign components (Brand System, Launch Kit,
// First Monday) and renders them as cards in a filterable grid. A global
// "Edit values" panel lets her change date, prices, cohort #, phone, etc.
// once and have every creative update.

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

// ─── Tokens ────────────────────────────────────────────────────────────
// Debra's palette (May 2026):
//   Primaries:  #1C492C forest · #4B781C leaf · #9DAE20 lime
//   Secondaries: #AFCC75 moss · #F4F0C2 cream
// Key mappings from the previous palette (kept under same names so the
// design grammar stays put):
//   inkwell  → #1C492C  (deepest green; text, logo, dark surfaces)
//   vellum   → #F4F0C2  (cream paper background — primary surface)
//   sage     → #AFCC75  (soft secondary green)
//   oxblood  → #4B781C  (single dark accent — links, active state, CTAs)
//   charcoal → #22382A  (green-tinted body text)
//   lime     → #9DAE20  (bright highlight, used sparingly)
const C = {
  inkwell:  "#1C492C",
  vellum:   "#EFE9A8",   // header — slightly more saturated cream (darker than body)
  cream:    "#F4F0C2",   // Debra's primary cream (mid-tone, used in artboards)
  sage:     "#AFCC75",
  oxblood:  "#4B781C",
  charcoal: "#22382A",
  lime:     "#9DAE20",
  paper:    "#FBF6D8",   // lightest cream — for cards/panels
  body:     "#F7F2CC",   // page background — lighter than header
  border:   "rgba(28,73,44,0.22)"
};

// ─── Default editable values ──────────────────────────────────────────
const DEFAULT_VALUES = {
  cohort: "01",
  cohortStartIso: "2026-06-01",
  date: "June 1",
  date_long: "June 1, 2026",
  founders_rate: "$249",
  regular_rate: "$499",
  phone: "(289) 207-2617",
  phone_dotted: "(289) 207-2617",
  url: "cccrecovery.ca",
  email: "debra@cccrecovery.ca",
  location: "Toronto"
};

// Parse a YYYY-MM-DD string into {date: "June 1", date_long: "June 1, 2026"}.
function parseCohortIso(iso) {
  if (!iso || !/^\d{4}-\d{2}-\d{2}$/.test(iso)) return null;
  // Avoid TZ issues: build a fixed local-noon date.
  const [y, m, d] = iso.split("-").map(Number);
  const date = new Date(y, m - 1, d, 12);
  if (isNaN(+date)) return null;
  const longFmt = date.toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" });
  const shortFmt = date.toLocaleDateString("en-US", { month: "long", day: "numeric" });
  return { iso, date: shortFmt, date_long: longFmt };
}

// ─── Find/Replace map: each rule is [pattern, fieldKey] ───────────────
// Applied to text nodes inside artboards after render. Patterns are matched
// in order; first match wins per text node. We use string matching (not
// regex) for stability — these strings are unique tokens in the designs.
const REPLACEMENTS = [
  ["June 1, 2026", "date_long"],
  ["June 1", "date"],
  ["$249 founders'", "founders_rate"],
  ["$249", "founders_rate"],
  ["$499 regular", "regular_rate"],
  ["$499", "regular_rate"],
  ["(289) 207-2617", "phone"],
  ["(289) 207-2617", "phone_dotted"],
  ["cccrecovery.ca", "url"],
  ["debra@cccrecovery.ca", "email"],
  ["Toronto", "location"]
];

// Whole-text-node replacements: only fire when the trimmed text node equals
// the needle. Used for stylized renderings where the month and day are
// rendered as SEPARATE text nodes (e.g. <h1>June<br/>1.</h1>) so the
// substring patterns above can never match.
const WHOLE_NODE_REPLACEMENTS = [
  ["June", "__month"],
  ["1.",   "__day_period"]
];

function deriveDateParts(values) {
  const iso = values.cohortStartIso;
  if (!iso || !/^\d{4}-\d{2}-\d{2}$/.test(iso)) return {};
  const [y, m, d] = iso.split("-").map(Number);
  const date = new Date(y, m - 1, d, 12);
  if (isNaN(+date)) return {};
  return {
    __month: date.toLocaleDateString("en-US", { month: "long" }),
    __day_period: d + "."
  };
}

// ─── Custom copy edits (authored in edits.html) ───────────────────
// Stored as { "<original text>": "<replacement>" } under "ccc-custom-edits".
// Matched against the WHOLE trimmed text-node content AFTER the values pass,
// so the keys are exactly the strings the copy editor extracts and shows.
function loadCustomEdits() {
  try {
    const raw = localStorage.getItem("ccc-custom-edits");
    if (raw) {
      const parsed = JSON.parse(raw);
      if (parsed && typeof parsed === "object") return parsed;
    }
  } catch {}
  return {};
}

// Walk all text nodes inside an element, applying replacements.
// Mutates DOM. Idempotent because each node remembers its very first value.
function applyReplacements(root, values) {
  const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
  const nodes = [];
  let n;
  while ((n = walker.nextNode())) nodes.push(n);
  const parts = deriveDateParts(values);
  for (const tn of nodes) {
    const orig = tn.__libOrig ?? (tn.__libOrig = tn.nodeValue);
    let next = orig;
    // Whole-node pass first (for split month/day renderings).
    const trimmed = orig.trim();
    for (const [needle, key] of WHOLE_NODE_REPLACEMENTS) {
      if (trimmed === needle) {
        const replacement = parts[key];
        if (replacement) next = orig.replace(needle, replacement);
        break;
      }
    }
    // Then partial-substring pass against the (possibly already-rewritten)
    // value, so e.g. "June 1, 2026" still wins when month+day live in one
    // node together.
    for (const [needle, key] of REPLACEMENTS) {
      if (next.includes(needle)) {
        next = next.split(needle).join(values[key] || "");
      }
    }
    if (tn.nodeValue !== next) tn.nodeValue = next;
  }
  // Also walk for "Cohort 01" → use cohort
  const cohortNeedle = "Cohort 01";
  for (const tn of nodes) {
    const orig = tn.__libOrigCohort ?? (tn.__libOrigCohort = tn.nodeValue);
    if (orig.includes(cohortNeedle)) {
      const replaced = orig.split(cohortNeedle).join("Cohort " + (values.cohort || "01"));
      if (tn.nodeValue !== replaced) tn.nodeValue = replaced;
    }
  }
  // Finally: Debra's custom copy edits (from edits.html). Whole-trimmed-node
  // match against the values-resolved text, preserving surrounding whitespace.
  const custom = loadCustomEdits();
  if (Object.keys(custom).length) {
    for (const tn of nodes) {
      const cur = tn.nodeValue;
      const t = cur.trim();
      if (t && Object.prototype.hasOwnProperty.call(custom, t) && custom[t] !== t) {
        const lead = (cur.match(/^\s*/) || [""])[0];
        const trail = (cur.match(/\s*$/) || [""])[0];
        tn.nodeValue = lead + custom[t] + trail;
      }
    }
  }
}

// ─── Manifest: all artboards from the 3 campaigns ─────────────────────
// Each entry maps to a React component name exported on `window` by the
// individual JSX files.

const MANIFEST = [
  // Stationery — business card (front & back)
  { campaign: "brand", format: "businesscard", id: "bs-card-front", name: "Business card — front", comp: "BS_BusinessCardFront", w: 1050, h: 600, whereUse: "Print at 3.5 × 2″ (standard card · add 1/8″ bleed at the shop). The identity side — Debra's name, role, and the promise. Hand out at meetings, clinics, and partner intros.", caption: "" },
  { campaign: "brand", format: "businesscard", id: "bs-card-back",  name: "Business card — back",  comp: "BS_BusinessCardBack",  w: 1050, h: 600, whereUse: "Print at 3.5 × 2″ — the reverse of the front. The program in one breath, contact details, and a QR that opens cccrecovery.ca. Scannable at print size.", caption: "" },

  // Brand system foundation pieces
  { campaign: "brand",   format: "profile", reference: true, id: "bs-profile-light",  name: "Profile photo — cream",  comp: "BS_ProfileLogo",     w: 1080, h: 1080, whereUse: "Profile avatar · 1080 × 1080 — Instagram, Facebook, LinkedIn. Update once on each account; refresh only if the wordmark changes.", caption: "" },
  { campaign: "brand",   format: "profile", reference: true, id: "bs-profile-dark",   name: "Profile photo — ink",    comp: "BS_ProfileLogoDark", w: 1080, h: 1080, whereUse: "Profile avatar · 1080 × 1080 — use on accounts where the cream version looks too pale, or for a dark email signature.", caption: "" },
  { campaign: "brand",   format: "horizontal", reference: true, id: "bs-wordmark",    name: "Wordmark",                     comp: "BS_WordmarkSheet",  w: 1400, h: 900,  whereUse: "Reference only — not for posting. Share with designers, sign-makers, or partners who need the lockups in horizontal and stacked form.",  caption: "" },
  { campaign: "brand",   format: "horizontal", reference: true, id: "bs-specimen",    name: "Palette + Type specimen",      comp: "BS_Specimen",       w: 1400, h: 900,  whereUse: "Reference only — not for posting. Share with anyone producing CCC materials so colors and typefaces match across surfaces.",  caption: "" },
  { campaign: "brand",   format: "flyer",      id: "bs-day1",        name: "Day 1 affirmation poster",     comp: "BS_Day1Poster",     w: 850,  h: 1100, whereUse: "Print at 8.5 × 11″. Gift it to each participant on day one of the cohort — a leave-behind for the wall, the fridge, the inside of a daytimer.",  caption: "You're not behind. You're exactly where you're supposed to be.\n\n#CCCrecovery #ChancesChoicesChanges" },
  { campaign: "brand",   format: "horizontal", reference: true, id: "bs-vocab-ref",    name: "Vocabulary reference",          comp: "BS_VocabularyReference", w: 1400, h: 900,  whereUse: "Reference only — not for posting. Share with new facilitators or partners so the H.A.L.T. and F.E.A.R. language stays consistent.",  caption: "" },
  { campaign: "brand",   format: "vertical",   id: "bs-story-halt",   name: "Story — H.A.L.T.",                comp: "BS_StoryHalt",            w: 1080, h: 1920, whereUse: "Story · 1080 × 1920 — Instagram & Facebook Stories. Use mid-cohort when a participant might be running into the four states. Pin to the \"Apply\" Highlight.",  caption: "H.A.L.T. — Hungry. Angry. Lonely. Tired.\nWhen you can name it, you can stop.\n\n’I was all of the above. So I went home.’\n\n#CCCrecovery #HALT" },

  // Circular stickers — die-cut, 3″
  { campaign: "brand", format: "sticker", transparent: true, id: "bs-sticker-wordmark", name: "Sticker — wordmark",             comp: "BS_StickerWordmark", w: 1000, h: 1000, whereUse: "Die-cut circle · print at 3″ on matte vinyl. Laptop lids, water bottles, notebook covers — hand a few to every participant and referral partner.", caption: "" },
  { campaign: "brand", format: "sticker", transparent: true, id: "bs-sticker-possible", name: "Sticker — Recovery is possible", comp: "BS_StickerPossible", w: 1000, h: 1000, whereUse: "Die-cut circle · print at 3″. The promise, pocket-sized — for daytimers, mirrors, and meeting-room doors.", caption: "" },
  { campaign: "brand", format: "sticker", transparent: true, id: "bs-sticker-scan",     name: "Sticker — scan to apply",       comp: "BS_StickerScan",     w: 1000, h: 1000, whereUse: "Die-cut circle · print at 3″. The QR version — café counters, bulletin-board corners, the back of a parking-meter pole. A scan opens cccrecovery.ca.", caption: "" },

  // Recovery Is Possible (Campaign 1)
  { campaign: "recovery", format: "flyer",      id: "rip-flyer-ever",   name: "Evergreen flyer",        comp: "LK_FlyerEvergreen", w: 850,  h: 1100, whereUse: "Print at 8.5 × 11″. Drop at café bulletin boards, library notice boards, and walk-in clinics. Refresh every 4–6 weeks — evergreen, no date pressure.",
    caption: "Recovery is possible. You're worth it.\n\nAn eight-week peer-led recovery program, starting {date}. Led by Debra King — with lived experience and the language to match.\n\nApply at {url} · {phone}\n\n#CCCrecovery #ChancesChoicesChanges #PeerRecovery" },
  { campaign: "recovery", format: "flyer",      id: "rip-flyer-coh",    name: "Cohort launch flyer",    comp: "LK_FlyerCohort",    w: 850,  h: 1100, whereUse: "Print at 8.5 × 11″. The hard-sell version with the date and price. Drop 4 weeks before cohort start and again 1 week out.",
    caption: "Cohort {cohort} begins {date}.\nEight Mondays, 7–9 p.m. {founders_rate} founders' rate.\n\nApply at {url}\n\n#CCCrecovery #DayOne" },
  { campaign: "recovery", format: "square",     id: "rip-ig-intro",     name: "IG — brand intro",       comp: "LK_IGIntro",        w: 1080, h: 1080, whereUse: "Square post · 1080 × 1080 — Instagram & Facebook feed. The opener — use as the first post of a new account, or as a re-introduction at the top of each cohort cycle.",
    caption: "You're not behind.\nYou're exactly where you're supposed to be.\n\nWe start {date}. Apply at {url}.\n\n#CCCrecovery #YoureWorthIt" },
  { campaign: "recovery", format: "square",     id: "rip-ig-insp",      name: "IG — inspirational",     comp: "LK_IGInspirational",w: 1080, h: 1080, whereUse: "Square post · 1080 × 1080 — Instagram & Facebook feed. Mid-cohort or anytime — the kind of post that keeps the account alive between launches.",
    caption: "You're worth it.\n\nNot because you've earned it. Because worth was never the question.\n\n#CCCrecovery #ChancesChoicesChanges" },
  { campaign: "recovery", format: "square",     id: "rip-ig-coh",       name: "IG — cohort launch",     comp: "LK_IGCohort",       w: 1080, h: 1080, whereUse: "Square post · 1080 × 1080 — Instagram & Facebook feed. The announcement post — use 2 weeks before cohort starts, then repost the day before.",
    caption: "Day one. {date}.\nEight Mondays. The work of staying sober.\n\nApply at {url} · {phone}\n\n#CCCrecovery #DayOne" },
  { campaign: "recovery", format: "vertical",   id: "rip-story-ever",   name: "Story — evergreen",      comp: "LK_StoryEvergreen", w: 1080, h: 1920, whereUse: "Story · 1080 × 1920 — Instagram & Facebook Stories. Anytime, no expiry. Pin to a Highlight called \"About\" so first-time visitors land on it.",
    caption: "Recovery is possible. You're worth it.\n\nApply at {url}." },
  { campaign: "recovery", format: "vertical",   id: "rip-story-coh",    name: "Story — cohort launch",  comp: "LK_StoryCohort",    w: 1080, h: 1920, whereUse: "Story · 1080 × 1920 — Instagram & Facebook Stories. Use 7 days before cohort. Pin to a Highlight called \"Apply\" until the cohort fills.",
    caption: "Day one · {date}.\nEight Mondays · 7–9 p.m. · {founders_rate}\n{url}" },
  { campaign: "recovery", format: "horizontal", id: "rip-landscape",    name: "Referral card",          comp: "LK_LandscapeReferral",w: 1200, h: 630,  whereUse: "Wide card · 1200 × 630 — LinkedIn post, Facebook link card, email signature, web page header. Send to any referral partner you'd like to send participants your way.",
    caption: "An eight-week guided recovery program for adults who've tried other things. Led by Debra King — peer counsellor with lived experience.\n\nMondays, 7–9 p.m., starting {date}. A small group, live on Zoom.\n\n{url} · {phone} · {email}" },

  // First Monday (Campaign 2)
  { campaign: "firstmonday", format: "flyer",      id: "fm-manifesto",       name: "Manifesto poster",         comp: "FM_Manifesto",        w: 850, h: 1100, whereUse: "Print at 8.5 × 11″. The campaign's thesis — leave behind after a partner meeting, or use as the opening page of any printed packet.",
    caption: "First Monday.\nRecovery is not a story arc. It's a Monday. And then another Monday.\n\nCohort {cohort} begins {date}. {url}" },
  { campaign: "firstmonday", format: "horizontal", id: "fm-index",           name: "Index card",               comp: "FM_IndexCard",        w: 1200, h: 630, whereUse: "Wide card · 1200 × 630 — LinkedIn post, Facebook link card, press inquiry attachment. Use as the campaign summary when introducing the First Monday work to anyone new.",
    caption: "First Monday — a peer-led recovery program. The seven creatives of the campaign. Cohort {cohort} begins {date}." },
  { campaign: "firstmonday", format: "flyer",      id: "fm-greenhouse",      name: "Greenhouse poster",        comp: "FM_GreenhousePoster", w: 850, h: 1100, whereUse: "Print at 8.5 × 11″. Use when Debra herself is the draw — intro events, talks, or referral partners who want a face to the program.",
    caption: "I've been there. That's the credential.\n\nA peer-led recovery program in Toronto. Cohort {cohort} begins {date}.\n{url} · {phone}" },
  { campaign: "firstmonday", format: "square",     id: "fm-eight-week",      name: "Eight-week grid",          comp: "FM_EightWeekGrid",    w: 1080, h: 1080, whereUse: "Square post · 1080 × 1080 — Instagram & Facebook feed. Use the week before cohort starts to set expectations — 'here's what eight weeks looks like.'",
    caption: "Eight Mondays. Eight verbs.\n\n01 Show up. 02 Tell the truth. 03 Make the call. 04 Write it down. 05 Sit with it. 06 Try again. 07 Forgive a Monday. 08 Start week nine.\n\nCohort {cohort} · {date}. {url}" },
  { campaign: "firstmonday", format: "flyer",      id: "fm-daytimer",        name: "Day-timer poster",         comp: "FM_ArtifactDayTimer", w: 850, h: 1100, whereUse: "Print at 8.5 × 11″. The 'what staying sober actually looks like' poster — less about announcement, more about texture. Use anytime.",
    caption: "Cross one thing off the list. The vet appointment counts. So does the call you didn't want to make.\n\n#CCCrecovery #FirstMonday" },
  { campaign: "firstmonday", format: "square",     id: "fm-notebook",        name: "F.E.A.R. notebook",        comp: "FM_ArtifactNotebook", w: 1080, h: 1080, whereUse: "Square post · 1080 × 1080 — Instagram & Facebook feed. Mid-cohort. Pairs with the H.A.L.T. story — post them in the same week.",
    caption: "Make it a list.\nFear shrinks when you check the boxes.\n\nF.E.A.R. — and what to do with it.\n\n#CCCrecovery #FirstMonday" },
  { campaign: "firstmonday", format: "square",     id: "fm-voicemail",       name: "Voicemail square",         comp: "FM_VoicemailSquare",  w: 1080, h: 1080, whereUse: "Square post · 1080 × 1080 — Instagram & Facebook feed. Use when you want to lower the barrier to calling. Pin to a Highlight called \"Call us\".",
    caption: "Hi. It's Monday. I'm calling.\nThree sentences. That's enough.\n\nCall {phone}. We answer.\n\n#CCCrecovery" },
  { campaign: "firstmonday", format: "vertical",   id: "fm-story-litany",    name: "Story — what we don't say",comp: "FM_StoryLitany",      w: 1080, h: 1920, whereUse: "Story · 1080 × 1920 — Instagram & Facebook Stories. Use to define the vocabulary on first contact. Pin to a Highlight called \"Voice\".",
    caption: "We don't say journey. We say Monday.\nWe don't say breakthrough. We say show up.\n\n#CCCrecovery #FirstMonday" },
  { campaign: "firstmonday", format: "vertical",   id: "fm-story-field",     name: "Story — field guide",      comp: "FM_StoryFieldGuide",  w: 1080, h: 1920, whereUse: "Story · 1080 × 1920 — Instagram & Facebook Stories. Use when you want the program to read as serious craft, not a quick pitch.",
    caption: "Staying sober. Eight chapters, one per Monday. Read it together, in an understanding room.\n\nCohort {cohort} · {date}. {url}" },
  { campaign: "firstmonday", format: "billboard",  id: "fm-billboard-ever",  name: "Billboard — evergreen",   comp: "FM_BillboardEvergreen", w: 1800, h: 600,  whereUse: "Outdoor · 3 : 1 (1800 × 600 native). Brand-anchor message — no dates. Use on transit signs, billboards, or sponsorship boards when no cohort is in market.",
    caption: "Recovery is possible. You're worth it.\n\nA peer-led recovery program in Toronto, led by Debra King. {url} · {phone}" },
  { campaign: "firstmonday", format: "magnet",     id: "fm-magnet-bumper",   name: "Bumper magnet",            comp: "FM_CarMagnetBumper",   w: 1200, h: 300,  whereUse: "Car magnet · 12 × 3″ (4:1). The classic bumper-strip format. Reads at one car-length back. Evergreen — no dates.",
    caption: "Recovery is possible. You're worth it. {url} · {phone}" },
  { campaign: "firstmonday", format: "magnet",     id: "fm-magnet-square",   name: "Door magnet",              comp: "FM_CarMagnetSquare",   w: 1200, h: 1200, whereUse: "Car magnet · 12 × 12″. For a car door, tailgate, or fridge. The wordmark set monumentally. Evergreen — no dates.",
    caption: "Chances. Choices. Changes. — a peer-led recovery program in Toronto. {url} · {phone}" },
  { campaign: "firstmonday", format: "billboard",  id: "fm-billboard",       name: "Billboard",                comp: "FM_Billboard",        w: 1800, h: 600,  whereUse: "Outdoor · 3 : 1 (1800 × 600 native). For transit posters, billboards, or any large outdoor placement. Coordinate sizing with the print partner.",
    caption: "Eight Mondays. That's the whole program. Cohort {cohort} begins {date}. {url}" }
];

// Replace {placeholder} tokens in caption with current values
function fillCaption(template, values) {
  return template.replace(/\{(\w+)\}/g, (_, key) => values[key] || "");
}

// Filter buttons config
const CAMPAIGNS = [
  { id: "all",         label: "All",                 count: () => MANIFEST.length },
  { id: "recovery",    label: "Recovery Is Possible", count: () => MANIFEST.filter(m => m.campaign === "recovery").length },
  { id: "firstmonday", label: "First Monday",        count: () => MANIFEST.filter(m => m.campaign === "firstmonday").length },
  { id: "brand",       label: "Brand system",        count: () => MANIFEST.filter(m => m.campaign === "brand").length }
];

const FORMATS = [
  { id: "all",        label: "All formats" },
  { id: "businesscard", label: "Business cards" },
  { id: "flyer",      label: "Print flyers" },
  { id: "sticker",    label: "Stickers" },
  { id: "square",     label: "Square posts" },
  { id: "vertical",   label: "Stories" },
  { id: "horizontal", label: "Wide cards" },
  { id: "billboard",  label: "Outdoor" },
  { id: "magnet",     label: "Car magnets" },
  { id: "profile",    label: "Profile & bios" },
  { id: "reference",  label: "Brand reference" }
];

// ─── Persistent values via localStorage ───────────────────────────────
// Stale contact info is migrated automatically: if a saved value matches a
// known-stale token, we drop it so DEFAULT_VALUES wins.
const STALE_TOKENS = new Set([
  "647-448-4662",
  "647 · 448 · 4662",
  "whishful@gmail.com"
]);
function migrateStaleContact(stored) {
  if (!stored || typeof stored !== "object") return stored;
  const out = { ...stored };
  for (const k of Object.keys(out)) {
    if (STALE_TOKENS.has(out[k])) delete out[k];
  }
  return out;
}

function useStoredValues() {
  const [values, setValues] = useState(() => {
    try {
      const raw = localStorage.getItem("ccc-lib-values");
      if (raw) {
        const parsed = migrateStaleContact(JSON.parse(raw));
        return { ...DEFAULT_VALUES, ...parsed };
      }
    } catch {}
    return DEFAULT_VALUES;
  });
  useEffect(() => {
    try { localStorage.setItem("ccc-lib-values", JSON.stringify(values)); } catch {}
  }, [values]);
  return [values, setValues];
}

// ─── Tally maker's mark ───────────────────────────────────────────────
function Tally({ size = 18, color = "#0E1A2B" }) {
  return (
    <svg width={size * 1.6} height={size} viewBox="0 0 48 30" fill="none" style={{ display: "block" }}>
      <path d="M8 3.2 L7.2 27.4"   stroke={color} strokeWidth="2.4" strokeLinecap="round" />
      <path d="M19 2.6 L19.4 27.8" stroke={color} strokeWidth="2.4" strokeLinecap="round" />
      <path d="M30 3.6 L29.6 27.2" stroke={color} strokeWidth="2.4" strokeLinecap="round" />
    </svg>
  );
}

// ─── ArtboardCard ─────────────────────────────────────────────────────
function ArtboardCard({ item, values, onPreview, onDownload, onCopy }) {
  const containerRef = useRef(null);
  const renderRef = useRef(null);
  const stageRef = useRef(null);
  const [captionOpen, setCaptionOpen] = useState(false);

  // Mount artboard component into the container at native size.
  useEffect(() => {
    const Comp = window[item.comp];
    if (!Comp || !renderRef.current) return;
    const root = ReactDOM.createRoot(renderRef.current);
    root.render(
      <div style={{ width: item.w, height: item.h, background: item.transparent ? "transparent" : "#fff" }}>
        <Comp />
      </div>
    );
    return () => { try { root.unmount(); } catch {} };
  }, [item.comp, item.w, item.h]);

  // After render, apply value replacements.
  useEffect(() => {
    const id = requestAnimationFrame(() => {
      if (renderRef.current) applyReplacements(renderRef.current, values);
    });
    return () => cancelAnimationFrame(id);
  });

  // ResizeObserver — keep the scaled artboard fit-to-container at every
  // viewport / layout change. Without this, narrow viewports stretch the
  // native-sized inner div out of bounds.
  useEffect(() => {
    const stage = stageRef.current;
    const inner = renderRef.current;
    if (!stage || !inner) return;
    const update = () => {
      const pw = stage.clientWidth;
      const ph = stage.clientHeight;
      if (pw === 0 || ph === 0) return;
      const s = Math.min(pw / item.w, ph / item.h);
      inner.style.transform = `scale(${s})`;
      inner.style.left = ((pw - item.w * s) / 2) + "px";
      inner.style.top  = ((ph - item.h * s) / 2) + "px";
    };
    update();
    const ro = new ResizeObserver(update);
    ro.observe(stage);
    window.addEventListener("resize", update);
    return () => { ro.disconnect(); window.removeEventListener("resize", update); };
  }, [item.w, item.h]);

  const aspectRatio = (item.h / item.w) * 100;
  const filledCaption = item.caption ? fillCaption(item.caption, values) : "";
  const captionPreview = filledCaption.split("\n")[0];
  const captionExpandable = filledCaption.includes("\n") || filledCaption.length > 40;

  return (
    <div
      ref={containerRef}
      style={{
        display: "flex", flexDirection: "column", gap: 16
      }}>
      {/* Preview area — quiet frame, no borders, no badges */}
      <div
        ref={stageRef}
        style={{
          position: "relative",
          paddingBottom: aspectRatio + "%",
          background: item.transparent ? "transparent" : C.vellum,
          overflow: "hidden",
          cursor: "zoom-in",
          borderRadius: 4
        }}
        onClick={() => onPreview(item)}
      >
        <div style={{ position: "absolute", inset: 0 }}>
          <div
            ref={(el) => { renderRef.current = el; }}
            style={{
              width: item.w + "px", height: item.h + "px",
              position: "absolute", top: 0, left: 0,
              transformOrigin: "top left"
            }}
          />
        </div>
      </div>

      {/* Title + size, in body type */}
      <div>
        <div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 12 }}>
          <div style={{
            fontFamily: '"Frank Ruhl Libre", serif', fontSize: 18, fontWeight: 500,
            color: C.inkwell, letterSpacing: "-0.01em", lineHeight: 1.25
          }}>{item.name}</div>
          <div style={{
            fontFamily: '"JetBrains Mono", monospace', fontSize: 10,
            color: "rgba(28,73,44,0.45)",
            letterSpacing: "0.06em", whiteSpace: "nowrap", flexShrink: 0
          }}>{item.w} × {item.h}</div>
        </div>
        <div style={{
          marginTop: 4,
          fontFamily: '"Frank Ruhl Libre", serif', fontStyle: "italic",
          fontSize: 14, color: C.charcoal, lineHeight: 1.5
        }}>{item.whereUse}</div>
      </div>

      {/* Inline caption block (shown by default) */}
      {filledCaption && (
        <div style={{
          padding: "12px 14px 12px 16px",
          background: "rgba(28,73,44,0.025)",
          borderLeft: `2px solid rgba(75,120,28,0.55)`,
          borderRadius: 2,
          position: "relative"
        }}>
          <div style={{
            fontFamily: '"Frank Ruhl Libre", serif', fontStyle: "italic",
            fontSize: 13.5, color: C.charcoal, lineHeight: 1.55,
            whiteSpace: captionOpen ? "pre-wrap" : "nowrap",
            overflow: captionOpen ? "visible" : "hidden",
            textOverflow: captionOpen ? "clip" : "ellipsis"
          }}>
            {captionOpen ? filledCaption : captionPreview}
          </div>
          <div style={{ marginTop: 8, display: "flex", justifyContent: "space-between", alignItems: "center", gap: 8 }}>
            {filledCaption && captionExpandable ? (
              <TextLink onClick={() => setCaptionOpen(!captionOpen)}>
                {captionOpen ? "Show less" : "Show full caption"}
              </TextLink>
            ) : <span />}
            <IconButton icon="⧉" label="Copy" onClick={() => onCopy(item)} />
          </div>
        </div>
      )}

      {/* Inline actions — quiet links, not big buttons */}
      <div style={{ display: "flex", gap: 18, alignItems: "center" }}>
        <IconButton icon="⤓" label="PNG" onClick={() => onDownload(item, "png")} />
        <IconButton icon="⤓" label="PDF" onClick={() => onDownload(item, "pdf")} />
        <TextLink onClick={() => onPreview(item)}>Open full size</TextLink>
      </div>
    </div>
  );
}

// Quiet text-link style for tertiary actions.
function TextLink({ children, onClick }) {
  return (
    <button onClick={onClick} style={{
      background: "transparent", border: 0, padding: 0,
      fontFamily: '"Inter", sans-serif', fontSize: 13,
      color: C.charcoal, opacity: 0.75,
      textDecoration: "underline", textDecorationColor: "rgba(28,73,44,0.25)",
      textUnderlineOffset: "3px",
      cursor: "pointer"
    }}>{children}</button>
  );
}

// Small icon + label, inline. Used for download/copy actions.
function IconButton({ icon, label, onClick }) {
  const [done, setDone] = useState(false);
  return (
    <button
      onClick={() => { onClick(); setDone(true); setTimeout(() => setDone(false), 1100); }}
      style={{
        background: "transparent", border: 0, padding: 0,
        display: "inline-flex", alignItems: "center", gap: 6,
        color: done ? C.oxblood : C.inkwell,
        fontFamily: '"Inter", sans-serif', fontSize: 13, fontWeight: 500,
        cursor: "pointer", transition: "color 0.15s"
      }}>
      <span style={{ fontSize: 16, lineHeight: 1 }}>{done ? "✓" : icon}</span>
      <span>{done ? "Copied" : label}</span>
    </button>
  );
}

// ─── Edit values panel ────────────────────────────────────────────────
function EditPanel({ values, setValues, open, onToggle }) {
  const fields = [
    ["cohort",        "Cohort number",       "01"],
    ["date",          "Date (short form)",   "June 1"],
    ["date_long",     "Date (long form)",    "June 1, 2026"],
    ["founders_rate", "Founders' rate",      "$249"],
    ["regular_rate",  "Regular rate",        "$499"],
    ["phone",         "Phone (dashed)",      "(289) 207-2617"],
    ["phone_dotted",  "Phone (dotted)",      "(289) 207-2617"],
    ["url",           "Website",             "cccrecovery.ca"],
    ["email",         "Email",               "debra@cccrecovery.ca"],
    ["location",      "Location",            "Toronto"]
  ];

  const update = (k, v) => setValues(prev => ({ ...prev, [k]: v }));
  // Picking a date in the picker is the canonical source — it overwrites
  // the human-readable date / date_long fields automatically.
  const updateIso = (iso) => {
    setValues(prev => {
      const parsed = parseCohortIso(iso);
      if (!parsed) return { ...prev, cohortStartIso: iso };
      return { ...prev, cohortStartIso: iso, date: parsed.date, date_long: parsed.date_long };
    });
  };
  const reset = () => setValues(DEFAULT_VALUES);

  return (
    <div style={{
      background: C.paper,
      border: `1px solid ${C.border}`,
      borderRadius: 8,
      overflow: "hidden"
    }}>
      <button
        onClick={onToggle}
        style={{
          width: "100%", background: "transparent", border: 0,
          padding: "14px 20px", cursor: "pointer",
          display: "flex", justifyContent: "space-between", alignItems: "center",
          fontFamily: '"Inter", sans-serif',
          fontSize: 14, color: C.inkwell, textAlign: "left"
        }}>
        <span>
          <span style={{ fontWeight: 500 }}>Edit dates & details</span>
          {Object.keys(values).filter(k => values[k] !== DEFAULT_VALUES[k]).length > 0 && (
            <span style={{ marginLeft: 10, color: C.oxblood, fontStyle: "italic",
              fontFamily: '"Frank Ruhl Libre", serif', fontSize: 13 }}>
              {Object.keys(values).filter(k => values[k] !== DEFAULT_VALUES[k]).length} changed
            </span>
          )}
        </span>
        <span style={{ fontSize: 14, color: "rgba(28,73,44,0.5)" }}>{open ? "Hide" : "Open"}</span>
      </button>
      {open && (
        <div style={{ padding: "4px 20px 20px" }}>
          <p style={{
            margin: "0 0 18px",
            fontFamily: '"Frank Ruhl Libre", serif', fontStyle: "italic",
            fontSize: 14, color: C.charcoal
          }}>
            Change a value here and every creative below updates instantly.
          </p>

          {/* Master cohort date — brand-styled calendar popover. */}
          <div style={{ marginBottom: 18 }}>
            <label style={{ display: "inline-flex", flexDirection: "column", gap: 4 }}>
              <span style={{
                fontFamily: '"JetBrains Mono", monospace', fontSize: 9,
                letterSpacing: "0.12em", textTransform: "uppercase",
                color: C.charcoal
              }}>Cohort start date</span>
              <BrandCalendar value={values.cohortStartIso} onChange={updateIso} />
              <span style={{
                marginTop: 4,
                fontFamily: '"Frank Ruhl Libre", serif', fontStyle: "italic",
                fontSize: 12, color: "rgba(28,73,44,0.55)", lineHeight: 1.4
              }}>
                Picking a date rewrites the two date fields below.
              </span>
            </label>
          </div>

          <div style={{
            display: "grid",
            gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))",
            gap: "12px 16px"
          }}>
            {fields.map(([k, label, placeholder]) => (
              <label key={k} style={{ display: "flex", flexDirection: "column", gap: 4 }}>
                <span style={{
                  fontFamily: '"JetBrains Mono", monospace', fontSize: 9,
                  letterSpacing: "0.12em", textTransform: "uppercase",
                  color: C.charcoal
                }}>{label}</span>
                <input
                  type="text"
                  value={values[k] || ""}
                  placeholder={placeholder}
                  onChange={(e) => update(k, e.target.value)}
                  style={{
                    fontFamily: '"Inter", sans-serif', fontSize: 14,
                    padding: "8px 10px",
                    border: `1px solid ${C.border}`,
                    borderRadius: 4,
                    background: "#fff",
                    color: C.inkwell,
                    outline: "none"
                  }}
                />
              </label>
            ))}
          </div>
          <div style={{ marginTop: 16, display: "flex", gap: 8 }}>
            <button onClick={reset} style={{
              background: "transparent", border: `1px solid ${C.inkwell}`,
              color: C.inkwell, padding: "6px 12px", borderRadius: 4,
              fontFamily: '"JetBrains Mono", monospace', fontSize: 10,
              letterSpacing: "0.12em", textTransform: "uppercase", cursor: "pointer"
            }}>Reset to defaults</button>
          </div>

          <DownloadSection values={values} />
        </div>
      )}
    </div>
  );
}

// ─── Preview modal ────────────────────────────────────────────────────
function PreviewModal({ item, values, onClose }) {
  const stageRef = useRef(null);   // outer container, used to measure
  const innerRef = useRef(null);   // the native-size div React renders into

  useEffect(() => {
    if (!item || !innerRef.current) return;
    const Comp = window[item.comp];
    if (!Comp) return;
    const root = ReactDOM.createRoot(innerRef.current);
    root.render(
      <div style={{ width: item.w, height: item.h, background: item.transparent ? "transparent" : "#fff" }}>
        <Comp />
      </div>
    );
    const t = setTimeout(() => { if (innerRef.current) applyReplacements(innerRef.current, values); }, 50);
    return () => { clearTimeout(t); try { root.unmount(); } catch {} };
  }, [item, values]);

  // ResizeObserver to fit-scale the inner element whenever the stage resizes.
  useEffect(() => {
    if (!item) return;
    const stage = stageRef.current;
    const inner = innerRef.current;
    if (!stage || !inner) return;
    const update = () => {
      const pw = stage.clientWidth;
      const ph = stage.clientHeight;
      if (pw === 0 || ph === 0) return;
      const s = Math.min(pw / item.w, ph / item.h);
      inner.style.transform = `scale(${s})`;
      inner.style.left = ((pw - item.w * s) / 2) + "px";
      inner.style.top  = ((ph - item.h * s) / 2) + "px";
    };
    update();
    const ro = new ResizeObserver(update);
    ro.observe(stage);
    window.addEventListener("resize", update);
    return () => { ro.disconnect(); window.removeEventListener("resize", update); };
  }, [item]);

  if (!item) return null;

  return (
    <div onClick={onClose} style={{
      position: "fixed", inset: 0, zIndex: 1000,
      background: "rgba(28,73,44,0.82)",
      display: "flex", alignItems: "center", justifyContent: "center",
      padding: 24, cursor: "zoom-out"
    }}>
      <div onClick={(e) => e.stopPropagation()} style={{
        background: "#fff",
        // Size the frame to exactly the largest-fit rectangle for the
        // artboard's aspect ratio within (95vw × 92vh). No aspect-ratio
        // CSS — we compute both dimensions explicitly so neither axis
        // ever leaves whitespace beside the rendered content.
        width:  `min(95vw, calc(92vh * ${item.w} / ${item.h}))`,
        height: `min(92vh, calc(95vw * ${item.h} / ${item.w}))`,
        boxShadow: "0 20px 60px rgba(0,0,0,0.4)",
        position: "relative",
        overflow: "hidden"
      }}>
        <div
          ref={stageRef}
          style={{
            position: "relative",
            width: "100%",
            height: "100%",
            background: "#fff",
            overflow: "hidden"
          }}>
          <div
            ref={innerRef}
            style={{
              width: item.w + "px", height: item.h + "px",
              position: "absolute", top: 0, left: 0,
              transformOrigin: "top left"
            }}
          />
        </div>
        <button onClick={onClose} style={{
          position: "absolute", top: 12, right: 12, zIndex: 2,
          background: "rgba(255,255,255,0.92)", color: C.inkwell,
          border: 0, borderRadius: 4,
          padding: "8px 12px", cursor: "pointer",
          fontFamily: '"Inter", sans-serif', fontSize: 13
        }}>Close</button>
      </div>
    </div>
  );
}

// ─── Toast for copy confirmation ──────────────────────────────────────
function Toast({ message, visible }) {
  if (!visible) return null;
  return (
    <div style={{
      position: "fixed", bottom: 24, left: "50%", transform: "translateX(-50%)",
      background: C.inkwell, color: C.vellum,
      padding: "12px 20px", borderRadius: 4,
      boxShadow: "0 6px 16px rgba(0,0,0,0.25)",
      fontFamily: '"JetBrains Mono", monospace', fontSize: 11,
      letterSpacing: "0.14em", textTransform: "uppercase",
      zIndex: 2000, pointerEvents: "none"
    }}>{message}</div>
  );
}

// ─── Section metadata ───────────────────────────────────────────────
const SECTIONS = [
  { id: "businesscard", title: "Business cards", blurb: "3.5 × 2″. Front and back of Debra's card — the identity side on cream, the message side on forest with a scannable QR to cccrecovery.ca.",
    when: "Print double-sided on uncoated or matte stock. Add 1/8″ bleed and keep text inside the safe margin. Order a fresh batch each time contact details change." },
  { id: "flyer",      title: "Print flyers",            blurb: "8.5 × 11″. For physical handouts — bulletin boards, café counters, clinic walls. Every flyer carries a scannable QR that opens cccrecovery.ca. The PDFs are print-shop ready.",
    when: "Print and drop off as soon as a cohort is announced. Refresh every 4–6 weeks." },
  { id: "sticker",    title: "Stickers",                blurb: "Die-cut circles · 3″ matte vinyl. Small enough to hand out by the dozen — laptops, water bottles, daytimers. The scan-to-apply version carries the QR to cccrecovery.ca.",
    when: "Order in bulk and keep a stack in your bag. Evergreen — no dates, so they never expire." },
  { id: "square",     title: "Square posts",          blurb: "1080 × 1080. Use as Instagram feed posts or Facebook feed posts. Copy the caption below the creative, then attach the PNG.",
    when: "Post 2–3 per week in the four weeks before a cohort starts. After it starts, drop to one per week." },
  { id: "vertical",   title: "Stories",               blurb: "1080 × 1920. Vertical posts for Instagram Stories and Facebook Stories — they disappear in 24 hours, but you can pin them to Highlights.",
    when: "Post 1–2 per week during cohort lead-up. Pin the cohort-launch story to a Highlight called \"Apply\" so people can find it." },
  { id: "horizontal", title: "Wide cards",            blurb: "1200 × 630. The link-preview format — use for LinkedIn posts, Facebook link cards, email signatures, and web pages.",
    when: "Send the referral card to a partner whenever you ask for referrals. Use the index card for press inquiries or campaign briefs." },
  { id: "billboard",  title: "Outdoor",                 blurb: "3 : 1 landscape. For transit signs, billboards, or any large outdoor placement. Each carries a QR to cccrecovery.ca — scannable at transit-shelter scale.",
    when: "One-off placement when budget allows. Coordinate with a print partner for sizing." },
  { id: "magnet",     title: "Car magnets",             blurb: "Vinyl car magnets for the family car. Both carry a QR to cccrecovery.ca — scannable on a parked car. Two sizes — the 12 × 3″ bumper strip and the 12 × 12″ door / tailgate. Both are evergreen (no dates), so they don't expire when a cohort wraps.",
    when: "Order one set per car at the start of each year. Hand a spare to a friend who's referring people." },
  { id: "profile",    title: "Profile photos & bios",   blurb: "Square avatars for Instagram, Facebook, and LinkedIn — designed to crop cleanly into a circle. Underneath, ready-to-paste bios in three lengths.",
    when: "Update the profile photo once on each account. Refresh the bio whenever a new cohort opens or the price changes." }
];

// ─── Bios ──────────────────────────────────────────────────────────────
const BIOS = [
  { id: "bio-short",
    title: "Short — Instagram (150 char limit)",
    text: "Eight Mondays. The work of staying sober. A peer-led recovery program in {location}, led by Debra King. ▍▍▍ {url}" },
  { id: "bio-medium",
    title: "Medium — Facebook & LinkedIn",
    text: "Chances. Choices. Changes. — an eight-week peer-led recovery program in {location}, led by Debra King. Eight Mondays, 7–9 p.m. Honest conversation about the work of staying sober. Cohort {cohort} begins {date}.\n\n▍▍▍ Recovery is possible. You're worth it.\n{url} · {phone}" },
  { id: "bio-long",
    title: "Long — referral form, About page, press",
    text: "Chances. Choices. Changes. is an eight-week peer-led recovery program in {location}, led by Debra King — peer counsellor with lived experience and the language to match. Each cohort meets on Mondays, 7–9 p.m., for eight evenings. Small group. Honest conversation about the work of staying sober — daytimers and to-do lists, boundaries and phone calls, quality over quantity.\n\nCohort {cohort} begins {date}. Founders' rate {founders_rate}; regular rate {regular_rate}. Sliding scale available — please ask.\n\nApply at {url} · {phone} · {email}\n\n\"Recovery is possible. You're worth it.\" — Blue skies and green lights." }
];

// ─── Main app ────────────────────────────────────────────────────────
function Library() {
  const [values, setValues] = useStoredValues();
  const [editOpen, setEditOpen] = useState(false);
  const [campaign, setCampaign] = useState("all");
  const [format, setFormat] = useState("all");
  const [preview, setPreview] = useState(null);
  const [toast, setToast] = useState("");

  const showToast = (msg) => {
    setToast(msg);
    setTimeout(() => setToast(""), 1600);
  };

  const filtered = useMemo(() => MANIFEST.filter(m => {
    if (campaign !== "all" && m.campaign !== campaign) return false;
    if (format === "all") return true;
    if (format === "reference") return !!m.reference;
    return m.format === format && !m.reference;
  }), [campaign, format]);

  // Group filtered by format — reference-only items go to a bottom section.
  const grouped = useMemo(() => {
    const main = {};
    const reference = [];
    const profile = [];
    for (const item of filtered) {
      if (item.format === "profile") profile.push(item);
      else if (item.reference) reference.push(item);
      else (main[item.format] = main[item.format] || []).push(item);
    }
    const sections = SECTIONS
      .filter(s => s.id !== "profile" && s.id !== "businesscard")
      .map((s) => ({ ...s, items: main[s.id] || [] }))
      .filter((s) => s.items.length > 0);
    // Profile section (with bios) — always include if we have items or bios available
    const profileSection = SECTIONS.find(s => s.id === "profile");
    if (profile.length > 0 || (campaign === "all" && format === "all") || campaign === "brand") {
      sections.push({ ...profileSection, items: profile });
    }
    // Business cards — stationery, placed just before the reference shelf.
    const cardSection = SECTIONS.find(s => s.id === "businesscard");
    if ((main["businesscard"] || []).length > 0) {
      sections.push({ ...cardSection, items: main["businesscard"] });
    }
    if (reference.length > 0) {
      sections.push({
        id: "reference",
        title: "Brand reference",
        blurb: "Not for posting — keep these on file for designers or partners who need the brand details.",
        items: reference
      });
    }
    return sections;
  }, [filtered, campaign, format]);

  // Re-apply replacements to all rendered artboards when values change.
  useEffect(() => {
    document.querySelectorAll("[data-artboard-host]").forEach(el => applyReplacements(el, values));
  }, [values, filtered]);

  const handleDownload = (item, kind) => {
    if (kind === "png") {
      downloadPng(item, values).then(() => showToast("PNG downloaded"));
    } else {
      downloadPdf(item, values).then(() => showToast("PDF downloaded"));
    }
  };

  const handleCopy = (item) => {
    const text = fillCaption(item.caption, values);
    navigator.clipboard.writeText(text).then(() => showToast("Caption copied"));
  };

  return (
    <div style={{ minHeight: "100vh", background: C.body, color: C.inkwell, fontFamily: '"Inter", sans-serif' }}>
      {/* Header */}
      <header style={{
        background: C.vellum,
        borderBottom: `1px solid ${C.border}`,
        padding: "24px 0"
      }}>
        <div style={{ maxWidth: 1100, margin: "0 auto", padding: "0 24px",
                      display: "flex", alignItems: "center", gap: 18 }}>
          <Tally size={22} />
          <div>
            <h1 style={{
              margin: 0,
              fontFamily: '"Frank Ruhl Libre", serif', fontWeight: 500,
              fontSize: 26, letterSpacing: "-0.015em", color: C.inkwell
            }}>Chances<span style={{ color: C.lime }}>.</span> Choices<span style={{ color: C.lime }}>.</span> Changes<span style={{ color: C.lime }}>.</span> <span style={{ color: C.charcoal, fontWeight: 400 }}>— Promotion Kit</span></h1>
            <div style={{
              marginTop: 4,
              fontFamily: '"Frank Ruhl Libre", serif', fontStyle: "italic",
              fontSize: 14, color: C.charcoal
            }}>Posters, posts, and stories for the program. Browse below, tap a creative to preview, and download what you need.</div>
          </div>
          <a href="edits.html" style={{
            marginLeft: "auto", whiteSpace: "nowrap",
            fontFamily: '"JetBrains Mono", monospace', fontSize: 10,
            letterSpacing: "0.14em", textTransform: "uppercase",
            color: C.oxblood, textDecoration: "none",
            border: `1px solid ${C.border}`, background: C.paper,
            padding: "8px 12px", borderRadius: 4
          }}>Edit copy →</a>
        </div>
      </header>

      <main style={{ maxWidth: 1100, margin: "0 auto", padding: "32px 24px 96px" }}>

        {/* Edit panel */}
        <EditPanel values={values} setValues={setValues} open={editOpen} onToggle={() => setEditOpen(!editOpen)} />

        {/* Filters */}
        <div style={{ marginTop: 12 }}>
          <FilterBar
            campaign={campaign} setCampaign={setCampaign}
            format={format} setFormat={setFormat}
          />
        </div>

        {/* Sections */}
        <div data-artboard-host>
          {grouped.map((section) => (
            <section key={section.id} style={{ marginTop: 56 }}>
              <div style={{
                display: "flex", alignItems: "baseline", gap: 14,
                paddingBottom: 8,
                borderBottom: `1px solid ${C.border}`,
                position: "relative"
              }}>
                <span aria-hidden="true" style={{
                  position: "absolute", left: 0, bottom: -1,
                  height: 2, width: 36, background: C.lime
                }} />
                <h2 style={{
                  margin: 0,
                  fontFamily: '"Frank Ruhl Libre", serif', fontWeight: 500,
                  fontSize: 28, letterSpacing: "-0.015em", color: C.inkwell
                }}>{section.title}</h2>
                <span style={{
                  fontFamily: '"JetBrains Mono", monospace', fontSize: 10,
                  color: "rgba(28,73,44,0.5)", letterSpacing: "0.1em"
                }}>{section.items.length}</span>
              </div>
              <p style={{
                margin: "14px 0 18px",
                fontFamily: '"Frank Ruhl Libre", serif', fontStyle: "italic",
                fontSize: 16, color: C.charcoal, maxWidth: 720, lineHeight: 1.55
              }}>
                {section.blurb}
                {section.when && (
                  <>
                    {" "}
                    <span style={{ color: "rgba(28,73,44,0.55)" }}>{section.when}</span>
                  </>
                )}
              </p>

              <div style={{
                display: "grid",
                gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
                gap: "44px 28px"
              }}>
                {section.items.map(item => (
                  <ArtboardCard
                    key={item.id}
                    item={item}
                    values={values}
                    onPreview={setPreview}
                    onDownload={handleDownload}
                    onCopy={handleCopy}
                  />
                ))}
              </div>

              {/* Bio cards appear under the Profile section */}
              {section.id === "profile" && (
                <div style={{ marginTop: 44, display: "flex", flexDirection: "column", gap: 24 }}>
                  {BIOS.map(bio => {
                    const filled = fillCaption(bio.text, values);
                    return (
                      <div key={bio.id} style={{
                        background: C.paper,
                        border: `1px solid ${C.border}`,
                        borderRadius: 6,
                        padding: "18px 22px"
                      }}>
                        <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 16, marginBottom: 10 }}>
                          <div style={{
                            fontFamily: '"Frank Ruhl Libre", serif', fontSize: 17, fontWeight: 500,
                            color: C.inkwell, letterSpacing: "-0.01em"
                          }}>{bio.title}</div>
                          <IconButton icon="⧉" label="Copy" onClick={() => {
                            navigator.clipboard.writeText(filled).then(() => showToast("Bio copied"));
                          }} />
                        </div>
                        <div style={{
                          fontFamily: '"Frank Ruhl Libre", serif', fontStyle: "italic",
                          fontSize: 14.5, color: C.charcoal, lineHeight: 1.6,
                          whiteSpace: "pre-wrap"
                        }}>{filled}</div>
                      </div>
                    );
                  })}
                </div>
              )}
            </section>
          ))}
        </div>

        {filtered.length === 0 && (
          <div style={{
            padding: 60, textAlign: "center",
            fontFamily: '"Frank Ruhl Libre", serif', fontStyle: "italic",
            fontSize: 18, color: C.charcoal
          }}>No creatives match that filter.</div>
        )}

        {/* Footer help */}
        <div style={{
          marginTop: 80, padding: "28px 0", borderTop: `1px solid ${C.border}`,
          fontFamily: '"Frank Ruhl Libre", serif', fontStyle: "italic", fontSize: 15, color: C.charcoal, textAlign: "center"
        }}>
          Blue skies and green lights.
          <div style={{
            marginTop: 8, fontStyle: "normal",
            fontFamily: '"JetBrains Mono", monospace', fontSize: 10,
            letterSpacing: "0.14em", textTransform: "uppercase", opacity: 0.6
          }}>{values.url} · {values.phone}</div>
        </div>
      </main>

      {preview && <PreviewModal item={preview} values={values} onClose={() => setPreview(null)} />}
      <Toast message={toast} visible={!!toast} />
    </div>
  );
}

function FilterBar({ campaign, setCampaign, format, setFormat }) {
  return (
    <div style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 18 }}>
      <FilterRow label="Campaign" items={CAMPAIGNS} selected={campaign} onSelect={setCampaign} />
      <FilterRow label="Format"   items={FORMATS}   selected={format}   onSelect={setFormat} />
    </div>
  );
}

function FilterRow({ label, items, selected, onSelect }) {
  return (
    <div style={{ display: "flex", alignItems: "center", gap: 14, flexWrap: "wrap" }}>
      <span style={{
        fontFamily: '"Inter", sans-serif', fontSize: 12,
        color: "rgba(28,73,44,0.55)", minWidth: 64
      }}>{label}</span>
      <div style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>
        {items.map(item => {
          const active = selected === item.id;
          return (
            <button key={item.id} onClick={() => onSelect(item.id)} style={{
              background: "transparent",
              color: active ? C.oxblood : "rgba(28,73,44,0.7)",
              border: 0,
              borderBottom: `1.5px solid ${active ? C.oxblood : "transparent"}`,
              borderRadius: 0,
              padding: "4px 2px",
              marginRight: 14,
              fontFamily: '"Inter", sans-serif', fontSize: 13,
              fontWeight: active ? 500 : 400,
              cursor: "pointer", transition: "all 0.15s"
            }}>{item.label}</button>
          );
        })}
      </div>
    </div>
  );
}

// ─── Download helpers ────────────────────────────────────────────────
// Render the artboard at native pixel size into an off-DOM div, then use
// html-to-image to capture it as a high-resolution PNG.

async function renderArtboardAtNative(item, values) {
  const Comp = window[item.comp];
  if (!Comp) throw new Error("Missing component: " + item.comp);
  const host = document.createElement("div");
  host.style.cssText = `position:fixed;top:-99999px;left:-99999px;width:${item.w}px;height:${item.h}px;background:${item.transparent ? "transparent" : "#fff"}`;
  document.body.appendChild(host);
  const root = ReactDOM.createRoot(host);
  root.render(<div style={{ width: item.w, height: item.h, background: item.transparent ? "transparent" : "#fff" }}><Comp /></div>);
  // Wait two frames for layout + apply replacements
  await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
  applyReplacements(host, values);
  // Wait for fonts
  if (document.fonts && document.fonts.ready) await document.fonts.ready;
  // Give images a moment
  await new Promise(r => setTimeout(r, 200));
  return { host, root };
}

async function downloadPng(item, values) {
  const { host, root } = await renderArtboardAtNative(item, values);
  try {
    if (!window.htmlToImage) throw new Error("htmlToImage not loaded");
    const dataUrl = await window.htmlToImage.toPng(host.firstChild, {
      width: item.w, height: item.h, pixelRatio: 1,
      backgroundColor: item.transparent ? undefined : "#ffffff"
    });
    triggerDownload(dataUrl, `${item.name}.png`);
  } finally {
    try { root.unmount(); } catch {}
    host.remove();
  }
}

async function downloadPdf(item, values) {
  // Build a real PDF client-side (same pipeline used for the full-kit zip)
  // so the user gets an actual .pdf download — no print dialog, no clipping.
  const pngBytes = await captureItemPng(item, values);
  const jpegBytes = await pngToJpeg(pngBytes, item.w, item.h);
  const pdf = new MiniPdf();
  await pdf.addJpegPage(jpegBytes, item.w * 72 / 96, item.h * 72 / 96);
  triggerBlobDownload(pdf.toBlob(), `${item.name}.pdf`);
}

function triggerDownload(dataUrl, filename) {
  const a = document.createElement("a");
  a.href = dataUrl;
  a.download = filename;
  document.body.appendChild(a);
  a.click();
  document.body.removeChild(a);
}

// ═══════════════════════════════════════════════════════════════════════
// COHORT PACK / FULL KIT DOWNLOAD
// ═══════════════════════════════════════════════════════════════════════
// Builds a .zip that mirrors the user's offline kit folder structure
// exactly, with date-bearing creatives swapped to the current cohort's
// values. The zip is generated client-side using a minimal store-only
// ZIP writer + JPEG-embedded PDF builder.

// ─── DOWNLOAD_CATALOG ─────────────────────────────────────────────────
// One entry per creative. `comp` is the window-exposed React component.
// `folder` is the exact offline-kit folder path. `cohort: true` flags
// creatives whose visible content changes per cohort.
const DOWNLOAD_CATALOG = [
  // 01 Brand System
  { comp: "BS_WordmarkSheet",     w: 1400, h: 900,  folder: "01 Brand System/Horizontal",         name: "01 Wordmark" },
  { comp: "BS_Specimen",          w: 1400, h: 900,  folder: "01 Brand System/Horizontal",         name: "02 Palette and type specimen" },
  { comp: "BS_Day1Poster",        w: 850,  h: 1100, folder: "01 Brand System/Flyers - Print",     name: "03 Day 1 affirmation poster" },
  { comp: "BS_AcrostichPoster",   w: 1200, h: 900,  folder: "01 Brand System/Horizontal",         name: "04 Vocabulary poster" },
  { comp: "BS_FlyerEvergreen",    w: 850,  h: 1100, folder: "01 Brand System/Flyers - Print",     name: "05 Flyer - evergreen" },
  { comp: "BS_FlyerCohort",       w: 850,  h: 1100, folder: "01 Brand System/Flyers - Print",     name: "06 Flyer - cohort launch", cohort: true },
  { comp: "BS_IGIntro",           w: 1080, h: 1080, folder: "01 Brand System/Square - Instagram", name: "07 IG - brand intro" },
  { comp: "BS_IGQuote",           w: 1080, h: 1080, folder: "01 Brand System/Square - Instagram", name: "08 IG - pull quote" },
  { comp: "BS_IGCohort",          w: 1080, h: 1080, folder: "01 Brand System/Square - Instagram", name: "09 IG - cohort launch", cohort: true },
  { comp: "BS_StoryEvergreen",    w: 1080, h: 1920, folder: "01 Brand System/Vertical - Stories", name: "10 Story - evergreen" },
  { comp: "BS_StoryCohort",       w: 1080, h: 1920, folder: "01 Brand System/Vertical - Stories", name: "11 Story - cohort launch", cohort: true },
  { comp: "BS_Landscape",         w: 1200, h: 630,  folder: "01 Brand System/Horizontal",         name: "12 Referral card" },
  { comp: "BS_StickerWordmark",   w: 1000, h: 1000, transparent: true, folder: "01 Brand System/Stickers - Print",   name: "13 Sticker - wordmark" },
  { comp: "BS_StickerPossible",   w: 1000, h: 1000, transparent: true, folder: "01 Brand System/Stickers - Print",   name: "14 Sticker - recovery is possible" },
  { comp: "BS_StickerScan",       w: 1000, h: 1000, transparent: true, folder: "01 Brand System/Stickers - Print",   name: "15 Sticker - scan to apply" },
  // 02 Recovery Is Possible
  { comp: "LK_FlyerEvergreen",    w: 850,  h: 1100, folder: "02 Recovery Is Possible -Campaign 1-/Flyers - Print",                 name: "01 Evergreen flyer" },
  { comp: "LK_FlyerCohort",       w: 850,  h: 1100, folder: "02 Recovery Is Possible -Campaign 1-/Flyers - Print",                 name: "02 Cohort launch flyer", cohort: true },
  { comp: "LK_IGIntro",           w: 1080, h: 1080, folder: "02 Recovery Is Possible -Campaign 1-/Square - Instagram posts",       name: "03 IG - brand intro" },
  { comp: "LK_IGInspirational",   w: 1080, h: 1080, folder: "02 Recovery Is Possible -Campaign 1-/Square - Instagram posts",       name: "04 IG - inspirational" },
  { comp: "LK_IGCohort",          w: 1080, h: 1080, folder: "02 Recovery Is Possible -Campaign 1-/Square - Instagram posts",       name: "05 IG - cohort launch", cohort: true },
  { comp: "LK_StoryEvergreen",    w: 1080, h: 1920, folder: "02 Recovery Is Possible -Campaign 1-/Vertical - Stories",             name: "06 Story - evergreen" },
  { comp: "LK_StoryCohort",       w: 1080, h: 1920, folder: "02 Recovery Is Possible -Campaign 1-/Vertical - Stories",             name: "07 Story - cohort launch", cohort: true },
  { comp: "LK_LandscapeReferral", w: 1200, h: 630,  folder: "02 Recovery Is Possible -Campaign 1-/Horizontal - LinkedIn and Facebook", name: "08 Referral card" },
  // 03 First Monday
  { comp: "FM_Manifesto",         w: 850,  h: 1100, folder: "03 First Monday -Campaign 2-/Flyers - Print",                          name: "01 Manifesto poster" },
  { comp: "FM_IndexCard",         w: 1200, h: 630,  folder: "03 First Monday -Campaign 2-/Horizontal - LinkedIn and Facebook",      name: "02 Index card", cohort: true },
  { comp: "FM_GreenhousePoster",  w: 850,  h: 1100, folder: "03 First Monday -Campaign 2-/Flyers - Print",                          name: "03 Greenhouse poster" },
  { comp: "FM_EightWeekGrid",     w: 1080, h: 1080, folder: "03 First Monday -Campaign 2-/Square - Instagram posts",                name: "04 Eight-week grid", cohort: true },
  { comp: "FM_ArtifactDayTimer",  w: 850,  h: 1100, folder: "03 First Monday -Campaign 2-/Flyers - Print",                          name: "05 Day-timer poster", cohort: true },
  { comp: "FM_ArtifactNotebook",  w: 1080, h: 1080, folder: "03 First Monday -Campaign 2-/Square - Instagram posts",                name: "06 F.E.A.R. notebook" },
  { comp: "FM_VoicemailSquare",   w: 1080, h: 1080, folder: "03 First Monday -Campaign 2-/Square - Instagram posts",                name: "07 Voicemail square", cohort: true },
  { comp: "FM_StoryLitany",       w: 1080, h: 1920, folder: "03 First Monday -Campaign 2-/Vertical - Stories",                      name: "08 Story - what we dont say" },
  { comp: "FM_StoryFieldGuide",   w: 1080, h: 1920, folder: "03 First Monday -Campaign 2-/Vertical - Stories",                      name: "09 Story - field guide", cohort: true },
  { comp: "FM_BillboardEvergreen", w: 1800, h: 600, folder: "03 First Monday -Campaign 2-/Billboard - Outdoor",                     name: "10 Billboard - evergreen" },
  { comp: "FM_Billboard",         w: 1800, h: 600,  folder: "03 First Monday -Campaign 2-/Billboard - Outdoor",                     name: "11 Billboard - cohort launch", cohort: true },
  { comp: "FM_CarMagnetBumper",   w: 1200, h: 300,  folder: "03 First Monday -Campaign 2-/Car magnets",                              name: "12 Bumper magnet" },
  { comp: "FM_CarMagnetSquare",   w: 1200, h: 1200, folder: "03 First Monday -Campaign 2-/Car magnets",                              name: "13 Door magnet" }
];
const COHORT_ITEMS = DOWNLOAD_CATALOG.filter(c => c.cohort);
const COHORT_PACK_BYTES_EST = COHORT_ITEMS.length * 540 * 1024;
const FULL_KIT_BYTES_EST    = DOWNLOAD_CATALOG.length * 540 * 1024;

// ─── CRC-32 (for store-mode ZIP) ──────────────────────────────────────
function _crc32(bytes) {
  let table = _crc32._t;
  if (!table) {
    table = new Uint32Array(256);
    for (let i = 0; i < 256; i++) {
      let c = i;
      for (let j = 0; j < 8; j++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
      table[i] = c >>> 0;
    }
    _crc32._t = table;
  }
  let c = 0xffffffff;
  for (let i = 0; i < bytes.length; i++) c = (table[(c ^ bytes[i]) & 0xff] ^ (c >>> 8)) >>> 0;
  return (c ^ 0xffffffff) >>> 0;
}

// ─── MiniZip (store-only, no compression) ─────────────────────────────
class MiniZip {
  constructor() { this.entries = []; }
  addFile(name, data) {
    let bytes;
    if (typeof data === "string") bytes = new TextEncoder().encode(data);
    else if (data instanceof Uint8Array) bytes = data;
    else if (data instanceof ArrayBuffer) bytes = new Uint8Array(data);
    else throw new Error("MiniZip.addFile: bad data type");
    this.entries.push({ name, bytes });
  }
  toBlob() {
    const parts = [];
    const centralEntries = [];
    let offset = 0;
    for (const e of this.entries) {
      const nameBytes = new TextEncoder().encode(e.name);
      const crc = _crc32(e.bytes);
      const size = e.bytes.length;
      const local = new Uint8Array(30 + nameBytes.length);
      const dv = new DataView(local.buffer);
      dv.setUint32(0, 0x04034b50, true);
      dv.setUint16(4, 20, true);
      dv.setUint16(6, 0, true);
      dv.setUint16(8, 0, true);
      dv.setUint16(10, 0, true);
      dv.setUint16(12, 0, true);
      dv.setUint32(14, crc, true);
      dv.setUint32(18, size, true);
      dv.setUint32(22, size, true);
      dv.setUint16(26, nameBytes.length, true);
      dv.setUint16(28, 0, true);
      local.set(nameBytes, 30);
      parts.push(local);
      parts.push(e.bytes);
      const central = new Uint8Array(46 + nameBytes.length);
      const cdv = new DataView(central.buffer);
      cdv.setUint32(0, 0x02014b50, true);
      cdv.setUint16(4, 20, true);
      cdv.setUint16(6, 20, true);
      cdv.setUint16(8, 0, true);
      cdv.setUint16(10, 0, true);
      cdv.setUint16(12, 0, true);
      cdv.setUint16(14, 0, true);
      cdv.setUint32(16, crc, true);
      cdv.setUint32(20, size, true);
      cdv.setUint32(24, size, true);
      cdv.setUint16(28, nameBytes.length, true);
      cdv.setUint16(30, 0, true);
      cdv.setUint16(32, 0, true);
      cdv.setUint16(34, 0, true);
      cdv.setUint16(36, 0, true);
      cdv.setUint32(38, 0, true);
      cdv.setUint32(42, offset, true);
      central.set(nameBytes, 46);
      centralEntries.push(central);
      offset += local.length + e.bytes.length;
    }
    const cdStart = offset;
    let cdSize = 0;
    for (const c of centralEntries) { parts.push(c); cdSize += c.length; }
    const eocd = new Uint8Array(22);
    const edv = new DataView(eocd.buffer);
    edv.setUint32(0, 0x06054b50, true);
    edv.setUint16(4, 0, true);
    edv.setUint16(6, 0, true);
    edv.setUint16(8, centralEntries.length, true);
    edv.setUint16(10, centralEntries.length, true);
    edv.setUint32(12, cdSize, true);
    edv.setUint32(16, cdStart, true);
    edv.setUint16(20, 0, true);
    parts.push(eocd);
    return new Blob(parts, { type: "application/zip" });
  }
}

// ─── MiniPdf — single-page PDF with a JPEG image filling the page ─────
// Note: PDFs produced here are raster (JPEG-embedded), not vector.
// For vector-quality print, users should open the individual creative and
// use the browser's "Save as PDF" via the print dialog.
class MiniPdf {
  constructor() { this.objects = []; this.pages = []; }
  _add(body) {
    const num = this.objects.length + 1;
    const bytes = typeof body === "string" ? new TextEncoder().encode(body) : body;
    this.objects.push({ num, body: bytes });
    return num;
  }
  async addJpegPage(jpegBytes, widthPt, heightPt) {
    let w = 0, h = 0;
    for (let i = 2; i < jpegBytes.length - 9; i++) {
      if (jpegBytes[i] === 0xff && (jpegBytes[i + 1] === 0xc0 || jpegBytes[i + 1] === 0xc2)) {
        h = (jpegBytes[i + 5] << 8) | jpegBytes[i + 6];
        w = (jpegBytes[i + 7] << 8) | jpegBytes[i + 8];
        break;
      }
    }
    const imgDict = `<< /Type /XObject /Subtype /Image /Width ${w} /Height ${h} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${jpegBytes.length} >>`;
    const head = new TextEncoder().encode(imgDict + "\nstream\n");
    const tail = new TextEncoder().encode("\nendstream");
    const body = new Uint8Array(head.length + jpegBytes.length + tail.length);
    body.set(head, 0); body.set(jpegBytes, head.length); body.set(tail, head.length + jpegBytes.length);
    const imgNum = this._add(body);
    const content = `q\n${widthPt} 0 0 ${heightPt} 0 0 cm\n/Im0 Do\nQ\n`;
    const contentNum = this._add(`<< /Length ${content.length} >>\nstream\n${content}\nendstream`);
    const pageNum = this._add(`<< /Type /Page /Parent __P__ /MediaBox [0 0 ${widthPt} ${heightPt}] /Resources << /XObject << /Im0 ${imgNum} 0 R >> >> /Contents ${contentNum} 0 R >>`);
    this.pages.push(pageNum);
  }
  toBlob() {
    const kids = this.pages.map(n => `${n} 0 R`).join(" ");
    const pagesNum = this._add(`<< /Type /Pages /Kids [${kids}] /Count ${this.pages.length} >>`);
    for (const o of this.objects) {
      const t = new TextDecoder().decode(o.body);
      if (t.includes("__P__")) o.body = new TextEncoder().encode(t.replace("__P__", `${pagesNum} 0 R`));
    }
    const catNum = this._add(`<< /Type /Catalog /Pages ${pagesNum} 0 R >>`);
    const parts = []; const offs = [0]; let cur = 0;
    const push = (s) => { const b = typeof s === "string" ? new TextEncoder().encode(s) : s; parts.push(b); cur += b.length; };
    push("%PDF-1.4\n%\xff\xff\xff\xff\n");
    for (const o of this.objects) { offs[o.num] = cur; push(`${o.num} 0 obj\n`); push(o.body); push("\nendobj\n"); }
    const xrefPos = cur;
    let xref = `xref\n0 ${this.objects.length + 1}\n0000000000 65535 f \n`;
    for (let i = 1; i <= this.objects.length; i++) xref += `${String(offs[i]).padStart(10, "0")} 00000 n \n`;
    push(xref);
    push(`trailer\n<< /Size ${this.objects.length + 1} /Root ${catNum} 0 R >>\nstartxref\n${xrefPos}\n%%EOF\n`);
    return new Blob(parts, { type: "application/pdf" });
  }
}

// ─── Capture pipeline ─────────────────────────────────────────────────
async function captureItemPng(cat, values) {
  const Comp = window[cat.comp];
  if (!Comp) throw new Error("Missing component: " + cat.comp);
  const host = document.createElement("div");
  host.style.cssText = `position:fixed;top:-99999px;left:-99999px;width:${cat.w}px;height:${cat.h}px;background:${cat.transparent ? "transparent" : "#fff"};`;
  document.body.appendChild(host);
  const root = ReactDOM.createRoot(host);
  root.render(<div style={{ width: cat.w, height: cat.h, background: cat.transparent ? "transparent" : "#fff" }}><Comp /></div>);
  await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
  applyReplacements(host, values);
  if (document.fonts && document.fonts.ready) await document.fonts.ready;
  await new Promise(r => setTimeout(r, 250));
  try {
    if (!window.htmlToImage) throw new Error("htmlToImage not loaded");
    const dataUrl = await window.htmlToImage.toPng(host.firstChild, {
      width: cat.w, height: cat.h, pixelRatio: 1,
      backgroundColor: cat.transparent ? undefined : "#ffffff"
    });
    const b64 = dataUrl.split(",")[1];
    const bin = atob(b64);
    const bytes = new Uint8Array(bin.length);
    for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
    return bytes;
  } finally {
    try { root.unmount(); } catch {}
    host.remove();
  }
}

async function pngToJpeg(pngBytes, w, h) {
  const blob = new Blob([pngBytes], { type: "image/png" });
  const img = await createImageBitmap(blob);
  const canvas = document.createElement("canvas");
  canvas.width = w; canvas.height = h;
  const ctx = canvas.getContext("2d");
  ctx.fillStyle = "#fff"; ctx.fillRect(0, 0, w, h);
  ctx.drawImage(img, 0, 0, w, h);
  const jpegBlob = await new Promise(r => canvas.toBlob(r, "image/jpeg", 0.92));
  return new Uint8Array(await jpegBlob.arrayBuffer());
}

async function buildPdf(jpegBytes, w, h) {
  const pdf = new MiniPdf();
  await pdf.addJpegPage(jpegBytes, w * 72 / 96, h * 72 / 96);
  return new Uint8Array(await pdf.toBlob().arrayBuffer());
}

function makeReadme(values, isFull) {
  const date = values.date_long || "your cohort";
  const lead = isFull
    ? `CCC — Full kit (set for cohort starting ${date})`
    : `CCC — Cohort files for ${date}`;
  const installExtra = isFull
    ? "\nThis kit includes BOTH the cohort-launch files AND the evergreen brand creatives. Use it once on first handoff, or when you want a fresh copy of everything.\n"
    : "";
  return `${lead}

These are the marketing creatives for the cohort starting ${date}.
Each file appears in two formats:

  .pdf  — for printing (8.5×11 flyers, billboards, anything physical)
  .png  — for social media (Instagram, Facebook, LinkedIn, Stories)

How to install:
${installExtra}
  1. Unzip this folder.
  2. Open your "CCC for Debra" folder on your computer.
  3. Drag this unzipped folder ONTO your "CCC for Debra" folder.
  4. When asked, click "Merge" (Mac) or "Yes to All" (Windows).
     This replaces the previous cohort's files with the new ones.
  5. Done. The old files for the previous cohort are now updated.

If you need the editable PowerPoint versions, they live in the
"Editable" subfolder of each campaign in your "CCC for Debra"
folder. They are unchanged by this zip.

Recovery is possible. You're worth it.

${values.url || "cccrecovery.ca"} · ${values.phone || "(289) 207-2617"} · ${values.email || "debra@cccrecovery.ca"}
`;
}

async function buildKitZip(values, opts) {
  const iso = values.cohortStartIso || "2026-06-01";
  const isFull = !!opts.full;
  const rootFolder = isFull ? `ccc-full-kit-${iso}` : `ccc-cohort-${iso}`;
  const items = isFull ? DOWNLOAD_CATALOG : COHORT_ITEMS;

  const zip = new MiniZip();
  zip.addFile(`${rootFolder}/README — replace your cohort files.txt`, makeReadme(values, isFull));

  for (let i = 0; i < items.length; i++) {
    const c = items[i];
    if (opts.onProgress) opts.onProgress(i, items.length, c);
    const pngBytes = await captureItemPng(c, values);
    zip.addFile(`${rootFolder}/${c.folder}/${c.name}.png`, pngBytes);
    const jpegBytes = await pngToJpeg(pngBytes, c.w, c.h);
    const pdfBytes  = await buildPdf(jpegBytes, c.w, c.h);
    zip.addFile(`${rootFolder}/${c.folder}/${c.name}.pdf`, pdfBytes);
  }
  if (opts.onProgress) opts.onProgress(items.length, items.length, null);
  return { blob: zip.toBlob(), filename: `${rootFolder}.zip` };
}

function triggerBlobDownload(blob, filename) {
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url; a.download = filename;
  document.body.appendChild(a); a.click(); document.body.removeChild(a);
  setTimeout(() => URL.revokeObjectURL(url), 4000);
}

function fmtBytes(n) {
  if (n < 1024) return n + " B";
  if (n < 1024 * 1024) return Math.round(n / 1024) + " KB";
  return Math.round(n / (1024 * 1024)) + " MB";
}

// ─── Brand calendar — replaces the native date input with a popover
// month-grid in CCC typography. Keyboard-friendly: Enter / Space toggle,
// Esc closes. Click outside closes.
function BrandCalendar({ value, onChange }) {
  const [open, setOpen] = useState(false);
  const initial = (() => {
    if (value && /^\d{4}-\d{2}-\d{2}$/.test(value)) {
      const [y, m] = value.split("-").map(Number);
      return new Date(y, m - 1, 1, 12);
    }
    return new Date();
  })();
  const [view, setView] = useState(initial);
  const ref = useRef(null);
  React.useEffect(() => {
    if (!open) return;
    const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
    const onKey = (e) => { if (e.key === "Escape") setOpen(false); };
    document.addEventListener("mousedown", onDoc);
    document.addEventListener("keydown", onKey);
    return () => { document.removeEventListener("mousedown", onDoc); document.removeEventListener("keydown", onKey); };
  }, [open]);

  const monthName = view.toLocaleDateString("en-US", { month: "long", year: "numeric" });
  const firstDayWeekday = new Date(view.getFullYear(), view.getMonth(), 1).getDay(); // 0=Sun
  const daysInMonth = new Date(view.getFullYear(), view.getMonth() + 1, 0).getDate();
  const selectedIso = value;

  const cells = [];
  for (let i = 0; i < firstDayWeekday; i++) cells.push(null);
  for (let d = 1; d <= daysInMonth; d++) cells.push(d);

  const buttonLabel = (() => {
    const p = parseCohortIso(value);
    return p ? p.date_long : "Choose a date";
  })();

  const pad = (n) => String(n).padStart(2, "0");

  return (
    <div ref={ref} style={{ position: "relative", display: "inline-block" }}>
      <button
        type="button"
        onClick={() => setOpen(!open)}
        style={{
          fontFamily: '"Inter", sans-serif', fontSize: 14,
          padding: "8px 30px 8px 12px",
          border: `1px solid ${C.border}`,
          borderRadius: 4,
          background: "#fff",
          color: value ? C.inkwell : "rgba(28,73,44,0.45)",
          cursor: "pointer", textAlign: "left",
          position: "relative", minWidth: 180
        }}>
        {buttonLabel}
        <span aria-hidden="true" style={{
          position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)",
          fontSize: 12, color: "rgba(28,73,44,0.55)"
        }}>{open ? "▴" : "▾"}</span>
      </button>
      {open && (
        <div style={{
          position: "absolute", top: "calc(100% + 6px)", left: 0, zIndex: 50,
          background: C.paper, border: `1px solid ${C.border}`,
          borderRadius: 6, padding: "14px 14px 12px",
          width: 280,
          boxShadow: "0 14px 36px -10px rgba(28,73,44,0.35), 0 2px 0 rgba(28,73,44,0.05)"
        }}>
          {/* Header */}
          <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 10 }}>
            <button type="button" onClick={() => setView(new Date(view.getFullYear(), view.getMonth() - 1, 1, 12))}
              style={{ background: "transparent", border: 0, cursor: "pointer", padding: "4px 8px", color: C.inkwell, fontSize: 14 }}>‹</button>
            <div style={{
              fontFamily: '"Frank Ruhl Libre", serif', fontWeight: 500,
              fontSize: 15, letterSpacing: "-0.01em", color: C.inkwell
            }}>{monthName}</div>
            <button type="button" onClick={() => setView(new Date(view.getFullYear(), view.getMonth() + 1, 1, 12))}
              style={{ background: "transparent", border: 0, cursor: "pointer", padding: "4px 8px", color: C.inkwell, fontSize: 14 }}>›</button>
          </div>
          {/* Day-of-week labels */}
          <div style={{ display: "grid", gridTemplateColumns: "repeat(7, 1fr)", gap: 2, marginBottom: 4 }}>
            {["S", "M", "T", "W", "T", "F", "S"].map((d, i) => (
              <div key={i} style={{
                textAlign: "center",
                fontFamily: '"JetBrains Mono", monospace', fontSize: 9,
                letterSpacing: "0.1em", color: "rgba(28,73,44,0.5)"
              }}>{d}</div>
            ))}
          </div>
          {/* Day cells */}
          <div style={{ display: "grid", gridTemplateColumns: "repeat(7, 1fr)", gap: 2 }}>
            {cells.map((d, i) => {
              if (d == null) return <div key={i} />;
              const iso = `${view.getFullYear()}-${pad(view.getMonth() + 1)}-${pad(d)}`;
              const isSelected = iso === selectedIso;
              return (
                <button
                  key={i}
                  type="button"
                  onClick={() => { onChange(iso); setOpen(false); }}
                  style={{
                    background: isSelected ? C.inkwell : "transparent",
                    color: isSelected ? C.vellum : C.inkwell,
                    border: 0, borderRadius: 3,
                    padding: "6px 0",
                    fontFamily: '"Inter", sans-serif', fontSize: 13,
                    cursor: "pointer", transition: "background 0.1s"
                  }}
                  onMouseEnter={(e) => { if (!isSelected) e.currentTarget.style.background = "rgba(28,73,44,0.08)"; }}
                  onMouseLeave={(e) => { if (!isSelected) e.currentTarget.style.background = "transparent"; }}
                >{d}</button>
              );
            })}
          </div>
        </div>
      )}
    </div>
  );
}

// ─── Download section UI (rendered inside the Edit panel) ─────────────
function DownloadSection({ values }) {
  const iso = values.cohortStartIso;
  const dateLabel = values.date_long || iso || "—";
  const cohortN = COHORT_ITEMS.length;
  const fullN = DOWNLOAD_CATALOG.length;
  const [status, setStatus] = useState(null); // null | {kind, total, done, label}
  const [error,  setError]  = useState(null);

  const run = async (full) => {
    setError(null);
    setStatus({ kind: full ? "full" : "cohort", total: full ? fullN : cohortN, done: 0, label: "" });
    const start = Date.now();
    try {
      const { blob, filename } = await buildKitZip(values, {
        full,
        onProgress: (done, total, item) => {
          setStatus({ kind: full ? "full" : "cohort", total, done, label: item ? item.name : "" });
        }
      });
      triggerBlobDownload(blob, filename);
      const ms = Date.now() - start;
      // If under 1.5s, no need to leave a status message lingering
      if (ms < 1500) {
        setStatus(null);
      } else {
        setStatus({ kind: full ? "full" : "cohort", total: 1, done: 1, label: "", finished: true });
        setTimeout(() => setStatus(null), 2200);
      }
      if (ms > 5000) {
        // eslint-disable-next-line no-console
        console.warn(`[CCC] zip build took ${ms}ms (${full ? "full kit" : "cohort pack"}). Investigate perf.`);
      }
    } catch (e) {
      setError(String(e && e.message || e));
      setStatus(null);
    }
  };

  const isoValid = !!iso && /^\d{4}-\d{2}-\d{2}$/.test(iso);

  return (
    <div style={{
      marginTop: 22,
      paddingTop: 18,
      borderTop: `1px solid ${C.border}`
    }}>
      <div style={{
        fontFamily: '"JetBrains Mono", monospace', fontSize: 9,
        letterSpacing: "0.12em", textTransform: "uppercase",
        color: C.charcoal, marginBottom: 12
      }}>Download for this cohort</div>

      {!isoValid ? (
        <div style={{
          fontFamily: '"Frank Ruhl Libre", serif', fontStyle: "italic",
          fontSize: 13.5, color: "rgba(28,73,44,0.55)", marginBottom: 2
        }}>Set a cohort date above to enable the download.</div>
      ) : (
        <>
          <button
            disabled={!!status}
            onClick={() => run(false)}
            style={{
              background: "transparent", border: 0, padding: 0,
              color: C.inkwell,
              fontFamily: '"Inter", sans-serif', fontSize: 15, fontWeight: 500,
              cursor: status ? "not-allowed" : "pointer",
              display: "inline-flex", alignItems: "center", gap: 8,
              textAlign: "left"
            }}>
            <span style={{ fontSize: 17, lineHeight: 1, color: C.oxblood }}>⤓</span>
            <span style={{
              borderBottom: `1.5px solid ${C.oxblood}`,
              paddingBottom: 1
            }}>Cohort pack for {dateLabel}</span>
            <span style={{
              fontFamily: '"JetBrains Mono", monospace', fontSize: 10,
              letterSpacing: "0.08em", color: "rgba(28,73,44,0.55)"
            }}>{cohortN} files · ~{fmtBytes(COHORT_PACK_BYTES_EST)}</span>
          </button>
        </>
      )}

      <div style={{ marginTop: 8 }}>
        <button
          disabled={!!status}
          onClick={() => run(true)}
          style={{
            background: "transparent",
            color: C.charcoal,
            border: 0,
            padding: "4px 0",
            fontFamily: '"Inter", sans-serif', fontSize: 13,
            textDecoration: "underline",
            textDecorationColor: "rgba(28,73,44,0.25)",
            textUnderlineOffset: "3px",
            cursor: status ? "not-allowed" : "pointer",
            opacity: status ? 0.5 : 1
          }}>
          Or download the full kit (all {fullN} creatives, ~{fmtBytes(FULL_KIT_BYTES_EST)}) →
        </button>
      </div>

      {status && !status.finished && (
        <div style={{
          marginTop: 14,
          fontFamily: '"Inter", sans-serif', fontSize: 13, color: C.charcoal,
          display: "flex", alignItems: "center", gap: 10
        }}>
          <span style={{
            display: "inline-block", width: 12, height: 12,
            border: `2px solid ${C.oxblood}`, borderTopColor: "transparent",
            borderRadius: "50%", animation: "ccc-spin 0.7s linear infinite"
          }} />
          <span>Bundling {status.done} of {status.total}…</span>
        </div>
      )}
      {status && status.finished && (
        <div style={{
          marginTop: 14,
          fontFamily: '"Inter", sans-serif', fontSize: 13, color: C.oxblood
        }}>✓ Download started.</div>
      )}
      {error && (
        <div style={{
          marginTop: 14,
          fontFamily: '"Inter", sans-serif', fontSize: 13, color: C.oxblood
        }}>Download failed: {error}</div>
      )}

      <style>{`@keyframes ccc-spin { to { transform: rotate(360deg); } }`}</style>
    </div>
  );
}

// ═══════════════════════════════════════════════════════════════════════
// ─── Mount ────────────────────────────────────────────────────────────
ReactDOM.createRoot(document.getElementById("root")).render(<Library />);
