From b26bb3e61a86c2d2b887c2b2fe6f75b14f6d720a Mon Sep 17 00:00:00 2001 From: fansilas Date: Thu, 25 Jun 2026 10:54:30 +0000 Subject: [PATCH 1/2] Build the guided episode setup intake as the first ingest step [#1326] --- index.html | 1 + preview/app-route-context.js | 17 +- preview/app-route-context.test.js | 25 + preview/app.html | 2 +- preview/episode-setup-handoff.js | 224 ++++++ preview/episode-setup-handoff.test.js | 137 ++++ preview/index.html | 14 +- preview/ingest-nav.js | 1 + preview/ingest-nav.test.js | 29 +- preview/tools-nav.test.js | 1 + .../episode-readiness-setup-handoff.test.js | 98 +++ prototype/episode-readiness.html | 55 +- prototype/episode-setup-intake.html | 639 ++++++++++++++++++ prototype/episode-setup-intake.test.js | 120 ++++ 14 files changed, 1348 insertions(+), 15 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..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 @@

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 for setup, readiness, roles, and social links — the same four steps as ingest navigation.

    +
  1. + + 1. Start a new episode + Bring in the recording, assign speaker roles, and add optional links. + +
  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..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