/* ============================================================
   Store — in-memory "database" + provider, persisted to localStorage.
   Simulates the API the real site would talk to.
   ============================================================ */
const { createContext, useContext, useState, useEffect, useRef, useMemo, useCallback } = React;

const DB_KEY = "cr_site_db_v12";

/* ---- section business types (Database Design Document v16 §5) ----
   A section's TYPE drives which content fields are pre-filled and how a piece
   renders. The legacy per-content `kind` is kept as the storage key; `type`
   is the v16 business type chosen at section creation.
     MULTIMODAL      → kind 'blog'      — rich article (text + alternative media)
     VIDEO           → kind 'video'     — a video
     IMAGE_GALLERY   → kind 'image'     — one piece = a gallery of images
     DOCUMENT_LIBRARY→ kind 'document'  — one piece = several documents (PDF)
     AUDIO_LIBRARY   → kind 'sound'     — one piece = an album of sounds        */
const SECTION_TYPES = {
  multimodal: { id: "multimodal", kind: "blog",     icon: "edit",       label: "Multimodal blog",  labelFr: "Blog multimodal",     blurb: "Rich articles — text, human & AI voice, music, video, illustrations." },
  video:      { id: "video",      kind: "video",    icon: "play",       label: "Videos",           labelFr: "Vidéos",             blurb: "A collection of videos (YouTube, other link, or upload)." },
  image:      { id: "image",      kind: "image",    icon: "compass",    label: "Image gallery",    labelFr: "Galerie d'images",   blurb: "Each piece is a gallery of images — cover + arrows, or auto-switch." },
  document:   { id: "document",   kind: "document", icon: "doc",        label: "Document library", labelFr: "Bibliothèque de documents", blurb: "Each piece holds several documents, each read in a PDF popup." },
  audio:      { id: "audio",      kind: "sound",    icon: "headphones", label: "Audio library",    labelFr: "Bibliothèque audio", blurb: "Podcasts, music & voice — each piece is an album of sounds with a player." },
};
const SECTION_TYPE_ORDER = ["multimodal", "video", "image", "document", "audio"];
/* legacy kinds → v16 business type */
function sectionTypeOf(s) {
  const k = (s && s.kind) || "blog";
  if (k === "blog") return "multimodal";
  if (k === "video") return "video";
  if (k === "image") return "image";
  if (k === "document") return "document";
  return "audio"; // podcast | music | audio | sound
}
function sectionTypeMeta(s) { return SECTION_TYPES[sectionTypeOf(s)] || SECTION_TYPES.multimodal; }
const AUDIO_KINDS = ["podcast", "music", "audio", "sound"];
function isAudioContentKind(k) { return AUDIO_KINDS.includes(k); }

const LEGAL_SEED = {
  mentions: {
    title: "Legal notice",
    body: "Site publisher: Cannelle Richter.\nContact: hello@cannellerichter.fr\n\nHosting: provided by a third-party hosting service (details on request).\n\nThis personal website presents the work, projects and writing of Cannelle Richter. All content is published under her own responsibility.",
    fallback: "fallback",
    tr: { fr: { title: "Mentions légales", body: "Éditrice du site : Cannelle Richter.\nContact : hello@cannellerichter.fr\n\nHébergement : assuré par un prestataire tiers (détails sur demande).\n\nCe site personnel présente les travaux, projets et écrits de Cannelle Richter. Tout le contenu est publié sous sa propre responsabilité." } },
  },
  copyright: {
    title: "Copyright",
    body: "© Cannelle Richter. All original articles, videos, illustrations and code on this site are the property of their author unless stated otherwise.\n\nYou may quote short excerpts with a clear link back. For any other reuse, please get in touch first.",
    fallback: "fallback",
    tr: { fr: { title: "Droits d'auteur", body: "© Cannelle Richter. Tous les articles, vidéos, illustrations et codes originaux de ce site sont la propriété de leur autrice, sauf mention contraire.\n\nVous pouvez citer de courts extraits avec un lien clair vers la source. Pour tout autre réemploi, merci de me contacter au préalable." } },
  },
  cgu: {
    title: "Terms of use (CGU)",
    body: "By using cannellerichter.fr you agree to browse it in good faith.\n\n1. Content is provided for information and inspiration; it comes with no warranty.\n2. External links are shared in good faith — their content is not under our control.\n3. Forms (contact, feedback, CV request) collect only the data you choose to send, used solely to reply to you.\n\nThese terms may evolve; the current version always applies.",
    fallback: "fallback",
    tr: { fr: { title: "Conditions d'utilisation (CGU)", body: "En utilisant cannellerichter.fr, vous acceptez de le consulter de bonne foi.\n\n1. Le contenu est fourni à titre d'information et d'inspiration ; il est sans garantie.\n2. Les liens externes sont partagés de bonne foi — leur contenu échappe à notre contrôle.\n3. Les formulaires (contact, feedback, demande de CV) ne collectent que les données que vous choisissez d'envoyer, utilisées uniquement pour vous répondre.\n\nCes conditions peuvent évoluer ; la version en vigueur s'applique toujours." } },
  },
};

/* editable copy for the simple static form pages (Contact / Feedback).
   EN = base fields, FR = tr.fr, with the same fallback model as content. */
const PAGES_SEED = {
  contact: {
    kicker: "", title: "Contact me", subtitle: "A question, a project, a collaboration? Drop me a line.",
    nameFirst: "First name", nameLast: "Last name", email: "Email", message: "Message", btn: "Send",
    fallback: "fallback",
    tr: { fr: { title: "Me contacter", subtitle: "Une question, un projet, une collaboration ? Écrivez-moi.", nameFirst: "Prénom", nameLast: "Nom", email: "E-mail", message: "Message", btn: "Envoyer" } },
  },
  feedback: {
    kicker: "", title: "Give feedback", subtitle: "Tell me what works, what doesn't, what you'd love to see.",
    name: "Name", rating: "Rating", message: "Message", btn: "Send feedback",
    fallback: "fallback",
    tr: { fr: { title: "Donner un feedback", subtitle: "Dites-moi ce qui marche, ce qui cloche, ce que vous aimeriez voir.", name: "Nom", rating: "Note", message: "Message", btn: "Envoyer le feedback" } },
  },
};

/* ---- roles & permissions (mirrors the backend user_access_grants model) ----
   admin    — can do everything (content, settings, users, permissions)
   editor   — edit DRAFT content & untranslated languages; no settings / users / publish / delete
   reviewer — see private + draft content, comment & give feedback; cannot edit/publish/delete
   tester   — same rights as guest, but also previews PRE-RELEASED (in-review) public pages
   guest    — view the private content shared with them, give feedback                       */
const ROLES = {
  admin:    { id: "admin",    label: "Admin",    color: "#2f6bdb", blurb: "Full access — content, settings, users & permissions.",
    can: { edit: true,  settings: true,  users: true,  publish: true,  delete: true,  board: true,  feedback: false, review: false, prerelease: true } },
  editor:   { id: "editor",   label: "Editor",   color: "#1f9e72", blurb: "Edit drafts and untranslated content. No settings, users or publishing.",
    can: { edit: true,  settings: false, users: false, publish: false, delete: false, board: false, feedback: false, review: false, prerelease: true } },
  reviewer: { id: "reviewer", label: "Reviewer", color: "#7a4fe0", blurb: "See private & draft work, comment and validate pieces in review. Cannot publish.",
    can: { edit: false, settings: false, users: false, publish: false, delete: false, board: false, feedback: true,  review: true,  prerelease: true } },
  publisher:{ id: "publisher",label: "Publisher",color: "#c0398a", blurb: "Publish validated content and schedule a go-live date. Cannot publish private pages.",
    can: { edit: false, settings: false, users: false, publish: true,  delete: false, board: true,  feedback: true,  review: false, prerelease: true } },
  tester:   { id: "tester",   label: "Tester",   color: "#0e9aa7", blurb: "Preview published & pre-released (in-review) public pages, plus the private content shared with you. Leaves feedback.",
    can: { edit: false, settings: false, users: false, publish: false, delete: false, board: false, feedback: true,  review: false, prerelease: true } },
  guest:    { id: "guest",    label: "Guest",    color: "#e08a1e", blurb: "View the private content shared with you and leave feedback.",
    can: { edit: false, settings: false, users: false, publish: false, delete: false, board: false, feedback: true,  review: false, prerelease: false } },
};
function roleMeta(r) { return ROLES[r] || ROLES.guest; }
function roleCan(r, perm) { const m = ROLES[r]; return !!(m && m.can[perm]); }

/* ---- editorial workflow: a piece moves Draft → Review needed → Validated → Published ----
   editor    : draft ⇄ review        (can flag "ready", never validate or publish)
   reviewer  : review → validated OR review → draft (kick back) + leave feedback
   publisher : validated → published (schedule a date) OR validated → review (kick back)
   admin     : free movement across all four                                    */
const WORKFLOW = {
  draft:     { id: "draft",     label: "Draft",         short: "Draft",     color: "#8a8478", ink: "#5f5b51", wash: "#f1efe9", note: "In progress — hidden from the site." },
  review:    { id: "review",    label: "Review needed", short: "In review", color: "#d97706", ink: "#b56b08", wash: "#fdf3e3", note: "Finished by the editor — waiting for a reviewer to validate." },
  validated: { id: "validated", label: "Validated",     short: "Validated", color: "#2f6bdb", ink: "#1f4fb0", wash: "#eaf0fc", note: "Approved by a reviewer — ready for a publisher to push live." },
  published: { id: "published", label: "Published",     short: "Published",  color: "#1f9e72", ink: "#137a57", wash: "#e3f5ee", note: "Live on the site." },
};
const WF_ORDER = ["draft", "review", "validated", "published"];
function wfMeta(s) { return WORKFLOW[s] || WORKFLOW.draft; }
/* status of any content/page object, derived from the legacy `published` flag when absent */
function statusOf(o) { return (o && o.status) || (o && o.published === false ? "draft" : "published"); }
/* ---- per-language editorial workflow ----
   The workflow is UNIQUE PER LANGUAGE: a piece can be Published in EN while still
   In review (or Draft) in FR. statusByLang holds {en, fr}; statusOf() stays the
   language-agnostic legacy view (used by the board & coarse counts). */
function statusOfLang(o, lang) {
  if (o && o.statusByLang && o.statusByLang[lang]) return o.statusByLang[lang];
  return statusOf(o);
}
function publishedInLang(o, lang) { return statusOfLang(o, lang) === "published"; }
/* who may SEE a given workflow status while browsing publicly (i.e. not inside a
   review/edit mode): everyone sees Published; testers & admins also see the
   PRE-RELEASED (in-review / validated) public pages; Draft is never public. */
function canSeeStatus(role, status) {
  if (status === "published") return true;
  if (status === "review" || status === "validated") return role === "tester" || role === "admin" || role === "publisher";
  return false;
}
/* which target statuses a role may move a piece TO, given its current status */
function allowedStatusTransitions(role, cur) {
  if (role === "admin") return WF_ORDER.filter(s => s !== cur);
  if (role === "editor") return cur === "draft" ? ["review"] : cur === "review" ? ["draft"] : [];
  if (role === "reviewer") return cur === "review" ? ["validated", "draft"] : cur === "validated" ? ["review"] : [];
  // a publisher never moves a piece on the workflow RAIL: validated→published happens
  // only through the explicit "Publish" button (which schedules a go-live), and a publisher
  // can never send a piece back to review. The rail is therefore indication-only for them.
  if (role === "publisher") return [];
  return [];
}

function seedUsers() {
  return [
    { id: "u_cannelle", name: "Cannelle Richter", first: "Cannelle", last: "Richter", email: "hello@cannellerichter.fr", role: "admin",    color: "#2f6bdb", pass: "hello", grants: [], editGrants: [] },
    { id: "u_tom",      name: "Tom Beaumont",     first: "Tom",      last: "Beaumont",  email: "tom@studio.fr",          role: "editor",   color: "#1f9e72", pass: "tom",   grants: ["c2", "v4"], editGrants: ["rl1", "podcasts"] },
    { id: "u_lea",      name: "L\u00e9a Marchand",   first: "L\u00e9a",     last: "Marchand",  email: "lea@revue.fr",            role: "reviewer", color: "#7a4fe0", pass: "lea",   grants: ["c1", "v1"], editGrants: ["rl1"] },
    { id: "u_nora",     name: "Nora Pelletier",   first: "Nora",     last: "Pelletier", email: "nora@press.fr",           role: "publisher",color: "#c0398a", pass: "nora",  grants: ["c3"], editGrants: ["documents"] },
    { id: "u_sam",      name: "Sam Rivera",       first: "Sam",      last: "Rivera",    email: "sam@beta.cc",             role: "tester",   color: "#0e9aa7", pass: "sam",   grants: ["c5", "game", "music"], editGrants: [] },
    { id: "u_max",      name: "Max Okafor",       first: "Max",      last: "Okafor",    email: "max@friends.cc",         role: "guest",    color: "#e08a1e", pass: "max",   grants: ["cv", "secret", "documents"], editGrants: [] },
  ];
}

const ACCENTS = {
  datascientist: { accent: "#2f6bdb", ink: "#1f4fb0", wash: "#eaf0fc" },
  engineer:      { accent: "#e08a1e", ink: "#b56b08", wash: "#fcf1de" },
  diy:           { accent: "#1f9e72", ink: "#137a57", wash: "#e3f5ee" },
  alpinist:      { accent: "#e1543f", ink: "#b73b29", wash: "#fcebe7" },
  human:         { accent: "#7a4fe0", ink: "#5d34bd", wash: "#f0eafd" },
  sportive:      { accent: "#0f9bb3", ink: "#0a7186", wash: "#e2f3f6" },
};

/* ---- a believable FAKE QR code (SVG data URL) for the site address ----
   Stored in R2/D1 (media + profile.qr) and shown when the name-brand flips. */
function seedQrDataUrl() {
  const N = 25, cell = 8, pad = 16, size = N * cell + pad * 2;
  let s = 1979339339 >>> 0; const rnd = () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
  const finder = (x, y) => `<rect x="${x}" y="${y}" width="${cell * 7}" height="${cell * 7}" fill="#211e1a"/><rect x="${x + cell}" y="${y + cell}" width="${cell * 5}" height="${cell * 5}" fill="#fff"/><rect x="${x + cell * 2}" y="${y + cell * 2}" width="${cell * 3}" height="${cell * 3}" fill="#211e1a"/>`;
  let mods = "";
  for (let r = 0; r < N; r++) for (let c = 0; c < N; c++) {
    const inFinder = (r < 8 && c < 8) || (r < 8 && c >= N - 8) || (r >= N - 8 && c < 8);
    if (inFinder) continue;
    if (rnd() > 0.52) mods += `<rect x="${pad + c * cell}" y="${pad + r * cell}" width="${cell}" height="${cell}" fill="#211e1a"/>`;
  }
  const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}"><rect width="${size}" height="${size}" rx="16" fill="#fff"/>${mods}${finder(pad, pad)}${finder(pad + (N - 7) * cell, pad)}${finder(pad, pad + (N - 7) * cell)}</svg>`;
  return "data:image/svg+xml;utf8," + encodeURIComponent(svg);
}

/* ---- reusable media library (Assets ↔ R2). `folder` is the logical R2 prefix
   the media picker searches; `url` is the playable/viewable source (a data URL
   once a file is uploaded, or an external link). ---- */
