// Sample content per layout type, used by both the editors (as default values)
// and the live PlayerPreview. Field names match catalogue §1/§3 exactly.
//
// One entry per layout type. Each entry is the layout's full data object —
// the structure the Player would read out of <layout> in content.xml.
//
// To keep the preview clean, image fields use solid color/gradient backgrounds
// keyed by purpose; no real photography is required.

// HTML media samples for quiz_images / hidden_items mini-quizzes live at
// uploads/html-samples/ and are catalogued in src/data.jsx (the HTML_SAMPLES
// export). Eight ORIGINAL, brand-free awareness samples authored for the
// OQ-077 spike — phishy emails, an oversharing social post, a fake login
// screen, a smishing text, etc. (No real organisation or product is
// reproduced; the source DFTI assets are not redistributed here.) The
// quiz_images sample below points its HTML question at the typical sample
// (module_4/03). See the catalogue notes in data.jsx for per-sample rationale.
const LAYOUT_CONTENT_SAMPLES = {

  // ─── 3.1 fullscreen_text_and_image ─────────────────────────────────────
  fullscreen_text_and_image: {
    type: 'fullscreen_text_and_image',
    titleMain: { en: 'When does a conversation become difficult?' },
    titleTextColor: '#ffffff',
    text: { en: "It rarely starts with the words. It starts with the temperature shift — the moment the room tightens and one of you starts choosing words more carefully than usual." },
    textColor: '#ffffff',
    backgroundImageUrl: 'placeholder:scene-tense-room',
    blockingSection: { state: 'false', color: '#0f172a', textColor: '#cccccc' },
  },

  // ─── 3.2 fullscreen_video ──────────────────────────────────────────────
  fullscreen_video: {
    type: 'fullscreen_video',
    titleMain: { en: 'Sarah & Jordan' },
    titleSub: { en: 'Watch the full scenario before answering the question that follows.' },
    titleTextColor: '#ffffff',
    titleBackgroundColor: 'rgba(0,0,0,0.4)',
    /* Start blank — author uploads / generates the video themselves from the
       VideoMediaBlock toolbar. In-video overlays only surface once a video
       exists, so the inspector doesn't lead with a section the user can't
       actually use yet. */
    videoUrl: '',
    videoThumbUrl: '',
    subtitlesUrl: '',
    transcriptUrl: '',
    blockingSection: { state: 'true', color: '#0f172a', textColor: '#ffffff' },
    interactions: [],
  },

  // ─── 3.3 text_and_image ────────────────────────────────────────────────
  text_and_image: {
    type: 'text_and_image',
    titleMain: { en: "Five signals it's becoming difficult" },
    titleSub: { en: "Each row pairs the signal with what it tends to mean." },
    titleTextColor: '#0f172a',
    titleBackgroundColor: '#ffffff',
    blockingSection: { state: 'false' },
    rows: [
      { kind: 'image', backgroundColor: '#ffffff', imageUrl: 'placeholder:scene-volume',
        text: { en: "When the volume rises in the room." }, textColor: '#0f172a', imageOnTheLeft: true },
      { kind: 'image', backgroundColor: '#f8fafc', imageUrl: 'placeholder:scene-eyecontact',
        text: { en: "When two people stop making eye contact." }, textColor: '#0f172a', imageOnTheLeft: false },
      { kind: 'video', backgroundColor: '#ffffff', videoUrl: 'content/silence.mp4',
        videoThumbUrl: 'placeholder:video-silence',
        text: { en: "When silence stretches longer than feels natural." }, textColor: '#0f172a', imageOnTheLeft: true },
    ],
  },

  // ─── 3.4 small_video ───────────────────────────────────────────────────
  small_video: {
    type: 'small_video',
    titleMain: { en: 'SME interview · Dr. Priya Naidu' },
    titleSub: { en: '02:14 · what cues do senior managers actually catch first?' },
    titleTextColor: '#0f172a',
    /* External background — the strip around the small video inside the
       layout. Surfaced as the "External bg" swatch in the Video media
       block's header (see docs/video-media-patterns.md §10). */
    contentBackgroundColor: '#a0a8b0',
    videoUrl: 'content/en/modules/m1/03/priya.mp4',
    videoThumbUrl: 'placeholder:video-priya',
    /* Starts UNSET. This used to seed `content/en/modules/m1/03/priya.txt` — a
       file no package has ever contained, and a `content/en/…` value in a field
       that every language's content.xml reads, i.e. a cross-language leak living
       in our own demo data. It was invisible only because the export builder
       DELETED this field outright; now that a per-language track really ships,
       the seed would have 422'd the demo course's export. Empty is the honest
       value until the author uploads or generates a track. */
    subtitlesUrl: '',
    blockingSection: { state: 'false' },
    /* Three in-video overlays demonstrating each marker type, so the
       inspector (docs/video-media-patterns.md §11) has something to surface
       on first open. Question is selected by default below — see the
       SmallVideoEditor3's controlled selectedIndex if/when wired up. */
    interactions: [
      { id: 'iv_disc', type: 'discover',
        timeStarts: 42, timeEnds: 58,
        interactionText: { en: 'Watch this' },
        descriptionTitle: { en: 'SBI in action' },
        descriptionText: { en: 'Notice how she names the situation first, before naming the behaviour.' } },
      { id: 'iv_q',    type: 'question',
        timeStarts: 67,
        interactionText: { en: 'Your turn' },
        text: { en: 'The question to surface to the learner at this moment.' },
        options: [
          { text: { en: 'Option A' }, isCorrect: true },
          { text: { en: 'Option B' } },
        ] },
      { id: 'iv_mand', type: 'mandatoryQuestion',
        timeStarts: 73,
        interactionText: { en: 'Answer to continue' },
        text: { en: '' },
        options: [
          { text: { en: '' }, isCorrect: true },
          { text: { en: '' } },
        ] },
    ],
  },

  // ─── 3.5 sequence ──────────────────────────────────────────────────────
  sequence: {
    type: 'sequence',
    titleMain: { en: 'Sarah & Jordan: a feedback scenario' },
    titleSub: { en: "How to use SBI when you'd rather not have the conversation" },
    titleTextColor: '#ffffff',
    titleBackgroundColor: 'rgba(0,0,0,0.5)',
    contentBackgroundColor: '#000000',
    dotSelectedColor: '#FFD166',
    blockingSection: { state: 'true' },
    bgColor: '#ffffff',
    textColor: '#0f172a',
    btnBgColor: '#0f172a',
    btnTextColor: '#ffffff',
    tabs: [
      { id: 't1', mobileText: { en: 'Set-up' }, tabTitle: { en: 'The missed deadline' }, kind: 'text',
        imageUrl: 'placeholder:scene-office',
        tabText: { en: 'Sarah notices Jordan has missed two consecutive deliverables. The team is starting to whisper.' } },
      { id: 't2', mobileText: { en: 'Prep' }, tabTitle: { en: 'Sarah prepares' }, kind: 'video',
        videoUrl: 'content/m2-03/02.mp4', videoThumbUrl: 'placeholder:video-sarah-prep',
        interactions: [] },
      /* Quiz step — new shape (kind:'question' with options[]), same as
         in-video Question interactions. See docs/video-media-patterns.md
         §11 and §13. */
      { id: 't3', mobileText: { en: 'Decide' }, tabTitle: { en: 'Which opener works best?' }, kind: 'question',
        imageUrl: 'placeholder:scene-office',
        text: { en: 'Pick the opener that uses SBI most cleanly.' },
        options: [
          { text: { en: '"When you missed the API spec review on Tuesday…"' },
            isCorrect: true,
            feedback: { en: 'Correct — that names the situation and the behaviour precisely.' } },
          { text: { en: '"You\'ve been disengaged from the team lately."' },
            isCorrect: false,
            feedback: { en: 'That\'s a judgement, not a behaviour — SBI asks you to name what you observed.' } },
          { text: { en: '"Your work has been below expectations."' },
            isCorrect: false,
            feedback: { en: 'Vague; the learner can\'t tell what specifically to change.' } },
        ] },
      { id: 't4', mobileText: { en: 'Feedback' }, tabTitle: { en: "That's an SBI opener" }, kind: 'feedback',
        imageUrl: 'placeholder:scene-office',
        text: { en: 'Naming the specific behaviour without judgement is the SBI core move.' },
        forAnswerId: '1' },
      { id: 't5', mobileText: { en: 'The talk' }, tabTitle: { en: 'The conversation' }, kind: 'video',
        videoUrl: 'content/m2-03/05.mp4', videoThumbUrl: 'placeholder:video-talk',
        interactions: [] },
      { id: 't6', mobileText: { en: 'Wrap' }, tabTitle: { en: 'After the talk' }, kind: 'text',
        imageUrl: 'placeholder:scene-handshake',
        tabText: { en: 'Both walk away with a concrete next step and a follow-up scheduled.' } },
    ],
  },

  // ─── 3.6 horizontal_tabs ───────────────────────────────────────────────
  horizontal_tabs: {
    type: 'horizontal_tabs',
    titleMain: { en: 'CLEAR vs SBI' },
    titleSub: { en: 'When does each framework serve the conversation better?' },
    titleTextColor: '#0f172a',
    selectedBgColor: '#D32F2F',
    selectedTextColor: '#ffffff',
    tabTextColor: '#475569',
    blockingSection: { state: 'false' },
    tabs: [
      { tabTitle: { en: 'Coaching' }, tabText: { en: 'CLEAR is built for the coaching context — when the other person needs space to think, not to be told.' }, imageUrl: 'placeholder:scene-coaching' },
      { tabTitle: { en: 'Giving' }, tabText: { en: 'SBI is built for giving feedback — specific behaviour, specific impact, specific moment.' }, imageUrl: 'placeholder:scene-giving' },
      { tabTitle: { en: 'Receiving' }, tabText: { en: 'When receiving, the same scaffolds work in reverse — ask for the situation, the behaviour, then the impact.' }, imageUrl: 'placeholder:scene-receiving' },
    ],
  },

  // ─── 3.7 vertical_tabs ─────────────────────────────────────────────────
  vertical_tabs: {
    type: 'vertical_tabs',
    titleMain: { en: 'Government Dealings' },
    titleSub: { en: 'We respect the political processes in each country where we operate.' },
    titleTextColor: '#0f172a',
    contentBackgroundColor: '#B8932D',
    backgroundImageUrl: '',
    blockingSection: { state: 'false' },
    tabs: [
      { tabTitle: { en: 'Payments to Government Officials' },
        tabText: { en: "We don't offer or accept any bribes or benefits to officials or colleagues of a political party, government or government body. If any official asks you for such a payment or benefit, you should refuse and tell your line manager or the Director of Finance or P&C right away." } },
      { tabTitle: { en: 'Political Activities' },
        tabText: { en: "We respect the political opinions of every colleague, but the company itself does not engage in party-political activity. Personal activity must stay personal and outside work time." } },
    ],
  },

  // ─── 3.8 icons_discover ────────────────────────────────────────────────
  icons_discover: {
    type: 'icons_discover',
    titleMain: { en: 'Diversity and Inclusion' },
    titleSub: { en: 'At Mandarin Oriental Hotel Group, we have colleagues from all over the world with different backgrounds, experiences, and cultures. This makes us a strong and diverse team.' },
    titleTextColor: '#ffffff',
    titleBackgroundColor: '#3F4E66',
    contentBackgroundColor: '#3F4E66',
    selectedBgColor: '#B8932D',
    blockingSection: { state: 'false' },
    icons: [
      { imageUrl: 'placeholder:icon-people',
        text: { en: 'We value the physical and mental health, safety and well-being of our colleagues. This is key to the success of MOHG.' } },
      { imageUrl: 'placeholder:icon-mind',
        text: { en: "We embrace different ways of thinking and seeing the world." } },
      { imageUrl: 'placeholder:icon-gear',
        text: { en: "We build inclusive systems and processes — not exceptions." } },
      { imageUrl: 'placeholder:icon-headset',
        text: { en: "We listen actively, then act on what we hear." } },
    ],
  },

  // ─── 3.9a hidden_items_flat_image ───────────────────────────────────────
  hidden_items_flat_image: {
    type: 'hidden_items',
    titleMain: { en: 'Fire awareness' },
    titleSub: { en: "It's important to remember that fires can start easily and spread rapidly with devastating costs to both business organisations and our personal lives. Select each item to learn more." },
    titleTextColor: '#ffffff',
    titleBackgroundColor: '#5514B4',
    contentBackgroundColor: '#5514B4',
    imageUrl: 'placeholder:scene-office-fire',
    blockingSection: { state: 'true' },
    bgColor: '#ffffff',
    borderColor: '#FF80FF',
    itemColor: '#FF80FF',
    textColor: '#0f172a',
    items: [
      { itemPosition: '64,40', itemTitle: { en: 'Blocked exit' }, itemText: { en: 'Doors must never be wedged open or obstructed — they are the only safe escape route in a fire.' } },
      { itemPosition: '38,58', itemTitle: { en: 'Cables under desk' }, itemText: { en: 'Loose cabling under a desk is a tripping hazard and a fire risk if it overheats.' } },
      { itemPosition: '40,82', itemTitle: { en: 'Cardboard near socket' }, itemText: { en: 'Combustible storage should never sit beside an electrical outlet.' } },
      { itemPosition: '20,38', itemTitle: { en: 'Portable heater' }, kind: 'question',
        itemText: { en: 'A portable heater has been left running next to a stack of paper. What should you do?' },
        answers: [
          { text: { en: 'Switch it off and move the paper away' }, isCorrect: true,
            feedback: { en: 'Correct — an unattended heat source beside combustibles is a serious fire risk.' } },
          { text: { en: 'Leave it — modern heaters cut out automatically' }, isCorrect: false,
            feedback: { en: 'Not quite — never rely on cut-outs near combustible material.' } },
        ] },
    ],
  },

  // ─── 3.9b hidden_items_360_image ────────────────────────────────────────
  hidden_items_360_image: {
    type: 'hidden_items',
    titleMain: { en: 'Spot the hazards · 360° walkthrough' },
    titleSub: { en: 'Drag to look around the kitchen, then tap each hotspot to identify the hazard.' },
    titleTextColor: '#ffffff',
    titleBackgroundColor: '#5514B4',
    contentBackgroundColor: '#5514B4',
    image360Url: 'placeholder:panorama-kitchen',
    image360CoverText: { en: 'Drag to look around' },
    blockingSection: { state: 'true' },
    bgColor: '#ffffff',
    borderColor: '#FF80FF',
    itemColor: '#FF80FF',
    textColor: '#0f172a',
    items: [
      { itemPosition: '50,50', itemTitle: { en: 'Unattended hob' },    itemText: { en: 'A pan left on a lit hob is a leading cause of kitchen fires.' } },
      { itemPosition: '40,55', itemTitle: { en: 'Tea-towel near flame' }, itemText: { en: 'Fabric near an open flame can ignite in seconds.' } },
      { itemPosition: '60,52', itemTitle: { en: 'Overloaded socket' }, itemText: { en: 'Multiple high-draw appliances on one socket can overheat the wiring.' } },
    ],
  },

  // ─── 3.9 hidden_items (legacy alias — surface-layout-editor-3 demo) ─────
  // Older static demo screens (and any pre-split saved layouts) still
  // look up LAYOUT_CONTENT_SAMPLES.hidden_items. Point it at the flat
  // variant so nothing breaks while authors migrate to the explicit ids.
  get hidden_items() { return this.hidden_items_flat_image; },

  // ─── 3.10 quiz_images ──────────────────────────────────────────────────
  quiz_images: {
    type: 'quiz_images',
    titleMain: { en: 'Quiz — Inclusive or Accessible?' },
    titleSub: { en: "A genuinely inclusive environment is more than just providing access. Indicate which image shows the genuinely inclusive solution rather than the solution that merely provides access." },
    titleTextColor: '#ffffff',
    contentBackgroundColor: '#000000',
    contentTextColor: '#ffffff',
    selectedBgColor: '#F26430',
    selectedTextColor: '#ffffff',
    dotSelectedColor: '#F26430',
    blockingSection: { state: 'true' },
    questions: [
      { text: { en: 'What option below delivers genuine inclusion, the revolving doors or the sliding double doors?' },
        content: { image: { imageUrl: 'placeholder:scene-doors-split' } },
        answers: [
          { text: { en: 'Sliding doors' }, isCorrect: true,
            feedback: { en: 'The sliding doors are a single entry for all users.' } },
          { text: { en: 'Revolving doors' }, isCorrect: false,
            feedback: { en: 'Not quite — look again at which option works for everyone without a separate route.' } },
        ] },
      { text: { en: 'Which kitchen setup includes everyone?' },
        content: { image: { imageUrl: 'placeholder:scene-kitchen' } },
        answers: [
          { text: { en: 'Adjustable-height counter' }, isCorrect: true,
            feedback: { en: 'One shared kitchen with adjustable counters means nobody is set apart.' } },
          { text: { en: 'Two separate kitchens' }, isCorrect: false,
            feedback: { en: 'Not quite — separate facilities set people apart.' } },
        ] },
      // Hotspot question: the pins are DISCOVERY elements (tap to reveal extra
      // context in the image) — NOT answers. The question is answered via the
      // separate answers + feedback below.
      { text: { en: 'Explore the entrance, then choose which option works for everyone without a separate route.' },
        content: { image: { imageUrl: 'placeholder:scene-doors-split',
          itemColor: '#FF80FF', bgColor: '#ffffff', borderColor: '#FF80FF', textColor: '#0f172a',
          items: [
            { id: 'h1', kind: 'text', itemPosition: '30,55', itemTitle: { en: 'Sliding doors' },
              itemText: { en: 'A single wide entry that opens automatically — usable by everyone at once.' } },
            { id: 'h2', kind: 'text', itemPosition: '70,52', itemTitle: { en: 'Revolving doors' },
              itemText: { en: 'Narrow segmented bays that are hard to use with a wheelchair, pram or luggage.' } },
          ] } },
        answers: [
          { id: '1', text: { en: 'Sliding doors' }, isCorrect: true,
            feedback: { en: 'Right — one shared entry serves everyone.' } },
          { id: '2', text: { en: 'Revolving doors' }, isCorrect: false,
            feedback: { en: 'These exclude wheelchair and pram users — look for the shared entry.' } },
        ] },
      // HTML-media question — the question media is an embedded HTML document
      // (nested content.html, mirroring content.image / content.video) rather
      // than an image. Points at the typical brand-free phishy-email sample;
      // see src/data.jsx HTML_SAMPLES.
      { text: { en: 'Read the embedded email below. What is the strongest sign it is phishy?' },
        content: { html: {
          htmlUrl: 'uploads/html-samples/content/en/modules/module_4/03.html',
          htmlAlt: 'A suspicious email from a "Freight Express" billing address asking you to download an invoice attachment and reply with your full name, address and phone number to release a parcel.' } },
        answers: [
          { text: { en: "Sender's email address" }, isCorrect: false,
            feedback: { en: "The sender address is one signal — but here it's not the strongest." } },
          { text: { en: 'Bad grammar' }, isCorrect: false,
            feedback: { en: 'The grammar is fine in this one — not the right reason.' } },
          { text: { en: 'Request for personal information' }, isCorrect: true,
            feedback: { en: 'Right — it asks you to reply with your name, address and phone number. That request for personal information is the classic phishing tell.' } },
        ] },
    ],
  },

  // ─── 3.10b quiz_gaming ─────────────────────────────────────────────────
  // Same question shape as quiz_images, plus the gaming wrapper: a running
  // correct/wrong counter header and start / win / fail screens. When
  // countAsPostAssessment is on, passing completes the course.
  quiz_gaming: {
    type: 'quiz_gaming',
    titleMain: { en: 'Challenge — Spot the inclusive choice' },
    titleSub: { en: 'Answer each question to build your score. Reach the pass threshold to complete the course.' },
    titleTextColor: '#ffffff',
    contentBackgroundColor: '#0b1020',
    contentTextColor: '#ffffff',
    selectedBgColor: '#F26430',
    selectedTextColor: '#ffffff',
    dotSelectedColor: '#F26430',
    blockingSection: { state: 'true' },
    showCorrectWrongAnswers: true,
    countAsPostAssessment: true,
    correctAnswersNeeded: 2,
    questions: [
      { text: { en: 'Which entrance works for the most people without a separate route?' },
        content: { image: { imageUrl: 'placeholder:scene-doors-split' } },
        answers: [
          { text: { en: 'Sliding doors' }, isCorrect: true, feedback: { en: 'Right — one entry serves everyone.' } },
          { text: { en: 'Revolving doors' }, isCorrect: false, feedback: { en: 'These exclude wheelchair and pram users.' } },
        ] },
      { text: { en: 'Which kitchen setup includes everyone?' },
        content: { image: { imageUrl: 'placeholder:scene-kitchen' } },
        answers: [
          { text: { en: 'Adjustable-height counter' }, isCorrect: true, feedback: { en: 'Right — one space adapts to all.' } },
          { text: { en: 'Two separate kitchens' }, isCorrect: false, feedback: { en: 'Separate facilities set people apart.' } },
        ] },
      // HTML-media question whose template (Phishy email — typical) exposes
      // tooltip slots. The author-filled `tooltipValues` are localised on the
      // Localisation surface's Quiz Gaming tab; the slot labels come from the
      // template's tooltipParams (see src/html-templates.jsx).
      { text: { en: 'Read the embedded email. What is the strongest sign it is phishy?' },
        content: { html: {
            htmlUrl: 'uploads/html-samples/content/en/modules/module_4/03.html',
            htmlAlt: 'A suspicious "Freight Express" invoice email asking you to reply with your full name, address and phone number to release a parcel.' },
          _templateId: 'tpl-phishy-email-typical',
          tooltipValues: {
            sender_domain: { en: 'The display name says "Freight Notifications", but the real address is a free mailbox — not a company domain.' },
            personal_info_request: { en: 'Asking you to reply with your name, address and phone number by email is a classic phishing pattern.' },
            suspicious_link: { en: 'The button is labelled "invoice" but points at an unfamiliar link — hover before you ever click.' },
            urgency_deadline: { en: 'A "within 24 hours" deadline is designed to make you act before you think.' },
          } },
        answers: [
          { text: { en: 'It asks you to reply with personal details' }, isCorrect: true, feedback: { en: 'Right — the request for personal information is the strongest tell.' } },
          { text: { en: 'The logo looks slightly off' }, isCorrect: false, feedback: { en: 'The branding isn\'t the giveaway here — look at what it asks you to do.' } },
        ] },
    ],
    gamingHeader: { enabled: true,
      imgUrlCorrect: 'placeholder:icon-correct', imgUrlWrong: 'placeholder:icon-wrong' },
    gamingStartScreen: { title: { en: 'Ready for a challenge?' },
      titleColor: '#FF80FF', bgColor: '#3b1d63',
      // NO answer-count tokens on the START screen — the Player never substitutes
      // them there (only {img1}/{img2}/{img3}), so this text used to ship the
      // literal "{totalAnswers}" to learners. Omar hit it on 2026-07-30 having
      // simply edited the end of the sentence this seed handed him. Kept
      // count-FREE rather than switched to literal numbers so it cannot go stale
      // when the author adds or removes a question. The win/fail bodies below keep
      // their tokens, which do work.
      body: { en: 'Answer all the questions. Get enough right to pass — good luck.' },
      bgImgUrl: 'placeholder:gaming-start' },
    gamingEndScreens: {
      win: { title: { en: 'Challenge complete' }, titleColor: '#FF80FF', bgColor: '#3b1d63',
        body: { en: 'You answered {neededAnswers} of {totalAnswers} correctly — well done.' },
        bgImgUrl: 'placeholder:gaming-win' },
      failure: { title: { en: 'Not quite' }, titleColor: '#FF80FF', bgColor: '#3b1d63',
        body: { en: 'You missed {wrongAnswers}. Review the module and try again.' },
        bgImgUrl: 'placeholder:gaming-fail' },
    },
  },

  // ─── 3.11 object_viewer ────────────────────────────────────────────────
  // Player consumes only two scene-level tags: <backgroundColor> and
  // <objectUrl>. The other fields here are convenience state for the
  // editor (hotspot list, panel chrome, behaviour toggles) — keep the
  // shape minimal at the scene level; everything else hangs off items.
  object_viewer: {
    type: 'object_viewer',
    titleMain: { en: '3D Product Overview' },
    titleSub: { en: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam pretium lobortis felis, et cursus urna luctus ac. Duis ultrices enim molestie nibh venenatis commodo.' },
    titleTextColor: '#ffffff',
    titleBackgroundColor: '#0f172a',
    textColor: '#0f172a',
    backgroundColor: '#000000',
    objectUrl: 'content/dfti/models/centrifuge.glb',
    objectAltText: { en: 'Interactive 3D product model — rotate and zoom to explore.' },
    initialCoverText: { en: 'Drag to explore' },
    mobileArEnabled: true,
    disableZoom: false,
    autoRotate: false,
    disablePan: false,
    disableInteractionPrompt: false,
    bgColor: '#ffffff',
    borderColor: '#D32F2F',
    itemColor: '#D32F2F',
    blockingSection: { state: 'true' },
    items: [
      { kind: 'text', itemPosition: '0.4,0.5,0.2', itemTitle: { en: 'Lid latch' },
        itemText: { en: 'The latch must click fully closed before the rotor will spin.' },
        itemImageUrl: 'placeholder:hotspot-image' },
      { kind: 'text', itemPosition: '0.5,0.2,0.3', itemTitle: { en: 'Rotor' },
        itemText: { en: 'The 6-tube rotor; check balance before every run.' },
        itemImageUrl: 'placeholder:hotspot-image' },
      { kind: 'text', itemPosition: '0.6,0.6,0.2', itemTitle: { en: 'Control dial' },
        itemText: { en: 'Set RPM and time here. Hold to confirm.' },
        itemImageUrl: 'placeholder:hotspot-image' },
    ],
  },

  // ─── 3.12 two_columns_text ─────────────────────────────────────────────
  two_columns_text: {
    type: 'two_columns_text',
    titleMain: { en: 'Do · Avoid' },
    titleSub: { en: 'Two columns of guidance for the tense moment.' },
    titleTextColor: '#ffffff',
    titleBackgroundColor: '#000000',
    blockingSection: { state: 'false' },
    leftColumnContent: { en: '<h3>Do</h3><ul><li>Name the behaviour, not the person.</li><li>Wait for the other person to bring context before you do.</li><li>Offer a concrete next step.</li></ul>' },
    leftColumnTextColor: '#ffffff',
    leftColumnBackgroundColor: '#5514B4',
    rightColumnContent: { en: '<h3>Avoid</h3><ul><li>Open with the impact before the behaviour.</li><li>Stockpile grievances for one big release.</li><li>Read minds — assume their intent.</li></ul>' },
    rightColumnTextColor: '#ffffff',
    rightColumnBackgroundColor: '#3D0D85',
  },

  // ─── 3.13 mandatoryQuestion ────────────────────────────────────────────
  mandatoryQuestion: {
    type: 'mandatoryQuestion',
    titleMain: { en: 'Knowledge check' },
    titleSub: { en: "Answer correctly to move on." },
    titleTextColor: '#0f172a',
    text: { en: 'What should Sarah do first when she sits down with Jordan?' },
    bgColor: '#ffffff',
    textColor: '#0f172a',
    blockingSection: { state: 'true' },
    options: [
      { text: { en: 'Start with the behaviour: "When you missed the spec review on Tuesday…"' }, isCorrect: true },
      { text: { en: 'Open with the impact first to soften the message.' } },
      { text: { en: 'Suggest Jordan take some time off to think it over.' } },
      { text: { en: 'Hand Jordan a 47-page deck and leave the room.' } },
    ],
    feedbacks: {
      correct: { en: "That's the SBI opener — behaviour first, then impact." },
      wrong: { en: "Not quite — SBI starts with the specific behaviour, not the impact." },
    },
  },

  // ─── 3.14 discover — REMOVED as a standalone layout. Lives only as an
  //                   in-video interaction now (see editor-tools.jsx
  //                   `addInteraction('discover')`).

  // ─── 3.15 question (standalone, non-blocking) ──────────────────────────
  question: {
    type: 'question',
    titleMain: { en: 'Quick check' },
    text: { en: 'Which of these is the SBI core move?' },
    bgColor: '#ffffff',
    textColor: '#0f172a',
    blockingSection: { state: '' },
    answers: [
      { text: { en: 'Naming the behaviour without judgement.' }, isCorrect: true },
      { text: { en: 'Asking the other person to explain themselves.' } },
      { text: { en: 'Restating the impact in your own words.' } },
    ],
    feedbackText: { en: 'Behaviour first — then impact, then context.' },
  },

  // ─── 3.16 title ────────────────────────────────────────────────────────
  title: {
    type: 'title',
    titleMain: { en: 'Section 2' },
    titleSub: { en: 'Frameworks for tough talks' },
    titleTextColor: '#ffffff',
    titleBackgroundColor: '#0f172a',
    blockingSection: { state: '' },
  },
};

// ── Sample content for the 7 course framing screens (§4.6) ──────────────────
const FRAMING_CONTENT_SAMPLES = {
  cover: {
    backgroundImageUrl: 'placeholder:scene-cover-hospitality',
    brandLogoUrl: 'placeholder:logo-mohg',
    titleMain: { en: 'Code of Conduct' },
    introText: { en: 'The code of conduct establishes the standards and values which Mandarin Oriental Hotel Group wishes to uphold in the operation of our business.' },
    ctaLabel: { en: 'Start Course' },
    ctaColor: '#ffffff',
    ctaTextColor: '#0f172a',
    have_startingCover: true,
  },
  language: {
    enabled: ['en','it','jp','hk','ch','ar','fr','es','de'],
  },
  brand: {
    selectBrand: true,
    dynamicBrandLogo: false,
    brands: [
      { code: 'mohg-hotels',  label: { en: 'MOHG Hotels' },  glyph: 'M' },
      { code: 'mohg-residences', label: { en: 'MOHG Residences' }, glyph: 'R' },
      { code: 'mohg-spa', label: { en: 'MOHG Spa' }, glyph: 'S' },
    ],
    promptText: { en: 'Select the part of the business you work for' },
  },
  role: {
    promptText: { en: 'Select your role' },
    helperText: { en: 'To make sure you get the right training, select your role from the list below.' },
    titleMain: { en: 'Environmental Compliance and You' },
    titleColor: '#0f172a',
    bgColor: '#f1f5f9',
    selectedBgColor: '#5514B4',
    selectedTextColor: '#ffffff',
    roles: [
      { code: 'eng', label: { en: 'Engineering and technician roles/managers (excluding Networks)' } },
      { code: 'office', label: { en: 'Office-based roles/managers (including Supply Chain)' } },
      { code: 'net',   label: { en: 'Networks engineers/managers' } },
    ],
  },
  home: {
    brandLogoUrl: 'placeholder:logo-mohg',
    titleMain: { en: 'Code of Conduct' },
    introText: { en: 'The code of conduct establishes the standards and values which Mandarin Oriental Hotel Group wishes to uphold in the operation of our business.' },
    postAssessmentLabel: { en: 'Start Post-assessment' },
    modules: [
      { id: 'M1', n: 1, title: { en: 'Recognising tension early' }, summary: { en: 'Spot the verbal and non-verbal cues that signal a conversation is sliding sideways.' }, duration: '04:48', state: 'done', thumbBg: 'placeholder:scene-m1' },
      { id: 'M2', n: 2, title: { en: 'Frameworks for tough talks' }, summary: { en: 'Two named frameworks (CLEAR and SBI) and when each one fits.' }, duration: '05:04', state: 'done', thumbBg: 'placeholder:scene-m2' },
      { id: 'M3', n: 3, title: { en: 'De-escalation in practice' }, summary: { en: 'When tempers rise — concrete moves to slow the conversation down.' }, duration: '02:39', state: 'open', thumbBg: 'placeholder:scene-m3' },
      { id: 'M4', n: 4, title: { en: 'After the conversation' }, summary: { en: "Repair, follow-through, and what to write down." }, duration: '01:57', state: 'locked', thumbBg: 'placeholder:scene-m4' },
    ],
  },
  preAssessment: {
    titleMain: { en: 'Pre-assessment' },
    backgroundImageUrl: 'placeholder:scene-cover-hospitality',
    mode: 'completion',
    passMark: 70,
    randomise: true,
    introTitle: { en: 'Thank you for taking the pre-assessment' },
    introBody: { en: 'This will help us recommend the right modules for you.' },
    resultTitle: { en: 'Thank you' },
    resultBody: { en: "You've completed your pre-assessment with {correct} out of {total} correct answers." },
    sampleQuestion: {
      text: { en: 'A colleague with a visible disability joins your team. Which of the following actions best supports an inclusive work culture?' },
      answers: [
        { text: { en: 'Avoid discussing the disability to respect personal boundaries and prevent discomfort.' } },
        { text: { en: 'Invite them to share how you can support their working needs.' }, isCorrect: true },
        { text: { en: 'Check with other colleagues to get their perspective on whether adjustments might be necessary.' } },
        { text: { en: 'Adjust their workload based on what you think might be challenging for them.' } },
      ],
    },
  },
  postAssessment: {
    titleMain: { en: 'Post-assessment' },
    mode: 'threshold',
    passMark: 80,
    requireExplicitPostStart: true,
    showPreAssessmentChoice: false,
    passTitle: { en: 'Well done!' },
    passBody: { en: "You've completed the course. Please confirm you understand the requirements." },
    acknowledgement: { en: 'I confirm I understand my responsibility to follow the legal, regulatory and policy requirements contained in this course.' },
    policyLinkUrl: 'policy.mohg/code-of-conduct',
    policyLinkLabel: { en: 'View policy' },
    failTitle: { en: 'Not quite yet' },
    failBody: { en: 'Review the modules above and try again. You scored {correct} of {total}.' },
  },
};

// ── Blank content skeletons (from-zero authoring) ───────────────────────────
// One entry per pickable layout type, mirroring LAYOUT_CONTENT_SAMPLES in
// SHAPE but not in copy: structure the editors need, no narrative sample text.
// Used as the merge base for every layout in a course whose
// `contentMode === 'blank'` (any course except the seeded demo course) — see
// `layoutContentBase` below. Rules:
//
// - REQUIRED LocalizedString fields are seeded `{ en: '' }` so the export's
//   translation-coverage gate names each unfilled field (empty string counts
//   as missing — packages/schema/src/coverage.ts).
// - OPTIONAL fields are OMITTED entirely (an optional field seeded `{ en: '' }`
//   would coverage-block a field the author legitimately left empty).
// - Media fields are seeded `''`; the export prunes empty media keys so the
//   schema's "Required" error (required media) or the UnresolvedMedia gate
//   (bad paths) names exactly what's missing.
// - Arrays are seeded at the schema's minimum length (horizontal_tabs min 3,
//   icons min 3, answers min 2 …) with blank items.
// - Colors keep the samples' defaults (valid ColorSchema values the author can
//   restyle); enum-ish strings (kind, blockingSection.state) keep valid values.
// - Generic UI copy (gaming start/win/fail screens' token text) is pre-filled —
//   it's functional chrome, not narrative content.
const LAYOUT_CONTENT_SKELETONS = {
  fullscreen_text_and_image: {
    type: 'fullscreen_text_and_image',
    titleMain: { en: '' },
    titleTextColor: '#ffffff',
    text: { en: '' },
    textColor: '#ffffff',
    // A concrete colour, not absent — and the one place this default lives for
    // blank courses. A from-zero screen starts with no image, so the colour is
    // what the learner will actually see; leaving it unset meant the editor
    // showed #0f172a while the package showed whatever the course shell happens
    // to be behind a transparent container. Blank-mode skeletons are otherwise
    // required-fields-only, and this is a deliberate exception: it is the value
    // that makes the preview and the ZIP agree.
    contentBackgroundColor: '#0f172a',
    backgroundImageUrl: '',
    blockingSection: { state: 'false', color: '#0f172a', textColor: '#cccccc' },
  },
  fullscreen_video: {
    type: 'fullscreen_video',
    titleMain: { en: '' },
    titleTextColor: '#ffffff',
    videoUrl: '',
    blockingSection: { state: 'true', color: '#0f172a', textColor: '#ffffff' },
    interactions: [],
  },
  text_and_image: {
    type: 'text_and_image',
    titleMain: { en: '' },
    titleTextColor: '#0f172a',
    blockingSection: { state: 'false' },
    rows: [
      { kind: 'image', backgroundColor: '#ffffff', imageUrl: '',
        text: { en: '' }, textColor: '#0f172a', imageOnTheLeft: true },
    ],
  },
  small_video: {
    type: 'small_video',
    videoUrl: '',
    blockingSection: { state: 'false' },
    interactions: [],
  },
  sequence: {
    type: 'sequence',
    titleMain: { en: '' },
    titleTextColor: '#ffffff',
    blockingSection: { state: 'true' },
    // Text tabs REQUIRE an image (SequenceTabSchema) — the blank imageUrl is
    // pruned at export so the schema names the missing image per tab.
    tabs: [
      { id: 't1', kind: 'text', imageUrl: '', tabText: { en: '' } },
    ],
  },
  horizontal_tabs: {
    type: 'horizontal_tabs',
    titleMain: { en: '' },
    titleTextColor: '#0f172a',
    blockingSection: { state: 'false' },
    tabs: [
      { tabTitle: { en: '' }, tabText: { en: '' }, imageUrl: '' },
      { tabTitle: { en: '' }, tabText: { en: '' }, imageUrl: '' },
      { tabTitle: { en: '' }, tabText: { en: '' }, imageUrl: '' },
    ],
  },
  vertical_tabs: {
    type: 'vertical_tabs',
    titleMain: { en: '' },
    titleTextColor: '#0f172a',
    blockingSection: { state: 'false' },
    tabs: [
      { tabTitle: { en: '' }, tabText: { en: '' } },
    ],
  },
  icons_discover: {
    type: 'icons_discover',
    titleMain: { en: '' },
    titleTextColor: '#ffffff',
    titleBackgroundColor: '#3F4E66',
    contentBackgroundColor: '#3F4E66',
    selectedBgColor: '#B8932D',
    blockingSection: { state: 'false' },
    icons: [
      { imageUrl: '', text: { en: '' } },
      { imageUrl: '', text: { en: '' } },
      { imageUrl: '', text: { en: '' } },
    ],
  },
  hidden_items_flat_image: {
    type: 'hidden_items',
    titleMain: { en: '' },
    titleTextColor: '#ffffff',
    imageUrl: '',
    blockingSection: { state: 'true' },
    bgColor: '#ffffff',
    borderColor: '#FF80FF',
    itemColor: '#FF80FF',
    textColor: '#0f172a',
    items: [
      { itemPosition: '50,50', itemTitle: { en: '' }, itemText: { en: '' } },
    ],
  },
  hidden_items_360_image: {
    type: 'hidden_items',
    titleMain: { en: '' },
    titleTextColor: '#ffffff',
    // TRUTHY sentinel, not '' — displayLayoutType() disambiguates flat vs 360°
    // by which media URL is populated, so an empty value would re-open the
    // layout in the FLAT editor. The export media gate names this value until
    // the author uploads a real panorama.
    image360Url: 'placeholder:panorama',
    image360CoverText: { en: 'Drag to look around' },
    blockingSection: { state: 'true' },
    bgColor: '#ffffff',
    borderColor: '#FF80FF',
    itemColor: '#FF80FF',
    textColor: '#0f172a',
    items: [
      { itemPosition: '50,50', itemTitle: { en: '' }, itemText: { en: '' } },
    ],
  },
  get hidden_items() { return this.hidden_items_flat_image; },
  quiz_images: {
    type: 'quiz_images',
    titleMain: { en: '' },
    titleTextColor: '#ffffff',
    contentBackgroundColor: '#000000',
    contentTextColor: '#ffffff',
    selectedBgColor: '#F26430',
    selectedTextColor: '#ffffff',
    dotSelectedColor: '#F26430',
    blockingSection: { state: 'true' },
    // Every quiz question REQUIRES media (QuizQuestionSchema.content) — the
    // blank imageUrl is pruned at export so the author must pick media per
    // question via the editor's image / video / HTML picker.
    questions: [
      { id: 'q1', text: { en: '' },
        content: { image: { imageUrl: '' } },
        answers: [
          { id: '1', text: { en: '' }, isCorrect: true },
          { id: '2', text: { en: '' } },
        ] },
    ],
  },
  quiz_gaming: {
    type: 'quiz_gaming',
    titleMain: { en: '' },
    titleTextColor: '#ffffff',
    contentBackgroundColor: '#0b1020',
    contentTextColor: '#ffffff',
    selectedBgColor: '#F26430',
    selectedTextColor: '#ffffff',
    dotSelectedColor: '#F26430',
    blockingSection: { state: 'true' },
    showCorrectWrongAnswers: true,
    // Off by default — a course-completing quiz is an explicit author choice.
    countAsPostAssessment: false,
    correctAnswersNeeded: 1,
    questions: [
      { id: 'q1', text: { en: '' },
        content: { image: { imageUrl: '' } },
        answers: [
          { id: '1', text: { en: '' }, isCorrect: true },
          { id: '2', text: { en: '' } },
        ] },
    ],
    // Icons are only required when the header is ON (schema refinement) —
    // the author uploads them via the editor when enabling the scoreboard.
    gamingHeader: { enabled: false },
    gamingStartScreen: { title: { en: 'Ready for a challenge?' },
      // Count-free for the same reason as the sample above: the START screen
      // substitutes no answer-count tokens.
      body: { en: 'Answer all the questions. Get enough right to pass — good luck.' } },
    gamingEndScreens: {
      win: { title: { en: 'Challenge complete' },
        body: { en: 'You answered {neededAnswers} of {totalAnswers} correctly — well done.' } },
      failure: { title: { en: 'Not quite' },
        body: { en: 'You missed {wrongAnswers}. Review the module and try again.' } },
    },
  },
  object_viewer: {
    type: 'object_viewer',
    titleMain: { en: '' },
    titleTextColor: '#ffffff',
    backgroundColor: '#000000',
    objectUrl: '',
    objectAltText: { en: '' },
    bgColor: '#ffffff',
    borderColor: '#D32F2F',
    itemColor: '#D32F2F',
    blockingSection: { state: 'true' },
    items: [
      { kind: 'text', itemPosition: '0.5,0.5,0.5', itemTitle: { en: '' }, itemText: { en: '' } },
    ],
  },
  two_columns_text: {
    type: 'two_columns_text',
    titleMain: { en: '' },
    titleTextColor: '#ffffff',
    titleBackgroundColor: '#000000',
    blockingSection: { state: 'false' },
    leftColumnContent: { en: '' },
    leftColumnTextColor: '#ffffff',
    leftColumnBackgroundColor: '#5514B4',
    rightColumnContent: { en: '' },
    rightColumnTextColor: '#ffffff',
    rightColumnBackgroundColor: '#3D0D85',
  },
  title: {
    type: 'title',
    titleMain: { en: '' },
    titleTextColor: '#ffffff',
    titleBackgroundColor: '#0f172a',
    blockingSection: { state: 'false' },
  },
};

// The per-layout content base for a course. `contentMode` comes from the
// active course ('sample' = the seeded demo course keeps its narrative
// samples; 'blank' = every other course starts from the skeletons above).
// Every merge site (layout editor seed, export builder, localisation walk,
// previews) resolves through this ONE helper so a course never mixes modes.
function layoutContentBase(type, contentMode) {
  const registry = contentMode === 'blank' ? LAYOUT_CONTENT_SKELETONS : LAYOUT_CONTENT_SAMPLES;
  return registry[type] || {};
}

// ── Quiz shared-feedback migration (2026-07-28) ──────────────────────────────
// A multi-correct quiz question's ONE shared message used to be written as
// `feedbacks: { correct, wrong }` — the SEQUENCE layout's shape. QuizQuestion
// has no such field, so the gateway's sanitizer deleted it on every save: the
// author (and the translator) could type it, see it, and never ship it. It now
// lives on `question.feedbackText`, which the schema models and the runtime
// reads. This moves any legacy value across so existing courses keep their text.
//
// True when a LocalizedString (or a legacy plain string) says anything at all.
const locHasText = (v) => !!v && (typeof v === 'string'
  ? !!v.trim()
  : (typeof v === 'object' && !Array.isArray(v)
    && Object.values(v).some(s => String(s || '').trim() !== '')));

// THE precedence for a quiz question's one shared message. Exported and shared
// because it is needed in two places — the editor's box and the migration that
// decides what ships — and a second copy is exactly how those two came to
// disagree: written out twice, they ordered `wrong` and `feedbackText`
// differently, so a demo-course question whose author had filled only the
// "incorrect" half showed the seed boilerplate in the box while the ZIP shipped
// the author's sentence (2026-07-28 E2E rejection).
//
// Order: a legacy `feedbacks` block is the author's, `correct` before `wrong`;
// `feedbackText` last, because no UI before 2026-07-28 could write it, so a
// value there alongside a legacy block can only be seed text. Returns null when
// nothing says anything.
function quizSharedFeedbackValue(q) {
  const legacy = (q && q.feedbacks) || {};
  if (locHasText(legacy.correct)) return legacy.correct;
  if (locHasText(legacy.wrong)) return legacy.wrong;
  if (locHasText(q && q.feedbackText)) return q.feedbackText;
  return null;
}

// Applied wherever assembled layout content is consumed — the export/save
// builder, the Localisation panel and the rail's coverage walk — so all three
// see the field that actually ships and nobody is invited to translate a key
// that is about to be discarded. `feedbacks.wrong` is dropped: `quiz_images`
// renders one message per question (`components.js:5675`), so a second one
// cannot be shown. Idempotent; returns the SAME object when nothing changed.
// Also drops an ALL-EMPTY `feedbackText` husk. That cannot be left to
// `pruneBlankMedia`, which only runs for blank-mode courses — a demo-mode course
// carrying a husk would 422 its own export on a field its author never wrote
// (found by the export E2E's unhappy-path control, 2026-07-28).
function migrateQuizFeedback(layout) {
  if (!layout || (layout.type !== 'quiz_images' && layout.type !== 'quiz_gaming')) return layout;
  const questions = layout.questions;
  const needsWork = (q) => q && (q.feedbacks
    || (q.feedbackText !== undefined && !locHasText(q.feedbackText)));
  if (!Array.isArray(questions) || !questions.some(needsWork)) return layout;
  return {
    ...layout,
    questions: questions.map(q => {
      if (!needsWork(q)) return q;
      if (!q.feedbacks) { const { feedbackText, ...bare } = q; return bare; }
      // BOTH keys come off, so an all-empty legacy block plus an empty husk
      // leaves nothing behind — otherwise the husk survived in `rest` and the
      // ZIP carried an empty `feedbackText` the box was showing as absent.
      const { feedbacks, feedbackText, ...rest } = q;
      // ONE rule, shared with the editor's box (see quizSharedFeedbackValue).
      // `wrong` is carried when `correct` is empty: this layout shows a single
      // message per question, so a second one cannot be displayed — but the
      // author typed it, and deleting authored text in silence is worse than
      // showing it to everyone.
      const carried = quizSharedFeedbackValue(q);
      return carried ? { ...rest, feedbackText: carried } : rest;
    }),
  };
}

// ── Map module-layout ids to a content-sample key ───────────────────────────
// This wires the existing course architecture (data.jsx) to renderable content.
const LAYOUT_ID_TO_TYPE = {};
(SAMPLE_COURSE.modules || []).forEach(m => {
  (m.layouts || []).forEach(l => { LAYOUT_ID_TO_TYPE[l.id] = l.type; });
});

function getLayoutContent(layoutId, fallbackType) {
  const type = LAYOUT_ID_TO_TYPE[layoutId] || fallbackType;
  return LAYOUT_CONTENT_SAMPLES[type] || LAYOUT_CONTENT_SAMPLES.fullscreen_text_and_image;
}

Object.assign(window, { LAYOUT_CONTENT_SAMPLES, LAYOUT_CONTENT_SKELETONS, layoutContentBase, migrateQuizFeedback, quizSharedFeedbackValue, locHasText, FRAMING_CONTENT_SAMPLES, LAYOUT_ID_TO_TYPE, getLayoutContent });
