From 62da7b6fac4b4d518461feac99f4bbb608cb6689 Mon Sep 17 00:00:00 2001 From: glorydavid03023 Date: Thu, 25 Jun 2026 03:31:29 -0500 Subject: [PATCH] Build the guided episode setup intake [#1326] Add the first usable setup step: a creator starts a new episode from a Riverside-style recording link or separate synced speaker files, sees each source as a speaker track, assigns every speaker a unique role (Host / Guest 1 / Guest 2), adds optional social links, and can continue only when the setup is complete. The chosen source type, roles, names, and links are carried into the next step (episode readiness), which surfaces them. - prototype/episode-setup-intake.html: the new intake screen, wired as the first ingest step across the ingest nav flow, the preview app, the preview shell ingest list, and the root catalog. - preview/episode-setup-handoff.js: a small shared save/load/summary helper that only carries a genuinely complete setup forward, mirroring layout-handoff.js. - prototype/episode-readiness.html: reads the handoff and surfaces the carried source and speakers on the next step. Focused coverage added for incomplete setup, role assignment, duplicate roles, and the handoff rendering into readiness. Both `node scripts/run-tests.mjs prototype` and `node scripts/run-tests.mjs preview` pass. Closes #1326 Co-Authored-By: Claude Opus 4.8 (1M context) --- index.html | 1 + preview/app-route-context.js | 9 +- preview/app-route-context.test.js | 4 +- preview/app.html | 2 +- preview/episode-setup-handoff.js | 260 +++++++ preview/episode-setup-handoff.test.js | 157 ++++ preview/index.html | 14 +- preview/ingest-nav.js | 1 + preview/ingest-nav.test.js | 21 +- preview/tools-nav.test.js | 1 + .../episode-readiness-setup-handoff.test.js | 118 +++ prototype/episode-readiness.html | 31 + prototype/episode-setup-intake.html | 692 ++++++++++++++++++ prototype/episode-setup-intake.test.js | 198 +++++ 14 files changed, 1497 insertions(+), 12 deletions(-) create mode 100644 preview/episode-setup-handoff.js create mode 100644 preview/episode-setup-handoff.test.js create mode 100644 prototype/episode-readiness-setup-handoff.test.js create mode 100644 prototype/episode-setup-intake.html create mode 100644 prototype/episode-setup-intake.test.js diff --git a/index.html b/index.html index fd9e56ca..b8c308c9 100644 --- a/index.html +++ b/index.html @@ -302,6 +302,7 @@

Podcast Design Canvas

title: "Bring in the recording", blurb: "Import synced speaker tracks, confirm they're healthy, and map every track to a clear role before styling.", screens: [ + ["episode-setup-intake", "Episode setup intake", "Start a new episode from a recording link or synced speaker files, and assign speaker roles."], ["source-media-health", "Source media health", "Check each speaker file is good enough for a polished episode."], ["episode-readiness", "Episode readiness", "Confirm the episode is ready to style and edit."], ["speaker-role-mapping", "Speaker role mapping", "Map each track to host, guest, or co-host roles."], diff --git a/preview/app-route-context.js b/preview/app-route-context.js index 47022978..cd50eb5c 100644 --- a/preview/app-route-context.js +++ b/preview/app-route-context.js @@ -73,6 +73,7 @@ function createPreviewAppRouting(order) { ...cleanupScreens, ]); const pathedIngestScreens = new Set([ + "episode-setup-intake", "episode-readiness", "speaker-role-mapping", "social-context-intake", @@ -233,9 +234,15 @@ function createPreviewAppRouting(order) { } } if (pathContext === "ingest") { - if (screen === "episode-readiness" && offset < 0) { + if (screen === "episode-setup-intake" && offset < 0) { return {}; } + if (screen === "episode-setup-intake" && offset > 0) { + return { screen: "episode-readiness", search }; + } + if (screen === "episode-readiness" && offset < 0) { + return { screen: "episode-setup-intake", search }; + } if (screen === "episode-readiness" && offset > 0) { return { screen: "speaker-role-mapping", search }; } diff --git a/preview/app-route-context.test.js b/preview/app-route-context.test.js index e605857f..f6f87c6b 100644 --- a/preview/app-route-context.test.js +++ b/preview/app-route-context.test.js @@ -523,8 +523,8 @@ assert.equal( styleEntry.reroute("#missing-screen?from=style"); assert.equal( styleEntry.nodes.frame.src, - "../prototype/source-media-health.html", - "unknown route hashes fall back to the first known screen without route context", + "../prototype/episode-setup-intake.html", + "unknown route hashes fall back to the first known screen (the episode setup intake) without route context", ); console.log("preview app route context: contextual visuals and ingest path context are preserved safely"); diff --git a/preview/app.html b/preview/app.html index dda601ca..01217de7 100644 --- a/preview/app.html +++ b/preview/app.html @@ -264,7 +264,7 @@

Podcast Design Canvas

// and navigable from one URL (preview/app.html#). const STAGES = [ ["Bring in the recording", [ - "source-media-health", "episode-readiness", "speaker-role-mapping", + "episode-setup-intake", "source-media-health", "episode-readiness", "speaker-role-mapping", "speaker-attribution-review", "speaker-visual-match", "speaker-eye-line-coherence", "guest-profile-reuse", "social-context-intake", "off-camera-speaker-presence", ]], diff --git a/preview/episode-setup-handoff.js b/preview/episode-setup-handoff.js new file mode 100644 index 00000000..c2f247fd --- /dev/null +++ b/preview/episode-setup-handoff.js @@ -0,0 +1,260 @@ +"use strict"; + +// Shared handoff for the guided episode setup intake (#1326). The intake screen lets a +// creator start a new episode from a recording link or separate synced speaker files, name +// each speaker, assign a role, and add optional social links. This helper carries ONLY a +// genuinely complete setup forward (every speaker named and assigned a unique role) so the +// next step — episode readiness — can surface the chosen source, speakers, and links. +// +// It mirrors the shape of layout-handoff.js: a pure module that works the same in the browser +// (window.PodcastEpisodeSetupHandoff) and under Node (module.exports), with a sessionStorage +// save/load plus a query-string fallback so a deep link or refresh keeps the setup. + +(function (global) { + const SOURCE_TYPES = { + "recording-link": { + id: "recording-link", + label: "Riverside recording link", + sourceNoun: "recording link", + }, + "uploaded-files": { + id: "uploaded-files", + label: "Uploaded speaker files", + sourceNoun: "speaker files", + }, + }; + + // Canonical speaker roles, in display order. Host and the two guest seats each hold exactly + // one speaker; assigning the same role twice is an incomplete setup, not a valid handoff. + const ROLES = { + host: { id: "host", label: "Host" }, + "guest-1": { id: "guest-1", label: "Guest 1" }, + "guest-2": { id: "guest-2", label: "Guest 2" }, + }; + const ROLE_ORDER = ["host", "guest-1", "guest-2"]; + + const STORAGE_KEY = "pdc-episode-setup-handoff"; + const SPEAKERS_QUERY_KEY = "speakers"; + const SOURCE_QUERY_KEY = "source"; + + function clone(value) { + return JSON.parse(JSON.stringify(value)); + } + + function normalizeSourceType(sourceType) { + return SOURCE_TYPES[sourceType] ? sourceType : ""; + } + + function normalizeRole(role) { + return ROLES[role] ? role : ""; + } + + function trimmed(value) { + return typeof value === "string" ? value.trim() : ""; + } + + // A speaker row is usable only when it names a real person and a known role. Social links are + // always optional, so a missing link never blocks the handoff. + function normalizeSpeaker(raw) { + if (!raw) { + return null; + } + const role = normalizeRole(raw.role); + const name = trimmed(raw.name); + if (!role || !name) { + return null; + } + const speaker = { role, roleLabel: ROLES[role].label, name }; + const social = trimmed(raw.social); + if (social) { + speaker.social = social; + } + return speaker; + } + + function normalizeSpeakers(rawSpeakers) { + const speakers = []; + const seenRoles = new Set(); + (rawSpeakers || []).forEach((raw) => { + const speaker = normalizeSpeaker(raw); + // Drop a row whose role repeats one already taken: two speakers can't share a single seat, + // and a "ready" setup must map each named speaker to a distinct role. + if (!speaker || seenRoles.has(speaker.role)) { + return; + } + seenRoles.add(speaker.role); + speakers.push(speaker); + }); + return speakers.sort((a, b) => ROLE_ORDER.indexOf(a.role) - ROLE_ORDER.indexOf(b.role)); + } + + // The single source of truth for "is this setup finished?": a known source type and at least + // one fully-specified speaker, where normalizeSpeakers has already dropped unnamed, unassigned, + // and duplicate-role rows. If normalization lost a row, the raw setup was not complete. + function stateFromRaw(raw) { + if (!raw) { + return null; + } + const sourceType = normalizeSourceType(raw.sourceType); + if (!sourceType) { + return null; + } + const speakers = normalizeSpeakers(raw.speakers); + if (!speakers.length) { + return null; + } + if (Array.isArray(raw.speakers) && speakers.length !== raw.speakers.length) { + return null; + } + return { + sourceType, + sourceLabel: SOURCE_TYPES[sourceType].label, + sourceNoun: SOURCE_TYPES[sourceType].sourceNoun, + speakers, + }; + } + + function isComplete(raw) { + return stateFromRaw(raw) !== null; + } + + function speakersQueryPayload(state) { + return state.speakers.map((speaker) => { + const entry = { role: speaker.role, name: speaker.name }; + if (speaker.social) { + entry.social = speaker.social; + } + return entry; + }); + } + + function queryForState(raw) { + const state = stateFromRaw(raw); + if (!state) { + return ""; + } + const params = new URLSearchParams(); + params.set(SOURCE_QUERY_KEY, state.sourceType); + params.set(SPEAKERS_QUERY_KEY, JSON.stringify(speakersQueryPayload(state))); + return params.toString(); + } + + function speakersFromQuery(params) { + const value = params.get(SPEAKERS_QUERY_KEY); + if (!value) { + return []; + } + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed : []; + } catch (error) { + return []; + } + } + + function stateFromQuery(rawSearch) { + const params = new URLSearchParams(String(rawSearch || "").replace(/^\?/, "")); + return stateFromRaw({ + sourceType: params.get(SOURCE_QUERY_KEY), + speakers: speakersFromQuery(params), + }); + } + + function hrefWithState(baseHref, raw) { + const query = queryForState(raw); + if (!baseHref || !query) { + return baseHref || ""; + } + const [beforeHash, hash = ""] = baseHref.split("#"); + if (!hash) { + return `${baseHref}${baseHref.includes("?") ? "&" : "?"}${query}`; + } + const [screen, search = ""] = hash.split("?"); + const params = new URLSearchParams(search); + for (const [key, value] of new URLSearchParams(query).entries()) { + params.set(key, value); + } + return `${beforeHash}#${screen}?${params.toString()}`; + } + + function save(storage, raw) { + const state = stateFromRaw(raw); + if (!storage || !state) { + return; + } + try { + storage.setItem(STORAGE_KEY, JSON.stringify(state)); + } catch (error) { + // The query-string fallback still carries the chosen source and speakers. + } + } + + function clear(storage) { + if (!storage) { + return; + } + try { + storage.removeItem(STORAGE_KEY); + } catch (error) { + // Clearing is best-effort; the URL handoff still reflects the chosen setup. + } + } + + function stateFromStorage(storage) { + if (!storage) { + return null; + } + try { + return stateFromRaw(JSON.parse(storage.getItem(STORAGE_KEY) || "null")); + } catch (error) { + return null; + } + } + + // Prefer the query handoff (the link the creator just followed) and fall back to stored state, + // so a refresh or a same-tab revisit still shows the carried setup. + function load(storage, rawSearch) { + return stateFromQuery(rawSearch) || stateFromStorage(storage); + } + + // Creator-facing summary rows for the readiness screen: the source line plus one line per + // speaker, naming the role, the person, and whether a social link came along. + function summaryLines(raw) { + const state = stateFromRaw(raw); + if (!state) { + return []; + } + const lines = [`Source: ${state.sourceLabel}`]; + state.speakers.forEach((speaker) => { + const social = speaker.social ? ` · ${speaker.social}` : ""; + lines.push(`${speaker.roleLabel}: ${speaker.name}${social}`); + }); + return lines; + } + + const api = { + STORAGE_KEY, + SOURCE_TYPES, + ROLES, + ROLE_ORDER, + normalizeSourceType, + normalizeRole, + stateFromRaw, + isComplete, + queryForState, + stateFromQuery, + hrefWithState, + save, + clear, + load, + summaryLines, + clone, + }; + + if (typeof module !== "undefined" && module.exports) { + module.exports = api; + return; + } + + global.PodcastEpisodeSetupHandoff = api; +}(typeof window !== "undefined" ? window : globalThis)); diff --git a/preview/episode-setup-handoff.test.js b/preview/episode-setup-handoff.test.js new file mode 100644 index 00000000..207e2ac6 --- /dev/null +++ b/preview/episode-setup-handoff.test.js @@ -0,0 +1,157 @@ +"use strict"; + +// Unit tests for the guided episode setup handoff helper (#1326 / #584). +// Run with: `node preview/episode-setup-handoff.test.js` + +const assert = require("assert"); +const handoff = require("./episode-setup-handoff.js"); + +const completeRaw = { + sourceType: "uploaded-files", + speakers: [ + { role: "host", name: "Dana Brooks", social: "https://example.com/dana" }, + { role: "guest-1", name: "Marcus Lee" }, + ], +}; + +// --- completeness gate --------------------------------------------------------------------- + +assert.ok(handoff.isComplete(completeRaw), "a named, role-assigned setup is complete"); + +assert.ok(!handoff.isComplete(null), "no setup is not complete"); +assert.ok( + !handoff.isComplete({ sourceType: "uploaded-files", speakers: [] }), + "a setup with no speakers is not complete", +); +assert.ok( + !handoff.isComplete({ sourceType: "mystery", speakers: [{ role: "host", name: "Dana" }] }), + "an unknown source type is not complete", +); +assert.ok( + !handoff.isComplete({ sourceType: "recording-link", speakers: [{ role: "host", name: "" }] }), + "an unnamed speaker blocks the handoff", +); +assert.ok( + !handoff.isComplete({ sourceType: "recording-link", speakers: [{ role: "", name: "Dana" }] }), + "an unassigned speaker blocks the handoff", +); +assert.ok( + !handoff.isComplete({ + sourceType: "recording-link", + speakers: [ + { role: "host", name: "Dana" }, + { role: "host", name: "Marcus" }, + ], + }), + "two speakers in the same role is not a complete setup", +); + +// --- normalization ------------------------------------------------------------------------- + +const state = handoff.stateFromRaw(completeRaw); +assert.equal(state.sourceLabel, "Uploaded speaker files", "source type resolves to its label"); +assert.equal(state.speakers.length, 2, "complete speakers are kept"); +assert.equal(state.speakers[0].role, "host", "speakers are returned in role order (host first)"); +assert.equal(state.speakers[0].roleLabel, "Host", "role label is attached"); +assert.equal(state.speakers[1].social, undefined, "a speaker with no link carries no social field"); + +const trimmedState = handoff.stateFromRaw({ + sourceType: "recording-link", + speakers: [{ role: "guest-1", name: " Priya Shah ", social: " https://x.test/priya " }], +}); +assert.equal(trimmedState.speakers[0].name, "Priya Shah", "speaker names are trimmed"); +assert.equal(trimmedState.speakers[0].social, "https://x.test/priya", "social links are trimmed"); + +const orderState = handoff.stateFromRaw({ + sourceType: "uploaded-files", + speakers: [ + { role: "guest-2", name: "Priya" }, + { role: "host", name: "Dana" }, + { role: "guest-1", name: "Marcus" }, + ], +}); +assert.deepStrictEqual( + orderState.speakers.map((s) => s.role), + ["host", "guest-1", "guest-2"], + "speakers are sorted into host / guest 1 / guest 2 order", +); + +// --- query round-trip ---------------------------------------------------------------------- + +const query = handoff.queryForState(completeRaw); +assert.ok(query.includes("source=uploaded-files"), "query carries the source type"); +const roundTripped = handoff.stateFromQuery(`?${query}`); +assert.ok(roundTripped, "a complete setup round-trips through the query string"); +assert.equal(roundTripped.speakers[0].name, "Dana Brooks", "speaker names survive the query round-trip"); +assert.equal( + roundTripped.speakers[0].social, + "https://example.com/dana", + "social links survive the query round-trip", +); +assert.equal(handoff.queryForState({ sourceType: "x", speakers: [] }), "", "an incomplete setup makes no query"); +assert.equal(handoff.stateFromQuery("?source=uploaded-files"), null, "a query with no speakers loads nothing"); + +const href = handoff.hrefWithState("episode-readiness.html?path=ingest", completeRaw); +assert.ok(href.startsWith("episode-readiness.html?path=ingest&"), "hrefWithState preserves the existing query"); +assert.ok(href.includes("source=uploaded-files"), "hrefWithState appends the setup handoff"); + +const hashHref = handoff.hrefWithState("../preview/app.html#episode-readiness?path=ingest", completeRaw); +assert.ok( + hashHref.startsWith("../preview/app.html#episode-readiness?"), + "hrefWithState merges into a hash route", +); +assert.ok(hashHref.includes("source=uploaded-files"), "hash route handoff carries the source type"); + +// --- storage save / load ------------------------------------------------------------------- + +function memoryStorage() { + const map = new Map(); + return { + getItem(key) { + return map.has(key) ? map.get(key) : null; + }, + setItem(key, value) { + map.set(key, String(value)); + }, + removeItem(key) { + map.delete(key); + }, + }; +} + +const storage = memoryStorage(); +handoff.save(storage, completeRaw); +const stored = handoff.load(storage, ""); +assert.ok(stored, "a saved complete setup loads back from storage"); +assert.equal(stored.speakers.length, 2, "stored speakers are restored"); + +handoff.save(storage, { sourceType: "x", speakers: [] }); +assert.ok(handoff.load(storage, ""), "an incomplete save does not overwrite a stored complete setup"); + +handoff.clear(storage); +assert.equal(handoff.load(storage, ""), null, "clearing removes the stored setup"); + +// load prefers the query handoff over stored state +handoff.save(storage, completeRaw); +const fromQuery = handoff.load(storage, `?${handoff.queryForState({ + sourceType: "recording-link", + speakers: [{ role: "host", name: "Solo Host" }], +})}`); +assert.equal(fromQuery.sourceType, "recording-link", "load prefers the query handoff over storage"); +assert.equal(fromQuery.speakers.length, 1, "query handoff speakers win over stored speakers"); + +// --- summary ------------------------------------------------------------------------------- + +const lines = handoff.summaryLines(completeRaw); +assert.equal(lines[0], "Source: Uploaded speaker files", "summary opens with the source line"); +assert.ok( + lines.some((line) => line.includes("Host: Dana Brooks") && line.includes("https://example.com/dana")), + "summary names the host with their social link", +); +assert.ok( + lines.some((line) => line === "Guest 1: Marcus Lee"), + "summary names a guest without a link cleanly", +); +assert.deepStrictEqual(handoff.summaryLines(null), [], "an incomplete setup has no summary"); + +console.log("episode setup handoff: gate, normalization, query round-trip, storage, and summary verified"); diff --git a/preview/index.html b/preview/index.html index 77267ad1..92b86150 100644 --- a/preview/index.html +++ b/preview/index.html @@ -767,23 +767,29 @@

Episode path

Episode ingest setup

-

Walk the connected ingest path for readiness, roles, and social links — the same three steps as ingest navigation.

+

Walk the connected ingest path: start a new episode, confirm readiness, map roles, and add social links — the same four steps as ingest navigation.

    +
  1. + + 1. Start a new episode + Import a recording link or synced speaker files and assign roles. + +
  2. - 1. Check episode readiness + 2. Check episode readiness Confirm every speaker has a file and a name before editing.
  3. - 2. Map speaker roles + 3. Map speaker roles Choose who is hosting and who is joining as a guest.
  4. - 3. Add social context + 4. Add social context Turn public links into accurate guest names.
  5. diff --git a/preview/ingest-nav.js b/preview/ingest-nav.js index abe92c3e..2ce09309 100644 --- a/preview/ingest-nav.js +++ b/preview/ingest-nav.js @@ -6,6 +6,7 @@ // const INGEST_FLOW = [ + { id: "episode-setup-intake", file: "episode-setup-intake.html", label: "Start a new episode" }, { id: "episode-readiness", file: "episode-readiness.html", label: "Episode readiness" }, { id: "speaker-role-mapping", file: "speaker-role-mapping.html", label: "Speaker roles" }, { id: "social-context-intake", file: "social-context-intake.html", label: "Social links" }, diff --git a/preview/ingest-nav.test.js b/preview/ingest-nav.test.js index 60ce7cb5..431e4749 100644 --- a/preview/ingest-nav.test.js +++ b/preview/ingest-nav.test.js @@ -25,6 +25,7 @@ assert.ok(navSource.includes('document.querySelector(".ingest-nav")'), "ingest n assert.ok(!/innerHTML/.test(navSource), "ingest nav builds the DOM without innerHTML"); const ingestScreens = [ + "episode-setup-intake.html", "episode-readiness.html", "speaker-role-mapping.html", "social-context-intake.html", @@ -293,7 +294,7 @@ assert.equal( ); assert.equal(dynamicSocialLink.target, "_top", "dynamic embedded ingest links target the parent app"); -const firstNav = renderNavFor("episode-readiness.html", "episode-readiness"); +const firstNav = renderNavFor("episode-setup-intake.html", "episode-setup-intake"); assert.ok(firstNav.nodes.some((node) => node.className === "ingest-nav"), "ingest nav renders on first screen"); assert.ok( !firstNav.nodes.some((node) => node.textContent === "Place videos in layout"), @@ -304,8 +305,20 @@ assert.ok( "first ingest screen does not render a previous link", ); assert.ok( - firstNav.nodes.some((node) => node.textContent === "Next: Speaker roles"), - "first ingest screen renders next link", + firstNav.nodes.some((node) => node.textContent === "Next: Episode readiness"), + "first ingest screen renders next link into episode readiness", +); +const firstStep = firstNav.nodes.find((node) => node.textContent === "Setup step 1 of 4 · Start a new episode"); +assert.ok(firstStep, "the setup intake is the first of four ingest steps"); + +const readinessNav = renderNavFor("episode-readiness.html", "episode-readiness", "?path=ingest"); +assert.ok( + readinessNav.nodes.some((node) => node.textContent === "Previous: Start a new episode"), + "episode readiness steps back to the setup intake", +); +assert.ok( + readinessNav.nodes.some((node) => node.textContent === "Next: Speaker roles"), + "episode readiness steps forward to speaker roles", ); const middleNav = renderNavFor("speaker-role-mapping.html", "speaker-role-mapping", "?path=ingest"); @@ -324,7 +337,7 @@ assert.ok( "ingest path at speaker roles links forward to social context", ); const currentStep = middleNav.nodes.find((node) => - node.textContent === "Setup step 2 of 3 · Speaker roles", + node.textContent === "Setup step 3 of 4 · Speaker roles", ); assert.ok(currentStep, "middle ingest screen renders visible step label"); assert.equal(currentStep.attributes["aria-current"], "step", "current ingest step exposes aria-current"); diff --git a/preview/tools-nav.test.js b/preview/tools-nav.test.js index 5e7e2ccd..b0306081 100644 --- a/preview/tools-nav.test.js +++ b/preview/tools-nav.test.js @@ -34,6 +34,7 @@ const coreFlow = new Set([ ]); const ingestFlow = new Set([ + "episode-setup-intake.html", "episode-readiness.html", "speaker-role-mapping.html", "social-context-intake.html", diff --git a/prototype/episode-readiness-setup-handoff.test.js b/prototype/episode-readiness-setup-handoff.test.js new file mode 100644 index 00000000..ad69dae2 --- /dev/null +++ b/prototype/episode-readiness-setup-handoff.test.js @@ -0,0 +1,118 @@ +"use strict"; + +// Guards that episode readiness surfaces the guided episode setup handoff (#1326): the source +// type and speakers chosen on the setup intake are visibly carried into the next step. +// Run with: `node prototype/episode-readiness-setup-handoff.test.js` + +const fs = require("fs"); +const path = require("path"); +const assert = require("assert"); +const vm = require("vm"); + +const html = fs.readFileSync(path.join(__dirname, "episode-readiness.html"), "utf8"); +const layoutHandoff = require("../preview/layout-handoff.js"); +const setupHandoff = require("../preview/episode-setup-handoff.js"); + +// --- structural wiring --------------------------------------------------------------------- + +assert.ok( + html.includes("../preview/episode-setup-handoff.js"), + "episode readiness loads the shared episode setup handoff helper", +); +assert.match( + html, + /id="setup-handoff" class="layout-handoff" hidden/, + "episode readiness reserves a hidden setup summary", +); +assert.ok( + html.includes("setupHandoffApi.load(layoutHandoffStorage(), window.location.search)"), + "episode readiness reads the setup handoff from the URL and stored state", +); +assert.ok( + html.includes("setupHandoffApi.summaryLines(setup)"), + "episode readiness summarizes the carried source and speakers", +); +assert.doesNotMatch( + html, + /setupHandoffElement\.innerHTML/, + "the setup handoff summary is not rendered with innerHTML", +); + +// --- behavioral: run the inline script with a setup handoff in the URL ---------------------- + +const setupSearch = "?" + setupHandoff.queryForState({ + sourceType: "uploaded-files", + speakers: [ + { role: "host", name: "Dana Brooks", social: "https://example.com/dana" }, + { role: "guest-1", name: "Marcus Lee" }, + ], +}); + +function makeNode(tag) { + return { + tagName: tag, id: "", _children: [], style: {}, dataset: {}, hidden: undefined, + textContent: "", value: "", checked: false, disabled: false, + set className(v) { this._cls = v; }, get className() { return this._cls; }, + setAttribute() {}, getAttribute() { return null; }, removeAttribute() {}, + addEventListener() {}, append(...c) { this._children.push(...c); }, + appendChild(c) { this._children.push(c); return c; }, + replaceChildren(...c) { this._children = c; }, + insertBefore(c) { this._children.unshift(c); return c; }, + remove() {}, querySelector() { return makeNode(); }, querySelectorAll() { return []; }, + }; +} + +const roots = {}; +["#tracks", "#status", "#issues", "#continue", "#continueNote", "#addGuest", "#reset", "#layout-handoff", "#setup-handoff"].forEach((sel) => { + roots[sel] = makeNode(); +}); +const documentStub = { + createElement: (tag) => makeNode(tag), + createTextNode: (text) => ({ textContent: text }), + querySelector: (sel) => roots[sel] || makeNode(), +}; +const windowStub = { + PodcastLayoutHandoff: layoutHandoff, + PodcastEpisodeSetupHandoff: setupHandoff, + location: { search: setupSearch }, + sessionStorage: undefined, +}; +const sandbox = { document: documentStub, window: windowStub, structuredClone: globalThis.structuredClone, console }; +vm.createContext(sandbox); +const script = html.match(/ +
    @@ -304,6 +305,7 @@

    Speaker tracks