function seedMedia() {
  return [
    // voice-overs live under the "readloud-blog" folder — reused by blog "My voice"
    { id: "md_voice_en", key: "contents/readloud-blog/gallery/toolkitblog_voice_version1_en.mp3", folder: "readloud-blog", type: "audio", locale: "en", label: "Formatting toolkit — my voice (EN)", url: "", mime: "audio/mpeg" },
    { id: "md_voice_fr", key: "contents/readloud-blog/gallery/toolkitblog_voice_version1_fr.mp3", folder: "readloud-blog", type: "audio", locale: "fr", label: "Formatting toolkit — ma voix (FR)", url: "", mime: "audio/mpeg" },
    // AI read-aloud renders, per language
    { id: "md_voiceai_en", key: "media/audio/voice_ai/en.mp3", folder: "voice_ai", type: "audio", locale: "en", label: "AI read-aloud (EN)", url: "", mime: "audio/mpeg" },
    { id: "md_voiceai_fr", key: "media/audio/voice_ai/fr.mp3", folder: "voice_ai", type: "audio", locale: "fr", label: "AI read-aloud (FR)", url: "", mime: "audio/mpeg" },
    // reading soundtrack
    { id: "md_music_focus", key: "media/audio/soundtrack/focus.mp3", folder: "soundtrack", type: "audio", locale: "", label: "Reading soundtrack — focus", url: "", mime: "audio/mpeg" },
    // field recordings (the Audio album)
    { id: "md_field_glacier", key: "media/audio/field/dawn-glacier.mp3", folder: "field", type: "audio", locale: "en", label: "Dawn on the glacier", url: "", mime: "audio/mpeg" },
    { id: "md_field_col", key: "media/audio/field/wind-col.mp3", folder: "field", type: "audio", locale: "en", label: "Wind on the col", url: "", mime: "audio/mpeg" },
    { id: "md_field_ice", key: "media/audio/field/crampons-ice.mp3", folder: "field", type: "audio", locale: "en", label: "Crampons & ice", url: "", mime: "audio/mpeg" },
    // gallery images
    { id: "md_img_ridge", key: "media/images/gallery_dawn/001.webp", folder: "gallery_dawn", type: "image", locale: "", label: "Dawn ridge — frame 1", url: "", mime: "image/webp" },
    // documents
    { id: "md_doc_cheatsheet", key: "media/docs/avalanche/cheatsheet.pdf", folder: "avalanche", type: "document", locale: "", label: "Avalanche cheat-sheet", url: "", mime: "application/pdf" },
    { id: "md_doc_tree", key: "media/docs/avalanche/decision-tree.pdf", folder: "avalanche", type: "document", locale: "", label: "Slope-angle decision tree", url: "", mime: "application/pdf" },
    { id: "md_doc_checklist", key: "media/docs/avalanche/checklist.pdf", folder: "avalanche", type: "document", locale: "", label: "Pre-tour checklist", url: "", mime: "application/pdf" },
    // the site QR code (fake) — reused when the name-brand flips
    { id: "md_qr", key: "media/image/site/qrcode.svg", folder: "site", type: "image", locale: "", label: "Site QR code", url: seedQrDataUrl(), mime: "image/svg+xml" },
  ];
}

function HUMAN_DOMAIN() {
  return {
    id: "human", route: "/human", label: "Human",
    title: "a Human", display: true, art: "human",
    presTitle: "the Human",
    quote: "Behind every model, every weld and every summit, there is just a person paying attention.",
    presBody: "The thread that ties the other hats together. I write about **attention**, doubt and learning in public — the soft skills nobody puts on a résumé. [Curiosity](https://github.com/cannellerichter) first, ego last.",
    presImage: null, puzzle: null,
    sections: ["blog", "videos"], extraPages: [],
    socials: ["linkedin", "instagram"],
    featured: { en: [], fr: [] },
    tr: { fr: { title: "un Être humain", presTitle: "l'Être humain", quote: "Derrière chaque modèle, chaque soudure et chaque sommet, il n'y a qu'une personne qui fait attention.", presBody: "Le fil qui relie toutes les autres casquettes. J'écris sur l'**attention**, le doute et l'apprentissage en public — les compétences que personne ne met sur un CV. La curiosité d'abord, l'ego en dernier." } },
  };
}
function SPORTIVE_DOMAIN() {
  return {
    id: "sportive", route: "/sportive", label: "Sportive",
    title: "an Athlete", display: true, art: "sportive",
    presTitle: "the Athlete",
    quote: "Train the system, not the symptom — the body keeps the most honest logbook there is.",
    presBody: "Trail running, ski touring and strength work — endurance treated like an engineering problem. **Heart-rate zones**, recovery and the quiet discipline of showing up.",
    presImage: null, puzzle: null,
    sections: ["blog", "videos"], extraPages: [],
    socials: ["instagram", "youtube"],
    featured: { en: [], fr: [] },
    tr: { fr: { title: "une Sportive", presTitle: "la Sportive", quote: "Entraîne le système, pas le symptôme — le corps tient le carnet le plus honnête qui soit.", presBody: "Trail, ski de randonnée et renforcement — l'endurance traitée comme un problème d'ingénierie. **Zones de fréquence cardiaque**, récupération et la discipline tranquille de se présenter." } },
  };
}

/* ---- domain hero illustrations (animated, abstract SVG) ----
   One per domain. Stored as an "Other" page (kind: illustration) so the code is
   editable in edit mode and the domain's "Illustration → page" type points at it.
   Pure primitives (circles, lines, polylines) animated with SMIL — same generative
   spirit as DomainArt, but live and editable. */
