diff --git a/index.html b/index.html
index fd9e56ca..3a6d3342 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", "Start a new episode and bring in the recording."],
["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..3a29abd5 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",
@@ -206,9 +207,15 @@ function createPreviewAppRouting(order) {
const publishContext = publishPathContext(search);
const orderedTarget = order[index + offset];
if (pathContext === "episode") {
- 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 };
}
@@ -233,9 +240,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..da9ae00d 100644
--- a/preview/app-route-context.test.js
+++ b/preview/app-route-context.test.js
@@ -250,6 +250,31 @@ assert.equal(
"invalid layout-first slot handoff is stripped by the preview app",
);
+// The guided episode setup intake is the first node of the ingest path: it has no previous
+// step, steps forward into episode readiness, and readiness now steps back to it.
+const setupIntakeIngest = runApp("#episode-setup-intake?path=ingest");
+assert.equal(
+ setupIntakeIngest.nodes.frame.src,
+ "../prototype/episode-setup-intake.html?path=ingest",
+ "the setup intake carries ingest path context in the preview app",
+);
+assert.equal(
+ setupIntakeIngest.nodes.prevStep.attributes["aria-disabled"],
+ "true",
+ "the setup intake is the first ingest step with no previous",
+);
+assert.equal(
+ setupIntakeIngest.nodes.nextStep.href,
+ "#episode-readiness?path=ingest",
+ "the setup intake steps forward into episode readiness",
+);
+setupIntakeIngest.reroute("#episode-readiness?path=ingest");
+assert.equal(
+ setupIntakeIngest.nodes.prevStep.href,
+ "#episode-setup-intake?path=ingest",
+ "episode readiness steps back to the guided setup intake",
+);
+
episodeRoles.reroute("#source-media-health?path=episode");
assert.equal(
episodeRoles.nodes.frame.src,
diff --git a/preview/app.html b/preview/app.html
index dda601ca..77372b75 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",
+ "source-media-health", "episode-setup-intake", "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..4687541a
--- /dev/null
+++ b/preview/episode-setup-handoff.js
@@ -0,0 +1,224 @@
+"use strict";
+
+// Shared episode-setup handoff helper. The guided setup intake (the first ingest step)
+// collects how the episode was started — a recording link or synced speaker files — plus
+// each speaker's role, name, and optional social links, then carries that setup into the
+// next preview step (episode readiness) so the creator sees the context they just entered.
+//
+// This mirrors the shape of layout-handoff.js (one sessionStorage key + a query-string
+// fallback, a single normalize gate, and small summary helpers) so the two start paths —
+// layout-first placement and the guided intake — stay consistent and easy to reason about.
+
+(function (global) {
+ const STORAGE_KEY = "pdc-episode-setup-handoff";
+ const SETUP_QUERY_KEY = "setup";
+ const SOURCE_QUERY_KEY = "source";
+
+ // The source paths a creator can start from. Kept here so the intake screen and the
+ // readiness summary agree on the labels without duplicating the list.
+ const SOURCE_TYPES = {
+ link: "Recording link",
+ upload: "Uploaded speaker files",
+ };
+
+ // The speaker roles a track can be assigned to, in the order the product offers them.
+ // The intake builds its dropdowns from this list, so a saved role always has a label.
+ const ROLES = [
+ { value: "host", label: "Host" },
+ { value: "guest-1", label: "Guest 1" },
+ { value: "guest-2", label: "Guest 2" },
+ { value: "guest-3", label: "Guest 3" },
+ { value: "co-host", label: "Co-host" },
+ { value: "producer", label: "Producer" },
+ ];
+ const ROLE_LABELS = ROLES.reduce((labels, role) => {
+ labels[role.value] = role.label;
+ return labels;
+ }, {});
+
+ function normalizeSourceType(value) {
+ return SOURCE_TYPES[value] ? value : "";
+ }
+
+ function sourceLabel(setup) {
+ return setup && SOURCE_TYPES[setup.sourceType] ? SOURCE_TYPES[setup.sourceType] : "";
+ }
+
+ function cleanText(value) {
+ return typeof value === "string" ? value.trim() : "";
+ }
+
+ function normalizeSpeaker(raw) {
+ if (!raw || typeof raw !== "object") {
+ return null;
+ }
+ const role = ROLE_LABELS[raw.role] ? raw.role : "";
+ return {
+ name: cleanText(raw.name),
+ role,
+ roleLabel: role ? ROLE_LABELS[role] : "",
+ social: cleanText(raw.social),
+ };
+ }
+
+ // A setup is only "complete" — and only ever carried forward — when the creator has
+ // chosen a source path and every speaker has both a name and a unique role. A half-filled
+ // intake never reaches the next step, so readiness never shows a misleading summary.
+ function normalize(setup) {
+ if (!setup || typeof setup !== "object") {
+ return null;
+ }
+ const sourceType = normalizeSourceType(setup.sourceType);
+ if (!sourceType) {
+ return null;
+ }
+ const speakers = Array.isArray(setup.speakers)
+ ? setup.speakers.map(normalizeSpeaker).filter(Boolean)
+ : [];
+ if (!speakers.length) {
+ return null;
+ }
+ const seenRoles = new Set();
+ for (const speaker of speakers) {
+ if (!speaker.name || !speaker.role) {
+ return null;
+ }
+ if (seenRoles.has(speaker.role)) {
+ return null;
+ }
+ seenRoles.add(speaker.role);
+ }
+ return { sourceType, speakers };
+ }
+
+ function isComplete(setup) {
+ return normalize(setup) !== null;
+ }
+
+ function save(storage, setup) {
+ const normalized = normalize(setup);
+ if (!storage || !normalized) {
+ return;
+ }
+ try {
+ storage.setItem(STORAGE_KEY, JSON.stringify(normalized));
+ } catch (error) {
+ // The query-string fallback still carries the setup if storage is unavailable.
+ }
+ }
+
+ function clear(storage) {
+ if (!storage) {
+ return;
+ }
+ try {
+ storage.removeItem(STORAGE_KEY);
+ } catch (error) {
+ // Clearing is best-effort.
+ }
+ }
+
+ function setupFromQuery(rawSearch) {
+ const params = new URLSearchParams(String(rawSearch || "").replace(/^\?/, ""));
+ const encoded = params.get(SETUP_QUERY_KEY);
+ if (!encoded) {
+ return null;
+ }
+ try {
+ const parsed = JSON.parse(encoded);
+ const speakers = Array.isArray(parsed) ? parsed : parsed && parsed.speakers;
+ const sourceType = params.get(SOURCE_QUERY_KEY)
+ || (parsed && !Array.isArray(parsed) ? parsed.sourceType : "");
+ return normalize({ sourceType, speakers });
+ } catch (error) {
+ return null;
+ }
+ }
+
+ function setupFromStorage(storage) {
+ if (!storage) {
+ return null;
+ }
+ try {
+ return normalize(JSON.parse(storage.getItem(STORAGE_KEY) || "null"));
+ } catch (error) {
+ return null;
+ }
+ }
+
+ // Read the carried setup: a fresh setup in the URL wins (a deep link or the intake's
+ // Continue href), otherwise fall back to what the intake stored this session.
+ function load(storage, rawSearch) {
+ return setupFromQuery(rawSearch) || setupFromStorage(storage);
+ }
+
+ function queryForState(setup) {
+ const normalized = normalize(setup);
+ if (!normalized) {
+ return "";
+ }
+ const params = new URLSearchParams();
+ params.set(SOURCE_QUERY_KEY, normalized.sourceType);
+ params.set(
+ SETUP_QUERY_KEY,
+ JSON.stringify(normalized.speakers.map((speaker) => ({
+ name: speaker.name,
+ role: speaker.role,
+ social: speaker.social,
+ }))),
+ );
+ return params.toString();
+ }
+
+ // A creator-facing description of how the episode was started, e.g.
+ // "Recording link — 2 speakers, 1 with a social link".
+ function sourceSummary(setup) {
+ const normalized = normalize(setup);
+ if (!normalized) {
+ return "";
+ }
+ const count = normalized.speakers.length;
+ const withSocial = normalized.speakers.filter((speaker) => speaker.social).length;
+ const speakerText = `${count} ${count === 1 ? "speaker" : "speakers"}`;
+ const socialText = withSocial
+ ? `, ${withSocial} with a social link`
+ : "";
+ return `${SOURCE_TYPES[normalized.sourceType]} — ${speakerText}${socialText}`;
+ }
+
+ // One line per speaker: "Host: Dana Brooks" (plus a "· link" hint when one was added),
+ // so the next step lists exactly who was set up and in which role.
+ function speakerLines(setup) {
+ const normalized = normalize(setup);
+ if (!normalized) {
+ return [];
+ }
+ return normalized.speakers.map((speaker) => {
+ const label = ROLE_LABELS[speaker.role] || speaker.role;
+ return `${label}: ${speaker.name}${speaker.social ? " · link" : ""}`;
+ });
+ }
+
+ const api = {
+ STORAGE_KEY,
+ SOURCE_TYPES,
+ ROLES,
+ ROLE_LABELS,
+ normalize,
+ isComplete,
+ save,
+ clear,
+ load,
+ queryForState,
+ sourceLabel,
+ sourceSummary,
+ speakerLines,
+ };
+
+ 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..5231a8a7
--- /dev/null
+++ b/preview/episode-setup-handoff.test.js
@@ -0,0 +1,137 @@
+"use strict";
+
+// Guards the shared episode-setup handoff helper: the single completeness gate (source +
+// every speaker named and uniquely roled), the sessionStorage + query-string carry, and the
+// creator-facing summaries the next step renders.
+// Run with: `node preview/episode-setup-handoff.test.js`
+
+const assert = require("assert");
+
+const handoff = require("../preview/episode-setup-handoff.js");
+
+function fakeStorage() {
+ const map = {};
+ return {
+ getItem(key) {
+ return Object.prototype.hasOwnProperty.call(map, key) ? map[key] : null;
+ },
+ setItem(key, value) {
+ map[key] = String(value);
+ },
+ removeItem(key) {
+ delete map[key];
+ },
+ };
+}
+
+const complete = {
+ sourceType: "link",
+ speakers: [
+ { name: "Dana Brooks", role: "host", social: "https://x.com/danabrooks" },
+ { name: "Marcus Lee", role: "guest-1", social: "" },
+ ],
+};
+
+// --- Incomplete-setup gating -------------------------------------------------
+assert.strictEqual(handoff.normalize(null), null, "null setup is incomplete");
+assert.strictEqual(
+ handoff.normalize({ sourceType: "", speakers: complete.speakers }),
+ null,
+ "a setup with no chosen source path is incomplete",
+);
+assert.strictEqual(
+ handoff.normalize({ sourceType: "nonsense", speakers: complete.speakers }),
+ null,
+ "an unknown source type is rejected",
+);
+assert.strictEqual(
+ handoff.normalize({ sourceType: "link", speakers: [] }),
+ null,
+ "a setup with no speakers is incomplete",
+);
+assert.strictEqual(
+ handoff.normalize({ sourceType: "link", speakers: [{ name: "", role: "host" }] }),
+ null,
+ "a speaker without a name keeps the setup incomplete",
+);
+assert.strictEqual(
+ handoff.normalize({ sourceType: "link", speakers: [{ name: "Dana", role: "" }] }),
+ null,
+ "a speaker without a role keeps the setup incomplete",
+);
+
+// --- Role assignment / duplicates --------------------------------------------
+assert.strictEqual(
+ handoff.normalize({
+ sourceType: "link",
+ speakers: [
+ { name: "Dana", role: "host" },
+ { name: "Marcus", role: "host" },
+ ],
+ }),
+ null,
+ "two speakers in the same role keep the setup incomplete",
+);
+assert.ok(
+ handoff.normalize({
+ sourceType: "upload",
+ speakers: [
+ { name: "Dana", role: "host" },
+ { name: "Marcus", role: "guest-1" },
+ { name: "Priya", role: "guest-3" },
+ ],
+ }),
+ "a third guest can be assigned a real Guest 3 role (no role-cap bug)",
+);
+assert.strictEqual(handoff.isComplete(complete), true, "a fully filled setup is complete");
+assert.strictEqual(
+ handoff.isComplete({ sourceType: "link", speakers: [{ name: "", role: "host" }] }),
+ false,
+ "isComplete mirrors the normalize gate",
+);
+
+// --- Query-string round-trip -------------------------------------------------
+const query = handoff.queryForState(complete);
+assert.ok(query.includes("source=link"), "query carries the source type");
+assert.ok(query.includes("setup="), "query carries the speaker payload");
+const fromQuery = handoff.load(null, "?" + query);
+assert.ok(fromQuery, "a complete setup round-trips back from the query string");
+assert.strictEqual(fromQuery.sourceType, "link", "source type survives the round-trip");
+assert.strictEqual(fromQuery.speakers.length, 2, "every speaker survives the round-trip");
+assert.strictEqual(fromQuery.speakers[0].name, "Dana Brooks", "speaker names survive the round-trip");
+assert.strictEqual(fromQuery.speakers[0].role, "host", "speaker roles survive the round-trip");
+assert.strictEqual(
+ fromQuery.speakers[0].social,
+ "https://x.com/danabrooks",
+ "social links survive the round-trip",
+);
+assert.strictEqual(handoff.queryForState({ sourceType: "link", speakers: [] }), "", "an incomplete setup has no carry query");
+
+// --- sessionStorage carry ----------------------------------------------------
+const storage = fakeStorage();
+handoff.save(storage, complete);
+const stored = handoff.load(storage, "");
+assert.ok(stored, "a saved setup loads back from storage when the URL has none");
+assert.strictEqual(stored.speakers[1].name, "Marcus Lee", "stored speakers carry their names");
+handoff.save(storage, { sourceType: "link", speakers: [{ name: "", role: "host" }] });
+assert.ok(
+ handoff.load(storage, ""),
+ "an incomplete save never overwrites a previously complete setup",
+);
+handoff.clear(storage);
+assert.strictEqual(handoff.load(storage, ""), null, "clearing storage drops the carried setup");
+
+// --- Creator-facing summaries ------------------------------------------------
+assert.strictEqual(handoff.sourceLabel(complete), "Recording link", "sourceLabel resolves the chosen path");
+const summary = handoff.sourceSummary(complete);
+assert.ok(summary.includes("Recording link"), "source summary names the source path");
+assert.ok(summary.includes("2 speakers"), "source summary counts the speakers");
+assert.ok(summary.includes("1 with a social link"), "source summary counts attached social links");
+const lines = handoff.speakerLines(complete);
+assert.deepStrictEqual(
+ lines,
+ ["Host: Dana Brooks · link", "Guest 1: Marcus Lee"],
+ "speaker lines label each speaker by role and flag attached links",
+);
+
+console.log("episode setup handoff: completeness gate, query/storage carry, and summaries verified");
diff --git a/preview/index.html b/preview/index.html
index 77267ad1..17437602 100644
--- a/preview/index.html
+++ b/preview/index.html
@@ -767,23 +767,29 @@
- Walk the connected ingest path for readiness, roles, and social links — the same three steps as ingest navigation.
+ Walk the connected ingest path for setup, readiness, roles, and social links — the same four steps as ingest navigation.
+ -
+
+ 1. Start a new episode
+ Bring in the recording, assign speaker roles, and add optional links.
+
+
-
- 1. Check episode readiness
+ 2. Check episode readiness
Confirm every speaker has a file and a name before editing.
-
- 2. Map speaker roles
+ 3. Map speaker roles
Choose who is hosting and who is joining as a guest.
-
- 3. Add social context
+ 4. Add social context
Turn public links into accurate guest names.
diff --git a/preview/ingest-nav.js b/preview/ingest-nav.js
index abe92c3e..00d25f4f 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: "Episode setup" },
{ 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..c4806e39 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,9 +305,23 @@ assert.ok(
"first ingest screen does not render a previous link",
);
assert.ok(
- firstNav.nodes.some((node) => node.textContent === "Next: Speaker roles"),
+ firstNav.nodes.some((node) => node.textContent === "Next: Episode readiness"),
"first ingest screen renders next link",
);
+const firstStep = firstNav.nodes.find((node) =>
+ node.textContent === "Setup step 1 of 4 · Episode setup",
+);
+assert.ok(firstStep, "first ingest screen renders the four-step setup label");
+
+const readinessNav = renderNavFor("episode-readiness.html", "episode-readiness", "?path=ingest");
+assert.ok(
+ readinessNav.nodes.some((node) => node.textContent === "Previous: Episode setup"),
+ "episode readiness now steps back to the guided setup intake",
+);
+assert.ok(
+ readinessNav.nodes.some((node) => node.textContent === "Next: Speaker roles"),
+ "episode readiness links forward to speaker roles",
+);
const middleNav = renderNavFor("speaker-role-mapping.html", "speaker-role-mapping", "?path=ingest");
const layoutPlacementLink = linkWithText(middleNav.nodes, "Place videos in layout");
@@ -324,7 +339,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");
@@ -363,21 +378,21 @@ assert.ok(
"last ingest screen does not render a next link",
);
-const embeddedFirstNav = renderNavFor("episode-readiness.html", "episode-readiness", "", true);
+const embeddedFirstNav = renderNavFor("episode-setup-intake.html", "episode-setup-intake", "", true);
const embeddedHome = linkWithText(embeddedFirstNav.nodes, "← Preview shell");
assert.equal(embeddedHome.href, "../preview/", "embedded ingest nav keeps the shell-home href");
assert.equal(embeddedHome.target, "_top", "embedded shell-home link targets the parent app");
const embeddedPreviewApp = linkWithText(embeddedFirstNav.nodes, "Preview app");
assert.equal(
embeddedPreviewApp.href,
- "../preview/app.html#episode-readiness",
+ "../preview/app.html#episode-setup-intake",
"embedded ingest nav opens the current screen in the preview app",
);
assert.equal(embeddedPreviewApp.target, "_top", "embedded preview app link targets the parent app");
-const embeddedNext = linkWithText(embeddedFirstNav.nodes, "Next: Speaker roles");
+const embeddedNext = linkWithText(embeddedFirstNav.nodes, "Next: Episode readiness");
assert.equal(
embeddedNext.href,
- "../preview/app.html#speaker-role-mapping",
+ "../preview/app.html#episode-readiness",
"embedded ingest nav routes next setup steps through the preview app hash",
);
assert.equal(embeddedNext.target, "_top", "embedded ingest next link targets the parent app");
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..a6f4517f
--- /dev/null
+++ b/prototype/episode-readiness-setup-handoff.test.js
@@ -0,0 +1,98 @@
+"use strict";
+
+// Guards that episode readiness surfaces the guided setup handoff the same way it surfaces
+// the layout-first start: a complete setup reveals a "From episode setup" summary naming the
+// source path and carried speakers, while no/incomplete setup leaves the summary hidden.
+// 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 handoff = require("../preview/episode-setup-handoff.js");
+
+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 episode-setup summary",
+);
+assert.ok(
+ html.includes("setupHandoffApi.load(setupHandoffStorage(), window.location.search)"),
+ "episode readiness reads the carried setup from the URL and stored state",
+);
+assert.doesNotMatch(html, /setupHandoffElement\.innerHTML/, "setup summary is not rendered with innerHTML");
+
+const script = html.match(/
+
@@ -304,6 +305,7 @@ Speaker tracks