function illusRng(seed) { let s = seed >>> 0; return () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; }; }
function domainIllusSvg(id) {
  const A = { datascientist: "#2f6bdb", engineer: "#e08a1e", diy: "#1f9e72", alpinist: "#e1543f", human: "#7a4fe0", sportive: "#0f9bb3" }[id] || "#2f6bdb";
  const open = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 384" width="100%" height="100%" preserveAspectRatio="xMidYMid meet" style="display:block">`;
  const close = `</svg>`;
  // animations are gated: begin="indefinite" so nothing moves until the viewer clicks the illustration
  if (id === "engineer") {
    let grid = "";
    for (let x = 40; x <= 440; x += 50) grid += `<line x1="${x}" y1="40" x2="${x}" y2="344" stroke="${A}" stroke-width="1" opacity="0.12"/>`;
    for (let y = 40; y <= 344; y += 50) grid += `<line x1="40" y1="${y}" x2="440" y2="${y}" stroke="${A}" stroke-width="1" opacity="0.12"/>`;
    return open + grid +
      `<circle cx="240" cy="192" r="118" fill="none" stroke="${A}" stroke-width="2" stroke-dasharray="10 8" opacity="0.85"><animateTransform attributeName="transform" type="rotate" from="0 240 192" to="360 240 192" dur="22s" begin="indefinite" repeatCount="indefinite"/></circle>` +
      `<rect x="170" y="122" width="140" height="140" fill="none" stroke="${A}" stroke-width="2.4" opacity="0.9"><animateTransform attributeName="transform" type="rotate" from="0 240 192" to="-360 240 192" dur="30s" begin="indefinite" repeatCount="indefinite"/></rect>` +
      `<circle cx="240" cy="192" r="46" fill="none" stroke="${A}" stroke-width="2.4"/>` +
      `<line x1="120" y1="192" x2="360" y2="192" stroke="${A}" stroke-width="1.4" opacity="0.6"/><line x1="240" y1="72" x2="240" y2="312" stroke="${A}" stroke-width="1.4" opacity="0.6"/>` +
      `<circle cx="240" cy="192" r="7" fill="${A}"/>` + close;
  }
  if (id === "diy") {
    // abstract "evolution / learning" — a branching, growing network (same line+dot family as the others)
    const N = [[240, 330], [165, 250], [315, 250], [120, 168], [210, 168], [270, 168], [360, 168], [96, 84], [162, 84], [300, 84], [388, 84]];
    const E = [[0, 1], [0, 2], [1, 3], [1, 4], [2, 5], [2, 6], [3, 7], [4, 8], [5, 9], [6, 10]];
    let s = open;
    E.forEach(([a, b]) => { s += `<line x1="${N[a][0]}" y1="${N[a][1]}" x2="${N[b][0]}" y2="${N[b][1]}" stroke="${A}" stroke-width="1.4" opacity="0.4"/>`; });
    N.forEach((n, i) => { const big = i === 0; const leaf = i >= 7; const rad = big ? 12 : leaf ? 5 : 8; const fill = (big || i % 3 === 0) ? A : "none"; s += `<circle cx="${n[0]}" cy="${n[1]}" r="${rad}" fill="${fill}" stroke="${A}" stroke-width="1.6"><animate attributeName="r" values="${rad};${rad + (leaf ? 4 : 3)};${rad}" dur="${(2.6 + i * 0.3).toFixed(1)}s" begin="indefinite" repeatCount="indefinite"/></circle>`; });
    return s + close;
  }
  if (id === "alpinist") {
    const ridge = (yb, amp, op, w) => {
      const pts = [];
      for (let i = 0; i <= 8; i++) { const x = i * 60; const y = yb - Math.sin(i * 1.1 + yb) * amp - (i === 4 ? amp : 0); pts.push(`${x},${Math.max(20, y).toFixed(0)}`); }
      return `<polyline points="${pts.join(" ")}" fill="none" stroke="${A}" stroke-width="${w}" opacity="${op}" stroke-linejoin="round"/>`;
    };
    return open +
      `<circle cx="360" cy="96" r="30" fill="${A}" opacity="0.18"><animate attributeName="r" values="30;34;30" dur="6s" begin="indefinite" repeatCount="indefinite"/></circle>` +
      ridge(300, 46, 0.3, 2) + ridge(280, 64, 0.45, 2) + ridge(250, 86, 0.65, 2.4) + ridge(220, 110, 0.95, 2.8) + close;
  }
  if (id === "human") {
    // "connection & learning" — a radial mind/synapse hub with a travelling signal
    const cx = 240, cy = 192, R = 116;
    const outer = Array.from({ length: 6 }, (_, k) => { const a = -Math.PI / 2 + k * Math.PI / 3; return [cx + Math.cos(a) * R, cy + Math.sin(a) * R]; });
    let s = open;
    outer.forEach(([x, y]) => { s += `<line x1="${cx}" y1="${cy}" x2="${x.toFixed(1)}" y2="${y.toFixed(1)}" stroke="${A}" stroke-width="1.4" opacity="0.42"/>`; });
    outer.forEach(([x, y], k) => { const [nx, ny] = outer[(k + 1) % 6]; s += `<line x1="${x.toFixed(1)}" y1="${y.toFixed(1)}" x2="${nx.toFixed(1)}" y2="${ny.toFixed(1)}" stroke="${A}" stroke-width="1.2" opacity="0.22"/>`; });
    outer.forEach(([x, y], k) => { s += `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="7" fill="${k % 2 ? A : "none"}" stroke="${A}" stroke-width="1.6"><animate attributeName="opacity" values="0.55;1;0.55" dur="${(2.4 + k * 0.4).toFixed(1)}s" begin="indefinite" repeatCount="indefinite"/></circle>`; });
    s += `<circle cx="${cx}" cy="${cy}" r="16" fill="${A}"><animate attributeName="r" values="16;21;16" dur="3.4s" begin="indefinite" repeatCount="indefinite"/></circle>`;
    const [tx, ty] = outer[0];
    s += `<circle cx="${cx}" cy="${cy}" r="5" fill="${A}"><animateMotion dur="2.2s" begin="indefinite" repeatCount="indefinite" path="M0,0 L${(tx - cx).toFixed(1)},${(ty - cy).toFixed(1)}"/></circle>`;
    return s + close;
  }
  if (id === "sportive") {
    const line = "M30,210 L120,210 L150,210 L168,150 L188,262 L210,120 L232,210 L320,210 L348,210 L366,176 L384,210 L450,210";
    return open +
      `<line x1="30" y1="210" x2="450" y2="210" stroke="${A}" stroke-width="1" opacity="0.2"/>` +
      `<path d="${line}" fill="none" stroke="${A}" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="900" stroke-dashoffset="0"><animate attributeName="stroke-dashoffset" values="900;0" dur="3.4s" begin="indefinite" repeatCount="indefinite"/></path>` +
      `<circle cx="450" cy="210" r="7" fill="${A}"><animateMotion dur="3.4s" begin="indefinite" repeatCount="indefinite" keyPoints="0;1" keyTimes="0;1" calcMode="linear" path="${line}"/></circle>` + close;
  }
  // default — datascientist: the original generative network (seed 7), click-to-animate twinkle
  const W = 520, H = 420; const r = illusRng(7);
  const nodes = Array.from({ length: 22 }, (_, i) => ({ x: 60 + r() * (W - 120), y: 50 + r() * (H - 100), s: 3 + r() * 7, i }));
  const edges = [];
  nodes.forEach((n, i) => { nodes.slice(i + 1).sort((a, b) => Math.hypot(a.x - n.x, a.y - n.y) - Math.hypot(b.x - n.x, b.y - n.y)).slice(0, 2).forEach((t) => edges.push([n, t])); });
  let s = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="100%" height="100%" preserveAspectRatio="xMidYMid meet" style="display:block">`;
  edges.forEach(([a, b]) => { s += `<line x1="${a.x.toFixed(1)}" y1="${a.y.toFixed(1)}" x2="${b.x.toFixed(1)}" y2="${b.y.toFixed(1)}" stroke="${A}" stroke-width="1" opacity="0.28"/>`; });
  nodes.forEach((n, i) => { s += `<circle cx="${n.x.toFixed(1)}" cy="${n.y.toFixed(1)}" r="${n.s.toFixed(1)}" fill="${i % 4 === 0 ? A : "none"}" stroke="${A}" stroke-width="1.6"><animate attributeName="opacity" values="0.5;1;0.5" dur="${2.5 + i % 5}s" begin="indefinite" repeatCount="indefinite"/></circle>`; });
  return s + close;
}

const ILLUS_DOMAIN_IDS = ["datascientist", "engineer", "diy", "alpinist", "human", "sportive"];
const ILLUS_LABELS = { datascientist: "Data Scientist", engineer: "Engineer", diy: "DIY / Maker", alpinist: "Alpinist", human: "Human", sportive: "Sportive" };
function illustrationPageId(domainId) { return "illustration-domain-" + domainId; }
function ILLUSTRATION_PAGES() {
  return ILLUS_DOMAIN_IDS.map((id) => {
    const label = ILLUS_LABELS[id] || id;
    return {
      id: illustrationPageId(id), kind: "illustration",
      label: "Illustration — " + label, route: "/other/" + illustrationPageId(id),
      desc: "Animated hero illustration for the " + label + " domain. Edit the SVG / code below to change what shows in the domain's centre illustration.",
      html: domainIllusSvg(id),
      published: true, status: "published", statusByLang: { en: "published", fr: "draft" },
      private: false, editedBy: "", reviewedBy: "", fallback: "en",
      tr: { fr: { label: "Illustration — " + label, desc: "Illustration animée pour le domaine " + label + ". Modifiez le code SVG ci-dessous pour changer l'illustration centrale du domaine." } },
    };
  });
}

/* The "making of this site" article — a flagship blog post that documents the
   idea and the build (Cloudflare D1 · R2 · Workers). Shows off the editor:
   headings, callouts, a table, image placeholders for the architecture &
   schema diagrams, and code blocks WITH a language tag and filename. */
function MAKING_OF_ARTICLE() {
  return {
    id: "mk_vitrine", ref: "making-of-vitrine", type: "blog",
    domain: "engineer", domains: ["engineer", "datascientist"],
    title: "Building Vitrine — one person, five hats, one site",
    description: "Why I built a personal site that wears many hats, and how it runs on Cloudflare D1, R2 and Workers — schema, architecture and a few code blocks included.",
    tags: ["meta", "cloudflare", "architecture"], source: "Original", rating: 5, votes: 31, views: 1840,
    ai: null, cover: null, alt: [], subscribe: ["linkedin", "github"],
    sources: [{ label: "Source code — github.com/cannellerichter/vitrine", url: "https://github.com/cannellerichter" }],
    status: "published", published: true, publishedAt: "2026-06-14", createdAt: "2026-06-14", updatedAt: "2026-06-16",
    statusByLang: { en: "published", fr: "published" },
    tr: { fr: {
      title: "Construire Vitrine — une personne, cinq casquettes, un site",
      description: "Pourquoi j'ai construit un site personnel à plusieurs casquettes, et comment il tourne sur Cloudflare D1, R2 et Workers — schéma, architecture et quelques blocs de code à l'appui.",
    } },
    body: [
      { id: "mk1", type: "p", text: "I am a data scientist. I am also an engineer, a maker, an alpinist, an athlete — and, underneath all of it, just a **human**. Most personal sites force you to pick one. This one doesn't: the home page lets a visitor choose the hat they came for, and the rest of the site reshapes around it." },
      { id: "mk2", type: "h", level: 2, text: "The idea" },
      { id: "mk3", type: "p", text: "One identity, several audiences. A recruiter, a fellow maker and a climbing partner should each land somewhere that speaks their language — without me maintaining five separate websites. So every piece of content is tagged by **domain**, bilingual (EN/FR), and moves through a small editorial workflow before it goes live." },
      { id: "mk4", type: "callout", tone: "tip", text: "**Design goal.** The reader site stays dead simple — only *published* content shows. Everything else (drafts, reviews, private pieces) lives behind roles: editor, reviewer, publisher, tester, guest." },
      { id: "mk5", type: "h", level: 2, text: "The architecture" },
      { id: "mk6", type: "p", text: "The whole thing runs on Cloudflare's edge. A single Worker answers the API, **D1** (SQLite) holds the structured data, and **R2** stores every heavy asset — images, audio, PDFs — addressed by an object key the database points to." },
      { id: "mk7", type: "img", src: null, caption: "architecture diagram — browser → Workers API → D1 (data) + R2 (assets)" },
      { id: "mk8", type: "table", header: true, rows: [["Concern", "Service", "Notes"], ["Database", "D1 (SQLite)", "Bilingual rows in *_translations tables"], ["Assets", "R2", "One file, reused across pieces & languages"], ["API + auth", "Workers", "Prepared statements, PBKDF2 sessions"], ["Front-end", "React", "Same UI reads local or remote"]] },
      { id: "mk9", type: "p", text: "Deploying is three commands — create the database, push the schema, seed it:" },
      { id: "mk10", type: "code", lang: "bash", file: "deploy.sh", code: "wrangler d1 create vitrine\nwrangler d1 execute vitrine --file=backend/schema.sql\nwrangler d1 execute vitrine --file=backend/seed.sql\nwrangler deploy" },
      { id: "mk11", type: "h", level: 2, text: "The schema" },
      { id: "mk12", type: "p", text: "Content is bilingual by design: a row in `contents` holds the language-agnostic facts, and one row per language in `content_translations` carries the title, body and its own editorial status — so a piece can be live in English while still in review in French." },
      { id: "mk13", type: "img", src: null, caption: "database schema (ERD) — contents ↔ content_translations ↔ assets" },
      { id: "mk14", type: "code", lang: "sql", file: "schema.sql", code: "CREATE TABLE content_translations (\n  content_id INTEGER NOT NULL REFERENCES contents(content_id),\n  locale     TEXT NOT NULL REFERENCES locales(code),\n  title      TEXT,\n  body       TEXT,\n  status     TEXT NOT NULL DEFAULT 'draft',\n  PRIMARY KEY (content_id, locale)\n);" },
      { id: "mk15", type: "p", text: "The bindings that glue D1 and R2 to the Worker live in one config file:" },
      { id: "mk16", type: "code", lang: "toml", file: "wrangler.toml", code: "name = \"vitrine-api\"\nmain = \"worker.js\"\n\n[[d1_databases]]\nbinding = \"DB\"\ndatabase_name = \"vitrine\"\n\n[[r2_buckets]]\nbinding = \"MEDIA\"\nbucket_name = \"vitrine-assets\"" },
      { id: "mk17", type: "quote", text: "Build the simplest thing that could possibly tell the truth about you — then make it editable.", author: "the brief I gave myself", bar: true },
      { id: "mk18", type: "h", level: 2, text: "What's next" },
      { id: "mk19", type: "p", text: "Real-time previews, a proper search, and an agent token so I can publish straight from my notes. The schema and seed files in `/backend` are the starting point — clone them, run the four commands above, and you have your own multi-hat site." },
    ],
  };
}

/* ---- interactive fitness-model demo: stored as an "Other" page AND embedded in
   the fitness article. srcdoc so its scripts actually run (innerHTML wouldn't). ---- */
function fitnessDemoDoc() { return (typeof window !== "undefined" && window.__FITNESS_DEMO_DOC__) || ""; }
function fitnessDemoEmbed(height) {
  const doc = fitnessDemoDoc().replace(/&/g, "&amp;").replace(/"/g, "&quot;");
  return `<iframe title="Fitness score model — interactive" style="width:100%;height:${height || 760}px;border:0;display:block;background:#fbf9f4" sandbox="allow-scripts" srcdoc="${doc}"></iframe>`;
}

/* ---- cross-domain science piece: sport data & the truth about fitness scores ----
   Classed in BOTH datascientist and sportive. Bilingual EN/FR, catchy title + hook. */
function FITNESS_SCORE_ARTICLE() {
  return {
    id: "sci_fitness", ref: "fitness-score-truth", type: "blog",
    domain: "sportive", domains: ["sportive", "datascientist"],
    title: "Your watch thinks you're fit. Here's what it actually knows.",
    description: "Strava Fitness, Garmin's score, Suunto's fitness level — three numbers that promise to summarise your body in one digit. A data scientist opens the black box: how they're computed, what they're worth, and when you should quietly ignore them.",
    tags: ["sport", "data", "wearables", "physiology"], source: "Original", rating: 5, votes: 47, views: 3120,
    ai: null, cover: null, alt: [], subscribe: ["instagram", "linkedin"],
    sources: [
      { label: "Banister TRIMP / impulse-response model — foundational training-load literature", url: "https://en.wikipedia.org/wiki/Training_effect" },
      { label: "Firstbeat Analytics — VO₂max estimation white paper (Garmin)", url: "https://www.firstbeat.com/en/science-and-physiology/white-papers-and-publications/" },
    ],
    status: "published", published: true, publishedAt: "2026-06-24", createdAt: "2026-06-24", updatedAt: "2026-06-26",
    statusByLang: { en: "published", fr: "published" },
    body: [
      { id: "fs1", type: "p", text: "Your watch buzzes: **Fitness 47. Trending up.** You feel a small, warm glow of validation — a whole human body, summarised in one tidy number. But where does it come from? Is 47 good? And why did it drop three points the week you actually felt strongest?" },
      { id: "fs2", type: "p", text: "I'm a data scientist who also spends too much time on trails, so I did the annoying thing and opened the black box. The short version: these scores are **clever, useful, and quietly overconfident** — a bit like a weather forecast for your body. Worth checking. Not worth arguing with." },
      { id: "fs3", type: "callout", tone: "tip", text: "**The one-line metaphor.** Think of a fitness score as a **bank balance for your legs**: hard training pays in, rest lets the interest settle, and — crucially — the bank only sees the transactions you let it record. No heart-rate strap, no deposit." },
      { id: "fs4", type: "h", level: 2, text: "What all three are secretly measuring" },
      { id: "fs5", type: "p", text: "Strava, Garmin and Suunto use different words, but under the hood they lean on the same two ideas that sports scientists have used for forty years: **training load** (how much stress a session cost you) and an **impulse-response model** (stress makes you fitter slowly, and tired quickly)." },
      { id: "fs6", type: "list", text: "Load in → a single session becomes a number, usually from heart rate × duration (the classic \"TRIMP\").\nTwo running averages → a fast one (fatigue, ~7 days) and a slow one (fitness, ~42 days).\nThe headline number → some flavour of **fitness minus fatigue**, dressed up in each brand's vocabulary." },
      { id: "fs7", type: "p", text: "That's the trick behind the curtain. Everything else is branding." },
      { id: "fs8", type: "h", level: 2, text: "The same idea, three accents" },
      { id: "fs9", type: "table", header: true, rows: [
        ["Brand", "The number", "Roughly how it's built"],
        ["Strava", "Fitness & Freshness", "42-day vs 7-day weighted average of daily load (\"Relative Effort\"). Fitness − Fatigue = Form."],
        ["Garmin", "VO₂max, Training Status & Load", "Firstbeat model estimates VO₂max from HR-vs-pace, then flags you Productive / Overreaching / Detraining."],
        ["Suunto", "Fitness level (est. VO₂max)", "Also VO₂max-based (an EPOC/training-effect lineage), shown as an age-equivalent fitness level."],
      ] },
      { id: "fs10", type: "callout", tone: "info", text: "Notice the quiet pattern: Garmin and Suunto ultimately point at **VO₂max** — your engine's estimated ceiling — while Strava tracks the **accumulated work** you've put in. Different questions. One asks **how big is the engine**, the other **how much have you been driving**." },
      { id: "fs11", type: "h", level: 2, text: "So… what's it actually worth?" },
      { id: "fs12", type: "p", text: "Genuinely a lot — for **trends**. The **direction** of the line over weeks is the honest signal: rising through a training block, dipping into a taper, flat when life gets busy. That's real, and it's motivating." },
      { id: "fs13", type: "p", text: "The **absolute** value is where I'd relax my grip. A VO₂max estimate is a model inferring your oxygen ceiling from wrist heart rate and GPS pace — never actually measuring oxygen. On a good day it's within a couple of points of a lab test. On a bad day it's confidently wrong." },
      { id: "fs14", type: "quote", text: "Treat the number as a prior, not a verdict. It's a decent first guess about your body — until your body offers better evidence.", author: "me, refreshing Strava on a chairlift", bar: true },
      { id: "fs15", type: "h", level: 2, text: "When to quietly ignore it" },
      { id: "fs16", type: "p", text: "The models make a few honest assumptions. Break them and the score lies — not out of malice, just arithmetic:" },
      { id: "fs17", type: "list", text: "**Strength & lifting** — huge real stress, almost no heart-rate signal. The watch thinks you rested.\nA wrist without a **chest strap** — optical HR smears during intervals, so hard sessions get under-counted.\n**Heat, altitude, illness, caffeine, a bad night** — all inflate heart rate; the model reads \"harder effort\" and mis-scores an easy day.\n**Uphill / technical terrain** — pace collapses but effort spikes; a pace-based VO₂max estimate gets grumpy.\nComing back from a **break** — fitness is a 42-day memory, so it lags reality by weeks in both directions." },
      { id: "fs18", type: "callout", tone: "warn", text: "**The golden rule.** If the score and your body disagree, your body wins. No algorithm has ever run your legs. Fatigue, motivation and a scratchy throat are data the watch simply cannot see." },
      { id: "fs_demo_h", type: "h", level: 2, text: "See it lie, live" },
      { id: "fs_demo_p", type: "p", text: "Enough words — here's the machine itself. Below is a runner's twelve-week block. The **teal** line is fitness built from the work she actually did; the **blue** line is what the watch computed from the load it **recorded**. Drag the window, then flip on a real-world anomaly and watch the two lines separate. (This little app also lives on its own in **[[Others|a standalone demo page in the Others section]]**.)" },
      { id: "fs_demo", type: "embed", html: fitnessDemoEmbed(760) },
      { id: "fs_proof_h", type: "h", level: 3, text: "The same story, on three wrists" },
      { id: "fs_proof1", type: "img", src: null, ratio: "16/9", caption: "proof — screenshot: Strava Fitness & Freshness curve across a training block" },
      { id: "fs_proof2", type: "img", src: null, ratio: "16/9", caption: "proof — screenshot: Garmin Training Status flipping to ‘Overreaching’ during the heat week" },
      { id: "fs_proof3", type: "img", src: null, ratio: "16/9", caption: "proof — photo: same run, chest strap vs wrist optical HR side by side" },
      { id: "fs19", type: "accordion", summary: "Under the hood: the tiny bit of maths (for the curious)", open: false, blocks: [
        { id: "fs19a", type: "p", text: "The whole family descends from Banister's **impulse-response** model. Daily training load feeds two exponentially-weighted moving averages with different memories:" },
        { id: "fs19b", type: "code", lang: "python", file: "fitness.py", code: "# Two EWMAs over daily training load\nfitness = fitness_prev + (load_today - fitness_prev) / 42   # slow: ~6 weeks\nfatigue = fatigue_prev + (load_today - fatigue_prev) / 7    # fast: ~1 week\n\nform = fitness - fatigue   # positive = fresh, negative = buried" },
        { id: "fs19c", type: "p", text: "`load_today` is usually **TRIMP** — heart-rate reserve weighted by an exponential factor and multiplied by duration, so time in the high zones counts far more than easy minutes. Change the constants (42 and 7) and you've basically reinvented every brand's \"fitness\" and \"fatigue\" curves." },
        { id: "fs19d", type: "callout", tone: "info", text: "This is why a single monster session barely moves **Fitness** but tanks **Freshness**: the 7-day average feels it immediately, the 42-day one shrugs." },
      ] },
      { id: "fs20", type: "h", level: 2, text: "The takeaway" },
      { id: "fs21", type: "p", text: "These scores are a well-built compass, not a GPS fix. Follow the **trend**, respect the **assumptions**, and when the number and your legs disagree on a cold, steep, jet-lagged morning — trust the legs. The watch is guessing too; it's just doing it with more decimal places." },
    ],
    tr: { fr: {
      title: "Ta montre te croit en forme. Voici ce qu'elle sait vraiment.",
      description: "Fitness de Strava, score Garmin, niveau de forme Suunto — trois chiffres qui prétendent résumer ton corps en un seul digit. Une data scientist ouvre la boîte noire : comment ils sont calculés, ce qu'ils valent, et quand il vaut mieux les ignorer.",
      body: [
        { id: "fs1", type: "p", text: "Ta montre vibre : **Forme 47. En hausse.** Petite bouffée de validation — tout un corps humain résumé en un chiffre bien propre. Mais il sort d'où, ce 47 ? Est-ce que c'est bien ? Et pourquoi a-t-il perdu trois points la semaine où tu te sentais le plus fort ?" },
        { id: "fs2", type: "p", text: "Je suis data scientist et je passe aussi trop de temps sur les sentiers, alors j'ai fait le truc pénible : ouvrir la boîte noire. Version courte : ces scores sont **malins, utiles, et discrètement trop sûrs d'eux** — un peu comme une météo de ton corps. À consulter. Pas à contredire." },
        { id: "fs3", type: "callout", tone: "tip", text: "**La métaphore en une ligne.** Vois le score de forme comme un **compte en banque pour tes jambes** : l'entraînement dur alimente le compte, le repos laisse les intérêts se poser, et — surtout — la banque ne voit que les transactions que tu l'autorises à enregistrer. Pas de ceinture cardio, pas de dépôt." },
        { id: "fs4", type: "h", level: 2, text: "Ce que les trois mesurent en secret" },
        { id: "fs5", type: "p", text: "Strava, Garmin et Suunto emploient des mots différents, mais sous le capot ils s'appuient sur les deux mêmes idées que les physiologistes utilisent depuis quarante ans : la **charge d'entraînement** (le stress qu'une séance t'a coûté) et un **modèle impulsion-réponse** (le stress te rend plus fort lentement, et fatigué vite)." },
        { id: "fs6", type: "list", text: "La charge en entrée → une séance devient un chiffre, souvent fréquence cardiaque × durée (le fameux « TRIMP »).\nDeux moyennes glissantes → une rapide (fatigue, ~7 jours) et une lente (forme, ~42 jours).\nLe chiffre affiché → une variante de **forme moins fatigue**, habillée du vocabulaire de chaque marque." },
        { id: "fs7", type: "p", text: "Voilà le tour de passe-passe. Le reste, c'est du marketing." },
        { id: "fs8", type: "h", level: 2, text: "La même idée, trois accents" },
        { id: "fs9", type: "table", header: true, rows: [
          ["Marque", "Le chiffre", "Grossièrement, comment il est bâti"],
          ["Strava", "Fitness & Fraîcheur", "Moyenne pondérée 42 j vs 7 j de la charge quotidienne (« Effort relatif »). Forme − Fatigue = Condition."],
          ["Garmin", "VO₂max, Training Status & Charge", "Le modèle Firstbeat estime la VO₂max via FC/allure, puis te classe Productif / Surmenage / Désentraînement."],
          ["Suunto", "Niveau de forme (VO₂max est.)", "Aussi basé VO₂max (lignée EPOC/effet d'entraînement), présenté comme un âge de forme équivalent."],
        ] },
        { id: "fs10", type: "callout", tone: "info", text: "Note le motif discret : Garmin et Suunto pointent au fond vers la **VO₂max** — le plafond estimé de ton moteur — tandis que Strava suit le **travail accumulé**. Deux questions différentes. L'une demande **quelle taille fait le moteur**, l'autre **combien tu as roulé**." },
        { id: "fs11", type: "h", level: 2, text: "Donc… ça vaut quoi ?" },
        { id: "fs12", type: "p", text: "Beaucoup — pour les **tendances**. La **direction** de la courbe sur plusieurs semaines est le vrai signal honnête : qui monte pendant un bloc, qui plonge en affûtage, plate quand la vie s'en mêle. C'est réel, et c'est motivant." },
        { id: "fs13", type: "p", text: "C'est la valeur **absolue** qui mérite qu'on lâche du lest. Une estimation de VO₂max est un modèle qui déduit ton plafond d'oxygène depuis la FC au poignet et l'allure GPS — sans jamais mesurer d'oxygène. Un bon jour, à deux points d'un test labo. Un mauvais jour, sûr de lui et faux." },
        { id: "fs14", type: "quote", text: "Traite le chiffre comme un a priori, pas un verdict. Une estimation correcte de ton corps — jusqu'à ce que ton corps fournisse une meilleure preuve.", author: "moi, rafraîchissant Strava sur un télésiège", bar: true },
        { id: "fs15", type: "h", level: 2, text: "Quand l'ignorer tranquillement" },
        { id: "fs16", type: "p", text: "Les modèles font quelques hypothèses honnêtes. Casse-les et le score ment — pas par malice, juste par arithmétique :" },
        { id: "fs17", type: "list", text: "**Musculation & renfo** — énorme stress réel, presque aucun signal cardio. La montre croit que tu t'es reposé.\nUn poignet sans **ceinture pectorale** — la FC optique bave sur les intervalles, donc les grosses séances sont sous-comptées.\n**Chaleur, altitude, maladie, caféine, mauvaise nuit** — tout gonfle la FC ; le modèle lit « effort plus dur » et sur-note une journée facile.\n**Montée / terrain technique** — l'allure s'effondre mais l'effort explose ; une VO₂max basée sur l'allure fait la tête.\nRetour après une **coupure** — la forme est une mémoire de 42 jours, donc elle retarde de plusieurs semaines dans les deux sens." },
        { id: "fs18", type: "callout", tone: "warn", text: "**La règle d'or.** Si le score et ton corps ne sont pas d'accord, ton corps gagne. Aucun algorithme n'a jamais couru à ta place. La fatigue, la motivation et une gorge qui gratte sont des données que la montre ne voit tout simplement pas." },
        { id: "fs_demo_h", type: "h", level: 2, text: "Vois-le mentir, en direct" },
        { id: "fs_demo_p", type: "p", text: "Assez de mots — voici la machine elle-même. Ci-dessous, le bloc de douze semaines d'une coureuse. La ligne **turquoise** est la forme construite à partir du travail réellement fait ; la ligne **bleue** est ce que la montre a calculé depuis la charge qu'elle a **enregistrée**. Fais glisser la fenêtre, puis active une anomalie réelle et regarde les deux lignes se séparer. (Cette petite appli vit aussi seule dans **[[Others|une page de démo autonome de la section Others]]**.)" },
        { id: "fs_demo", type: "embed", html: fitnessDemoEmbed(760) },
        { id: "fs_proof_h", type: "h", level: 3, text: "La même histoire, sur trois poignets" },
        { id: "fs_proof1", type: "img", src: null, ratio: "16/9", caption: "preuve — capture : la courbe Fitness & Fraîcheur de Strava sur un bloc d'entraînement" },
        { id: "fs_proof2", type: "img", src: null, ratio: "16/9", caption: "preuve — capture : le Training Status Garmin qui bascule en « Surmenage » pendant la semaine de chaleur" },
        { id: "fs_proof3", type: "img", src: null, ratio: "16/9", caption: "preuve — photo : même sortie, ceinture pectorale vs FC optique au poignet, côte à côte" },
        { id: "fs19", type: "accordion", summary: "Sous le capot : le petit bout de maths (pour les curieux)", open: false, blocks: [
          { id: "fs19a", type: "p", text: "Toute la famille descend du modèle **impulsion-réponse** de Banister. La charge quotidienne alimente deux moyennes mobiles exponentielles à mémoires différentes :" },
          { id: "fs19b", type: "code", lang: "python", file: "forme.py", code: "# Deux EWMA sur la charge quotidienne\nforme = forme_prec + (charge_jour - forme_prec) / 42   # lente : ~6 semaines\nfatigue = fatigue_prec + (charge_jour - fatigue_prec) / 7  # rapide : ~1 semaine\n\ncondition = forme - fatigue   # positif = frais, négatif = enterré" },
          { id: "fs19c", type: "p", text: "`charge_jour`, c'est souvent le **TRIMP** — la réserve de FC pondérée par un facteur exponentiel et multipliée par la durée, si bien que le temps passé en zones hautes compte bien plus que les minutes faciles. Change les constantes (42 et 7) et tu as en gros réinventé les courbes « forme » et « fatigue » de chaque marque." },
          { id: "fs19d", type: "callout", tone: "info", text: "C'est pour ça qu'une seule séance monstre bouge à peine la **Forme** mais démolit la **Fraîcheur** : la moyenne 7 jours la ressent tout de suite, celle de 42 jours hausse les épaules." },
        ] },
        { id: "fs20", type: "h", level: 2, text: "À retenir" },
        { id: "fs21", type: "p", text: "Ces scores sont une boussole bien construite, pas un point GPS. Suis la **tendance**, respecte les **hypothèses**, et quand le chiffre et tes jambes ne sont pas d'accord un matin froid, raide et décalé — fais confiance aux jambes. La montre devine elle aussi ; elle le fait juste avec plus de décimales." },
      ],
    } },
  };
}

/* ---- gear review: a pack built for a woman's back (Cirque Ultra 25, size S) ----
   Classed in alpinist + sportive. Bilingual, honest verdict from real field use. */
function PACK_REVIEW_ARTICLE() {
  return {
    id: "gear_cirque", ref: "cirque-ultra-25-womans-back", type: "blog",
    domain: "alpinist", domains: ["alpinist", "sportive"],
    title: "A pack that finally stays put — and the details it shouldn't have missed",
    description: "The unglamorous quest for a ski-touring pack that doesn't dance on a shorter, narrower back. I lived in the Black Diamond Cirque Ultra 25 (size S) across ski tours and climbs. The fit is genuinely excellent — but at this price a few decisions are hard to forgive.",
    tags: ["gear", "ski-touring", "review", "alpinism"], source: "Original", rating: 4, votes: 38, views: 2470,
    ai: null, cover: null, alt: [], subscribe: ["instagram", "youtube"],
    sources: [
      { label: "Black Diamond — Cirque Ultra 25 (official specs)", url: "https://www.blackdiamondequipment.com/" },
    ],
    status: "published", published: true, publishedAt: "2026-06-30", createdAt: "2026-06-30", updatedAt: "2026-07-01",
    statusByLang: { en: "published", fr: "published" },
    body: [
      { id: "pk1", type: "p", text: "Most pack reviews start with litres and grams. Mine starts with a shrug — the specific, defeated shrug of hitching a pack higher for the hundredth time because it's swinging across my shoulders on a kick-turn. I don't have a big back. It's shorter and narrower than the mannequin most packs are cut for, and the thing I wanted more than any feature was simple: **stay where I put you.**" },
      { id: "pk2", type: "callout", tone: "info", text: "**What I was actually shopping for.** Not a spec sheet — a pack that doesn't move. On a shorter, narrower (read: many women's) back, a torso-length that's even slightly long turns every dynamic move into a wrestling match. Fit first. Everything else is negotiable." },
      { id: "pk3", type: "h", level: 2, text: "The fit — where it genuinely delivers" },
      { id: "pk4", type: "p", text: "I tested the **size S**, and this is the headline: it fits. The S torso lands exactly where my back ends, the harness wraps without gapping, and — the part I care about — it **holds through movement**. Booting up a steep couloir, throwing a kick-turn, reaching through a climbing sequence: the load stays glued to my back instead of penduluming off it. That alone put it ahead of every larger-frame pack I'd fought with." },
      { id: "pk5", type: "img", src: null, ratio: "4/5", caption: "fit test — size S on a shorter torso, harness wrap during a kick-turn" },
      { id: "pk6", type: "quote", text: "A pack you stop noticing is doing its most important job. For the first time in a while, I stopped noticing.", bar: true },
      { id: "pk7", type: "h", level: 2, text: "The design I loved" },
      { id: "pk8", type: "p", text: "The build is beautiful, and the **inverted-cone opening** is the standout: it flares open so you can actually **see** to the bottom of the pack, which sounds trivial until you're rooting for skins with cold hands. Genuinely one of the best access designs I've used." },
      { id: "pk9", type: "list", text: "**Inverted-cone access** — wide throat, real visibility all the way down.\n**Clean, handsome build** — the aesthetics are spot on.\n**Rock-solid carry** in the S — the reason I kept using it." },
      { id: "pk10", type: "h", level: 2, text: "The details it shouldn't have missed" },
      { id: "pk11", type: "p", text: "Here's where the price tag starts to itch. None of these are dealbreakers on their own; together, at this money, they read like decisions nobody pushed back on." },
      { id: "pk12", type: "list", text: "**No side compression straps** to lash skis or poles — the 35 L has them, but that version drops the vest harness, so you can't have both. Choosing between good carry and basic lash points is a false choice at this price.\n**A-frame ski carry is poorly optimised** — the lower strap buckle sits too low (unless I missed a hidden attachment), so the skis never sit quite right.\n**The bottom pocket is under-built and oddly sized** — too big for crampons alone, borderline for crampons **plus** knives, and not reinforced enough for sharp steel. A couple of internal tiers would have solved it.\n**The ice-axe attachment is flimsy** — I added cord loops to actually trust it; the metal hardware is mediocre, and I can't see what the reinforcement is for, since a technical/leashless tool doesn't even seat in it." },
      { id: "pk13", type: "img", src: null, ratio: "16/9", caption: "detail — A-frame ski carry: lower buckle sits too low; bottom pocket with crampons + knives" },
      { id: "pk14", type: "callout", tone: "warn", text: "**The bit that stings.** On a pack this expensive I shouldn't have to **add** things (cord to secure the axes) and **want to remove** others (bulk I don't use). The premium materials are clearly there — they're just not put everywhere they're needed." },
      { id: "pk15", type: "h", level: 2, text: "The small annoyances" },
      { id: "pk16", type: "list", text: "**The top back-panel pocket zip snags** — regularly, always at the wrong moment.\n**You can't close the main opening from the side** — the roll-style closure eats a lot of space, and you instinctively grab the pack by it instead of the haul loop that's actually designed for it.\n**The rope carry isn't independent** of either the front pocket or the main compartment — to open either one you have to unstrap the rope first.\n**The side pocket has no second zip** to reach the inside without opening the other section." },
      { id: "pk17", type: "accordion", summary: "Quick spec & who it's for", open: false, blocks: [
        { id: "pk17a", type: "table", header: true, rows: [
          ["", "This test"],
          ["Model", "Black Diamond Cirque Ultra 25"],
          ["Size", "S (fits a shorter / narrower torso)"],
          ["Best at", "Ski touring & climbing where carry stability matters most"],
          ["Weak at", "Ski/pole lashing, sharp-tool storage, one-handed access"],
        ] },
        { id: "pk17b", type: "p", text: "If your priority is a pack that stays locked to a smaller back through dynamic movement, the S is a rare good answer. If you need slick ski-carry and modular storage out of the box, budget for some DIY." },
      ] },
      { id: "pk18", type: "h", level: 2, text: "The verdict" },
      { id: "pk19", type: "p", text: "It's still a **good pack — genuinely practical**, and versatile enough to cross over into other pursuits beyond ski touring. The fit in the S is the best I've found for a smaller back, and the access design is a joy. But at the price it's sold for, the ignored details and the uneven use of otherwise-premium materials keep it from being the pack it clearly wanted to be. **Beautiful, capable, and just perfectionist-short of worth every euro.** Fixable things — which is exactly why they're frustrating." },
    ],
    tr: { fr: {
      title: "Un sac qui tient enfin en place — et les détails qu'il n'aurait pas dû ignorer",
      description: "La quête sans gloire d'un sac de ski de rando qui ne danse pas sur un dos plus court et plus fin. J'ai vécu dans le Black Diamond Cirque Ultra 25 (taille S), à ski comme en escalade. Le maintien est vraiment excellent — mais à ce prix, certains choix passent mal.",
      body: [
        { id: "pk1", type: "p", text: "La plupart des tests commencent par des litres et des grammes. Le mien commence par un haussement d'épaules — celui, résigné, de remonter le sac pour la centième fois parce qu'il balance sur les épaules en conversion. Je n'ai pas un grand dos. Il est plus court et plus fin que le mannequin pour lequel les sacs sont taillés, et ce que je voulais avant toute fonction, c'était simple : **reste où je te mets.**" },
        { id: "pk2", type: "callout", tone: "info", text: "**Ce que je cherchais vraiment.** Pas une fiche technique — un sac qui ne bouge pas. Sur un dos plus court et plus fin (comprendre : beaucoup de dos féminins), une longueur de torse même un peu trop grande transforme chaque mouvement dynamique en corps à corps. Le maintien d'abord. Le reste se négocie." },
        { id: "pk3", type: "h", level: 2, text: "Le maintien — là où il tient vraiment ses promesses" },
        { id: "pk4", type: "p", text: "J'ai testé la **taille S**, et voici l'essentiel : il va. Le torse S s'arrête exactement où mon dos finit, le harnais épouse sans bâiller et — ce qui m'importe — il **tient dans le mouvement**. En montée dans un couloir raide, en conversion, en enchaînant une séquence d'escalade : la charge reste collée au dos au lieu de balancer. À elle seule, cette qualité l'a placé devant tous les sacs à armature plus grande contre lesquels je m'étais battue." },
        { id: "pk5", type: "img", src: null, ratio: "4/5", caption: "test de maintien — taille S sur un torse plus court, harnais en conversion" },
        { id: "pk6", type: "quote", text: "Un sac qu'on oublie fait son travail le plus important. Pour la première fois depuis longtemps, je l'ai oublié.", bar: true },
        { id: "pk7", type: "h", level: 2, text: "Le design que j'ai adoré" },
        { id: "pk8", type: "p", text: "La finition est superbe, et l'**ouverture en cône inversé** est le point fort : elle s'évase pour qu'on **voie** réellement jusqu'au fond du sac — anodin, jusqu'à ce qu'on cherche ses peaux les mains gelées. Vraiment l'un des meilleurs accès que j'aie utilisés." },
        { id: "pk9", type: "list", text: "**Accès en cône inversé** — grande gorge, vraie visibilité jusqu'en bas.\n**Finition propre et élégante** — l'esthétique est au rendez-vous.\n**Portage irréprochable** en S — la raison pour laquelle j'ai continué à l'utiliser." },
        { id: "pk10", type: "h", level: 2, text: "Les détails qu'il n'aurait pas dû ignorer" },
        { id: "pk11", type: "p", text: "C'est ici que le prix commence à démanger. Aucun n'est rédhibitoire seul ; ensemble, à ce tarif, on dirait des choix que personne n'a remis en question." },
        { id: "pk12", type: "list", text: "**Pas de sangles de compression latérales** pour attacher skis ou bâtons — la version 35 L les a, mais elle perd le gilet, donc impossible d'avoir les deux. Choisir entre bon portage et points d'attache de base est un faux choix à ce prix.\n**Le portage ski en A est mal optimisé** — la boucle de la sangle basse est trop basse (à moins que j'aie manqué une attache cachée), donc les skis ne se tiennent jamais tout à fait bien.\n**La poche du bas est sous-conçue et mal dimensionnée** — trop grande pour les seuls crampons, limite pour crampons **plus** couteaux, et pas assez renforcée pour de l'acier tranchant. Deux niveaux internes auraient réglé ça.\n**L'attache piolet est légère** — j'ai ajouté des encordements pour vraiment m'y fier ; les attaches métalliques sont moyennes, et je ne vois pas à quoi sert le renfort, puisqu'un piolet à traction n'y rentre même pas." },
        { id: "pk13", type: "img", src: null, ratio: "16/9", caption: "détail — portage ski en A : boucle basse trop basse ; poche du bas avec crampons + couteaux" },
        { id: "pk14", type: "callout", tone: "warn", text: "**Ce qui pique.** Sur un sac aussi cher, je ne devrais pas avoir à **ajouter** des choses (de la cordelette pour fixer les piolets) et à **vouloir en retirer** d'autres (du volume que je n'utilise pas). Les matériaux haut de gamme sont clairement là — ils ne sont juste pas mis partout où il le faut." },
        { id: "pk15", type: "h", level: 2, text: "Les petits agacements" },
        { id: "pk16", type: "list", text: "**La fermeture éclair de la poche extérieure haute du dos se coince** — régulièrement, toujours au mauvais moment.\n**On ne peut pas refermer l'ouverture principale par le côté** — la fermeture enroulée prend beaucoup de place, et on attrape instinctivement le sac par là au lieu de la boucle prévue pour.\n**Le porte-corde n'est pas indépendant** de la poche avant ni de la grande poche — pour ouvrir l'une ou l'autre, il faut d'abord enlever la corde.\n**La poche latérale n'a pas de second zip** pour accéder à l'intérieur sans ouvrir l'autre partie." },
        { id: "pk17", type: "accordion", summary: "Fiche rapide & pour qui", open: false, blocks: [
          { id: "pk17a", type: "table", header: true, rows: [
            ["", "Ce test"],
            ["Modèle", "Black Diamond Cirque Ultra 25"],
            ["Taille", "S (dos plus court / plus fin)"],
            ["Excellent pour", "Ski de rando & escalade où la stabilité du portage prime"],
            ["Faible pour", "Attache skis/bâtons, rangement tranchant, accès à une main"],
          ] },
          { id: "pk17b", type: "p", text: "Si ta priorité est un sac qui reste verrouillé sur un dos plus petit dans le mouvement dynamique, la S est une rare bonne réponse. Si tu veux un portage ski nickel et un rangement modulaire d'origine, prévois un peu de bricolage." },
        ] },
        { id: "pk18", type: "h", level: 2, text: "Le bilan" },
        { id: "pk19", type: "p", text: "Ça reste un **bon sac — vraiment pratique**, et assez polyvalent pour déborder vers d'autres pratiques que le ski de rando. Le maintien en S est le meilleur que j'aie trouvé pour un petit dos, et l'accès est un plaisir. Mais au prix où il est vendu, les détails ignorés et l'usage inégal de matériaux pourtant haut de gamme l'empêchent d'être le sac qu'il voulait manifestement être. **Beau, capable, et à un cheveu de perfectionniste près de valoir chaque euro.** Des choses corrigeables — c'est bien pour ça qu'elles frustrent." },
      ],
    } },
  };
}

/** a little content for the two new domains so their pages aren't empty **/
function EXTRA_DEMO_CONTENT() {
  return [
    { id: "hm1", ref: "human-five-hats", type: "blog", domain: "human", domains: ["human"], title: "The person behind the five hats", description: "On context-switching, impostor feelings and why I refuse to be just one thing on the internet.", tags: ["essay", "learning"], source: "Original", rating: 5, status: "published", published: true, createdAt: "2026-05-30", updatedAt: "2026-05-30",
      tr: { fr: { title: "La personne derrière les cinq casquettes", description: "Sur le changement de contexte, le syndrome de l'imposteur et pourquoi je refuse de n'être qu'une seule chose sur internet." } } },
    { id: "sp1", ref: "sportive-train-like-system", type: "blog", domain: "sportive", domains: ["sportive", "engineer"], title: "Training like a system: a runner's logbook", description: "Heart-rate zones, recovery debt and treating a training block like a pipeline you can observe.", tags: ["endurance", "data"], source: "Original", rating: 4, status: "published", published: true, createdAt: "2026-05-12", updatedAt: "2026-05-12",
      tr: { fr: { title: "S'entraîner comme un système : le carnet d'une coureuse", description: "Zones de fréquence cardiaque, dette de récupération et un bloc d'entraînement traité comme un pipeline observable." } } },
    { id: "sp2", ref: "sportive-vert-vid", type: "video", domain: "sportive", domains: ["sportive", "alpinist"], title: "1000m of vert before breakfast", description: "A dawn vertical-kilometre effort, watch on the wrist, lungs on fire.", tags: ["trail", "film"], source: "YouTube", rating: 5, youtube: "ScMzIvxBSi4", status: "published", published: true, createdAt: "2026-04-20", updatedAt: "2026-04-20" },
  ];
}

function seed() {
  return {
    profile: {
      first: "Cannelle",
      last: "Richter",
      tagline: "Trust me — I am",
      defaultRoute: "/datascientist", // home redirects here
    },
    domains: [
      {
        id: "datascientist", route: "/datascientist", label: "Data Scientist",
        title: "a Data Scientist", display: true, art: "network",
        presTitle: "the Data Scientist",
        quote: "All models are wrong, but some are useful — and a few are beautiful.",
        presBody: "I turn messy data into decisions. **Statistics**, machine learning and a stubborn love for the question behind the question. I build [pipelines](https://github.com/cannellerichter), dashboards and the occasional brain-shaped neural net.",
        presImage: null,
        puzzle: "gradient",
        sections: ["blog", "videos"],
        extraPages: ["cv"],
        socials: ["linkedin", "github", "youtube"],
        featured: { en: [], fr: [] },
      },
      {
        id: "engineer", route: "/engineer", label: "Engineer",
        title: "an Engineer", display: true, art: "blueprint",
        presTitle: "the Engineer",
        quote: "Everything should be made as simple as possible — but no simpler.",
        presBody: "From firmware to data platforms, I like systems that are robust, observable and a little bit elegant. If it has an API, I want to understand it; if it doesn't, I'll probably build one.",
        presImage: null,
        puzzle: null,
        sections: ["blog", "videos"],
        extraPages: [],
        socials: ["linkedin", "github"],
        featured: { en: [], fr: [] },
      },
      {
        id: "diy", route: "/diy", label: "DIY / Maker",
        title: "a Maker", display: true, art: "tools",
        presTitle: "the Maker",
        quote: "If you can't open it, you don't own it.",
        presBody: "Wood, electronics, 3D prints and a soldering iron that's always warm. I document my builds so other people can break them faster than I did.",
        presImage: null,
        puzzle: null,
        sections: ["blog", "videos"],
        extraPages: [],
        socials: ["instagram", "youtube"],
        featured: { en: [], fr: [] },
      },
      {
        id: "alpinist", route: "/alpinist", label: "Alpinist",
        title: "an Alpinist", display: true, art: "contour",
        presTitle: "the Alpinist",
        quote: "The mountain decides. We just get to choose how we ask.",
        presBody: "Long approaches, thin air and good coffee at altitude. Trail running, ski touring and the quiet math of risk on a ridge line.",
        presImage: null,
        puzzle: null,
        sections: ["blog", "videos"],
        extraPages: [],
        socials: ["instagram", "youtube"],
        featured: { en: [], fr: [] },
      },
      HUMAN_DOMAIN(),
      SPORTIVE_DOMAIN(),
    ],
    // content sections (CRUD-able). Fixed menu items handled separately.
    sections: [
      { id: "blog", route: "/blog", label: "Blog", kind: "blog", inMenu: true, logo: null, tr: { fr: { label: "Blog" } } },
      { id: "videos", route: "/videos", label: "Videos", kind: "video", inMenu: true, logo: null, tr: { fr: { label: "Vidéos" } } },
      { id: "photos", route: "/photos", label: "Photos", kind: "image", inMenu: true, logo: null, tr: { fr: { label: "Photos" } } },
      { id: "podcasts", route: "/podcasts", label: "Podcasts", kind: "podcast", inMenu: false, logo: null, tr: { fr: { label: "Podcasts" } } },
      { id: "music", route: "/music", label: "Music", kind: "music", inMenu: false, logo: null, tr: { fr: { label: "Musique" } } },
      { id: "audio", route: "/audio", label: "Audio", kind: "audio", inMenu: false, logo: null, tr: { fr: { label: "Audio" } } },
      { id: "documents", route: "/documents", label: "Documents", kind: "document", inMenu: false, logo: null, tr: { fr: { label: "Documents" } } },
    ],
    // "Other" pages — created pages (CV, survey, game, secret) not shown in the public Sections menu.
    otherPages: [
      { id: "cv",     label: "CV",                route: "/datascientist/cv", kind: "page",   desc: "On-request CV page" },
      { id: "survey", label: "Reader survey",     route: "/other/survey",      kind: "page",   desc: "Short feedback survey" },
      { id: "game",   label: "Mini-game",         route: "/other/game",        kind: "page",   desc: "A small interactive game" },
      { id: "fitness-model", label: "Fitness score model", route: "/other/fitness-model", kind: "page",
        desc: "Interactive demo: how a runner's fitness score is computed from a sliding window of training load — and how anomalies (strength days, heat, breaks) make it diverge from reality.",
        html: fitnessDemoEmbed(760), published: true, status: "published", statusByLang: { en: "published", fr: "published" },
        private: false, editedBy: "", reviewedBy: "", fallback: "en",
        tr: { fr: { label: "Modèle de score de forme", desc: "Démo interactive : comment le score de forme d'une coureuse se calcule à partir d'une fenêtre glissante de charge — et comment les anomalies (renfo, chaleur, coupures) le font diverger de la réalité.", html: fitnessDemoEmbed(760) } } },
      { id: "secret", label: "Cannelle's secret", route: "/other/secret",      kind: "secret", desc: "Hidden — unlocked by solving an illustration puzzle" },
      ...ILLUSTRATION_PAGES(),
    ],
    secretUnlocked: false,
    socials: [
      { id: "linkedin",  name: "LinkedIn",  link: "https://linkedin.com/in/cannellerichter",  color: "#0a66c2", domains: ["datascientist","engineer"] },
      { id: "github",    name: "GitHub",    link: "https://github.com/cannellerichter",        color: "#211e1a", domains: ["datascientist","engineer","diy"] },
      { id: "youtube",   name: "YouTube",   link: "https://youtube.com/@cannellerichter",       color: "#e1543f", domains: ["datascientist","diy","alpinist"] },
      { id: "instagram", name: "Instagram", link: "https://instagram.com/cannellerichter",      color: "#d62976", domains: ["diy","alpinist"] },
      { id: "x",         name: "X",         link: "https://x.com/cannellerichter",              color: "#211e1a", domains: ["datascientist"] },
    ],
    /* ---- reusable media library (Cloudflare R2 mirror — Assets table) ----
       One physical file, reusable across contents / languages / domains without
       duplication (DDD v16 §13-15). `key` is the R2 object key; `folder` is its
       logical folder used by the media picker's search. */
    media: seedMedia(),
    apps: [
      { id: "a1", name: "Notion",  link: "https://notion.so",  color: "#211e1a", glyph: "N", desc: "Notes & roadmap" },
      { id: "a2", name: "GitHub",  link: "https://github.com", color: "#211e1a", glyph: "G", desc: "Code & open source" },
      { id: "a3", name: "Strava",  link: "https://strava.com", color: "#e1543f", glyph: "S", desc: "Trails & ski touring" },
      { id: "a4", name: "Figma",   link: "https://figma.com",  color: "#7a4fe0", glyph: "F", desc: "Design files" },
      { id: "a5", name: "Kaggle",  link: "https://kaggle.com", color: "#2f6bdb", glyph: "K", desc: "Datasets & notebooks" },
    ],
    content: [
      { id: "c1", ref: "ds-causal-2026", type: "blog", domain: "datascientist", title: "Causal inference without the hand-waving", description: "A practical walk-through of do-calculus on a real churn dataset — and where it quietly breaks.", tags: ["stats","causality","python"], source: "Original", rating: 5, createdAt: "2026-05-02", updatedAt: "2026-05-10" },
      { id: "c2", ref: "ds-embeddings", type: "blog", domain: "datascientist", title: "What embeddings actually remember", description: "Probing a small model to see which features survive compression. Spoiler: not the ones you'd hope.", tags: ["ml","nlp","experiments"], source: "Original", rating: 4, createdAt: "2026-04-18", updatedAt: "2026-04-18" },
      { id: "c3", ref: "eng-observability", type: "blog", domain: "engineer", title: "Observability for data pipelines that won't page you at 3am", description: "Metrics, traces and the three dashboards I actually look at.", tags: ["platform","ops","reliability"], source: "Original", rating: 5, createdAt: "2026-03-30", updatedAt: "2026-04-01" },
      { id: "c4", ref: "diy-soldering-rig", type: "blog", domain: "diy", title: "A fume extractor from a dead PC fan", description: "Forty minutes, two parts I already owned, and lungs that thank me.", tags: ["electronics","build","cheap"], source: "Original", rating: 4, createdAt: "2026-02-11", updatedAt: "2026-02-11" },
      { id: "c5", ref: "alp-risk-math", type: "blog", domain: "alpinist", title: "The quiet math of avalanche risk", description: "Bayesian thinking on a ridge line, and why gut feeling is a prior, not a verdict.", tags: ["risk","ski","decision"], source: "Original", rating: 5, createdAt: "2026-01-22", updatedAt: "2026-01-25" },
      { id: "c6", ref: "ds-dashboards", type: "blog", domain: "datascientist", title: "Dashboards nobody asked for (but everybody uses)", description: "Designing for the question, not the metric.", tags: ["viz","product"], source: "Original", rating: 3, createdAt: "2025-12-09", updatedAt: "2025-12-09" },

      { id: "v1", ref: "ds-vid-pca", type: "video", domain: "datascientist", title: "PCA explained with a coffee grinder", description: "Dimensionality reduction you can taste.", tags: ["stats","explainer"], source: "YouTube", rating: 5, youtube: "wTcMtvyXcSE", createdAt: "2026-05-20", updatedAt: "2026-05-20" },
      { id: "v2", ref: "diy-vid-cnc", type: "video", domain: "diy", title: "Building a desktop CNC under €300", description: "Full build log, mistakes included.", tags: ["build","cnc"], source: "YouTube", rating: 4, youtube: "aircAruvnKk", createdAt: "2026-04-02", updatedAt: "2026-04-02" },
      { id: "v3", ref: "alp-vid-touring", type: "video", domain: "alpinist", title: "Dawn patrol: ski touring the Aiguilles", description: "Headlamps, frozen fingers, perfect light.", tags: ["ski","film"], source: "YouTube", rating: 5, youtube: "ScMzIvxBSi4", createdAt: "2026-03-15", updatedAt: "2026-03-15" },
      { id: "v4", ref: "eng-vid-kafka", type: "video", domain: "engineer", title: "Kafka in 12 minutes, honestly", description: "No buzzwords, just topics, partitions and back-pressure.", tags: ["platform","streaming"], source: "YouTube", rating: 4, youtube: "B5j3uNBH8X4", createdAt: "2026-02-28", updatedAt: "2026-02-28" },

      // ---- podcasts (hidden section) ----
      { id: "p1", ref: "pod-signal-noise-1", type: "podcast", domain: "datascientist", title: "Signal & Noise — Ep.1: priors that pay off", description: "A conversation about Bayesian thinking in everyday data work, with too many coffee metaphors.", tags: ["bayes","conversation"], source: "Original", rating: 5, playlistKind: "spotify", playlist: "https://open.spotify.com/embed/episode/4rOoJ6Egrf8K2IrywzwOMk", subscribe: ["youtube","x"], createdAt: "2026-05-28", updatedAt: "2026-05-28" },
      { id: "p2", ref: "pod-trail-talk", type: "podcast", domain: "alpinist", title: "Trail Talk — decisions above 3000m", description: "Two alpinists unpack how they actually call it on a marginal day.", tags: ["risk","mountain"], source: "Original", rating: 4, playlistKind: "spotify", playlist: "https://open.spotify.com/embed/episode/5XzBjJ8Xy9b3a1V4yY9JpQ", subscribe: ["instagram","youtube"], createdAt: "2026-04-09", updatedAt: "2026-04-09" },

      // ---- music (hidden section) ----
      { id: "m1", ref: "mus-compile-focus", type: "music", domain: "engineer", title: "Focus set for long compiles", description: "A low-key instrumental playlist for deep work and slow builds.", tags: ["focus","instrumental"], source: "Original", rating: 5, playlistKind: "spotify", playlist: "https://open.spotify.com/embed/playlist/37i9dQZF1DWZeKCadgRdKQ", subscribe: ["youtube"], createdAt: "2026-03-21", updatedAt: "2026-03-21" },

      // ---- audio (hidden section) ----
      { id: "au1", ref: "aud-dawn-glacier", type: "audio", domain: "alpinist", title: "Field recording — dawn on the glacier", description: "Eight minutes of wind, crampons and a distant serac. Headphones recommended.", tags: ["field","ambient"], source: "Original", rating: 5, playlistKind: "url", playlist: "https://cdn.example.com/audio/dawn-glacier.mp3", subscribe: ["instagram"], createdAt: "2026-02-02", updatedAt: "2026-02-02" },

      // ---- documents (hidden section) ----
      { id: "d1", ref: "doc-avalanche-cheatsheet", type: "document", domain: "alpinist", title: "Avalanche risk — field references", description: "A small library of printable decision aids for marginal slopes.", tags: ["risk","reference"], source: "Original", rating: 5, pdf: "", subscribe: [], createdAt: "2026-01-12", updatedAt: "2026-01-12",
        docs: [
          { id: "dc1", label: "Field cheat-sheet (1 page)", assetId: "md_doc_cheatsheet", pdf: "" },
          { id: "dc2", label: "Slope-angle decision tree",  assetId: "md_doc_tree",       pdf: "" },
          { id: "dc3", label: "Pre-tour checklist",         assetId: "md_doc_checklist",  pdf: "" },
        ] },
      { id: "d2", ref: "doc-pipeline-runbook", type: "document", domain: "engineer", title: "Pipeline on-call runbook", description: "What to check, in what order, when a data pipeline pages you.", tags: ["ops","reference"], source: "Original", rating: 4, pdf: "", subscribe: [], createdAt: "2025-12-18", updatedAt: "2025-12-18" },

      // ---- IMAGE_GALLERY (photos section) — one piece = a gallery of images ----
      { id: "g1", ref: "img-dawn-ridge", type: "image", domain: "alpinist", title: "Dawn on the ridge — a photo essay", description: "Eight frames from a single morning above 3000m, in sequence.", tags: ["mountain","photo"], source: "Original", rating: 5, subscribe: ["instagram"],
        galleryMode: "auto", galleryInterval: 5,
        gallery: [
          { id: "gi1", src: null, caption: "04:50 — leaving the hut", cover: true },
          { id: "gi2", src: null, caption: "05:30 — first light on the face" },
          { id: "gi3", src: null, caption: "06:10 — the crux pitch" },
          { id: "gi4", src: null, caption: "07:00 — summit ridge" },
        ], createdAt: "2026-05-18", updatedAt: "2026-05-18" },
      { id: "g2", ref: "img-bench-builds", type: "image", domain: "diy", title: "Workbench builds, frame by frame", description: "A visual log of the shop bench coming together.", tags: ["build","photo"], source: "Original", rating: 4, subscribe: ["instagram","youtube"],
        galleryMode: "arrows", galleryInterval: 10,
        gallery: [
          { id: "gb1", src: null, caption: "Raw stock, cut to length", cover: true },
          { id: "gb2", src: null, caption: "Dry-fit of the leg frame" },
          { id: "gb3", src: null, caption: "Glue-up under clamps" },
        ], createdAt: "2026-04-26", updatedAt: "2026-04-26" },

      // ---- AUDIO_LIBRARY (album of sounds) — sample on the Audio section ----
      { id: "al1", ref: "aud-mountain-sessions", type: "audio", domain: "alpinist", title: "Mountain field sessions", description: "An album of short field recordings from a season in the Alps.", tags: ["field","ambient"], source: "Original", rating: 5, subscribe: ["instagram"], playlistKind: "album",
        tracks: [
          { id: "t_alp1", title: "Dawn on the glacier", assetId: "md_field_glacier", locale: "en" },
          { id: "t_alp2", title: "Wind on the col",     assetId: "md_field_col",     locale: "en" },
          { id: "t_alp3", title: "Crampons & ice",      assetId: "md_field_ice",     locale: "en" },
        ], createdAt: "2026-03-06", updatedAt: "2026-03-06" },

      // ---- PRIVATE content (DDD v16 §7) — never published publicly; an audio
      //      asset library reused by the blog's “My voice” alternative media. ----
      { id: "rl1", ref: "readloud-blog", type: "audio", domain: "datascientist", title: "Read-aloud — blog voice‑overs (PRIVATE)", description: "My recorded voice-overs for blog articles. Private — reused as “My voice” alternative media, never published on its own.", tags: ["voice","readloud"], source: "Original", rating: 0, subscribe: [], playlistKind: "album", private: true,
        tracks: [
          { id: "tk_en", title: "Formatting toolkit — EN", assetId: "md_voice_en", locale: "en" },
          { id: "tk_fr", title: "Formatting toolkit — FR", assetId: "md_voice_fr", locale: "fr" },
        ], createdAt: "2026-06-13", updatedAt: "2026-06-13" },

      // ---- flagship "making of" article + content for the new Human & Sportive domains ----
      MAKING_OF_ARTICLE(),
      FITNESS_SCORE_ARTICLE(),
      PACK_REVIEW_ARTICLE(),
      ...EXTRA_DEMO_CONTENT(),
    ],
    // -------- admin / board --------
    personalBoard: [
      { id: "t1", title: "Job & Career", color: "#2f6bdb", links: [
        { title: "Notion roadmap", url: "https://notion.so/roadmap" },
        { title: "Check CV last update", url: "https://docs/cv" },
        { title: "Job board", url: "https://jobs" },
      ]},
      { id: "t2", title: "Perso / Location", color: "#1f9e72", links: [
        { title: "Furnished flat search", url: "https://flats" },
        { title: "Cloud Files", url: "https://drive" },
        { title: "GitHub", url: "https://github.com" },
        { title: "My map", url: "https://maps" },
      ]},
      { id: "t3", title: "Hype services", color: "#7a4fe0", links: [
        { title: "Site", url: "https://cannellerichter.fr" },
        { title: "Drive", url: "https://drive" },
        { title: "Mail", url: "https://mail" },
      ]},
      { id: "t4", title: "Daily", color: "#e08a1e", links: [
        { title: "Newsletter flat", url: "https://news" },
        { title: "App routine", url: "https://routine" },
        { title: "CGV update", url: "https://cgv" },
      ]},
      { id: "t5", title: "Tools", color: "#e1543f", links: [
        { title: "Figma", url: "https://figma.com" },
        { title: "Excalidraw", url: "https://excalidraw.com" },
      ]},
    ],
    notifications: [
      { id: "n1", tile: "t1", time: "2026-06-11 09:14", text: "New job alert matched 'Lead Data Scientist · Remote'." },
      { id: "n2", tile: "t3", time: "2026-06-11 08:02", text: "cannellerichter.fr — uptime 99.98% over last 30 days." },
      { id: "n3", tile: "t5", time: "2026-06-10 19:40", text: "Figma file 'Site v4' was edited by an agent." },
      { id: "n4", tile: "t2", time: "2026-06-10 12:11", text: "Drive backup completed (4.2 GB)." },
      { id: "n5", tile: "t1", time: "2026-06-09 17:55", text: "CV last updated 41 days ago — consider refreshing." },
      { id: "n6", tile: "t4", time: "2026-06-09 07:30", text: "Newsletter scheduled: 1,204 recipients." },
      { id: "n7", tile: "t5", time: "2026-06-08 22:03", text: "GitHub Action 'deploy-site' succeeded." },
    ],
    feedbacks: [
      { id: "f1", name: "Léa M.", date: "2026-06-10", rating: 5, text: "Love the multi-hat idea — found your avalanche article from the data page. Great cross-links!" },
      { id: "f2", name: "Anon", date: "2026-06-08", rating: 4, text: "The hero is gorgeous on mobile. Podcast section when?" },
      { id: "f3", name: "T. Okafor", date: "2026-06-05", rating: 5, text: "Used your observability post at work today. Thank you." },
      { id: "f4", name: "Anon", date: "2026-06-01", rating: 3, text: "Menu took me a sec to find on tablet." },
    ],
    analytics: {
      visitors30d: 4218, pageviews30d: 11940, avgMin: 2.7, bounce: 38,
      spark: [12,18,15,22,19,26,24,31,28,35,30,42],
      topPages: [
        { path: "/datascientist", views: 3120 },
        { path: "/blog", views: 2480 },
        { path: "/alpinist", views: 1610 },
        { path: "/diy", views: 1190 },
      ],
    },
    contactMessages: [],
    cvRequests: [],
    users: seedUsers(),
    contentFeedback: [
      { id: "cf1", target: "c3", targetLabel: "Observability for data pipelines", by: "L\u00e9a Marchand", role: "reviewer", date: "2026-06-12", text: "The three-dashboards framing is great. Could you add a note on alert fatigue before publishing?" },
      { id: "cf2", target: "game", targetLabel: "Mini-game", by: "Max Okafor", role: "guest", date: "2026-06-11", text: "Played it twice — loved it. The restart button is a little hard to find on mobile." },
    ],
    legal: JSON.parse(JSON.stringify(LEGAL_SEED)),
  };
}

function seedOtherPages() {
  return [
    { id: "cv",     label: "CV",                route: "/datascientist/cv", kind: "page",   desc: "On-request CV page" },
    { id: "survey", label: "Reader survey",     route: "/other/survey",      kind: "page",   desc: "Short feedback survey" },
    { id: "game",   label: "Mini-game",         route: "/other/game",        kind: "page",   desc: "A small interactive game" },
    { id: "secret", label: "Cannelle's secret", route: "/other/secret",      kind: "secret", desc: "Hidden — unlocked by solving an illustration puzzle" },
  ];
}

/* hidden reference article that demonstrates every editor feature.
   Kept unpublished — visible only in edit mode. */
function TEMPLATE_ARTICLE() {
  return {
    id: "tpl_toolkit", ref: "template-toolkit", type: "blog", domain: "datascientist",
    domains: ["datascientist", "engineer"],
    title: "Formatting toolkit — every block, one page",
    description: "A hidden, unpublished reference article that shows every text and block feature available in the editor.",
    tags: ["template", "reference"], source: "Original", rating: 0, votes: 0, views: 0,
    ai: null, cover: null, alt: [], sources: [{ label: "Editor documentation — internal", url: "#" }],
    published: false, publishedAt: "", createdAt: "2026-06-13", updatedAt: "2026-06-13",
    tr: {},
    body: [
      { id: "t1", type: "h", level: 1, text: "Everything the editor can do" },
      { id: "t2", type: "p", text: "This paragraph mixes **bold**, __underline__ and an [external link](https://example.com). Internal links work too — jump to [the Blog](#/blog).\nThis sentence sits on a new line thanks to a single line break." },
      { id: "t3", type: "p", text: "Hover a defined term like [[do-calculus|A set of rules for reasoning about cause and effect from a causal graph.]] to read its definition in a bubble. You can reference [[PCA|Principal Component Analysis — compressing correlated features into fewer axes.]] the same way." },
      { id: "t4", type: "h", level: 2, text: "Lists" },
      { id: "t5", type: "list", text: "- A bullet written with a dash\n* Or written with a star\n- Each line becomes its own point" },
      { id: "t6", type: "callout", tone: "tip", text: "**Callout box.** Use it to highlight a tip, a warning or an important aside. Inline **bold** and __underline__ work inside it too." },
      { id: "t7", type: "quote", text: "All models are wrong, but some are useful — and a few are beautiful.", author: "paraphrasing George Box" },
      { id: "t8", type: "h", level: 2, text: "Tables" },
      { id: "t9", type: "table", header: true, rows: [["Method","Strength","Cost"],["Linear model","Interpretable","Low"],["Gradient boosting","Accurate","Medium"],["Neural net","Flexible","High"]] },
      { id: "t10", type: "img", src: null, caption: "a figure / diagram placeholder" },
      { id: "t11", type: "code", lang: "python", file: "means.py", code: "import numpy as np\nX = np.array([[1,2],[3,4]])\nprint(X.mean(axis=0))" },
      { id: "t12", type: "hr" },
      { id: "t13", type: "p", text: "That's the full toolkit. Duplicate this article as a starting point, or keep it unpublished as a living reference." },
    ],
  };
}

/* default reading body (block model) generated from a description */
function bodyFromDesc(c) {
  return [
    { id: "b1", type: "p", text: `This piece is part of my ${c.domain} work. ${c.description}` },
    { id: "b2", type: "h", text: "How it actually went" },
    { id: "b3", type: "p", text: "Below I walk through the approach, the dead-ends, and what I'd do differently next time. Where there's **code**, it's linked from the sources at the bottom." },
    { id: "b4", type: "img", src: null, caption: "inline figure / diagram" },
    { id: "b5", type: "p", text: "The full toolkit shows up in the tags. If something here is useful to you, that's the whole point." },
  ];
}
function wordsOf(body) {
  return (body || []).reduce((n, b) => n + ((b.text || b.code || "").trim().split(/\s+/).filter(Boolean).length), 0);
}
function readTime(body) { return Math.max(1, Math.round(wordsOf(body) / 200)); }

function normalize(db) {
  db.myRatings = db.myRatings || {};
  if (db.secretUnlocked == null) db.secretUnlocked = false;
  // multi-user accounts + role-scoped feedback (injected for older saved DBs)
  if (!db.users || !db.users.length) db.users = seedUsers();
  // backfill last names on older saved DBs so avatar/name initials stay 2-letter (e.g. CR)
  if (db.users) { const seed = seedUsers(); db.users.forEach(u => { if (u.last == null) { const s = seed.find(z => z.id === u.id); u.last = (s && s.last) || (u.name && u.first ? String(u.name).replace(u.first, "").trim() : ""); } }); }
  if (!db.contentFeedback) db.contentFeedback = [];
  // tagline migration: drop a trailing " a" (the domain title already starts with a/an)
  if (db.profile && /\s+a$/i.test(db.profile.tagline || "")) db.profile.tagline = db.profile.tagline.replace(/\s+a$/i, "");
  // About page: portrait image OR embedded app
  if (db.about) { if (db.about.portrait === undefined) db.about.portrait = null; if (db.about.embed === undefined) db.about.embed = null; if (db.about.tr === undefined) db.about.tr = {}; if (db.about.fallback === undefined) db.about.fallback = "fallback"; }
  if (!db.legal) db.legal = JSON.parse(JSON.stringify(LEGAL_SEED));
  if (!db.media || !db.media.length) db.media = seedMedia();
  // the site QR code is auto-generated by the front end — no global setting stored
  if (db.profile) delete db.profile.qr;
  // legal pages are bilingual: base = EN, tr.fr override, with a fallback mode
  Object.keys(LEGAL_SEED).forEach(k => {
    db.legal[k] = db.legal[k] || JSON.parse(JSON.stringify(LEGAL_SEED[k]));
    if (db.legal[k].tr === undefined) db.legal[k].tr = {};
    if (db.legal[k].fallback === undefined) db.legal[k].fallback = "fallback";
  });
  // editable copy for static form pages (contact / feedback)
  if (!db.pages) db.pages = JSON.parse(JSON.stringify(PAGES_SEED));
  Object.keys(PAGES_SEED).forEach(k => {
    db.pages[k] = { ...JSON.parse(JSON.stringify(PAGES_SEED[k])), ...(db.pages[k] || {}) };
    if (db.pages[k].tr === undefined) db.pages[k].tr = JSON.parse(JSON.stringify(PAGES_SEED[k].tr));
    if (db.pages[k].fallback === undefined) db.pages[k].fallback = "fallback";
  });
  if (!db.otherPages) db.otherPages = JSON.parse(JSON.stringify(seedOtherPages()));
  // inject the interactive fitness-score model into Others (idempotent) + keep its code fresh
  if (db.otherPages) {
    let fm = db.otherPages.find(p => p.id === "fitness-model");
    const embed = fitnessDemoEmbed(760);
    if (!fm) {
      db.otherPages.push({ id: "fitness-model", label: "Fitness score model", route: "/other/fitness-model", kind: "page",
        desc: "Interactive demo: how a runner's fitness score is computed from a sliding window of training load — and how anomalies make it diverge from reality.",
        html: embed, published: true, status: "published", statusByLang: { en: "published", fr: "published" },
        private: false, editedBy: "", reviewedBy: "", fallback: "en",
        tr: { fr: { label: "Modèle de score de forme", desc: "Démo interactive : fenêtre glissante de charge et anomalies de calcul.", html: embed } } });
    } else if (fitnessDemoDoc()) {
      fm.html = embed; if (fm.tr && fm.tr.fr) fm.tr.fr.html = embed;   // refresh the demo code
    }
  }
  ILLUSTRATION_PAGES().forEach(ip => { if (!(db.otherPages || []).find(p => p.id === ip.id)) db.otherPages.push(ip); });
  // one-time refresh of the seeded illustration HTML (new DIY art + click-to-activate gating)
  if (!db._illusV3) {
    ILLUSTRATION_PAGES().forEach(ip => { const p = (db.otherPages || []).find(x => x.id === ip.id); if (p) p.html = ip.html; });
    db._illusV3 = true;
  }
  (db.otherPages || []).forEach(p => {
    if (p.html === undefined) p.html = "";
    if (p.private === undefined) p.private = false;   // PRIVATE "Other" page — never published publicly
    if (p.published === undefined) p.published = true;
    if (p.status === undefined) p.status = p.published === false ? "draft" : "published";
    p.published = p.status === "published";
    if (p.statusByLang === undefined) p.statusByLang = { en: pageHasLang(p, "en") ? p.status : "draft", fr: pageHasLang(p, "fr") ? p.status : "draft" };  // per-language workflow (missing language = draft)
    if (p.editedBy === undefined) p.editedBy = "";
    if (p.reviewedBy === undefined) p.reviewedBy = "";
    if (p.tr === undefined) p.tr = {};          // { fr: { label, desc, html } }
    if (p.fallback === undefined) p.fallback = "en";  // 'en' = EN-by-default | 'strict' = published only if defined
  });
  (db.sections || []).forEach(s => { if (s.tr === undefined) s.tr = {}; if (s.logo === undefined) s.logo = null; if (s.type === undefined) s.type = sectionTypeOf(s); });   // { fr: { label } }
  (db.domains || []).forEach(d => {
    if (d.presImage === undefined) d.presImage = null;
    if (d.extraPages === undefined) d.extraPages = [];
    if (d.puzzle === undefined) d.puzzle = d.id === "datascientist" ? "gradient" : null;
    if (d.puzzle === "median") d.puzzle = "gradient";
    if (d.appEmbed === undefined) d.appEmbed = null;
    // unified "Illustration" (replaces the separate appEmbed source): pattern | image | svg | page
    if (d.illusImage === undefined) d.illusImage = null;
    if (d.illusSvg === undefined) d.illusSvg = "";
    if (d.illusPage === undefined) d.illusPage = "";
    if (d.illusKind === undefined) {
      if (d.appEmbed) { d.illusKind = "page"; d.illusPage = d.appEmbed; }
      else if ((db.otherPages || []).some(p => p.id === "illustration-domain-" + d.id)) { d.illusKind = "page"; d.illusPage = "/other/illustration-domain-" + d.id; }
      else d.illusKind = "pattern";
    }
    if (d.tr === undefined) d.tr = {};   // { fr: { title, presTitle, quote, presBody } }
    // the Human domain gets its own dedicated artwork (migrate from the default)
    if (d.id === "human" && (!d.art || d.art === "network")) d.art = "human";
    // Top picks are PER-LANGUAGE: { en:[ids], fr:[ids] }. Migrate old flat arrays
    // (they were chosen in EN) into the EN slot only, so they stop bleeding into FR.
    if (Array.isArray(d.featured)) d.featured = { en: d.featured.slice(), fr: [] };
    else if (!d.featured || typeof d.featured !== "object") d.featured = { en: [], fr: [] };
    else { if (!Array.isArray(d.featured.en)) d.featured.en = []; if (!Array.isArray(d.featured.fr)) d.featured.fr = []; }
  });
  (db.apps || []).forEach(a => { if (a.desc === undefined) a.desc = ""; if (a.published === undefined) a.published = true; });
  // inject new domains (Human, Sportive) into older saved DBs (idempotent)
  if (db.domains) {
    if (!db.domains.find(d => d.id === "human")) db.domains.push(HUMAN_DOMAIN());
    if (!db.domains.find(d => d.id === "sportive")) db.domains.push(SPORTIVE_DOMAIN());
  }
  // inject the hidden formatting-template article once
  if (db.content && !db.content.find(c => c.ref === "template-toolkit")) db.content.unshift(TEMPLATE_ARTICLE());
  // inject the "making of" flagship + the new-domain demo content (idempotent)
  if (db.content && !db.content.find(c => c.ref === "making-of-vitrine")) db.content.push(MAKING_OF_ARTICLE());
  if (db.content && !db.content.find(c => c.ref === "fitness-score-truth")) db.content.push(FITNESS_SCORE_ARTICLE());
  if (db.content && !db.content.find(c => c.ref === "cirque-ultra-25-womans-back")) db.content.push(PACK_REVIEW_ARTICLE());
  // one-time content repair: older saved DBs hold a stale body that used single-asterisk
  // *italics* (unsupported by renderInline → literal asterisks). Re-sync body + tr from source.
  if (db.content) {
    const _fit = db.content.find(c => c.ref === "fitness-score-truth");
    const _stale = (blocks) => (blocks || []).some(b => typeof b.text === "string" && /(^|[^*])\*[^*\n]+\*(?!\*)/.test(b.text));
    const _hasDemo = (blocks) => (blocks || []).some(b => b.id === "fs_demo");
    if (_fit && (_stale(_fit.body) || _stale(_fit.tr && _fit.tr.fr && _fit.tr.fr.body) || !_hasDemo(_fit.body))) {
      const fresh = FITNESS_SCORE_ARTICLE();
      _fit.body = fresh.body;
      _fit.tr = fresh.tr;
    }
    // same one-time repair for the pack review (any single-asterisk italic → re-sync from source)
    const _pk = db.content.find(c => c.ref === "cirque-ultra-25-womans-back");
    const _staleDeep = (blocks) => (blocks || []).some(b => (typeof b.text === "string" && /(^|[^*])\*[^*\n]+\*(?!\*)/.test(b.text)) || (b.blocks && _staleDeep(b.blocks)));
    if (_pk && (_staleDeep(_pk.body) || _staleDeep(_pk.tr && _pk.tr.fr && _pk.tr.fr.body))) {
      const freshPk = PACK_REVIEW_ARTICLE();
      _pk.body = freshPk.body;
      _pk.tr = freshPk.tr;
    }
  }
  if (db.content) EXTRA_DEMO_CONTENT().forEach(c => { if (!db.content.find(x => x.ref === c.ref)) db.content.push(c); });
  (db.content || []).forEach((c, i) => {
    // stale test data: a translation note ("Translated from French") is NOT a publication
    // source — translation belongs to the AI-transparency field. Never surface it as a
    // source chip on cards or in the sources list.
    if (typeof c.source === "string" && /translated from/i.test(c.source)) c.source = "Original";
    if (Array.isArray(c.sources)) c.sources = c.sources.filter((s) => !(s && /translated from/i.test(s.label || "")));
    if (c.views == null) c.views = 240 + ((i * 1337 + (c.ref ? c.ref.length : 0) * 97) % 4600);
    if (c.votes == null) c.votes = 6 + ((i * 7) % 42);
    if (c.published === undefined) c.published = true;
    // editorial workflow status — derive from the legacy `published` flag, then keep them in sync
    if (c.status === undefined) c.status = c.published === false ? "draft" : "published";
    c.published = c.status === "published";
    if (c.statusByLang === undefined) c.statusByLang = { en: langExists(c, "en") ? c.status : "draft", fr: langExists(c, "fr") ? c.status : "draft" };   // per-language workflow (missing language = draft, never inherits)
    if (c.editedBy === undefined) c.editedBy = "";       // hidden metadata — who last edited
    if (c.reviewedBy === undefined) c.reviewedBy = "";   // hidden metadata — who approved
    if (!c.publishedAt) c.publishedAt = c.createdAt;
    if (!c.body) c.body = bodyFromDesc(c);
    if (!c.domains) c.domains = c.domain ? [c.domain] : [];           // multi-domain association
    if (c.domain == null && c.domains.length) c.domain = c.domains[0];
    if (!c.tr) c.tr = {};                                              // { fr: { title, description, body } }
    if (c.ai === undefined) c.ai = null;                 // null | 'generated' | 'augmented' | 'translated'
    if (c.cover === undefined) c.cover = null;           // optional cover image (data URL)
    if (!c.alt) c.alt = [];                               // linked alternative media
    if (!c.related) c.related = [];                       // manually-chosen related items (ordered ids)
    if (c.subscribe === undefined) c.subscribe = [];      // social ids offered as "subscribe / follow"
    if (c.playlist === undefined) c.playlist = "";        // podcast / music / audio embed URL
    if (c.playlistKind === undefined) c.playlistKind = "spotify";
    if (c.pdf === undefined) c.pdf = "";                  // document section — uploaded PDF (data URL)
    if (c.private === undefined) c.private = false;       // PRIVATE content (DDD v16 §7) — never published publicly
    if (c.gallery === undefined) c.gallery = [];          // IMAGE_GALLERY — ordered images
    if (c.galleryMode === undefined) c.galleryMode = "arrows";   // 'arrows' | 'auto'
    if (c.galleryInterval === undefined) c.galleryInterval = 5;  // seconds between auto-switch (5 | 10)
    if (c.docs === undefined) c.docs = [];                // DOCUMENT_LIBRARY — several documents
    if (c.tracks === undefined) c.tracks = [];            // AUDIO_LIBRARY — album of sounds
    if (c.fallback === undefined) c.fallback = "en";      // 'en' = EN-by-default | 'strict' = published only if defined
    if (!c.sources) c.sources = (c.source && c.source !== "Original")
      ? [{ label: c.source, url: "#" }]
      : [{ label: "Original publication — cannellerichter.fr", url: "#" }];
  });
  // demo: link the causal-inference article to a video + audio alternatives
  const causal = (db.content || []).find(c => c.ref === "ds-causal-2026");  if (causal && (!causal.alt || causal.alt.length === 0)) {
    causal.alt = [
      { kind: "video", ref: "ds-vid-pca", label: "Watch" },
      { kind: "voice", label: "Listen (my voice)" },
      { kind: "aivoice", label: "AI read-aloud" },
      { kind: "music", label: "Reading soundtrack" },
    ];
    causal.ai = "augmented";
  }
  const pca = (db.content || []).find(c => c.ref === "ds-vid-pca");
  if (pca && (!pca.alt || pca.alt.length === 0)) {
    pca.alt = [{ kind: "read", ref: "ds-causal-2026", label: "Read instead" }];
  }
  // demo: a few Blog & Video pieces offer subscribe / follow networks
  [["ds-causal-2026", ["linkedin", "github", "x"]], ["eng-observability", ["linkedin", "github"]], ["ds-vid-pca", ["youtube", "x"]], ["eng-vid-kafka", ["youtube", "linkedin"]]].forEach(([ref, subs]) => {
    const c = (db.content || []).find(x => x.ref === ref); if (c && (!c.subscribe || !c.subscribe.length)) c.subscribe = subs;
  });
  // demo: the hidden formatting-toolkit article reuses the PRIVATE readloud-blog
  // voice-overs as its "My voice" alternative media (R2 reuse, no duplication).
  const tpl = (db.content || []).find(c => c.ref === "template-toolkit");
  if (tpl && (!tpl.alt || tpl.alt.length === 0)) {
    tpl.alt = [
      { kind: "voice", label: "Listen (my voice)", assetId: "md_voice_en" },
      { kind: "aivoice", label: "AI read-aloud" },
      { kind: "music", label: "Reading soundtrack", assetId: "md_music_focus" },
    ];
  }
  // demo workflow — seed a few pieces mid-pipeline so Edit & Review modes have something to show
  if (!db._wfDemo) {
    const setS = (ref, s, edited, reviewed) => { const c = (db.content || []).find(x => x.ref === ref); if (c) { c.status = s; c.published = s === "published"; c.statusByLang = { en: langExists(c, "en") ? s : "draft", fr: langExists(c, "fr") ? s : "draft" }; if (edited) c.editedBy = edited; if (reviewed) c.reviewedBy = reviewed; } };
    setS("ds-embeddings", "review", "Tom Beaumont");
    setS("eng-observability", "published", "Tom Beaumont", "L\u00e9a Marchand");
    setS("ds-dashboards", "review", "Tom Beaumont");
    setS("diy-soldering-rig", "draft", "Tom Beaumont");
    setS("diy-vid-cnc", "validated", "Tom Beaumont", "L\u00e9a Marchand");
    setS("alp-risk-math", "published", "Tom Beaumont", "L\u00e9a Marchand");
    // new hidden sections — a couple mid-pipeline so Edit/Review/Publish modes have something to show
    setS("pod-trail-talk", "review", "Tom Beaumont");
    setS("mus-compile-focus", "draft", "Tom Beaumont");
    setS("doc-pipeline-runbook", "validated", "Tom Beaumont", "L\u00e9a Marchand");
    // ds-causal-2026 is published in EN but never translated to FR — normalize already
    // seeds { en: published, fr: draft } (a missing language stays Draft, never inherits).
    const gm = (db.otherPages || []).find(p => p.id === "game"); if (gm) { gm.status = "review"; gm.published = false; gm.statusByLang = { en: "review", fr: pageHasLang(gm, "fr") ? "review" : "draft" }; gm.editedBy = "Tom Beaumont"; }
    const sv = (db.otherPages || []).find(p => p.id === "survey"); if (sv) { sv.status = "draft"; sv.published = false; sv.statusByLang = { en: "draft", fr: "draft" }; sv.editedBy = "Tom Beaumont"; }
    db._wfDemo = true;
  }
  return db;
}

function load() {
  try {
    const raw = localStorage.getItem(DB_KEY);
    if (raw) return normalize(JSON.parse(raw));
  } catch (e) {}
  return normalize(seed());
}

const StoreCtx = createContext(null);

function StoreProvider({ children }) {
  const [db, setDb] = useState(load);
  useEffect(() => {
    try { localStorage.setItem(DB_KEY, JSON.stringify(db)); } catch (e) {}
  }, [db]);

  const update = useCallback((fn) => {
    setDb((prev) => {
      const next = JSON.parse(JSON.stringify(prev));
      fn(next);
      return next;
    });
  }, []);

  const reset = useCallback(() => setDb(normalize(seed())), []);

  const api = useMemo(() => ({ db, update, reset, setDb }), [db, update, reset]);
  return <StoreCtx.Provider value={api}>{children}</StoreCtx.Provider>;
}

function useStore() { return useContext(StoreCtx); }

/* navigation context (provided by App) */
const NavCtx = createContext({ route: { name: "home" }, go: () => {}, auth: false, setAuth: () => {} });
function useNav() { return useContext(NavCtx); }

// helpers
function accentFor(id) { return ACCENTS[id] || ACCENTS.datascientist; }
/* socials shown for a domain = the ones picked (and ordered) in that domain's own settings.
   Domain settings are the single source of truth — the social definition no longer carries a domain list. */
function domainSocials(db, domainId) {
  const d = (db.domains || []).find(x => x.id === domainId);
  const ids = (d && d.socials) || [];
  return ids.map(id => (db.socials || []).find(s => s.id === id)).filter(Boolean);
}
function uid(p) { return (p || "id") + "_" + Math.random().toString(36).slice(2, 8); }

/* ---- media library helpers (Assets ↔ R2) ---- */
function assetById(db, id) { return (db.media || []).find(m => m.id === id) || null; }
function mediaFolders(db, type) {
  const set = [];
  (db.media || []).forEach(m => { if ((!type || m.type === type) && m.folder && !set.includes(m.folder)) set.push(m.folder); });
  return set;
}
function mediaInFolder(db, { type, folder, locale, q } = {}) {
  const needle = (q || "").trim().toLowerCase();
  return (db.media || []).filter(m =>
    (!type || m.type === type) &&
    (!folder || folder === "all" || m.folder === folder) &&
    (!locale || locale === "all" || !m.locale || m.locale === locale) &&
    (!needle || (m.label + " " + m.key + " " + (m.folder || "")).toLowerCase().includes(needle))
  );
}
/* a playable/viewable URL for an asset: its uploaded data URL / external url, else
   \u2014 when the app is pointed at a live backend \u2014 the R2 object streamed through the
   API at /api/assets/<key>. Stays "" locally when only a key exists (no file yet). */
function assetSrc(m) {
  if (!m) return "";
  if (m.url) return m.url;
  if (m.key) {
    if (window.__R2_CONFIG__ && window.__R2_CONFIG__.isRemote()) {
      const u = window.__R2_CONFIG__.assetUrl(m.key);
      if (u) return u;
    }
    if (window.VitrineAPI && window.VitrineAPI.isRemote()) return window.VitrineAPI.assetUrl(m.key);
  }
  return "";
}

/* ---- access grants: resolve a granted id (content / other-page / section) to an openable target ---- */
function resolveGrant(db, gid) {
  const c = (db.content || []).find(x => x.id === gid);
  if (c) {
    const sec = (db.sections || []).find(s => s.kind === c.type);
    // a grant is content-level (not per language): show the WHOLE original-language
    // unit (EN preferred, falling back entirely to FR) — title & blurb from one language.
    const w = wholeLang(c, "en");
    const label = w.title || c.ref;
    const desc = w.description || "";
    return { id: gid, label, route: `/${c.type === "video" ? "videos" : "blog"}/${c.ref}`, kind: c.type === "video" ? "video" : "article", privateItem: !!c.private || c.published === false, desc, logo: (sec && sec.logo) || null, cover: c.cover || null, statusByLang: c.statusByLang || null, sectionId: sec ? sec.id : null, sectionLabel: sec ? (secLabel(sec, "en") || sec.label) : (c.type === "video" ? "Videos" : "Blog"), sectionKind: c.type };
  }
  const p = (db.otherPages || []).find(x => x.id === gid);
  if (p) { const w = pageWholeLang(p, "en"); return { id: gid, label: w.label || p.id, route: p.route, kind: p.kind === "secret" ? "secret" : "page", privateItem: p.published === false, desc: w.desc || "", logo: null, cover: null, statusByLang: p.statusByLang || null, sectionId: "other", sectionLabel: "Other", sectionKind: p.kind }; }
  const s = (db.sections || []).find(x => x.id === gid);
  if (s) return { id: gid, label: secLabel(s, "en") || s.label, route: s.route, kind: "section", privateItem: false, desc: "", logo: s.logo || null, cover: null, statusByLang: null, sectionId: gid, sectionLabel: secLabel(s, "en") || s.label, sectionKind: s.kind };
  return null;
}
function userGrants(db, user) { return ((user && user.grants) || []).map(g => resolveGrant(db, g)).filter(Boolean); }
/* grants ranked by popularity (views) — used by the "Shared with you" shortcut (top 3 most-viewed) */
function grantViews(db, gid) { const c = (db.content || []).find(x => x.id === gid); return c ? (c.views || 0) : 0; }
function userGrantsRanked(db, user) {
  return userGrants(db, user).map(g => ({ ...g, views: grantViews(db, g.id) })).sort((a, b) => b.views - a.views);
}

/* ---- multi-domain helpers ---- */
function domainsOf(c) { return (c && c.domains && c.domains.length) ? c.domains : (c && c.domain ? [c.domain] : []); }
function primaryDomain(c) { return (c && c.domains && c.domains[0]) || (c && c.domain) || "datascientist"; }

/* ---- bilingual helpers — symmetric: EN = base fields, FR = c.tr.fr.
   Either language may be missing; the reader falls back to whichever exists. ---- */
function _fieldFilled(v) { return Array.isArray(v) ? v.length > 0 : (v != null && String(v).trim() !== ""); }
/* raw fields for ONE language, no fallback (used by editors) */
function langFieldsRaw(c, lang) {
  if (lang === "fr") {
    const t = (c.tr && c.tr.fr) || {};
    return { title: t.title || "", description: t.description || "", body: (t.body && t.body.length) ? t.body : [] };
  }
  return { title: c.title || "", description: c.description || "", body: (c.body && c.body.length) ? c.body : [] };
}
/* does a given language have authored content? (symmetric — EN is no longer assumed) */
function langExists(c, lang) {
  const f = langFieldsRaw(c, lang);
  return _fieldFilled(f.title) || _fieldFilled(f.body);
}
function hasLang(c, lang) { return langExists(c, lang); }
/* reader fields: requested language, falling back per-field to the other language */
function langFields(c, lang) {
  const want = langFieldsRaw(c, lang);
  const other = langFieldsRaw(c, lang === "fr" ? "en" : "fr");
  const pick = (k) => _fieldFilled(want[k]) ? want[k] : other[k];
  return { title: pick("title"), description: pick("description"), body: pick("body") };
}
/* ---- WHOLE-LANGUAGE display fields (no per-field mixing) ----
   A content version in a language is a UNIT: a thumbnail/cover shows one language's
   title + description + body together — never EN title with an empty/FR description.
   If the requested language has nothing authored, the WHOLE unit falls back to the
   other language. Use this everywhere a piece is *displayed* (cards, hero, grants). */
function wholeLang(c, dl) {
  const a = langFieldsRaw(c, dl);
  const ol = dl === "fr" ? "en" : "fr";
  const b = langFieldsRaw(c, ol);
  // anchor on the TITLE — a thumbnail shows the language that actually has a title,
  // wholesale. A half-authored language (e.g. only a body, or only a description)
  // never “wins” the display; we fall back entirely to the language that has a title.
  if (_fieldFilled(a.title)) return { ...a, lang: dl };
  if (_fieldFilled(b.title)) return { ...b, lang: ol };
  if (_fieldFilled(a.description) || (a.body && a.body.length)) return { ...a, lang: dl };
  return { ...b, lang: ol };
}
/* same idea for "Other" pages */
function pageWholeLang(p, dl) {
  const a = pageLangFieldsRaw(p, dl);
  const ol = dl === "fr" ? "en" : "fr";
  const b = pageLangFieldsRaw(p, ol);
  if (_fieldFilled(a.label)) return { ...a, lang: dl };
  if (_fieldFilled(b.label)) return { ...b, lang: ol };
  if (_fieldFilled(a.desc) || _fieldFilled(a.html)) return { ...a, lang: dl };
  return { ...b, lang: ol };
}
/* per-language status summary for a content/page — e.g. [{lang:'en',exists:false,status:'draft'},{lang:'fr',exists:true,status:'validated'}].
   Used by the access selector to print "EN — · FR validated" next to each item. */
function langStatusSummary(o) {
  return ["en", "fr"].map((lg) => ({ lang: lg, exists: itemExists(o, lg), status: statusOfLang(o, lg) }));
}
/* should a piece appear at all in the given language?
   strict hides it when the language is missing; otherwise it falls back to the other language. */
function isVisibleInLang(c, lang) {
  if (langExists(c, lang)) return true;
  if ((c && c.fallback) === "strict") return false;
  return langExists(c, lang === "fr" ? "en" : "fr");   // fall back to whatever exists
}
/* ---- PUBLIC reading visibility (role-INDEPENDENT) ----
   The normal reading site is identical for everyone (signed-out or signed-in) — it
   shows only PUBLISHED content. Pre-released (review/validated) pieces live in Tester
   mode and the collaborator modes, never in passive reading. A piece appears in `lang`
   iff that language is published, or (non-strict) the other language is published and
   is shown as a fallback. */
function readableInLang(c, lang) {
  if (langExists(c, lang) && publishedInLang(c, lang)) return true;
  if (c && c.fallback === "strict") return false;
  const o = lang === "fr" ? "en" : "fr";
  return langExists(c, o) && publishedInLang(c, o);
}
/* which language a public reader actually sees for `lang`: the requested one when it's
   published, else the published fallback language. */
function publicDisplayLang(c, lang) {
  if (langExists(c, lang) && publishedInLang(c, lang)) return lang;
  const o = lang === "fr" ? "en" : "fr";
  if ((!c || c.fallback !== "strict") && langExists(c, o) && publishedInLang(c, o)) return o;
  return lang;
}
/* ---- per-language "ready" gate ----
   A language is READY (may be flagged for review / validated / published) only when its
   title + description + the section's MAIN content are all filled. Title & description are
   per-language; the main content is per-type (body is per-language; video link / album /
   gallery / documents are shared across languages). */
function _bodyFilled(body) { return (body || []).some(b => _fieldFilled(b.text) || _fieldFilled(b.code) || (b.type === "img" && b.src) || b.type === "table"); }
function mainContentFilled(c, lang) {
  const k = (c && c.type) || "blog";
  if (k === "blog") { const f = langFieldsRaw(c, lang); return _bodyFilled(f.body); }
  if (k === "video") return _fieldFilled(c.youtube) || _fieldFilled(c.videoUrl) || !!c.videoFile;
  if (k === "image") return Array.isArray(c.gallery) && c.gallery.length > 0;
  if (k === "document") return (Array.isArray(c.docs) && c.docs.length > 0) || _fieldFilled(c.pdf);
  // audio kinds (podcast / music / audio / sound)
  if ((Array.isArray(c.tracks) && c.tracks.some(t => t.assetId || _fieldFilled(t.title)))) return true;
  return _fieldFilled(c.playlist) || !!c.playlistFile;
}
function langReady(c, lang) {
  const f = langFieldsRaw(c, lang);
  return _fieldFilled(f.title) && _fieldFilled(f.description) && mainContentFilled(c, lang);
}
/* missing fields, for an inline checklist */
function langMissingFields(c, lang) {
  const f = langFieldsRaw(c, lang); const out = [];
  if (!_fieldFilled(f.title)) out.push("title");
  if (!_fieldFilled(f.description)) out.push("description");
  if (!mainContentFilled(c, lang)) out.push((c && c.type) === "video" ? "video link" : (c && c.type) === "image" ? "images" : (c && c.type) === "document" ? "documents" : ((c && c.type) && c.type !== "blog") ? "audio source" : "body");
  return out;
}
/* ---- unified readiness / existence across content items AND other-pages ----
   content items carry a `type`; other-pages don't. A piece displays as a real
   language version (badge shows its status) only when that language is READY;
   otherwise it reads as "—" (missing). */
function itemReady(o, lang) { return o && o.type ? langReady(o, lang) : pageReady(o, lang); }
function itemExists(o, lang) { return o && o.type ? langExists(o, lang) : pageHasLang(o, lang); }
/* is a single language actionable in a given workflow status (draft counts untranslated
   languages too; every other status needs the language to actually exist). status may be
   a single id or an array (e.g. tester previews review+validated). */
function itemLangActionable(o, lg, status) {
  const st = statusOfLang(o, lg);
  if (Array.isArray(status)) return status.includes(st) && itemExists(o, lg);
  if (status === "draft") return st === "draft";
  return st === status && itemExists(o, lg);
}
/* does a piece belong in a collaborator's queue for `status`? Scoped to the caller's
   EDITORIAL access (Permissions_v2): public sections are reachable by default; hidden
   sections, PRIVATE content and "Other" pages need an explicit editorial grant. */
function sectionOfContent(db, c) { return (db.sections || []).find(s => s.kind === (c && c.type)) || null; }
function hasEditorialAccess(db, o, session, role) {
  if (role === "admin") return true;
  const eg = (session && session.editGrants) || [];
  const off = (session && session.editGrantsOff) || [];
  if (o && o.type) { // content
    const sec = sectionOfContent(db, o);
    const secId = sec && sec.id;
    const grantedItem = eg.includes(o.id);
    const grantedSec = !!secId && eg.includes(secId);
    if (o.private) { if (role === "publisher" || role === "tester") return false; return grantedItem || grantedSec; }
    if (sec && !sec.inMenu) return grantedItem || grantedSec;          // hidden section → grant required
    if (secId && off.includes(secId)) return grantedItem;             // public section explicitly removed
    if (off.includes(o.id)) return false;
    return true;                                                       // public section → default access
  }
  return eg.includes(o.id);                                           // "Other" page → explicit grant required
}
/* SHARED (view-only) access — the normal-mode “Shared content” menu */
function hasSharedAccess(db, o, session) {
  const g = (session && session.grants) || [];
  if (o && o.type) { const sec = sectionOfContent(db, o); return g.includes(o.id) || (!!sec && g.includes(sec.id)); }
  return g.includes(o.id);
}
/* can this role act on this SECTION at all in a collab mode? (mode dashboard + restricted menu) */
function sectionEditorialAccess(db, sec, session, role) {
  if (role === "admin") return true;
  if (!sec) return false;
  const eg = (session && session.editGrants) || [];
  const off = (session && session.editGrantsOff) || [];
  if (sec.inMenu) return !off.includes(sec.id);                       // public, unless explicitly removed
  if (eg.includes(sec.id)) return true;                               // hidden — whole-section grant
  return (db.content || []).some(c => c.type === sec.kind && eg.includes(c.id)); // … or a granted item inside
}
function itemActionable(db, o, status, session, role, adminEdit) {
  if (adminEdit) return true;
  if (!hasEditorialAccess(db, o, session, role)) return false;
  return ["en", "fr"].some(lg => itemLangActionable(o, lg, status));
}

/* other-page "ready" gate — title + description + content (html) all filled */
function pageReady(p, lang) {
  const f = pageLangFieldsRaw(p, lang);
  if (p && p.kind === "secret") return _fieldFilled(f.label);
  return _fieldFilled(f.label) && _fieldFilled(f.desc) && _fieldFilled(f.html);
}
/* translated section label: base = EN label, s.tr.fr.label override */
function secLabel(s, lang) {
  if (lang === "fr" && s && s.tr && s.tr.fr && s.tr.fr.label && s.tr.fr.label.trim()) return s.tr.fr.label;
  return s ? s.label : "";
}
/* other-page bilingual fields (symmetric, with per-field fallback) */
function pageLangFieldsRaw(p, lang) {
  if (lang === "fr") { const t = (p.tr && p.tr.fr) || {}; return { label: t.label || "", desc: t.desc || "", html: t.html || "" }; }
  return { label: p.label || "", desc: p.desc || "", html: p.html || "" };
}
function pageLangFields(p, lang) {
  const want = pageLangFieldsRaw(p, lang);
  const other = pageLangFieldsRaw(p, lang === "fr" ? "en" : "fr");
  const pick = (k) => _fieldFilled(want[k]) ? want[k] : other[k];
  return { label: pick("label"), desc: pick("desc"), html: pick("html") };
}
function pageHasLang(p, lang) {
  const f = pageLangFieldsRaw(p, lang);
  return _fieldFilled(f.label) || _fieldFilled(f.html);
}
function pageVisibleInLang(p, lang) {
  if (pageHasLang(p, lang)) return true;
  if ((p && p.fallback) === "strict") return false;
  return pageHasLang(p, lang === "fr" ? "en" : "fr");
}
/* domain bilingual: base = EN, d.tr.fr override */
function domLangFields(d, lang) {
  const base = { title: d.title, presTitle: d.presTitle, quote: d.quote, presBody: d.presBody };
  if (lang === "fr" && d.tr && d.tr.fr) {
    const t = d.tr.fr;
    return {
      title: t.title || d.title, presTitle: t.presTitle || d.presTitle,
      quote: t.quote || d.quote, presBody: t.presBody || d.presBody,
    };
  }
  return base;
}

/* generic bilingual object: base = EN fields, FR = obj.tr.fr override.
   Used by legal pages and the static form pages (contact / feedback / about). */
function objLangRaw(obj, lang, keys) {
  const src = lang === "fr" ? ((obj && obj.tr && obj.tr.fr) || {}) : (obj || {});
  const out = {};
  keys.forEach(k => { out[k] = src[k] != null ? src[k] : ""; });
  return out;
}
function objLangFields(obj, lang, keys) {
  const want = objLangRaw(obj, lang, keys);
  const other = objLangRaw(obj, lang === "fr" ? "en" : "fr", keys);
  const out = {};
  keys.forEach(k => { out[k] = _fieldFilled(want[k]) ? want[k] : other[k]; });
  return out;
}
function objHasLang(obj, lang, keys) {
  const f = objLangRaw(obj, lang, keys);
  return keys.some(k => _fieldFilled(f[k]));
}
/* set a bilingual field on an object: EN writes base, FR writes tr.fr */
function setObjLang(obj, lang, key, value) {
  if (lang === "fr") { obj.tr = obj.tr || {}; obj.tr.fr = obj.tr.fr || {}; obj.tr.fr[key] = value; }
  else obj[key] = value;
}

function fmtDate(s) {
  try { return new Date(s).toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" }); }
  catch (e) { return s; }
}

Object.assign(window, { StoreProvider, useStore, NavCtx, useNav, accentFor, ACCENTS, ROLES, roleMeta, roleCan, WORKFLOW, WF_ORDER, wfMeta, statusOf, statusOfLang, publishedInLang, canSeeStatus, allowedStatusTransitions, langReady, mainContentFilled, langMissingFields, pageReady, itemReady, itemExists, itemLangActionable, itemActionable, hasEditorialAccess, hasSharedAccess, sectionEditorialAccess, sectionOfContent, readableInLang, publicDisplayLang, seedUsers, userGrants, userGrantsRanked, grantViews, resolveGrant, uid, fmtDate, seedDB: seed, LEGAL_SEED, PAGES_SEED, bodyFromDesc, readTime, wordsOf, domainsOf, primaryDomain, langFields, langFieldsRaw, wholeLang, pageWholeLang, langStatusSummary, langExists, hasLang, isVisibleInLang, secLabel, pageLangFields, pageLangFieldsRaw, pageHasLang, pageVisibleInLang, domLangFields, domainSocials, objLangRaw, objLangFields, objHasLang, setObjLang, SECTION_TYPES, SECTION_TYPE_ORDER, sectionTypeOf, sectionTypeMeta, isAudioContentKind, AUDIO_KINDS, assetById, mediaFolders, mediaInFolder, assetSrc, seedMedia, seedQrDataUrl, domainIllusSvg, illustrationPageId });
