- Pick a starter block below, or open the full block
- library if you want to build from a different pattern.
+ Generate a first draft from a prompt, or pick a starter
+ block below if you want to build manually.
+
{EMPTY_CANVAS_STARTERS.map((starterKey) => {
const block = BLOCK_LIBRARY.find(
@@ -9936,6 +10081,19 @@ export function ExperienceEditor({
)
return
}
+ const nextPublishedSlug = cleanRoutePart(slug)
+ setPublishedSlug(nextPublishedSlug)
+ const nextPublishedWatchUrl = buildPublishedWatchUrl(
+ nextPublishedSlug,
+ activeLocaleCode,
+ )
+ if (nextPublishedWatchUrl) {
+ window.open(
+ nextPublishedWatchUrl,
+ "_blank",
+ "noopener,noreferrer",
+ )
+ }
pushToast("Locale published.", "success")
} else {
pushToast("Locale saved.", "success")
diff --git a/apps/admin/src/app/dashboard/experiences/experience-editor/ai-draft-panel.tsx b/apps/admin/src/app/dashboard/experiences/experience-editor/ai-draft-panel.tsx
new file mode 100644
index 000000000..88ab2a8c7
--- /dev/null
+++ b/apps/admin/src/app/dashboard/experiences/experience-editor/ai-draft-panel.tsx
@@ -0,0 +1,114 @@
+"use client"
+
+import { Sparkles, X } from "lucide-react"
+
+export function AiDraftPanel({
+ open,
+ prompt,
+ pending,
+ error,
+ onPromptChange,
+ onOpen,
+ onCancel,
+ onGenerate,
+}: {
+ open: boolean
+ prompt: string
+ pending: boolean
+ error: string
+ onPromptChange: (value: string) => void
+ onOpen: () => void
+ onCancel: () => void
+ onGenerate: () => void
+}) {
+ if (!open) {
+ return (
+
+
+
+
+
+
+ AI Draft
+
+
+ Generate with AI
+
+
+ Type a theme, story, or angle and let AI build a first draft with
+ title, description, and blocks using the real catalog.
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+ AI Draft
+
+
+ Generate with AI
+
+
+ Describe the theme, story, or angle you want. AI will draft title,
+ description, and blocks into this empty canvas.
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/apps/admin/src/app/dashboard/experiences/generate-draft-action.test.ts b/apps/admin/src/app/dashboard/experiences/generate-draft-action.test.ts
new file mode 100644
index 000000000..3b3b359b6
--- /dev/null
+++ b/apps/admin/src/app/dashboard/experiences/generate-draft-action.test.ts
@@ -0,0 +1,325 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+import type { Principal } from "@/auth/principal"
+import * as experienceAiService from "@/services/experience-ai/experience-ai.service"
+import { BlocksSchema } from "@/domain/blocks"
+import { runGenerateDraftAction, USER_MESSAGES } from "./generate-draft-action"
+
+const ADMIN: Principal = { id: "admin-1", role: "ADMIN" }
+
+type WriteSpies = {
+ experienceLocaleUpdate: ReturnType
+ experienceLocaleUpsert: ReturnType
+ experienceUpdate: ReturnType
+ contentRevisionCreate: ReturnType
+ contentRevisionUpdate: ReturnType
+}
+
+type DepsWithSpies = ReturnType & {
+ prisma: {
+ contentRevision: {
+ findFirst: ReturnType
+ }
+ }
+ writeSpies: WriteSpies
+}
+
+function mockDeps(overrides?: {
+ blocks?: unknown
+ user?: Principal | null
+ draftSnapshot?: unknown | null
+}): DepsWithSpies {
+ const writeSpies: WriteSpies = {
+ experienceLocaleUpdate: vi.fn(),
+ experienceLocaleUpsert: vi.fn(),
+ experienceUpdate: vi.fn(),
+ contentRevisionCreate: vi.fn(),
+ contentRevisionUpdate: vi.fn(),
+ }
+
+ return {
+ prisma: {
+ experienceLocale: {
+ findUnique: vi.fn().mockResolvedValue({
+ id: "locale-1",
+ status: "DRAFT",
+ blocks: overrides?.blocks ?? [],
+ experienceId: "exp-1",
+ experience: {
+ ownerId: "admin-1",
+ archivedAt: null,
+ },
+ }),
+ update: writeSpies.experienceLocaleUpdate,
+ upsert: writeSpies.experienceLocaleUpsert,
+ },
+ experience: {
+ update: writeSpies.experienceUpdate,
+ },
+ contentRevision: {
+ findFirst: vi
+ .fn()
+ .mockResolvedValue(
+ overrides?.draftSnapshot
+ ? { snapshot: overrides.draftSnapshot }
+ : null,
+ ),
+ create: writeSpies.contentRevisionCreate,
+ update: writeSpies.contentRevisionUpdate,
+ },
+ video: {
+ findMany: vi.fn(),
+ },
+ videoLocale: {
+ findMany: vi.fn(),
+ },
+ videoDub: {
+ findMany: vi.fn(),
+ },
+ videoImage: {
+ findMany: vi.fn(),
+ },
+ },
+ user: overrides?.user ?? ADMIN,
+ writeSpies,
+ } as unknown as DepsWithSpies
+}
+
+describe("runGenerateDraftAction", () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it("rejects non-empty canonical canvases with CANVAS_NOT_EMPTY", async () => {
+ const aiSpy = vi.spyOn(experienceAiService, "generateExperienceAiDraft")
+ const deps = mockDeps({ blocks: [{ t: "text" }] })
+ const result = await runGenerateDraftAction(deps, {
+ localeId: "locale-1",
+ locale: "en",
+ prompt: "forgiveness",
+ })
+
+ expect(result).toEqual({
+ ok: false,
+ code: "CANVAS_NOT_EMPTY",
+ error: USER_MESSAGES.CANVAS_NOT_EMPTY,
+ })
+ expect(aiSpy).not.toHaveBeenCalled()
+ })
+
+ it("rejects when a DRAFT revision has non-empty content even if canonical is empty", async () => {
+ const aiSpy = vi.spyOn(experienceAiService, "generateExperienceAiDraft")
+ const deps = mockDeps({
+ blocks: [],
+ draftSnapshot: {
+ v: 1,
+ data: { blocks: [{ t: "text", heading: "WIP" }] },
+ },
+ })
+ const result = await runGenerateDraftAction(deps, {
+ localeId: "locale-1",
+ locale: "en",
+ prompt: "forgiveness",
+ })
+
+ expect(result).toEqual({
+ ok: false,
+ code: "CANVAS_NOT_EMPTY",
+ error: USER_MESSAGES.CANVAS_NOT_EMPTY,
+ })
+ expect(aiSpy).not.toHaveBeenCalled()
+ })
+
+ it("rejects users who cannot edit the locale", async () => {
+ const result = await runGenerateDraftAction(
+ mockDeps({ user: { id: "viewer-1", role: "VIEWER" } }),
+ {
+ localeId: "locale-1",
+ locale: "en",
+ prompt: "forgiveness",
+ },
+ )
+
+ expect(result).toEqual({
+ ok: false,
+ code: "FORBIDDEN",
+ error: USER_MESSAGES.FORBIDDEN,
+ })
+ })
+
+ it("returns a draft on success", async () => {
+ vi.spyOn(
+ experienceAiService,
+ "generateExperienceAiDraft",
+ ).mockResolvedValueOnce({
+ title: "Forgiven and Free",
+ metaDescription: "A generated draft",
+ blocks: [{ t: "text", heading: "Hello" }],
+ })
+
+ const result = await runGenerateDraftAction(mockDeps(), {
+ localeId: "locale-1",
+ locale: "en",
+ prompt: "forgiveness",
+ currentTitle: "Hint",
+ })
+
+ expect(result).toEqual({
+ ok: true,
+ draft: {
+ title: "Forgiven and Free",
+ metaDescription: "A generated draft",
+ blocks: [{ t: "text", heading: "Hello" }],
+ },
+ })
+ expect(JSON.parse(JSON.stringify(result))).toEqual(result)
+ })
+
+ it("maps typed service errors to editor-safe messages", async () => {
+ vi.spyOn(
+ experienceAiService,
+ "generateExperienceAiDraft",
+ ).mockRejectedValueOnce(
+ new experienceAiService.ExperienceAiGenerationError(
+ "NO_CANDIDATES",
+ "no candidates",
+ ),
+ )
+
+ const result = await runGenerateDraftAction(mockDeps(), {
+ localeId: "locale-1",
+ locale: "en",
+ prompt: "forgiveness",
+ })
+
+ expect(result).toEqual({
+ ok: false,
+ code: "NO_CANDIDATES",
+ error: USER_MESSAGES.NO_CANDIDATES,
+ })
+ })
+
+ it("collapses unknown errors into a generic message", async () => {
+ vi.spyOn(
+ experienceAiService,
+ "generateExperienceAiDraft",
+ ).mockRejectedValueOnce(new Error("boom"))
+
+ const result = await runGenerateDraftAction(mockDeps(), {
+ localeId: "locale-1",
+ locale: "en",
+ prompt: "forgiveness",
+ })
+
+ expect(result).toEqual({
+ ok: false,
+ code: "UNKNOWN",
+ error: USER_MESSAGES.UNKNOWN,
+ })
+ })
+
+ it("integration: returns a BlocksSchema-valid draft, calls no Prisma writes, and respects catalog refs", async () => {
+ const candidates = [
+ {
+ ref: "v01" as const,
+ videoId: "video-1",
+ slug: "hope-story",
+ title: "Hope Story",
+ description: "A hopeful story",
+ previewImageUrl: "https://example.com/hope.jpg",
+ previewStreamUrl: "https://example.com/hope.m3u8",
+ label: null,
+ },
+ {
+ ref: "v02" as const,
+ videoId: "video-2",
+ slug: "prayer-story",
+ title: "Prayer Story",
+ description: "A prayer story",
+ previewImageUrl: null,
+ previewStreamUrl: "https://example.com/prayer.m3u8",
+ label: null,
+ },
+ ]
+
+ // Hand-written normalized fixture mirroring Unit 1's structural
+ // shape: videoHero + section wrapping a cross-block navigation
+ // carousel and a media collection. videoIds and streamingUrls all
+ // trace to the candidate set above by construction.
+ const normalizedFixture = {
+ title: "Hope for the Journey",
+ metaDescription: "A first draft.",
+ blocks: [
+ {
+ t: "videoHero" as const,
+ sectionKey: "ai-s01",
+ videoId: "video-1",
+ streamingUrl: "https://example.com/hope.m3u8",
+ ctaLabel: "Watch",
+ headingSource: "videoTitle" as const,
+ },
+ {
+ t: "section" as const,
+ sectionKey: "ai-s02",
+ content: [
+ {
+ t: "navigationCarousel" as const,
+ items: [{ contentId: "ai-s01", title: "Watch the story" }],
+ },
+ {
+ t: "videoCarousel" as const,
+ items: [
+ {
+ videoId: "video-2",
+ streamingUrl: "https://example.com/prayer.m3u8",
+ titleOverride: "Prayer",
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ }
+
+ const draftSpy = vi
+ .spyOn(experienceAiService, "generateExperienceAiDraft")
+ .mockResolvedValue(normalizedFixture)
+
+ const deps = mockDeps()
+ const result = await runGenerateDraftAction(deps, {
+ localeId: "locale-1",
+ locale: "en",
+ prompt: "hope",
+ })
+
+ expect(result.ok).toBe(true)
+ if (!result.ok) throw new Error("expected ok result")
+
+ // R3 / R9 — BlocksSchema-valid output.
+ expect(BlocksSchema.safeParse(result.draft.blocks).success).toBe(true)
+
+ // R6 — every videoId traces to a candidate; every persisted streamingUrl
+ // matches a candidate previewStreamUrl.
+ const candidateVideoIds = new Set(candidates.map((c) => c.videoId))
+ const candidateStreamUrls = new Set(
+ candidates
+ .map((c) => c.previewStreamUrl)
+ .filter((url): url is string => Boolean(url)),
+ )
+ const json = JSON.stringify(result.draft.blocks)
+ for (const match of json.matchAll(/"videoId"\s*:\s*"([^"]+)"/g)) {
+ expect(candidateVideoIds.has(match[1])).toBe(true)
+ }
+ for (const match of json.matchAll(/"streamingUrl"\s*:\s*"([^"]+)"/g)) {
+ expect(candidateStreamUrls.has(match[1])).toBe(true)
+ }
+
+ // R5 — ephemeral. No Prisma write entry point should fire.
+ expect(deps.writeSpies.experienceLocaleUpdate).not.toHaveBeenCalled()
+ expect(deps.writeSpies.experienceLocaleUpsert).not.toHaveBeenCalled()
+ expect(deps.writeSpies.experienceUpdate).not.toHaveBeenCalled()
+ expect(deps.writeSpies.contentRevisionCreate).not.toHaveBeenCalled()
+ expect(deps.writeSpies.contentRevisionUpdate).not.toHaveBeenCalled()
+
+ draftSpy.mockRestore()
+ })
+})
diff --git a/apps/admin/src/app/dashboard/experiences/generate-draft-action.ts b/apps/admin/src/app/dashboard/experiences/generate-draft-action.ts
new file mode 100644
index 000000000..0974ae90e
--- /dev/null
+++ b/apps/admin/src/app/dashboard/experiences/generate-draft-action.ts
@@ -0,0 +1,197 @@
+import type { PrismaClient } from "@prisma/client"
+import { canEditExperienceLocale } from "@/auth/permissions"
+import type { Principal } from "@/auth/principal"
+import {
+ ExperienceAiGenerationError,
+ generateExperienceAiDraft,
+} from "@/services/experience-ai/experience-ai.service"
+
+export type GenerateDraftActionInput = {
+ localeId: string
+ locale: string
+ prompt: string
+ currentTitle?: string
+ currentMetaDescription?: string
+}
+
+/// Typed error codes returned by the action layer. Keep in sync with
+/// USER_MESSAGES below.
+export type GenerateDraftActionErrorCode =
+ | "EMPTY_PROMPT"
+ | "LOCALE_NOT_FOUND"
+ | "FORBIDDEN"
+ | "CANVAS_NOT_EMPTY"
+ | "NOT_CONFIGURED"
+ | "NO_CANDIDATES"
+ | "SCHEMA_MISMATCH"
+ | "UPSTREAM_ERROR"
+ | "UNKNOWN"
+
+export type GenerateDraftActionResult =
+ | {
+ ok: true
+ draft: {
+ title: string
+ metaDescription: string
+ blocks: unknown[]
+ }
+ }
+ | {
+ ok: false
+ code: GenerateDraftActionErrorCode
+ error: string
+ }
+
+export const USER_MESSAGES: Record = {
+ EMPTY_PROMPT: "Enter a theme or story prompt first.",
+ LOCALE_NOT_FOUND: "Locale not found.",
+ FORBIDDEN: "You do not have permission to generate a draft for this locale.",
+ CANVAS_NOT_EMPTY: "AI drafting is only available on an empty canvas in v1.",
+ NOT_CONFIGURED: "AI drafting is not configured for this environment.",
+ NO_CANDIDATES:
+ "No suitable in-catalog videos were found for this theme. Try broader wording.",
+ SCHEMA_MISMATCH:
+ "The AI response could not be turned into a valid editor draft. Try again.",
+ UPSTREAM_ERROR:
+ "The AI drafting service is unavailable right now. Try again shortly.",
+ UNKNOWN: "Unable to generate a draft right now.",
+}
+
+type GenerateDraftActionDeps = {
+ prisma: Pick<
+ PrismaClient,
+ | "experienceLocale"
+ | "video"
+ | "videoLocale"
+ | "videoDub"
+ | "videoImage"
+ | "contentRevision"
+ >
+ user: Principal | null
+}
+
+function buildPrompt(input: GenerateDraftActionInput) {
+ const parts = [input.prompt.trim()]
+ if (input.currentTitle?.trim()) {
+ parts.push(`Optional editor title hint: ${input.currentTitle.trim()}`)
+ }
+ if (input.currentMetaDescription?.trim()) {
+ parts.push(
+ `Optional editor description hint: ${input.currentMetaDescription.trim()}`,
+ )
+ }
+ return parts.join("\n\n")
+}
+
+function fail(
+ code: GenerateDraftActionErrorCode,
+): Extract {
+ return { ok: false, code, error: USER_MESSAGES[code] }
+}
+
+function isNonEmptyBlocksValue(value: unknown): boolean {
+ if (Array.isArray(value)) return value.length > 0
+ if (value && typeof value === "object") {
+ // ContentRevision snapshots are stored as { v, data: { blocks: [...] } }.
+ const data = (value as { data?: unknown }).data
+ if (data && typeof data === "object") {
+ const blocks = (data as { blocks?: unknown }).blocks
+ if (Array.isArray(blocks)) return blocks.length > 0
+ }
+ const blocks = (value as { blocks?: unknown }).blocks
+ if (Array.isArray(blocks)) return blocks.length > 0
+ }
+ return false
+}
+
+export async function runGenerateDraftAction(
+ deps: GenerateDraftActionDeps,
+ input: GenerateDraftActionInput,
+): Promise {
+ const prompt = input.prompt.trim()
+ if (!prompt) {
+ return fail("EMPTY_PROMPT")
+ }
+
+ const locale = await deps.prisma.experienceLocale.findUnique({
+ where: { id: input.localeId },
+ select: {
+ id: true,
+ status: true,
+ blocks: true,
+ experienceId: true,
+ experience: {
+ select: {
+ ownerId: true,
+ archivedAt: true,
+ },
+ },
+ },
+ })
+
+ if (!locale) {
+ return fail("LOCALE_NOT_FOUND")
+ }
+
+ if (!canEditExperienceLocale(deps.user, locale)) {
+ return fail("FORBIDDEN")
+ }
+
+ // Server-side empty-canvas guard (R4). Read canonical blocks AND any
+ // pending DRAFT revision; non-empty in either path means the action
+ // must NOT invoke the AI service. Returns the typed CANVAS_NOT_EMPTY
+ // code so the UI can render a precise message regardless of which
+ // surface (canonical or draft) is non-empty.
+ if (isNonEmptyBlocksValue(locale.blocks)) {
+ return fail("CANVAS_NOT_EMPTY")
+ }
+
+ const draftRevision = await deps.prisma.contentRevision.findFirst({
+ where: {
+ entityType: "ExperienceLocale",
+ entityId: locale.id,
+ status: "DRAFT",
+ },
+ select: { snapshot: true },
+ })
+
+ if (draftRevision && isNonEmptyBlocksValue(draftRevision.snapshot)) {
+ return fail("CANVAS_NOT_EMPTY")
+ }
+
+ try {
+ const draft = await generateExperienceAiDraft(deps.prisma as PrismaClient, {
+ experienceLocaleId: input.localeId,
+ locale: input.locale,
+ prompt: buildPrompt(input),
+ user: deps.user,
+ experienceId: locale.experienceId ?? null,
+ })
+
+ return {
+ ok: true,
+ draft: {
+ title: draft.title,
+ metaDescription: draft.metaDescription,
+ blocks: draft.blocks,
+ },
+ }
+ } catch (error) {
+ if (error instanceof ExperienceAiGenerationError) {
+ switch (error.code) {
+ case "NOT_CONFIGURED":
+ return fail("NOT_CONFIGURED")
+ case "NO_CANDIDATES":
+ return fail("NO_CANDIDATES")
+ case "NORMALIZATION_ERROR":
+ case "SCHEMA_MISMATCH":
+ return fail("SCHEMA_MISMATCH")
+ case "UPSTREAM_ERROR":
+ return fail("UPSTREAM_ERROR")
+ }
+ }
+
+ console.error("[runGenerateDraftAction] unexpected error", error)
+ return fail("UNKNOWN")
+ }
+}
diff --git a/apps/admin/src/config/env.ts b/apps/admin/src/config/env.ts
index d142e37b2..10a62f21a 100644
--- a/apps/admin/src/config/env.ts
+++ b/apps/admin/src/config/env.ts
@@ -50,6 +50,14 @@ export const env = createEnv({
OPENROUTER_API_KEY: z.string().min(1).optional(),
OPENAI_API_KEY: z.string().min(1).optional(),
OPENAI_BASE_URL: z.string().url().optional(),
+ // Gates the local-only codex CLI fallback for Experience AI drafting.
+ // Defaults to false so production deployments without OPENROUTER_API_KEY
+ // / OPENAI_API_KEY surface NOT_CONFIGURED instead of silently spawning
+ // a CLI process at request time. Set to true on developer machines to
+ // keep AI drafting available without an API key.
+ EXPERIENCE_AI_ALLOW_CODEX_FALLBACK: z.coerce.boolean().default(false),
+ OLLAMA_BASE_URL: z.string().url().optional(),
+ OLLAMA_EMBEDDING_MODEL: z.string().min(1).optional(),
WORKFLOW_API_KEYS: z.string().min(1).optional(),
WORKFLOW_HMAC_SECRET: z.string().min(1).optional(),
RAILWAY_S3_ENDPOINT: z.string().url().optional(),
@@ -67,6 +75,7 @@ export const env = createEnv({
},
client: {
NEXT_PUBLIC_APP_NAME: z.string().min(1).default("forge-admin"),
+ NEXT_PUBLIC_WATCH_URL: z.string().url().optional(),
},
skipValidation: !!process.env.CI,
runtimeEnv: {
@@ -106,6 +115,13 @@ export const env = createEnv({
OPENROUTER_API_KEY: emptyToUndefined(process.env.OPENROUTER_API_KEY),
OPENAI_API_KEY: emptyToUndefined(process.env.OPENAI_API_KEY),
OPENAI_BASE_URL: emptyToUndefined(process.env.OPENAI_BASE_URL),
+ EXPERIENCE_AI_ALLOW_CODEX_FALLBACK: emptyToUndefined(
+ process.env.EXPERIENCE_AI_ALLOW_CODEX_FALLBACK,
+ ),
+ OLLAMA_BASE_URL: emptyToUndefined(process.env.OLLAMA_BASE_URL),
+ OLLAMA_EMBEDDING_MODEL: emptyToUndefined(
+ process.env.OLLAMA_EMBEDDING_MODEL,
+ ),
WORKFLOW_API_KEYS: emptyToUndefined(process.env.WORKFLOW_API_KEYS),
WORKFLOW_HMAC_SECRET: emptyToUndefined(process.env.WORKFLOW_HMAC_SECRET),
RAILWAY_S3_ENDPOINT: emptyToUndefined(process.env.RAILWAY_S3_ENDPOINT),
@@ -120,5 +136,6 @@ export const env = createEnv({
CMS_DATABASE_URL: emptyToUndefined(process.env.CMS_DATABASE_URL),
NODE_ENV: emptyToUndefined(process.env.NODE_ENV),
NEXT_PUBLIC_APP_NAME: process.env.NEXT_PUBLIC_APP_NAME,
+ NEXT_PUBLIC_WATCH_URL: emptyToUndefined(process.env.NEXT_PUBLIC_WATCH_URL),
},
})
diff --git a/apps/admin/src/graphql/schema.test.ts b/apps/admin/src/graphql/schema.test.ts
index 3a3d5feca..f5454b17f 100644
--- a/apps/admin/src/graphql/schema.test.ts
+++ b/apps/admin/src/graphql/schema.test.ts
@@ -205,6 +205,7 @@ describe("ExperienceLocale type", () => {
>
expect(fields.blocks.type.toString()).toMatch(/JSON/)
expect(fields.status.type.toString()).toMatch(/LocaleStatus/)
+ expect(fields.referencedVideos.type.toString()).toMatch(/Video/)
})
it("does not expose any embedding-shaped field", () => {
diff --git a/apps/admin/src/graphql/types/experience.ts b/apps/admin/src/graphql/types/experience.ts
index ca7a3f88d..70fb5df46 100644
--- a/apps/admin/src/graphql/types/experience.ts
+++ b/apps/admin/src/graphql/types/experience.ts
@@ -25,6 +25,26 @@ import { builder } from "@/graphql/builder"
// module can reference `type: "JSON"` below. Also exports `LocaleStatusEnum`.
import { LocaleStatusEnum } from "@/graphql/types/reference"
+function collectVideoIdsFromBlocks(value: unknown, ids = new Set()) {
+ if (Array.isArray(value)) {
+ for (const entry of value) collectVideoIdsFromBlocks(entry, ids)
+ return ids
+ }
+
+ if (!value || typeof value !== "object") return ids
+
+ const record = value as Record
+ if (typeof record.videoId === "string" && record.videoId.trim()) {
+ ids.add(record.videoId)
+ }
+
+ for (const entry of Object.values(record)) {
+ collectVideoIdsFromBlocks(entry, ids)
+ }
+
+ return ids
+}
+
// -----------------------------------------------------------------------------
// ExperienceLocale
// -----------------------------------------------------------------------------
@@ -56,6 +76,24 @@ builder.prismaObject("ExperienceLocale", {
"Array of Experience blocks. Schema shape enforced at write time by the domain Zod union; see `src/domain/blocks.ts`.",
resolve: (row) => row.blocks,
}),
+ referencedVideos: t.prismaField({
+ type: ["Video"],
+ authScopes: { public: true },
+ description:
+ "Videos referenced by this locale's JSON blocks. Used by public preview renderers to hydrate admin-authored videoId refs without exposing arbitrary video lookups.",
+ resolve: (query, row, _args, ctx) => {
+ const ids = Array.from(collectVideoIdsFromBlocks(row.blocks))
+ if (ids.length === 0) return []
+
+ return ctx.prisma.video.findMany({
+ ...query,
+ where: {
+ id: { in: ids },
+ deletedAt: null,
+ },
+ })
+ },
+ }),
status: t.expose("status", { type: LocaleStatusEnum }),
publishedAt: t.string({
nullable: true,
diff --git a/apps/admin/src/scripts/index-local-video-candidate-embeddings.ts b/apps/admin/src/scripts/index-local-video-candidate-embeddings.ts
new file mode 100644
index 000000000..05417a679
--- /dev/null
+++ b/apps/admin/src/scripts/index-local-video-candidate-embeddings.ts
@@ -0,0 +1,156 @@
+import { PrismaClient } from "@prisma/client"
+import { toPgVector } from "@/db/pgvector"
+import {
+ generateOllamaEmbedding,
+ OLLAMA_EMBEDDING_DIMENSIONS,
+} from "@/services/ollama-embedding.service"
+import { env } from "@/config/env"
+
+const prisma = new PrismaClient()
+const locale = process.argv.slice(2).find((arg) => arg !== "--") ?? "en"
+
+type VideoRow = {
+ id: string
+ slug: string
+ title: string | null
+ description: string | null
+}
+
+function buildSourceText(video: VideoRow) {
+ return [video.title, video.description, video.slug]
+ .filter((value): value is string => Boolean(value?.trim()))
+ .join("\n")
+}
+
+async function ensureTable() {
+ await prisma.$executeRawUnsafe(`
+ CREATE TABLE IF NOT EXISTS video_candidate_embedding (
+ video_id TEXT NOT NULL REFERENCES video(id) ON DELETE CASCADE,
+ locale TEXT NOT NULL,
+ source_text TEXT NOT NULL,
+ embedding vector(${OLLAMA_EMBEDDING_DIMENSIONS}),
+ model TEXT NOT NULL,
+ dimensions INTEGER NOT NULL DEFAULT ${OLLAMA_EMBEDDING_DIMENSIONS},
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ PRIMARY KEY (video_id, locale)
+ )
+ `)
+ await prisma.$executeRawUnsafe(`
+ CREATE INDEX IF NOT EXISTS video_candidate_embedding_hnsw
+ ON video_candidate_embedding USING hnsw (embedding vector_cosine_ops)
+ WHERE embedding IS NOT NULL
+ `)
+ await prisma.$executeRawUnsafe(`
+ CREATE INDEX IF NOT EXISTS video_candidate_embedding_locale_hnsw
+ ON video_candidate_embedding USING hnsw (embedding vector_cosine_ops)
+ WHERE embedding IS NOT NULL AND locale = 'en'
+ `)
+}
+
+async function loadVideos() {
+ return prisma.$queryRaw`
+ SELECT
+ v.id,
+ v.slug,
+ vl.title,
+ COALESCE(vl.description, vl.snippet) AS description
+ FROM video v
+ LEFT JOIN LATERAL (
+ SELECT title, description, snippet
+ FROM video_locale vl
+ WHERE vl.video_id = v.id
+ ORDER BY
+ CASE WHEN vl.locale = ${locale} THEN 0 ELSE 1 END,
+ CASE WHEN vl.status = 'published' THEN 0 ELSE 1 END,
+ vl.updated_at DESC
+ LIMIT 1
+ ) vl ON TRUE
+ WHERE v.deleted_at IS NULL
+ ORDER BY v.updated_at DESC
+ `
+}
+
+async function main() {
+ await ensureTable()
+
+ const videos = await loadVideos()
+ let indexed = 0
+ let skipped = 0
+
+ for (const video of videos) {
+ const sourceText = buildSourceText(video)
+ if (!sourceText) {
+ skipped += 1
+ continue
+ }
+
+ const existing = await prisma.$queryRaw>`
+ SELECT source_text
+ FROM video_candidate_embedding
+ WHERE video_id = ${video.id}
+ AND locale = ${locale}
+ LIMIT 1
+ `
+ if (existing[0]?.source_text === sourceText) {
+ skipped += 1
+ continue
+ }
+
+ const embedding = await generateOllamaEmbedding(sourceText)
+ await prisma.$executeRaw`
+ INSERT INTO video_candidate_embedding (
+ video_id,
+ locale,
+ source_text,
+ embedding,
+ model,
+ dimensions,
+ updated_at
+ )
+ VALUES (
+ ${video.id},
+ ${locale},
+ ${sourceText},
+ ${toPgVector(embedding)}::vector,
+ ${env.OLLAMA_EMBEDDING_MODEL ?? "embeddinggemma"},
+ ${OLLAMA_EMBEDDING_DIMENSIONS},
+ NOW()
+ )
+ ON CONFLICT (video_id, locale)
+ DO UPDATE SET
+ source_text = EXCLUDED.source_text,
+ embedding = EXCLUDED.embedding,
+ model = EXCLUDED.model,
+ dimensions = EXCLUDED.dimensions,
+ updated_at = NOW()
+ `
+ indexed += 1
+ if (indexed % 50 === 0) {
+ console.log(
+ `[local-video-embeddings] indexed ${indexed}/${videos.length}`,
+ )
+ }
+ }
+
+ console.log(
+ JSON.stringify({
+ locale,
+ total: videos.length,
+ indexed,
+ skipped,
+ model: env.OLLAMA_EMBEDDING_MODEL ?? "embeddinggemma",
+ dimensions: OLLAMA_EMBEDDING_DIMENSIONS,
+ }),
+ )
+}
+
+main()
+ .catch((error) => {
+ console.error(
+ `[local-video-embeddings] failed: ${error instanceof Error ? error.stack : String(error)}`,
+ )
+ process.exitCode = 1
+ })
+ .finally(async () => {
+ await prisma.$disconnect()
+ })
diff --git a/apps/admin/src/services/experience-ai/experience-ai-normalize.test.ts b/apps/admin/src/services/experience-ai/experience-ai-normalize.test.ts
new file mode 100644
index 000000000..4bd02b4e9
--- /dev/null
+++ b/apps/admin/src/services/experience-ai/experience-ai-normalize.test.ts
@@ -0,0 +1,413 @@
+import { describe, expect, it } from "vitest"
+import {
+ ExperienceAiNormalizationError,
+ normalizeExperienceDraft,
+} from "./experience-ai-normalize"
+import type { DraftExperience, VideoCandidate } from "./experience-ai.schemas"
+
+const candidates: VideoCandidate[] = [
+ {
+ ref: "v01",
+ videoId: "video-1",
+ slug: "forgiven",
+ title: "Forgiven",
+ description: "A story about forgiveness.",
+ previewImageUrl: "https://example.com/forgiven.jpg",
+ previewStreamUrl: "https://example.com/forgiven.m3u8",
+ label: "FEATURE_FILM",
+ },
+ {
+ ref: "v02",
+ videoId: "video-2",
+ slug: "freedom",
+ title: "Freedom",
+ description: "A story about freedom.",
+ previewImageUrl: "https://example.com/freedom.jpg",
+ previewStreamUrl: "https://example.com/freedom.m3u8",
+ label: "SHORT_FILM",
+ },
+]
+
+describe("normalizeExperienceDraft", () => {
+ it("normalizes candidate refs and section refs into admin blocks", () => {
+ const draft: DraftExperience = {
+ title: "Forgiven and Free",
+ metaDescription: "A guided story about forgiveness and freedom.",
+ blocks: [
+ {
+ t: "videoHero",
+ sectionRef: "s01",
+ candidateRef: "v01",
+ heading: "Watch the story",
+ },
+ {
+ t: "section",
+ sectionRef: "s02",
+ content: [
+ {
+ t: "text",
+ sectionRef: "s03",
+ heading: "You are not alone",
+ contentParagraphs: ["Grace meets us where we are."],
+ },
+ {
+ t: "video",
+ sectionRef: "s04",
+ candidateRef: "v01",
+ titleSource: "manual",
+ title: "Watch the story",
+ },
+ ],
+ },
+ {
+ t: "navigationCarousel",
+ items: [
+ {
+ targetRef: "s02",
+ title: "Start here",
+ },
+ ],
+ },
+ {
+ t: "mediaCollection",
+ title: "Keep exploring",
+ variant: "collection",
+ items: [
+ {
+ candidateRef: "v02",
+ targetRef: "s02",
+ },
+ ],
+ },
+ ],
+ }
+
+ const normalized = normalizeExperienceDraft(draft, candidates)
+
+ expect(normalized.title).toBe("Forgiven and Free")
+ expect(normalized.blocks[0]).toMatchObject({
+ t: "videoHero",
+ sectionKey: "ai-s01",
+ })
+ expect(normalized.blocks[1]).toMatchObject({
+ t: "section",
+ sectionKey: "ai-s02",
+ })
+ expect(normalized.blocks[2]).toMatchObject({
+ t: "navigationCarousel",
+ items: [{ contentId: "ai-s02" }],
+ })
+ expect(normalized.blocks[3]).toMatchObject({
+ t: "mediaCollection",
+ items: [{ videoId: "video-2", linkToSectionKey: "ai-s02" }],
+ })
+ })
+
+ it("flattens container slots into containerSlot markers plus nested content", () => {
+ const draft: DraftExperience = {
+ title: "Two Column Story",
+ metaDescription: "A two-column story.",
+ blocks: [
+ {
+ t: "container",
+ sectionRef: "s01",
+ slots: [
+ {
+ gridSpan: 7,
+ content: [{ t: "text", heading: "Column one" }],
+ },
+ {
+ gridSpan: 5,
+ content: [{ t: "video", candidateRef: "v01" }],
+ },
+ ],
+ },
+ ],
+ }
+
+ const normalized = normalizeExperienceDraft(draft, candidates)
+ expect(normalized.blocks[0]).toMatchObject({
+ t: "container",
+ sectionKey: "ai-s01",
+ content: [
+ { t: "containerSlot", gridSpan: 7 },
+ { t: "text", heading: "Column one" },
+ { t: "containerSlot", gridSpan: 5 },
+ { t: "video", videoId: "video-1" },
+ ],
+ })
+ })
+
+ it("fails on unknown video refs", () => {
+ const draft: DraftExperience = {
+ title: "Unknown",
+ metaDescription: "Unknown",
+ blocks: [{ t: "video", candidateRef: "v99" }],
+ }
+
+ expect(() => normalizeExperienceDraft(draft, candidates)).toThrowError(
+ ExperienceAiNormalizationError,
+ )
+ })
+
+ it("fails on duplicate section refs", () => {
+ const draft: DraftExperience = {
+ title: "Duplicate refs",
+ metaDescription: "Duplicate refs",
+ blocks: [
+ { t: "text", sectionRef: "s01", heading: "One" },
+ { t: "text", sectionRef: "s01", heading: "Two" },
+ ],
+ }
+
+ expect(() => normalizeExperienceDraft(draft, candidates)).toThrowError(
+ ExperienceAiNormalizationError,
+ )
+ })
+
+ describe("presentation defaults", () => {
+ it("fills hero clip seconds with 0/8 when both are omitted", () => {
+ const draft: DraftExperience = {
+ title: "Hero defaults",
+ metaDescription: "Hero defaults",
+ blocks: [
+ {
+ t: "videoHero",
+ sectionRef: "s01",
+ candidateRef: "v01",
+ },
+ {
+ t: "text",
+ sectionRef: "s02",
+ heading: "Body",
+ contentParagraphs: ["copy"],
+ },
+ ],
+ }
+
+ const normalized = normalizeExperienceDraft(draft, candidates)
+ expect(normalized.blocks[0]).toMatchObject({
+ t: "videoHero",
+ clipStartSeconds: 0,
+ clipEndSeconds: 8,
+ })
+ })
+
+ it("preserves an explicit hero clipStartSeconds without defaulting clipEndSeconds", () => {
+ const draft: DraftExperience = {
+ title: "Hero start only",
+ metaDescription: "Hero start only",
+ blocks: [
+ {
+ t: "videoHero",
+ sectionRef: "s01",
+ candidateRef: "v01",
+ clipStartSeconds: 5,
+ },
+ {
+ t: "text",
+ sectionRef: "s02",
+ heading: "Body",
+ contentParagraphs: ["copy"],
+ },
+ ],
+ }
+
+ const normalized = normalizeExperienceDraft(draft, candidates)
+ expect(normalized.blocks[0]).toMatchObject({
+ t: "videoHero",
+ clipStartSeconds: 5,
+ })
+ expect(normalized.blocks[0]).not.toHaveProperty("clipEndSeconds")
+ })
+
+ it("fills section dynamicBackgroundImage and backgroundOpacity when first video-bearing nested block has previewImageUrl", () => {
+ const draft: DraftExperience = {
+ title: "Section bg",
+ metaDescription: "Section bg",
+ blocks: [
+ {
+ t: "videoHero",
+ sectionRef: "s01",
+ candidateRef: "v01",
+ },
+ {
+ t: "section",
+ sectionRef: "s02",
+ content: [
+ {
+ t: "text",
+ heading: "Intro",
+ contentParagraphs: ["copy"],
+ },
+ {
+ t: "video",
+ candidateRef: "v01",
+ },
+ ],
+ },
+ ],
+ }
+
+ const normalized = normalizeExperienceDraft(draft, candidates)
+ expect(normalized.blocks[1]).toMatchObject({
+ t: "section",
+ dynamicBackgroundImage: true,
+ backgroundOpacity: 0.65,
+ })
+ })
+
+ it("preserves explicit dynamicBackgroundImage: false even when candidate previewImageUrl is present", () => {
+ const draft: DraftExperience = {
+ title: "Explicit false",
+ metaDescription: "Explicit false",
+ blocks: [
+ {
+ t: "videoHero",
+ sectionRef: "s01",
+ candidateRef: "v01",
+ },
+ {
+ t: "section",
+ sectionRef: "s02",
+ dynamicBackgroundImage: false,
+ content: [
+ {
+ t: "video",
+ candidateRef: "v01",
+ },
+ ],
+ },
+ ],
+ }
+
+ const normalized = normalizeExperienceDraft(draft, candidates)
+ expect(normalized.blocks[1]).toMatchObject({
+ t: "section",
+ dynamicBackgroundImage: false,
+ })
+ expect(normalized.blocks[1]).not.toHaveProperty("backgroundOpacity")
+ })
+
+ it("does not fill section dynamicBackgroundImage when candidate previewImageUrl is missing", () => {
+ const candidatesWithoutPreview: VideoCandidate[] = [
+ {
+ ref: "v01",
+ videoId: "video-1",
+ slug: "no-preview",
+ title: "No preview",
+ description: null,
+ previewImageUrl: null,
+ previewStreamUrl: null,
+ label: null,
+ },
+ ]
+ const draft: DraftExperience = {
+ title: "No preview",
+ metaDescription: "No preview",
+ blocks: [
+ {
+ t: "videoHero",
+ sectionRef: "s01",
+ candidateRef: "v01",
+ },
+ {
+ t: "section",
+ sectionRef: "s02",
+ content: [
+ {
+ t: "video",
+ candidateRef: "v01",
+ },
+ ],
+ },
+ ],
+ }
+
+ const normalized = normalizeExperienceDraft(
+ draft,
+ candidatesWithoutPreview,
+ )
+ expect(normalized.blocks[1]).toMatchObject({
+ t: "section",
+ dynamicBackgroundImage: false,
+ })
+ expect(normalized.blocks[1]).not.toHaveProperty("backgroundOpacity")
+ })
+
+ it("fills container slot spans with balanced layout when omitted (3 slots → md:4)", () => {
+ const draft: DraftExperience = {
+ title: "Three slots",
+ metaDescription: "Three slots",
+ blocks: [
+ {
+ t: "videoHero",
+ sectionRef: "s00",
+ candidateRef: "v01",
+ },
+ {
+ t: "container",
+ sectionRef: "s01",
+ slots: [
+ { content: [{ t: "text", heading: "One" }] },
+ { content: [{ t: "text", heading: "Two" }] },
+ { content: [{ t: "text", heading: "Three" }] },
+ ],
+ },
+ ],
+ }
+
+ const normalized = normalizeExperienceDraft(draft, candidates)
+ const container = normalized.blocks[1] as {
+ t: "container"
+ content: Array<{ t: string; spans?: { md?: number } }>
+ }
+ const slotMarkers = container.content.filter(
+ (entry) => entry.t === "containerSlot",
+ )
+ expect(slotMarkers).toHaveLength(3)
+ slotMarkers.forEach((marker) => {
+ expect(marker.spans).toEqual({ md: 4 })
+ })
+ })
+
+ it("only fills omitted slot spans, leaving model-set spans untouched (2 slots)", () => {
+ const draft: DraftExperience = {
+ title: "Mixed slots",
+ metaDescription: "Mixed slots",
+ blocks: [
+ {
+ t: "videoHero",
+ sectionRef: "s00",
+ candidateRef: "v01",
+ },
+ {
+ t: "container",
+ sectionRef: "s01",
+ slots: [
+ {
+ spans: { md: 8 },
+ content: [{ t: "text", heading: "Wide" }],
+ },
+ {
+ content: [{ t: "text", heading: "Narrow" }],
+ },
+ ],
+ },
+ ],
+ }
+
+ const normalized = normalizeExperienceDraft(draft, candidates)
+ const container = normalized.blocks[1] as {
+ t: "container"
+ content: Array<{ t: string; spans?: { md?: number } }>
+ }
+ const slotMarkers = container.content.filter(
+ (entry) => entry.t === "containerSlot",
+ )
+ expect(slotMarkers).toHaveLength(2)
+ expect(slotMarkers[0]?.spans).toEqual({ md: 8 })
+ expect(slotMarkers[1]?.spans).toEqual({ md: 6 })
+ })
+ })
+})
diff --git a/apps/admin/src/services/experience-ai/experience-ai-normalize.ts b/apps/admin/src/services/experience-ai/experience-ai-normalize.ts
new file mode 100644
index 000000000..8ce0acab2
--- /dev/null
+++ b/apps/admin/src/services/experience-ai/experience-ai-normalize.ts
@@ -0,0 +1,548 @@
+import type {
+ Block,
+ ContainerContentBlock,
+ ContainerSlotBlock,
+ SectionContentBlock,
+} from "@/domain/blocks"
+import { BlocksSchema } from "@/domain/blocks"
+import type {
+ DraftAnyBlock,
+ DraftBlock,
+ DraftContainerBlock,
+ DraftContainerContentBlock,
+ DraftExperience,
+ DraftSectionBlock,
+ DraftSectionContentBlock,
+ VideoCandidate,
+} from "./experience-ai.schemas"
+
+const HERO_DEFAULTS = {
+ clipStartSeconds: 0,
+ clipEndSeconds: 8,
+} as const
+
+const SECTION_DEFAULTS = {
+ backgroundOpacity: 0.65,
+} as const
+
+const SLOT_SPAN_DEFAULTS: Record = {
+ 1: undefined,
+ 2: { md: 6 },
+ 3: { md: 4 },
+ 4: { md: 3 },
+}
+
+function findFirstCandidateRefInNestedBlocks(
+ blocks: readonly DraftAnyBlock[],
+): string | undefined {
+ for (const block of blocks) {
+ if (block.t === "videoHero" || block.t === "video") {
+ return block.candidateRef
+ }
+ if (block.t === "videoCarousel" || block.t === "mediaCollection") {
+ const firstItem = block.items[0]
+ if (firstItem?.candidateRef) return firstItem.candidateRef
+ }
+ if (block.t === "container") {
+ for (const slot of block.slots) {
+ const found = findFirstCandidateRefInNestedBlocks(slot.content)
+ if (found) return found
+ }
+ }
+ if (block.t === "section") {
+ const found = findFirstCandidateRefInNestedBlocks(block.content)
+ if (found) return found
+ }
+ }
+ return undefined
+}
+
+export type NormalizedExperienceDraft = {
+ title: string
+ metaDescription: string
+ blocks: Block[]
+}
+
+export class ExperienceAiNormalizationError extends Error {
+ constructor(
+ readonly code:
+ | "UNKNOWN_VIDEO_REF"
+ | "UNKNOWN_SECTION_REF"
+ | "DUPLICATE_SECTION_REF"
+ | "INVALID_BLOCKS",
+ message: string,
+ ) {
+ super(message)
+ this.name = "ExperienceAiNormalizationError"
+ }
+}
+
+type PathSegment = string | number
+type SectionKeyRegistry = {
+ aliases: Map
+ paths: Map
+ counts: Map
+}
+
+function getSectionRef(block: DraftAnyBlock) {
+ return "sectionRef" in block ? block.sectionRef : undefined
+}
+
+function compactRecord>(value: T): T {
+ return Object.fromEntries(
+ Object.entries(value).filter(([, entry]) => {
+ if (entry === undefined || entry === null) return false
+ if (typeof entry === "string") return entry.trim().length > 0
+ if (Array.isArray(entry)) return entry.length > 0
+ return true
+ }),
+ ) as T
+}
+
+function normalizeAlias(value: string): string {
+ return value.trim().toLowerCase()
+}
+
+function buildSectionKey(
+ sectionRef: string | undefined,
+ path: readonly PathSegment[],
+): string {
+ if (sectionRef) {
+ return `ai-${normalizeAlias(sectionRef)}`
+ }
+ return `ai-${path.join("-")}`
+}
+
+function pathId(path: readonly PathSegment[]) {
+ return path.join("/")
+}
+
+function registerSectionKeys(
+ blocks: readonly DraftAnyBlock[],
+ sectionKeys: SectionKeyRegistry,
+ path: readonly PathSegment[] = [],
+) {
+ blocks.forEach((block, index) => {
+ const blockPath = [...path, index]
+ const sectionRef = getSectionRef(block)
+ if (sectionRef) {
+ const alias = normalizeAlias(sectionRef)
+ const seenCount = sectionKeys.counts.get(alias) ?? 0
+ const key =
+ seenCount === 0
+ ? buildSectionKey(sectionRef, blockPath)
+ : `${buildSectionKey(sectionRef, blockPath)}-${blockPath.join("-")}`
+ sectionKeys.counts.set(alias, seenCount + 1)
+ sectionKeys.paths.set(pathId(blockPath), key)
+ if (seenCount === 0) {
+ sectionKeys.aliases.set(alias, key)
+ }
+ }
+
+ if (block.t === "section") {
+ registerSectionKeys(block.content, sectionKeys, [...blockPath, "content"])
+ } else if (block.t === "container") {
+ block.slots.forEach((slot, slotIndex) => {
+ registerSectionKeys(slot.content, sectionKeys, [
+ ...blockPath,
+ "slot",
+ slotIndex,
+ ])
+ })
+ }
+ })
+}
+
+function resolveSectionKey(
+ ref: string | undefined,
+ sectionKeys: SectionKeyRegistry,
+) {
+ if (!ref) return undefined
+ const sectionKey = sectionKeys.aliases.get(normalizeAlias(ref))
+ if (!sectionKey) {
+ throw new ExperienceAiNormalizationError(
+ "UNKNOWN_SECTION_REF",
+ `Unknown section ref "${ref}" in AI draft`,
+ )
+ }
+ return sectionKey
+}
+
+function resolveVideoCandidate(
+ ref: string,
+ candidates: Map,
+) {
+ const candidate = candidates.get(ref)
+ if (!candidate) {
+ throw new ExperienceAiNormalizationError(
+ "UNKNOWN_VIDEO_REF",
+ `Unknown video candidate "${ref}" in AI draft`,
+ )
+ }
+ return candidate
+}
+
+function toTopLevelSectionKey(
+ block: { sectionRef?: string | undefined },
+ sectionKeys: SectionKeyRegistry,
+ path: readonly PathSegment[],
+) {
+ return (
+ sectionKeys.paths.get(pathId(path)) ??
+ buildSectionKey(block.sectionRef, path)
+ )
+}
+
+function normalizeDraftBlock(
+ block: DraftAnyBlock,
+ sectionKeys: SectionKeyRegistry,
+ candidates: Map,
+ path: readonly PathSegment[],
+): Block | ContainerContentBlock | SectionContentBlock {
+ switch (block.t) {
+ case "adventCountdown":
+ return compactRecord({
+ t: "adventCountdown",
+ sectionKey: toTopLevelSectionKey(block, sectionKeys, path),
+ backgroundColor: block.backgroundColor,
+ title: block.title,
+ scripture: block.scripture,
+ scriptureReference: block.scriptureReference,
+ locale: block.locale,
+ })
+ case "bibleQuotesCarousel":
+ return compactRecord({
+ t: "bibleQuotesCarousel",
+ sectionKey: toTopLevelSectionKey(block, sectionKeys, path),
+ backgroundColor: block.backgroundColor,
+ heading: block.heading,
+ quotes: block.quotes.map((quote) =>
+ compactRecord({
+ reference: quote.reference,
+ text: quote.text,
+ attribution: quote.attribution,
+ ctaEnabled: quote.ctaEnabled,
+ ctaLabel: quote.ctaLabel,
+ ctaLink: quote.ctaLink,
+ backgroundColor: quote.backgroundColor,
+ }),
+ ),
+ })
+ case "card":
+ return compactRecord({
+ t: "card",
+ sectionKey: toTopLevelSectionKey(block, sectionKeys, path),
+ title: block.title,
+ description: block.description,
+ backgroundColor: block.backgroundColor,
+ link: block.link,
+ variant: block.variant ?? "default",
+ })
+ case "cta":
+ return compactRecord({
+ t: "cta",
+ sectionKey: toTopLevelSectionKey(block, sectionKeys, path),
+ backgroundColor: block.backgroundColor,
+ heading: block.heading,
+ body: block.body,
+ buttonLabel: block.buttonLabel,
+ buttonLink: block.buttonLink,
+ variant: block.variant ?? "primary",
+ })
+ case "easterDates":
+ return compactRecord({
+ t: "easterDates",
+ sectionKey: toTopLevelSectionKey(block, sectionKeys, path),
+ backgroundColor: block.backgroundColor,
+ easterDatesTitle: block.easterDatesTitle,
+ westernEasterLabel: block.westernEasterLabel,
+ orthodoxEasterLabel: block.orthodoxEasterLabel,
+ passoverLabel: block.passoverLabel,
+ westernEasterEnabled: block.westernEasterEnabled,
+ orthodoxEasterEnabled: block.orthodoxEasterEnabled,
+ passoverEnabled: block.passoverEnabled,
+ locale: block.locale,
+ })
+ case "infoBlocks":
+ return compactRecord({
+ t: "infoBlocks",
+ sectionKey: toTopLevelSectionKey(block, sectionKeys, path),
+ backgroundColor: block.backgroundColor,
+ widthPercent: block.widthPercent,
+ intro: block.intro,
+ heading: block.heading,
+ description: block.description,
+ blocks: block.blocks.map((item) => compactRecord(item)),
+ })
+ case "mediaCollection":
+ return compactRecord({
+ t: "mediaCollection",
+ sectionKey: toTopLevelSectionKey(block, sectionKeys, path),
+ backgroundColor: block.backgroundColor,
+ categoryLabel: block.categoryLabel,
+ variant: block.variant,
+ itemsSource: "manual" as const,
+ title: block.title,
+ subtitle: block.subtitle,
+ description: block.description,
+ ctaLink: block.ctaLink,
+ ctaLabel: block.ctaLabel,
+ showItemNumbers: block.showItemNumbers ?? false,
+ footerText: block.footerText,
+ items: block.items.map((item) => {
+ const candidate = resolveVideoCandidate(item.candidateRef, candidates)
+ return compactRecord({
+ videoId: candidate.videoId,
+ imageOverrideUrl: candidate.previewImageUrl ?? undefined,
+ titleOverride: item.titleOverride ?? candidate.title,
+ subtitleOverride:
+ item.subtitleOverride ?? candidate.description ?? undefined,
+ labelOverride: item.labelOverride,
+ collectionSize: item.collectionSize,
+ linkToSectionKey: resolveSectionKey(item.targetRef, sectionKeys),
+ })
+ }),
+ })
+ case "navigationCarousel":
+ return compactRecord({
+ t: "navigationCarousel",
+ sectionKey: toTopLevelSectionKey(block, sectionKeys, path),
+ backgroundColor: block.backgroundColor,
+ items: block.items.map((item) =>
+ compactRecord({
+ contentId: resolveSectionKey(item.targetRef, sectionKeys) as string,
+ title: item.title,
+ category: item.category,
+ backgroundColor: item.backgroundColor,
+ }),
+ ),
+ })
+ case "promoBanner":
+ return compactRecord({
+ t: "promoBanner",
+ sectionKey: toTopLevelSectionKey(block, sectionKeys, path),
+ backgroundColor: block.backgroundColor,
+ widthPercent: block.widthPercent,
+ intro: block.intro,
+ heading: block.heading,
+ description: block.description,
+ ctaEnabled: block.ctaEnabled,
+ ctaLabel: block.ctaLabel,
+ ctaLink: block.ctaLink,
+ })
+ case "quizButton":
+ return compactRecord({
+ t: "quizButton",
+ buttonText: block.buttonText,
+ iframeSrc: block.iframeSrc,
+ })
+ case "relatedQuestions":
+ return compactRecord({
+ t: "relatedQuestions",
+ sectionKey: toTopLevelSectionKey(block, sectionKeys, path),
+ backgroundColor: block.backgroundColor,
+ heading: block.heading,
+ questions: block.questions.map((question) => compactRecord(question)),
+ ctaEnabled: block.ctaEnabled,
+ ctaLabel: block.ctaLabel,
+ ctaLink: block.ctaLink,
+ })
+ case "text":
+ return compactRecord({
+ t: "text",
+ sectionKey: toTopLevelSectionKey(block, sectionKeys, path),
+ backgroundColor: block.backgroundColor,
+ heading: block.heading,
+ headingLevel: block.headingLevel,
+ subtitle: block.subtitle,
+ contentParagraphs: block.contentParagraphs,
+ variant: block.variant,
+ })
+ case "video": {
+ const candidate = resolveVideoCandidate(block.candidateRef, candidates)
+ return compactRecord({
+ t: "video",
+ sectionKey: toTopLevelSectionKey(block, sectionKeys, path),
+ useRouteVideo: false,
+ videoId: candidate.videoId,
+ streamingUrl: candidate.previewStreamUrl ?? undefined,
+ clipStartSeconds: block.clipStartSeconds,
+ clipEndSeconds: block.clipEndSeconds,
+ autoplay: block.autoplay,
+ muted: block.muted,
+ loop: block.loop,
+ showControls: block.showControls,
+ titleSource: block.title ? "manual" : "videoTitle",
+ subtitleSource: block.subtitle
+ ? "manual"
+ : candidate.description
+ ? "videoDescription"
+ : undefined,
+ title: block.title ?? candidate.title,
+ subtitle: block.subtitle ?? candidate.description ?? undefined,
+ })
+ }
+ case "videoCarousel":
+ return compactRecord({
+ t: "videoCarousel",
+ sectionKey: toTopLevelSectionKey(block, sectionKeys, path),
+ backgroundColor: block.backgroundColor,
+ itemsSource: "manual" as const,
+ title: block.title,
+ subtitle: block.subtitle,
+ description: block.description,
+ items: block.items.map((item) => {
+ const candidate = resolveVideoCandidate(item.candidateRef, candidates)
+ return compactRecord({
+ videoId: candidate.videoId,
+ imageOverrideUrl: candidate.previewImageUrl ?? undefined,
+ titleOverride: item.titleOverride ?? candidate.title,
+ subtitleOverride:
+ item.subtitleOverride ?? candidate.description ?? undefined,
+ backgroundColor: item.backgroundColor,
+ })
+ }),
+ })
+ case "videoHero": {
+ const candidate = resolveVideoCandidate(block.candidateRef, candidates)
+ const heroClipStartOmitted = block.clipStartSeconds === undefined
+ const heroClipEndOmitted = block.clipEndSeconds === undefined
+ const fillHeroClipWindow = heroClipStartOmitted && heroClipEndOmitted
+ return compactRecord({
+ t: "videoHero",
+ sectionKey: toTopLevelSectionKey(block, sectionKeys, path),
+ useRouteVideo: false,
+ videoId: candidate.videoId,
+ streamingUrl: candidate.previewStreamUrl ?? undefined,
+ ctaEnabled: block.ctaEnabled,
+ clipStartSeconds: fillHeroClipWindow
+ ? HERO_DEFAULTS.clipStartSeconds
+ : block.clipStartSeconds,
+ clipEndSeconds: fillHeroClipWindow
+ ? HERO_DEFAULTS.clipEndSeconds
+ : block.clipEndSeconds,
+ autoplay: block.autoplay,
+ muted: block.muted,
+ loop: block.loop,
+ showControls: block.showControls,
+ headingSource: block.heading ? "manual" : "videoTitle",
+ subheadingSource: block.subheading
+ ? "manual"
+ : candidate.description
+ ? "videoDescription"
+ : undefined,
+ heading: block.heading ?? candidate.title,
+ subheading: block.subheading ?? candidate.description ?? undefined,
+ ctaLink: block.ctaLink,
+ ctaLabel: block.ctaLabel,
+ })
+ }
+ case "container":
+ return compactRecord({
+ t: "container",
+ sectionKey: toTopLevelSectionKey(block, sectionKeys, path),
+ backgroundColor: block.backgroundColor,
+ content: block.slots.flatMap((slot, slotIndex) => {
+ const slotCount = block.slots.length
+ const balancedSpan = SLOT_SPAN_DEFAULTS[slotCount]
+ const filledSpans =
+ slot.spans === undefined && balancedSpan !== undefined
+ ? balancedSpan
+ : slot.spans
+ const marker: ContainerSlotBlock = compactRecord({
+ t: "containerSlot" as const,
+ gridSpan: slot.gridSpan ?? 6,
+ spans: filledSpans,
+ backgroundColor: slot.backgroundColor,
+ })
+ const nested = slot.content.map((nestedBlock, nestedIndex) =>
+ normalizeDraftBlock(nestedBlock, sectionKeys, candidates, [
+ ...path,
+ "slot",
+ slotIndex,
+ nestedIndex,
+ ]),
+ ) as ContainerContentBlock[]
+ return [marker, ...nested]
+ }),
+ })
+ case "section": {
+ let resolvedDynamicBackgroundImage: boolean
+ if (block.dynamicBackgroundImage === undefined) {
+ const firstCandidateRef = findFirstCandidateRefInNestedBlocks(
+ block.content,
+ )
+ const firstCandidate = firstCandidateRef
+ ? candidates.get(firstCandidateRef)
+ : undefined
+ resolvedDynamicBackgroundImage = Boolean(
+ firstCandidate?.previewImageUrl,
+ )
+ } else {
+ resolvedDynamicBackgroundImage = block.dynamicBackgroundImage
+ }
+ const resolvedBackgroundOpacity =
+ block.backgroundOpacity === undefined && resolvedDynamicBackgroundImage
+ ? SECTION_DEFAULTS.backgroundOpacity
+ : block.backgroundOpacity
+ return compactRecord({
+ t: "section",
+ sectionKey: toTopLevelSectionKey(block, sectionKeys, path),
+ backgroundColor: block.backgroundColor,
+ backgroundOpacity: resolvedBackgroundOpacity,
+ dynamicBackgroundImage: resolvedDynamicBackgroundImage,
+ staticOverlay: block.staticOverlay ?? false,
+ content: block.content.map((nestedBlock, nestedIndex) =>
+ normalizeDraftBlock(nestedBlock, sectionKeys, candidates, [
+ ...path,
+ "content",
+ nestedIndex,
+ ]),
+ ) as SectionContentBlock[],
+ })
+ }
+ }
+}
+
+export function normalizeExperienceDraft(
+ draft: DraftExperience,
+ videoCandidates: VideoCandidate[],
+): NormalizedExperienceDraft {
+ const candidateMap = new Map(
+ videoCandidates.map((candidate) => [candidate.ref, candidate]),
+ )
+ const sectionKeys: SectionKeyRegistry = {
+ aliases: new Map(),
+ paths: new Map(),
+ counts: new Map(),
+ }
+
+ registerSectionKeys(draft.blocks, sectionKeys)
+
+ const blocks = draft.blocks.map((block, index) =>
+ normalizeDraftBlock(block, sectionKeys, candidateMap, [index]),
+ ) as Block[]
+
+ const parsed = BlocksSchema.safeParse(blocks)
+ if (!parsed.success) {
+ throw new ExperienceAiNormalizationError(
+ "INVALID_BLOCKS",
+ "AI draft did not normalize into a valid admin BlocksSchema payload",
+ )
+ }
+
+ return {
+ title: draft.title.trim(),
+ metaDescription: draft.metaDescription.trim(),
+ blocks: parsed.data,
+ }
+}
+
+export type {
+ DraftAnyBlock,
+ DraftBlock,
+ DraftContainerBlock,
+ DraftContainerContentBlock,
+ DraftSectionBlock,
+ DraftSectionContentBlock,
+}
diff --git a/apps/admin/src/services/experience-ai/experience-ai-prompts.ts b/apps/admin/src/services/experience-ai/experience-ai-prompts.ts
new file mode 100644
index 000000000..8e4934334
--- /dev/null
+++ b/apps/admin/src/services/experience-ai/experience-ai-prompts.ts
@@ -0,0 +1,161 @@
+/**
+ * Pure string-builder module for the Experience AI drafting prompt.
+ * No IO, no Prisma. Imported by `experience-ai.service.ts` to assemble
+ * the system content for both the OpenRouter / OpenAI chat path and
+ * the Codex CLI path.
+ *
+ * The structural directives must stay identical across providers — the
+ * service composes them by concatenation, so mutating any export here
+ * affects every provider in lockstep.
+ */
+
+/**
+ * High-level editorial brief: tone, voice, and length expectations.
+ * Locale-agnostic; locale-specific copy guidance is layered on top via
+ * {@link localeCopyGuidance}.
+ */
+export const SYSTEM_BRIEF = `You are an editorial designer drafting a first-pass admin experience for the JesusFilm Forge platform.
+
+Editorial voice: warm, plain-spoken, invitational. Write for a curious general reader, not a theology student. Keep paragraphs short (2-4 sentences). Avoid stock phrases and avoid stacking adjectives.
+
+Composition discipline: produce a layered page that feels close to a hand-crafted experience — not a single-block skeleton. Lean on cross-block carousels and sections to give the page rhythm.`
+
+/**
+ * Structural template the model must follow. Mirrors the editorial
+ * shape used by the curated Easter (`feat-029`) and Christmas
+ * (`feat-034`) experiences without binding to any specific theme.
+ */
+export const STRUCTURAL_TEMPLATE = `Structural template — every draft MUST contain at minimum:
+
+1. Open with a videoHero block referencing one of the provided candidates (the strongest by relevance).
+2. Follow with 2-4 section blocks. Each section should wrap one of:
+ - navigationCarousel (links to other sections inside the draft)
+ - mediaCollection (curated set of candidate videos)
+ - videoCarousel (horizontal scroll of candidate videos)
+ - container (mixed slot content: text + video + cta)
+3. Close with a quizButton or cta block when the prompt invites a response or reflection.
+
+Cross-block linking: at least one navigationCarousel item MUST reference a sectionRef emitted by another block in the same draft (use the s01, s02, ... refs you assign).
+
+Block diversity: a draft with only one block kind is unacceptable. Aim for at least three distinct block kinds across the page.`
+
+/**
+ * Hand-written truncated draft AST mirroring the Christmas seed shape:
+ * videoHero + section[navigationCarousel] + section[mediaCollection].
+ *
+ * Shape only, NOT theme. Copy is intentionally neutral so the model
+ * borrows structure (block nesting, sectionRef linking, candidateRef
+ * usage) without inheriting Christmas tone. Candidate refs use the
+ * generic v01 / v02 aliases the catalog assigns.
+ *
+ * Frozen so accidental mutation surfaces immediately.
+ */
+export const FEW_SHOT_EXAMPLE = Object.freeze({
+ title: "Example Title",
+ metaDescription: "Example meta description for the experience.",
+ blocks: [
+ {
+ t: "videoHero",
+ sectionRef: "s01",
+ candidateRef: "v01",
+ heading: "Headline",
+ subheading: "One-line subheading.",
+ ctaEnabled: true,
+ ctaLabel: "Watch now",
+ },
+ {
+ t: "section",
+ sectionRef: "s02",
+ content: [
+ {
+ t: "navigationCarousel",
+ items: [
+ { targetRef: "s03", title: "Jump to topic" },
+ { targetRef: "s01", title: "Back to top" },
+ ],
+ },
+ ],
+ },
+ {
+ t: "section",
+ sectionRef: "s03",
+ content: [
+ {
+ t: "mediaCollection",
+ variant: "collection",
+ title: "More to watch",
+ items: [{ candidateRef: "v01" }, { candidateRef: "v02" }],
+ },
+ ],
+ },
+ ],
+} as const)
+
+const FEW_SHOT_SERIALIZED = JSON.stringify(FEW_SHOT_EXAMPLE)
+
+/**
+ * Few-shot block, prefixed with the load-bearing "shape only" caveat.
+ * Kept under 1 KB serialized to bound per-call token cost.
+ */
+export const FEW_SHOT_SECTION = `Few-shot example (shape only — borrow the structure, NOT the copy or theme):
+${FEW_SHOT_SERIALIZED}`
+
+const LOCALE_LANGUAGE_NAMES: Readonly> = {
+ en: "English",
+ es: "Spanish (español)",
+ fr: "French (français)",
+ pt: "Portuguese (português)",
+ de: "German (Deutsch)",
+ it: "Italian (italiano)",
+ zh: "Chinese (中文)",
+ ja: "Japanese (日本語)",
+ ko: "Korean (한국어)",
+ ar: "Arabic (العربية)",
+ hi: "Hindi (हिन्दी)",
+ ru: "Russian (русский)",
+ th: "Thai (ภาษาไทย)",
+}
+
+function languageNameFor(locale: string): string {
+ const base = locale.toLowerCase().split(/[-_]/)[0] ?? locale
+ return LOCALE_LANGUAGE_NAMES[base] ?? `the ${locale} locale`
+}
+
+/**
+ * Locale-aware copy guidance. Always mentions the language by name so
+ * the directive is unambiguous to the model.
+ */
+export function localeCopyGuidance(locale: string): string {
+ const name = languageNameFor(locale)
+ if (locale === "en" || locale.toLowerCase().startsWith("en")) {
+ return `Write ALL generated copy in English. Use natural, conversational English. Do not slip into other languages, even for proper nouns where translation is conventional.`
+ }
+ return `Write ALL generated copy in ${name} (locale code: ${locale}). Match the register and idioms of a fluent native speaker. Do NOT default to English phrasing — translate fully, including headings, subtitles, CTAs, and meta description.`
+}
+
+const INVARIANTS = `Invariants the draft MUST satisfy (the response will be rejected otherwise):
+- Every candidateRef must be one of the v01, v02, ... refs in the input videoCandidates list. Never invent a ref.
+- Every targetRef on a navigationCarousel item must match a sectionRef that this draft itself emits.
+- Section refs use the form sNN (s01, s02, ...). Video refs use the form vNN.
+- Output strict JSON only. No markdown fences, no commentary, no prose outside the JSON object.
+- The blocks array must contain at least 2 entries.`
+
+/**
+ * Build the full system prompt for a generation call. Provider-agnostic;
+ * the OpenRouter / OpenAI chat path passes this as the system message
+ * content, the Codex CLI path concatenates it with the JSON schema and
+ * input payload.
+ */
+export function buildSystemPrompt(locale: string): string {
+ return [
+ SYSTEM_BRIEF,
+ "",
+ STRUCTURAL_TEMPLATE,
+ "",
+ localeCopyGuidance(locale),
+ "",
+ INVARIANTS,
+ "",
+ FEW_SHOT_SECTION,
+ ].join("\n")
+}
diff --git a/apps/admin/src/services/experience-ai/experience-ai.schemas.ts b/apps/admin/src/services/experience-ai/experience-ai.schemas.ts
new file mode 100644
index 000000000..f9d09677c
--- /dev/null
+++ b/apps/admin/src/services/experience-ai/experience-ai.schemas.ts
@@ -0,0 +1,419 @@
+import { z } from "zod"
+
+const DraftSectionRefSchema = z
+ .string()
+ .trim()
+ .min(1)
+ .max(80)
+ .regex(/^s\d{2}$/)
+
+const DraftVideoRefSchema = z
+ .string()
+ .trim()
+ .min(1)
+ .max(20)
+ .regex(/^v\d{2}$/)
+
+const DraftHeadingLevelSchema = z.enum(["h1", "h2", "h3", "h4", "h5", "h6"])
+
+export const DraftBibleQuoteItemSchema = z
+ .object({
+ reference: z.string().min(1),
+ text: z.string().min(1),
+ attribution: z.string().optional(),
+ ctaEnabled: z.boolean().optional(),
+ ctaLabel: z.string().optional(),
+ ctaLink: z.string().optional(),
+ backgroundColor: z.string().optional(),
+ })
+ .strict()
+
+export const DraftInfoBlockItemSchema = z
+ .object({
+ icon: z.string().min(1),
+ title: z.string().min(1),
+ description: z.string().min(1),
+ })
+ .strict()
+
+export const DraftMediaCollectionItemSchema = z
+ .object({
+ candidateRef: DraftVideoRefSchema,
+ titleOverride: z.string().optional(),
+ subtitleOverride: z.string().optional(),
+ labelOverride: z.string().optional(),
+ collectionSize: z.string().optional(),
+ targetRef: DraftSectionRefSchema.optional(),
+ })
+ .strict()
+
+export const DraftNavigationCarouselItemSchema = z
+ .object({
+ targetRef: DraftSectionRefSchema,
+ title: z.string().min(1),
+ category: z.string().optional(),
+ backgroundColor: z.string().optional(),
+ })
+ .strict()
+
+export const DraftRelatedQuestionItemSchema = z
+ .object({
+ question: z.string().min(1),
+ answer: z.string().min(1),
+ })
+ .strict()
+
+export const DraftVideoCarouselItemSchema = z
+ .object({
+ candidateRef: DraftVideoRefSchema,
+ titleOverride: z.string().optional(),
+ subtitleOverride: z.string().optional(),
+ backgroundColor: z.string().optional(),
+ })
+ .strict()
+
+export const DraftAdventCountdownBlockSchema = z
+ .object({
+ t: z.literal("adventCountdown"),
+ sectionRef: DraftSectionRefSchema.optional(),
+ backgroundColor: z.string().optional(),
+ title: z.string().min(1),
+ scripture: z.string().optional(),
+ scriptureReference: z.string().optional(),
+ locale: z.string().optional(),
+ })
+ .strict()
+
+export const DraftBibleQuotesCarouselBlockSchema = z
+ .object({
+ t: z.literal("bibleQuotesCarousel"),
+ sectionRef: DraftSectionRefSchema.optional(),
+ backgroundColor: z.string().optional(),
+ heading: z.string().optional(),
+ quotes: z.array(DraftBibleQuoteItemSchema).default([]),
+ })
+ .strict()
+
+export const DraftCardBlockSchema = z
+ .object({
+ t: z.literal("card"),
+ sectionRef: DraftSectionRefSchema.optional(),
+ title: z.string().min(1),
+ description: z.string().min(1),
+ backgroundColor: z.string().optional(),
+ link: z.string().optional(),
+ variant: z.enum(["default", "featured"]).optional(),
+ })
+ .strict()
+
+export const DraftCtaBlockSchema = z
+ .object({
+ t: z.literal("cta"),
+ sectionRef: DraftSectionRefSchema.optional(),
+ backgroundColor: z.string().optional(),
+ heading: z.string().optional(),
+ body: z.string().optional(),
+ buttonLabel: z.string().min(1),
+ buttonLink: z.string().optional(),
+ variant: z.enum(["primary", "secondary"]).optional(),
+ })
+ .strict()
+
+export const DraftEasterDatesBlockSchema = z
+ .object({
+ t: z.literal("easterDates"),
+ sectionRef: DraftSectionRefSchema.optional(),
+ backgroundColor: z.string().optional(),
+ easterDatesTitle: z.string().min(1),
+ westernEasterLabel: z.string().min(1),
+ orthodoxEasterLabel: z.string().min(1),
+ passoverLabel: z.string().min(1),
+ westernEasterEnabled: z.boolean().optional(),
+ orthodoxEasterEnabled: z.boolean().optional(),
+ passoverEnabled: z.boolean().optional(),
+ locale: z.string().optional(),
+ })
+ .strict()
+
+export const DraftInfoBlocksBlockSchema = z
+ .object({
+ t: z.literal("infoBlocks"),
+ sectionRef: DraftSectionRefSchema.optional(),
+ backgroundColor: z.string().optional(),
+ widthPercent: z.number().int().min(1).max(100).optional(),
+ intro: z.string().optional(),
+ heading: z.string().optional(),
+ description: z.string().optional(),
+ blocks: z.array(DraftInfoBlockItemSchema).default([]),
+ })
+ .strict()
+
+export const DraftMediaCollectionBlockSchema = z
+ .object({
+ t: z.literal("mediaCollection"),
+ sectionRef: DraftSectionRefSchema.optional(),
+ backgroundColor: z.string().optional(),
+ categoryLabel: z.string().optional(),
+ variant: z
+ .enum(["carousel", "grid", "collection", "hero", "player"])
+ .default("collection"),
+ title: z.string().optional(),
+ subtitle: z.string().optional(),
+ description: z.string().optional(),
+ ctaLink: z.string().optional(),
+ ctaLabel: z.string().optional(),
+ showItemNumbers: z.boolean().optional(),
+ footerText: z.string().optional(),
+ items: z.array(DraftMediaCollectionItemSchema).default([]),
+ })
+ .strict()
+
+export const DraftNavigationCarouselBlockSchema = z
+ .object({
+ t: z.literal("navigationCarousel"),
+ sectionRef: DraftSectionRefSchema.optional(),
+ backgroundColor: z.string().optional(),
+ items: z.array(DraftNavigationCarouselItemSchema).default([]),
+ })
+ .strict()
+
+export const DraftPromoBannerBlockSchema = z
+ .object({
+ t: z.literal("promoBanner"),
+ sectionRef: DraftSectionRefSchema.optional(),
+ backgroundColor: z.string().optional(),
+ widthPercent: z.number().int().min(1).max(100).optional(),
+ intro: z.string().optional(),
+ heading: z.string().min(1),
+ description: z.string().min(1),
+ ctaEnabled: z.boolean().optional(),
+ ctaLabel: z.string().optional(),
+ ctaLink: z.string().min(1),
+ })
+ .strict()
+
+export const DraftQuizButtonBlockSchema = z
+ .object({
+ t: z.literal("quizButton"),
+ buttonText: z.string().min(1),
+ iframeSrc: z.string().regex(/^https:\/\/[\w.-]+\.nextstep\.is\/.*$/),
+ })
+ .strict()
+
+export const DraftRelatedQuestionsBlockSchema = z
+ .object({
+ t: z.literal("relatedQuestions"),
+ sectionRef: DraftSectionRefSchema.optional(),
+ backgroundColor: z.string().optional(),
+ heading: z.string().optional(),
+ questions: z.array(DraftRelatedQuestionItemSchema).default([]),
+ ctaEnabled: z.boolean().optional(),
+ ctaLabel: z.string().optional(),
+ ctaLink: z.string().optional(),
+ })
+ .strict()
+
+export const DraftTextBlockSchema = z
+ .object({
+ t: z.literal("text"),
+ sectionRef: DraftSectionRefSchema.optional(),
+ backgroundColor: z.string().optional(),
+ heading: z.string().optional(),
+ headingLevel: DraftHeadingLevelSchema.optional(),
+ subtitle: z.string().optional(),
+ contentParagraphs: z.array(z.string()).optional(),
+ variant: z.enum(["default", "lead", "small"]).optional(),
+ })
+ .strict()
+
+export const DraftVideoBlockSchema = z
+ .object({
+ t: z.literal("video"),
+ sectionRef: DraftSectionRefSchema.optional(),
+ candidateRef: DraftVideoRefSchema,
+ clipStartSeconds: z.number().min(0).optional(),
+ clipEndSeconds: z.number().min(0).optional(),
+ autoplay: z.boolean().optional(),
+ muted: z.boolean().optional(),
+ loop: z.boolean().optional(),
+ showControls: z.boolean().optional(),
+ titleSource: z.enum(["manual", "videoTitle"]).optional(),
+ subtitleSource: z.enum(["manual", "videoDescription"]).optional(),
+ title: z.string().optional(),
+ subtitle: z.string().optional(),
+ })
+ .strict()
+
+export const DraftVideoCarouselBlockSchema = z
+ .object({
+ t: z.literal("videoCarousel"),
+ sectionRef: DraftSectionRefSchema.optional(),
+ backgroundColor: z.string().optional(),
+ title: z.string().optional(),
+ subtitle: z.string().optional(),
+ description: z.string().optional(),
+ items: z.array(DraftVideoCarouselItemSchema).default([]),
+ })
+ .strict()
+
+export const DraftVideoHeroBlockSchema = z
+ .object({
+ t: z.literal("videoHero"),
+ sectionRef: DraftSectionRefSchema.optional(),
+ candidateRef: DraftVideoRefSchema,
+ ctaEnabled: z.boolean().optional(),
+ clipStartSeconds: z.number().min(0).optional(),
+ clipEndSeconds: z.number().min(0).optional(),
+ autoplay: z.boolean().optional(),
+ muted: z.boolean().optional(),
+ loop: z.boolean().optional(),
+ showControls: z.boolean().optional(),
+ headingSource: z.enum(["manual", "videoTitle"]).optional(),
+ subheadingSource: z.enum(["manual", "videoDescription"]).optional(),
+ heading: z.string().optional(),
+ subheading: z.string().optional(),
+ ctaLink: z.string().optional(),
+ ctaLabel: z.string().optional(),
+ })
+ .strict()
+
+export const DraftContainerSlotSpansSchema = z
+ .object({
+ xs: z.number().int().min(1).max(12).optional(),
+ sm: z.number().int().min(1).max(12).optional(),
+ md: z.number().int().min(1).max(12).optional(),
+ lg: z.number().int().min(1).max(12).optional(),
+ xl: z.number().int().min(1).max(12).optional(),
+ })
+ .strict()
+
+export const DraftContainerSlotSchema = z.lazy(() =>
+ z
+ .object({
+ gridSpan: z.number().int().min(1).max(12).optional(),
+ spans: DraftContainerSlotSpansSchema.optional(),
+ backgroundColor: z.string().optional(),
+ content: z.array(DraftContainerContentBlockSchema).default([]),
+ })
+ .strict(),
+)
+
+export const DraftContainerBlockSchema = z
+ .object({
+ t: z.literal("container"),
+ sectionRef: DraftSectionRefSchema.optional(),
+ backgroundColor: z.string().optional(),
+ slots: z.array(DraftContainerSlotSchema).default([]),
+ })
+ .strict()
+
+export const DraftSectionBlockSchema = z
+ .object({
+ t: z.literal("section"),
+ sectionRef: DraftSectionRefSchema.optional(),
+ backgroundColor: z.string().optional(),
+ backgroundOpacity: z.number().min(0).max(1).optional(),
+ dynamicBackgroundImage: z.boolean().optional(),
+ staticOverlay: z.boolean().optional(),
+ content: z.array(z.lazy(() => DraftSectionContentBlockSchema)).default([]),
+ })
+ .strict()
+
+export const DraftContainerContentBlockSchema = z.discriminatedUnion("t", [
+ DraftMediaCollectionBlockSchema,
+ DraftTextBlockSchema,
+ DraftRelatedQuestionsBlockSchema,
+ DraftCtaBlockSchema,
+ DraftBibleQuotesCarouselBlockSchema,
+ DraftCardBlockSchema,
+ DraftEasterDatesBlockSchema,
+ DraftAdventCountdownBlockSchema,
+ DraftVideoBlockSchema,
+])
+
+export const DraftSectionContentBlockSchema = z.discriminatedUnion("t", [
+ DraftMediaCollectionBlockSchema,
+ DraftTextBlockSchema,
+ DraftPromoBannerBlockSchema,
+ DraftInfoBlocksBlockSchema,
+ DraftCtaBlockSchema,
+ DraftContainerBlockSchema,
+ DraftRelatedQuestionsBlockSchema,
+ DraftBibleQuotesCarouselBlockSchema,
+ DraftCardBlockSchema,
+ DraftVideoBlockSchema,
+ DraftQuizButtonBlockSchema,
+ DraftVideoCarouselBlockSchema,
+ DraftNavigationCarouselBlockSchema,
+])
+
+export const DraftBlockSchema = z.discriminatedUnion("t", [
+ DraftMediaCollectionBlockSchema,
+ DraftPromoBannerBlockSchema,
+ DraftInfoBlocksBlockSchema,
+ DraftCtaBlockSchema,
+ DraftVideoHeroBlockSchema,
+ DraftContainerBlockSchema,
+ DraftTextBlockSchema,
+ DraftSectionBlockSchema,
+ DraftRelatedQuestionsBlockSchema,
+ DraftBibleQuotesCarouselBlockSchema,
+ DraftCardBlockSchema,
+ DraftEasterDatesBlockSchema,
+ DraftAdventCountdownBlockSchema,
+ DraftVideoBlockSchema,
+ DraftVideoCarouselBlockSchema,
+ DraftNavigationCarouselBlockSchema,
+])
+
+export const DraftExperienceSchema = z
+ .object({
+ title: z.string().min(1),
+ metaDescription: z.string().min(1),
+ blocks: z.array(z.lazy(() => DraftBlockSchema)).min(2),
+ })
+ .strict()
+
+export type DraftExperience = z.infer
+export type DraftBlock = z.infer
+export type DraftTopLevelBlock = DraftBlock
+export type DraftSectionBlock = z.infer
+export type DraftContainerBlock = z.infer
+export type DraftSectionContentBlock = z.infer<
+ typeof DraftSectionContentBlockSchema
+>
+export type DraftContainerContentBlock = z.infer<
+ typeof DraftContainerContentBlockSchema
+>
+export type DraftAnyBlock =
+ | DraftBlock
+ | DraftSectionContentBlock
+ | DraftContainerContentBlock
+
+export type VideoCandidate = {
+ ref: z.infer
+ videoId: string
+ slug: string
+ title: string
+ description: string | null
+ previewImageUrl: string | null
+ previewStreamUrl: string | null
+ label: string | null
+}
+
+export function buildDraftExperienceJsonSchema() {
+ if (typeof z.toJSONSchema === "function") {
+ return z.toJSONSchema(DraftExperienceSchema)
+ }
+
+ return {
+ type: "object",
+ additionalProperties: false,
+ required: ["title", "metaDescription", "blocks"],
+ properties: {
+ title: { type: "string", minLength: 1 },
+ metaDescription: { type: "string", minLength: 1 },
+ blocks: { type: "array", minItems: 2 },
+ },
+ }
+}
diff --git a/apps/admin/src/services/experience-ai/experience-ai.service.test.ts b/apps/admin/src/services/experience-ai/experience-ai.service.test.ts
new file mode 100644
index 000000000..d45fcb7dd
--- /dev/null
+++ b/apps/admin/src/services/experience-ai/experience-ai.service.test.ts
@@ -0,0 +1,959 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+import type { PrismaClient } from "@prisma/client"
+import type { Principal } from "@/auth/principal"
+import { EventEmitter } from "node:events"
+
+const { envState } = vi.hoisted(() => ({
+ envState: {
+ OPENROUTER_API_KEY: undefined as string | undefined,
+ OPENAI_API_KEY: undefined as string | undefined,
+ OPENAI_BASE_URL: undefined as string | undefined,
+ EXPERIENCE_AI_ALLOW_CODEX_FALLBACK: false as boolean,
+ },
+}))
+
+const { spawnMock } = vi.hoisted(() => ({
+ spawnMock: vi.fn(),
+}))
+
+const { generateExperienceEmbeddingMock } = vi.hoisted(() => ({
+ generateExperienceEmbeddingMock: vi.fn(),
+}))
+
+const { generateOllamaEmbeddingMock } = vi.hoisted(() => ({
+ generateOllamaEmbeddingMock: vi.fn(),
+}))
+
+vi.mock("@/config/env", () => ({
+ env: envState,
+}))
+
+vi.mock("node:child_process", () => ({
+ spawn: spawnMock,
+}))
+
+vi.mock("@/services/embeddings.service", () => ({
+ generateExperienceEmbedding: generateExperienceEmbeddingMock,
+}))
+
+vi.mock("@/services/ollama-embedding.service", () => ({
+ generateOllamaEmbedding: generateOllamaEmbeddingMock,
+}))
+
+import {
+ buildExperienceAiMessages,
+ generateExperienceAiDraft,
+ loadExperienceAiVideoCandidates,
+} from "./experience-ai.service"
+import {
+ buildDraftExperienceJsonSchema,
+ DraftExperienceSchema,
+} from "./experience-ai.schemas"
+
+const EDITOR: Principal = { id: "editor-1", role: "EDITOR" }
+
+type MockPrisma = PrismaClient & {
+ experienceLocale: {
+ findUnique: ReturnType
+ }
+ video: {
+ findMany: ReturnType
+ }
+ videoLocale: {
+ findMany: ReturnType
+ }
+ videoDub: {
+ findMany: ReturnType
+ }
+ videoImage: {
+ findMany: ReturnType
+ }
+ $transaction: ReturnType
+}
+
+function makePrisma(): MockPrisma {
+ const experienceLocale = {
+ findUnique: vi.fn(),
+ }
+ const video = {
+ findMany: vi.fn(),
+ }
+ const videoLocale = {
+ findMany: vi.fn(),
+ }
+ const videoDub = {
+ findMany: vi.fn(),
+ }
+ const videoImage = {
+ findMany: vi.fn(),
+ }
+ const tx = {
+ $executeRawUnsafe: vi.fn(),
+ $queryRaw: vi.fn().mockResolvedValue([]),
+ }
+ const $transaction = vi.fn((callback) => callback(tx))
+
+ return {
+ experienceLocale,
+ video,
+ videoLocale,
+ videoDub,
+ videoImage,
+ $transaction,
+ } as unknown as MockPrisma
+}
+
+function seedCatalog(prisma: MockPrisma) {
+ prisma.video.findMany.mockResolvedValue([
+ {
+ id: "video-1",
+ slug: "hope-story",
+ label: "episode",
+ updatedAt: new Date("2026-04-22T10:00:00Z"),
+ },
+ {
+ id: "video-2",
+ slug: "prayer-story",
+ label: "segment",
+ updatedAt: new Date("2026-04-21T10:00:00Z"),
+ },
+ {
+ id: "video-3",
+ slug: "fallback-story",
+ label: null,
+ updatedAt: new Date("2026-04-20T10:00:00Z"),
+ },
+ ])
+ prisma.videoLocale.findMany.mockResolvedValue([
+ {
+ videoId: "video-1",
+ locale: "en",
+ title: "Hope Story",
+ description: "A hopeful story",
+ status: "PUBLISHED",
+ updatedAt: new Date("2026-04-22T10:00:00Z"),
+ },
+ {
+ videoId: "video-2",
+ locale: "en",
+ title: "Prayer Story",
+ description: "A prayer story",
+ status: "PUBLISHED",
+ updatedAt: new Date("2026-04-21T10:00:00Z"),
+ },
+ {
+ videoId: "video-3",
+ locale: "en",
+ title: "Fallback Story",
+ description: null,
+ status: "PUBLISHED",
+ updatedAt: new Date("2026-04-20T10:00:00Z"),
+ },
+ ])
+ prisma.videoDub.findMany.mockResolvedValue([
+ {
+ videoId: "video-1",
+ hls: "https://example.com/hope.m3u8",
+ dash: null,
+ share: null,
+ language: { bcp47: "en", iso3: "eng", slug: "english" },
+ updatedAt: new Date("2026-04-22T10:00:00Z"),
+ },
+ ])
+ prisma.videoImage.findMany.mockResolvedValue([
+ {
+ videoId: "video-1",
+ url: "https://example.com/hope.jpg",
+ createdAt: new Date("2026-04-22T10:00:00Z"),
+ },
+ {
+ videoId: "video-2",
+ url: null,
+ createdAt: new Date("2026-04-21T10:00:00Z"),
+ },
+ ])
+}
+
+function seedEmptyLocale(prisma: MockPrisma) {
+ prisma.experienceLocale.findUnique.mockResolvedValue({
+ id: "locale-1",
+ blocks: [],
+ experience: {
+ ownerId: EDITOR.id,
+ archivedAt: null,
+ },
+ })
+}
+
+describe("loadExperienceAiVideoCandidates", () => {
+ beforeEach(() => {
+ generateExperienceEmbeddingMock.mockReset()
+ generateExperienceEmbeddingMock.mockRejectedValue(
+ new Error("not configured"),
+ )
+ generateOllamaEmbeddingMock.mockReset()
+ generateOllamaEmbeddingMock.mockRejectedValue(new Error("not running"))
+ })
+
+ it("returns bounded candidates with stable aliases in ranked order", async () => {
+ const prisma = makePrisma()
+ seedCatalog(prisma)
+
+ const candidates = await loadExperienceAiVideoCandidates(prisma, {
+ locale: "en",
+ prompt: "hope and prayer",
+ limit: 2,
+ })
+
+ expect(candidates).toHaveLength(2)
+ expect(candidates[0]).toMatchObject({
+ ref: "v01",
+ videoId: "video-1",
+ title: "Hope Story",
+ previewImageUrl: "https://example.com/hope.jpg",
+ previewStreamUrl: "https://example.com/hope.m3u8",
+ })
+ expect(candidates[1]).toMatchObject({
+ ref: "v02",
+ videoId: "video-2",
+ })
+ })
+
+ it("does not fall back to another language for candidate copy", async () => {
+ const prisma = makePrisma()
+ seedCatalog(prisma)
+ prisma.videoLocale.findMany.mockResolvedValue([
+ {
+ videoId: "video-1",
+ locale: "es",
+ title: "Historia de esperanza",
+ description: "Una historia esperanzadora",
+ status: "PUBLISHED",
+ updatedAt: new Date("2026-04-22T10:00:00Z"),
+ },
+ {
+ videoId: "video-2",
+ locale: "en",
+ title: "Prayer Story",
+ description: "A prayer story",
+ status: "PUBLISHED",
+ updatedAt: new Date("2026-04-21T10:00:00Z"),
+ },
+ ])
+
+ const candidates = await loadExperienceAiVideoCandidates(prisma, {
+ locale: "en",
+ prompt: "hope and prayer",
+ limit: 3,
+ })
+
+ expect(candidates.map((candidate) => candidate.videoId)).toEqual([
+ "video-2",
+ ])
+ expect(candidates[0]).toMatchObject({
+ title: "Prayer Story",
+ description: "A prayer story",
+ })
+ })
+
+ it("uses a matching-language dub for preview streams", async () => {
+ const prisma = makePrisma()
+ seedCatalog(prisma)
+ prisma.videoDub.findMany.mockResolvedValue([
+ {
+ videoId: "video-1",
+ hls: "https://example.com/spanish.m3u8",
+ dash: null,
+ share: null,
+ language: { bcp47: "es", iso3: "spa", slug: "spanish" },
+ updatedAt: new Date("2026-04-23T10:00:00Z"),
+ },
+ {
+ videoId: "video-1",
+ hls: "https://example.com/english.m3u8",
+ dash: null,
+ share: null,
+ language: { bcp47: "en", iso3: "eng", slug: "english" },
+ updatedAt: new Date("2026-04-22T10:00:00Z"),
+ },
+ ])
+
+ const candidates = await loadExperienceAiVideoCandidates(prisma, {
+ locale: "en",
+ prompt: "hope",
+ limit: 1,
+ })
+
+ expect(candidates[0]?.previewStreamUrl).toBe(
+ "https://example.com/english.m3u8",
+ )
+ })
+
+ it("uses scene/transcript vector hits before token ranking", async () => {
+ const prisma = makePrisma()
+ seedCatalog(prisma)
+ generateExperienceEmbeddingMock.mockResolvedValue({
+ model: "text-embedding-3-small",
+ dimensions: 1536,
+ embedding: Array.from({ length: 1536 }, () => 0.01),
+ })
+ prisma.$transaction.mockImplementationOnce(async (callback) =>
+ callback({
+ $executeRawUnsafe: vi.fn(),
+ $queryRaw: vi.fn().mockResolvedValue([
+ { videoId: "video-2", distance: 0.1 },
+ { videoId: "video-1", distance: 0.2 },
+ ]),
+ }),
+ )
+
+ const candidates = await loadExperienceAiVideoCandidates(prisma, {
+ locale: "en",
+ prompt: "hope and prayer",
+ limit: 2,
+ })
+
+ expect(generateExperienceEmbeddingMock).toHaveBeenCalledWith(
+ "hope and prayer",
+ )
+ expect(prisma.video.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: {
+ id: { in: ["video-2", "video-1"] },
+ deletedAt: null,
+ },
+ }),
+ )
+ expect(candidates.map((candidate) => candidate.videoId)).toEqual([
+ "video-2",
+ "video-1",
+ ])
+ })
+
+ it("uses the local Ollama vector index when primary embeddings are unavailable", async () => {
+ const prisma = makePrisma()
+ seedCatalog(prisma)
+ generateOllamaEmbeddingMock.mockResolvedValue(
+ Array.from({ length: 768 }, () => 0.01),
+ )
+ prisma.$transaction.mockImplementationOnce(async (callback) =>
+ callback({
+ $executeRawUnsafe: vi.fn(),
+ $queryRaw: vi.fn().mockResolvedValue([
+ { videoId: "video-2", distance: 0.1 },
+ { videoId: "video-1", distance: 0.2 },
+ ]),
+ }),
+ )
+
+ const candidates = await loadExperienceAiVideoCandidates(prisma, {
+ locale: "en",
+ prompt: "Jesus",
+ limit: 2,
+ })
+
+ expect(generateOllamaEmbeddingMock).toHaveBeenCalledWith("Jesus")
+ expect(candidates.map((candidate) => candidate.videoId)).toEqual([
+ "video-2",
+ "video-1",
+ ])
+ })
+})
+
+describe("generateExperienceAiDraft", () => {
+ beforeEach(() => {
+ envState.OPENROUTER_API_KEY = undefined
+ envState.OPENAI_API_KEY = undefined
+ envState.OPENAI_BASE_URL = undefined
+ envState.EXPERIENCE_AI_ALLOW_CODEX_FALLBACK = false
+ generateExperienceEmbeddingMock.mockReset()
+ generateExperienceEmbeddingMock.mockRejectedValue(
+ new Error("not configured"),
+ )
+ generateOllamaEmbeddingMock.mockReset()
+ generateOllamaEmbeddingMock.mockRejectedValue(new Error("not running"))
+ vi.unstubAllGlobals()
+ spawnMock.mockReset()
+ })
+
+ it("throws NOT_CONFIGURED when no provider env is present and the codex gate is off", async () => {
+ const prisma = makePrisma()
+ seedCatalog(prisma)
+ seedEmptyLocale(prisma)
+ // Codex gate is off by default — pickProvider returns null without
+ // ever spawning the CLI.
+
+ await expect(
+ generateExperienceAiDraft(prisma, {
+ experienceLocaleId: "locale-1",
+ locale: "en",
+ prompt: "A story of hope",
+ user: EDITOR,
+ }),
+ ).rejects.toMatchObject({
+ code: "NOT_CONFIGURED",
+ })
+ expect(spawnMock).not.toHaveBeenCalled()
+ })
+
+ it("throws NOT_CONFIGURED with codex gate on when the codex CLI is unavailable", async () => {
+ const prisma = makePrisma()
+ seedCatalog(prisma)
+ seedEmptyLocale(prisma)
+ envState.EXPERIENCE_AI_ALLOW_CODEX_FALLBACK = true
+ const proc = new EventEmitter() as EventEmitter & {
+ stdout: EventEmitter
+ stderr: EventEmitter
+ stdin: { write: ReturnType; end: ReturnType }
+ }
+ proc.stdout = new EventEmitter()
+ proc.stderr = new EventEmitter()
+ proc.stdin = { write: vi.fn(), end: vi.fn() }
+ spawnMock.mockImplementation(() => {
+ queueMicrotask(() => {
+ const error = Object.assign(new Error("spawn codex ENOENT"), {
+ code: "ENOENT",
+ })
+ proc.emit("error", error)
+ })
+ return proc
+ })
+
+ await expect(
+ generateExperienceAiDraft(prisma, {
+ experienceLocaleId: "locale-1",
+ locale: "en",
+ prompt: "A story of hope",
+ user: EDITOR,
+ }),
+ ).rejects.toMatchObject({
+ code: "NOT_CONFIGURED",
+ })
+ })
+
+ it("falls back to codex when the gate is on and no API key is present", async () => {
+ envState.EXPERIENCE_AI_ALLOW_CODEX_FALLBACK = true
+ const prisma = makePrisma()
+ seedCatalog(prisma)
+ seedEmptyLocale(prisma)
+ const proc = new EventEmitter() as EventEmitter & {
+ stdout: EventEmitter
+ stderr: EventEmitter
+ stdin: { write: ReturnType; end: ReturnType }
+ }
+ proc.stdout = new EventEmitter()
+ proc.stderr = new EventEmitter()
+ proc.stdin = { write: vi.fn(), end: vi.fn() }
+ spawnMock.mockImplementation(() => {
+ queueMicrotask(() => {
+ proc.stdout.emit(
+ "data",
+ Buffer.from(
+ JSON.stringify({
+ title: "Hope for the Journey",
+ metaDescription: "A short first draft.",
+ blocks: [
+ {
+ t: "videoHero",
+ sectionRef: "s01",
+ candidateRef: "v01",
+ ctaLabel: "Watch",
+ },
+ {
+ t: "section",
+ sectionRef: "s02",
+ content: [{ t: "text", heading: "Start here" }],
+ },
+ ],
+ }),
+ ),
+ )
+ proc.emit("close", 0, null)
+ })
+ return proc
+ })
+
+ const draft = await generateExperienceAiDraft(prisma, {
+ experienceLocaleId: "locale-1",
+ locale: "en",
+ prompt: "A story of hope",
+ user: EDITOR,
+ })
+
+ expect(spawnMock).toHaveBeenCalledWith(
+ "codex",
+ ["exec", "-m", "gpt-5.4", "--sandbox", "read-only", "-"],
+ expect.objectContaining({
+ stdio: ["pipe", "pipe", "pipe"],
+ }),
+ )
+ expect(draft.title).toBe("Hope for the Journey")
+ expect(draft.blocks[0]).toMatchObject({
+ t: "videoHero",
+ videoId: "video-1",
+ })
+ })
+
+ it("throws UPSTREAM_ERROR on provider failures", async () => {
+ const prisma = makePrisma()
+ seedCatalog(prisma)
+ seedEmptyLocale(prisma)
+ envState.OPENROUTER_API_KEY = "test-openrouter-key"
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValue(new Response("upstream error", { status: 503 }))
+ vi.stubGlobal("fetch", fetchMock)
+
+ await expect(
+ generateExperienceAiDraft(prisma, {
+ experienceLocaleId: "locale-1",
+ locale: "en",
+ prompt: "A story of hope",
+ user: EDITOR,
+ }),
+ ).rejects.toMatchObject({
+ code: "UPSTREAM_ERROR",
+ })
+ })
+
+ it("throws SCHEMA_MISMATCH when the provider output is structurally invalid", async () => {
+ const prisma = makePrisma()
+ seedCatalog(prisma)
+ seedEmptyLocale(prisma)
+ envState.OPENROUTER_API_KEY = "test-openrouter-key"
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ choices: [
+ {
+ message: {
+ content: JSON.stringify({
+ title: "Draft",
+ }),
+ },
+ },
+ ],
+ }),
+ {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ },
+ ),
+ ),
+ )
+
+ await expect(
+ generateExperienceAiDraft(prisma, {
+ experienceLocaleId: "locale-1",
+ locale: "en",
+ prompt: "A story of hope",
+ user: EDITOR,
+ }),
+ ).rejects.toMatchObject({
+ code: "SCHEMA_MISMATCH",
+ })
+ })
+
+ it("throws NORMALIZATION_ERROR when the model references an unknown candidate", async () => {
+ const prisma = makePrisma()
+ seedCatalog(prisma)
+ seedEmptyLocale(prisma)
+ envState.OPENROUTER_API_KEY = "test-openrouter-key"
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ choices: [
+ {
+ message: {
+ content: JSON.stringify({
+ title: "Draft",
+ metaDescription: "Draft",
+ blocks: [
+ {
+ t: "videoHero",
+ candidateRef: "v99",
+ },
+ {
+ t: "section",
+ sectionRef: "s02",
+ content: [{ t: "text", heading: "Start" }],
+ },
+ ],
+ }),
+ },
+ },
+ ],
+ }),
+ {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ },
+ ),
+ ),
+ )
+
+ await expect(
+ generateExperienceAiDraft(prisma, {
+ experienceLocaleId: "locale-1",
+ locale: "en",
+ prompt: "A story of hope",
+ user: EDITOR,
+ }),
+ ).rejects.toMatchObject({
+ code: "NORMALIZATION_ERROR",
+ })
+ })
+
+ it("returns a normalized draft when the provider response is valid", async () => {
+ const prisma = makePrisma()
+ seedCatalog(prisma)
+ seedEmptyLocale(prisma)
+ envState.OPENROUTER_API_KEY = "test-openrouter-key"
+ const fetchMock = vi.fn().mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ choices: [
+ {
+ message: {
+ content: JSON.stringify({
+ title: "Hope for the Journey",
+ metaDescription: "A short first draft.",
+ blocks: [
+ {
+ t: "videoHero",
+ sectionRef: "s01",
+ candidateRef: "v01",
+ ctaLabel: "Watch",
+ },
+ {
+ t: "section",
+ sectionRef: "s02",
+ content: [
+ {
+ t: "text",
+ sectionRef: "s03",
+ heading: "Start here",
+ },
+ {
+ t: "navigationCarousel",
+ items: [{ targetRef: "s03", title: "Start here" }],
+ },
+ ],
+ },
+ ],
+ }),
+ },
+ },
+ ],
+ }),
+ {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ },
+ ),
+ )
+ vi.stubGlobal("fetch", fetchMock)
+
+ const draft = await generateExperienceAiDraft(prisma, {
+ experienceLocaleId: "locale-1",
+ locale: "en",
+ prompt: "A story of hope",
+ user: EDITOR,
+ })
+
+ expect(fetchMock).toHaveBeenCalled()
+ expect(draft.title).toBe("Hope for the Journey")
+ expect(draft.blocks[0]).toMatchObject({
+ t: "videoHero",
+ sectionKey: "ai-s01",
+ videoId: "video-1",
+ })
+ expect(draft.blocks[1]).toMatchObject({
+ t: "section",
+ sectionKey: "ai-s02",
+ })
+ const section = draft.blocks[1] as Extract<
+ (typeof draft.blocks)[number],
+ { t: "section" }
+ >
+ expect(section.content[0]).toMatchObject({
+ t: "text",
+ sectionKey: "ai-s03",
+ })
+ expect(section.content[1]).toMatchObject({
+ t: "navigationCarousel",
+ items: [{ contentId: "ai-s03" }],
+ })
+ })
+
+ it("repairs duplicate section refs from the provider", async () => {
+ const prisma = makePrisma()
+ seedCatalog(prisma)
+ seedEmptyLocale(prisma)
+ envState.OPENROUTER_API_KEY = "test-openrouter-key"
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ choices: [
+ {
+ message: {
+ content: JSON.stringify({
+ title: "Forgiveness After Failure",
+ metaDescription: "A short first draft.",
+ blocks: [
+ {
+ t: "text",
+ sectionRef: "s04",
+ heading: "Start again",
+ },
+ {
+ t: "videoHero",
+ sectionRef: "s04",
+ candidateRef: "v01",
+ ctaLabel: "Watch",
+ },
+ {
+ t: "navigationCarousel",
+ items: [{ targetRef: "s04", title: "Start again" }],
+ },
+ ],
+ }),
+ },
+ },
+ ],
+ }),
+ {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ },
+ ),
+ ),
+ )
+
+ const draft = await generateExperienceAiDraft(prisma, {
+ experienceLocaleId: "locale-1",
+ locale: "en",
+ prompt: "forgiveness after failure",
+ user: EDITOR,
+ })
+
+ expect(draft.blocks[0]).toMatchObject({
+ t: "text",
+ sectionKey: "ai-s04",
+ })
+ expect(draft.blocks[1]).toMatchObject({
+ t: "videoHero",
+ sectionKey: "ai-s04-1",
+ })
+ expect(draft.blocks[2]).toMatchObject({
+ t: "navigationCarousel",
+ items: [{ contentId: "ai-s04" }],
+ })
+ })
+})
+
+describe("rule-witness log", () => {
+ beforeEach(() => {
+ envState.OPENROUTER_API_KEY = undefined
+ envState.OPENAI_API_KEY = undefined
+ envState.OPENAI_BASE_URL = undefined
+ envState.EXPERIENCE_AI_ALLOW_CODEX_FALLBACK = false
+ generateExperienceEmbeddingMock.mockReset()
+ generateExperienceEmbeddingMock.mockRejectedValue(
+ new Error("not configured"),
+ )
+ generateOllamaEmbeddingMock.mockReset()
+ generateOllamaEmbeddingMock.mockRejectedValue(new Error("not running"))
+ vi.unstubAllGlobals()
+ spawnMock.mockReset()
+ })
+
+ it("emits exactly one draft_generated line on success and never includes prompt or candidate metadata", async () => {
+ const prisma = makePrisma()
+ seedCatalog(prisma)
+ seedEmptyLocale(prisma)
+ envState.OPENROUTER_API_KEY = "test-openrouter-key"
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ choices: [
+ {
+ message: {
+ content: JSON.stringify({
+ title: "Hope for the Journey",
+ metaDescription: "A short first draft.",
+ blocks: [
+ {
+ t: "videoHero",
+ sectionRef: "s01",
+ candidateRef: "v01",
+ ctaLabel: "Watch",
+ },
+ {
+ t: "section",
+ sectionRef: "s02",
+ content: [{ t: "text", heading: "Start here" }],
+ },
+ ],
+ }),
+ },
+ },
+ ],
+ }),
+ {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ },
+ ),
+ ),
+ )
+
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => {})
+
+ await generateExperienceAiDraft(prisma, {
+ experienceLocaleId: "locale-1",
+ locale: "en",
+ prompt: "Sensitive operator prompt about hope",
+ user: EDITOR,
+ experienceId: "exp-1",
+ })
+
+ const witnessLines = logSpy.mock.calls
+ .map((args) => args[0])
+ .filter(
+ (entry): entry is string =>
+ typeof entry === "string" && entry.includes('"draft_generated"'),
+ )
+ expect(witnessLines).toHaveLength(1)
+ const payload = JSON.parse(witnessLines[0]) as Record
+ expect(payload).toMatchObject({
+ service: "experience-ai",
+ event: "draft_generated",
+ experienceId: "exp-1",
+ experienceLocaleId: "locale-1",
+ locale: "en",
+ providerKind: "openrouter",
+ rulesSatisfied: {
+ catalogOnly: true,
+ localeMatchedDubs: true,
+ blocksSchemaParsed: true,
+ ephemeralAction: true,
+ },
+ })
+ expect(typeof payload.candidateCount).toBe("number")
+ expect(typeof payload.blockCount).toBe("number")
+ expect(typeof payload.durationMs).toBe("number")
+
+ // No operator prompt or candidate metadata may leak into the log.
+ const serialized = JSON.stringify(payload)
+ expect(serialized).not.toContain("Sensitive operator prompt")
+ expect(serialized).not.toMatch(/"prompt"\s*:/)
+ expect(serialized).not.toMatch(/"query"\s*:/)
+ expect(serialized).not.toMatch(/"title"\s*:/)
+ expect(serialized).not.toMatch(/"description"\s*:/)
+ expect(serialized).not.toMatch(/"url"\s*:/)
+ expect(serialized).not.toContain("Hope Story")
+ expect(serialized).not.toContain("hope.m3u8")
+
+ logSpy.mockRestore()
+ })
+})
+
+describe("buildExperienceAiMessages", () => {
+ const candidates = [
+ {
+ ref: "v01" as const,
+ videoId: "video-1",
+ slug: "hope-story",
+ title: "Hope Story",
+ description: null,
+ previewImageUrl: null,
+ previewStreamUrl: null,
+ label: null,
+ },
+ ]
+
+ it("includes the editorial brief, structural template, and shape-only few-shot for English", () => {
+ const messages = buildExperienceAiMessages({
+ prompt: "A story of hope",
+ locale: "en",
+ candidates,
+ })
+ const system = messages.find((message) => message.role === "system")
+ expect(system).toBeDefined()
+ const content = system?.content ?? ""
+ // Editorial brief markers
+ expect(content).toMatch(/editorial/i)
+ // Structural template markers
+ expect(content).toMatch(/videoHero/)
+ expect(content).toMatch(/section/)
+ expect(content).toMatch(/navigationCarousel|mediaCollection/)
+ // Shape-only few-shot label
+ expect(content.toLowerCase()).toContain("shape only")
+ // Locale guidance
+ expect(content).toMatch(/English/i)
+ // Invariants restated
+ expect(content).toMatch(/candidateRef/)
+ })
+
+ it("emits Spanish-specific copy guidance when locale is es", () => {
+ const messages = buildExperienceAiMessages({
+ prompt: "Una historia de esperanza",
+ locale: "es",
+ candidates,
+ })
+ const system = messages.find((message) => message.role === "system")
+ const content = system?.content ?? ""
+ expect(content).toMatch(/es\b|Spanish|español/i)
+ // Must NOT default-tag the prompt as English when locale is es
+ expect(content).not.toMatch(/Write all generated copy in English/)
+ })
+})
+
+describe("DraftExperienceSchema block floor", () => {
+ it("rejects a draft with a single block (min floor is 2)", () => {
+ const result = DraftExperienceSchema.safeParse({
+ title: "T",
+ metaDescription: "M",
+ blocks: [
+ {
+ t: "videoHero",
+ candidateRef: "v01",
+ },
+ ],
+ })
+ expect(result.success).toBe(false)
+ })
+
+ it("accepts a draft with two blocks", () => {
+ const result = DraftExperienceSchema.safeParse({
+ title: "T",
+ metaDescription: "M",
+ blocks: [
+ { t: "videoHero", candidateRef: "v01" },
+ {
+ t: "section",
+ content: [{ t: "text", heading: "Hi" }],
+ },
+ ],
+ })
+ expect(result.success).toBe(true)
+ })
+})
+
+describe("buildDraftExperienceJsonSchema", () => {
+ it("aligns blocks.minItems with the Zod floor of 2", () => {
+ const schema = buildDraftExperienceJsonSchema() as Record
+ const properties = schema.properties as Record | undefined
+ const blocks = properties?.blocks as { minItems?: number } | undefined
+ expect(blocks?.minItems).toBe(2)
+ })
+})
diff --git a/apps/admin/src/services/experience-ai/experience-ai.service.ts b/apps/admin/src/services/experience-ai/experience-ai.service.ts
new file mode 100644
index 000000000..746db7623
--- /dev/null
+++ b/apps/admin/src/services/experience-ai/experience-ai.service.ts
@@ -0,0 +1,934 @@
+import { spawn } from "node:child_process"
+import type { PrismaClient } from "@prisma/client"
+import type { Principal } from "@/auth/principal"
+import { canEditExperienceLocale, hasPermission } from "@/auth/permissions"
+import { ForbiddenError, NotFoundError } from "@/services/errors"
+import { env } from "@/config/env"
+import { toPgVector } from "@/db/pgvector"
+import { generateExperienceEmbedding } from "@/services/embeddings.service"
+import { generateOllamaEmbedding } from "@/services/ollama-embedding.service"
+import {
+ buildDraftExperienceJsonSchema,
+ DraftExperienceSchema,
+ type VideoCandidate,
+} from "./experience-ai.schemas"
+import { buildSystemPrompt } from "./experience-ai-prompts"
+import {
+ ExperienceAiNormalizationError,
+ normalizeExperienceDraft,
+ type NormalizedExperienceDraft,
+} from "./experience-ai-normalize"
+
+const OPENROUTER_CHAT_MODEL = "openai/gpt-4.1-mini"
+const OPENAI_CHAT_MODEL = "gpt-4.1-mini"
+const CODEX_CHAT_MODEL = "gpt-5.5"
+const GENERATION_REQUEST_TIMEOUT_MS = 30_000
+const DEFAULT_CANDIDATE_LIMIT = 12
+const CANDIDATE_FETCH_WINDOW = 80
+const VECTOR_SEARCH_EF_SEARCH = 80
+
+type ProviderSelection =
+ | {
+ kind: "openrouter"
+ apiKey: string
+ model: string
+ url: string
+ }
+ | {
+ kind: "openai"
+ apiKey: string
+ model: string
+ url: string
+ }
+ | {
+ kind: "codex"
+ model: string
+ }
+
+type ExperienceAiGenerationInput = {
+ experienceLocaleId: string
+ locale: string
+ prompt: string
+ user: Principal | null
+ candidateLimit?: number
+ /// Optional Experience id used for the rule-witness log. The action layer
+ /// supplies it when available so logs are greppable per-experience.
+ experienceId?: string | null
+}
+
+export class ExperienceAiGenerationError extends Error {
+ constructor(
+ readonly code:
+ | "NOT_CONFIGURED"
+ | "NO_CANDIDATES"
+ | "UPSTREAM_ERROR"
+ | "SCHEMA_MISMATCH"
+ | "NORMALIZATION_ERROR",
+ message: string,
+ ) {
+ super(message)
+ this.name = "ExperienceAiGenerationError"
+ }
+}
+
+type RankedCandidate = VideoCandidate & {
+ score: number
+ updatedAt: Date
+}
+
+type VideoEmbeddingHit = {
+ videoId: string
+ distance: number
+}
+
+function normalizeText(value: string) {
+ return value.trim().toLowerCase()
+}
+
+function tokenizePrompt(prompt: string) {
+ return Array.from(
+ new Set(
+ normalizeText(prompt)
+ .match(/[a-z0-9]+/g)
+ ?.filter((token) => token.length >= 3) ?? [],
+ ),
+ )
+}
+
+function candidateText(candidate: {
+ title: string
+ description: string | null
+ slug: string
+ label: string | null
+}) {
+ return [
+ candidate.title,
+ candidate.description,
+ candidate.slug,
+ candidate.label,
+ ]
+ .filter(Boolean)
+ .join(" ")
+ .toLowerCase()
+}
+
+function scoreCandidate(
+ candidate: {
+ title: string
+ description: string | null
+ slug: string
+ label: string | null
+ previewImageUrl: string | null
+ previewStreamUrl: string | null
+ updatedAt: Date
+ },
+ tokens: readonly string[],
+) {
+ let score = 0
+ if (candidate.previewImageUrl) score += 1
+ if (candidate.previewStreamUrl) score += 2
+ if (tokens.length === 0) {
+ return score
+ }
+
+ const title = candidate.title.toLowerCase()
+ const description = candidate.description?.toLowerCase() ?? ""
+ const slug = candidate.slug.toLowerCase()
+ const label = candidate.label?.toLowerCase() ?? ""
+ const text = candidateText(candidate)
+
+ for (const token of tokens) {
+ if (title.includes(token)) score += 8
+ if (description.includes(token)) score += 5
+ if (slug.includes(token)) score += 4
+ if (label.includes(token)) score += 2
+ if (text.includes(token)) score += 1
+ }
+
+ return score
+}
+
+function pickProvider(): ProviderSelection | null {
+ if (env.OPENROUTER_API_KEY) {
+ return {
+ kind: "openrouter",
+ apiKey: env.OPENROUTER_API_KEY,
+ model: OPENROUTER_CHAT_MODEL,
+ url: "https://openrouter.ai/api/v1/chat/completions",
+ }
+ }
+ if (env.OPENAI_API_KEY) {
+ return {
+ kind: "openai",
+ apiKey: env.OPENAI_API_KEY,
+ model: OPENAI_CHAT_MODEL,
+ url: new URL(
+ "chat/completions",
+ `${(env.OPENAI_BASE_URL ?? "https://api.openai.com/v1").replace(/\/$/, "")}/`,
+ ).toString(),
+ }
+ }
+ // Codex CLI fallback is gated to avoid surprising production deployments
+ // without an API key. When the gate is off and no API key is set, return
+ // null so the caller surfaces NOT_CONFIGURED instead of spawning a CLI.
+ if (env.EXPERIENCE_AI_ALLOW_CODEX_FALLBACK !== true) {
+ return null
+ }
+ return {
+ kind: "codex",
+ model: CODEX_CHAT_MODEL,
+ }
+}
+
+function stripMarkdownFence(content: string) {
+ const trimmed = content.trim()
+ const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i)
+ return fenced ? fenced[1].trim() : trimmed
+}
+
+function parseProviderDraftContent(content: string): unknown {
+ const normalized = stripMarkdownFence(content)
+ let parsed: unknown
+ try {
+ parsed = JSON.parse(normalized)
+ } catch {
+ throw new ExperienceAiGenerationError(
+ "SCHEMA_MISMATCH",
+ "AI provider returned invalid JSON content",
+ )
+ }
+ if (typeof parsed === "string") {
+ try {
+ parsed = JSON.parse(parsed)
+ } catch {
+ throw new ExperienceAiGenerationError(
+ "SCHEMA_MISMATCH",
+ "AI provider returned invalid JSON content",
+ )
+ }
+ }
+ return parsed
+}
+
+function buildCodexPrompt({
+ prompt,
+ locale,
+ candidates,
+}: {
+ prompt: string
+ locale: string
+ candidates: VideoCandidate[]
+}) {
+ return [
+ buildSystemPrompt(locale),
+ "",
+ "Schema:",
+ JSON.stringify(buildDraftExperienceJsonSchema(), null, 2),
+ "",
+ "Input:",
+ JSON.stringify(
+ {
+ prompt: prompt.trim(),
+ locale,
+ videoCandidates: candidates.map((candidate) => ({
+ ref: candidate.ref,
+ title: candidate.title,
+ description: candidate.description,
+ label: candidate.label,
+ previewImageUrl: candidate.previewImageUrl,
+ previewStreamUrl: candidate.previewStreamUrl,
+ })),
+ },
+ null,
+ 2,
+ ),
+ ].join("\n")
+}
+
+async function createStructuredDraftWithCodex({
+ prompt,
+ locale,
+ candidates,
+ model,
+}: {
+ prompt: string
+ locale: string
+ candidates: VideoCandidate[]
+ model: string
+}) {
+ return await new Promise((resolve, reject) => {
+ const proc = spawn(
+ "codex",
+ [
+ "exec",
+ "-m",
+ model,
+ "-c",
+ 'model_reasoning_effort="high"',
+ "--sandbox",
+ "read-only",
+ "-",
+ ],
+ {
+ env: { ...process.env, LANG: "en_US.UTF-8" },
+ stdio: ["pipe", "pipe", "pipe"],
+ },
+ )
+
+ let stdout = ""
+ let stderr = ""
+
+ const timeoutHandle = setTimeout(() => {
+ proc.kill("SIGTERM")
+ }, GENERATION_REQUEST_TIMEOUT_MS)
+
+ proc.stdout.on("data", (chunk: Buffer | string) => {
+ stdout += chunk.toString()
+ })
+
+ proc.stderr.on("data", (chunk: Buffer | string) => {
+ stderr += chunk.toString()
+ })
+
+ proc.on("error", (error) => {
+ clearTimeout(timeoutHandle)
+ reject(
+ new ExperienceAiGenerationError(
+ "NOT_CONFIGURED",
+ error instanceof Error && "code" in error && error.code === "ENOENT"
+ ? "codex CLI is not installed or not available on PATH"
+ : error instanceof Error
+ ? error.message
+ : "codex CLI failed to start",
+ ),
+ )
+ })
+
+ proc.on("close", (code, signal) => {
+ clearTimeout(timeoutHandle)
+
+ if (signal) {
+ reject(
+ new ExperienceAiGenerationError(
+ "UPSTREAM_ERROR",
+ `Codex request timed out after ${GENERATION_REQUEST_TIMEOUT_MS}ms`,
+ ),
+ )
+ return
+ }
+
+ if (code !== 0) {
+ reject(
+ new ExperienceAiGenerationError(
+ "UPSTREAM_ERROR",
+ stderr.trim() || `codex exited with status ${code ?? "unknown"}`,
+ ),
+ )
+ return
+ }
+
+ try {
+ resolve(parseProviderDraftContent(stdout))
+ } catch (error) {
+ reject(error)
+ }
+ })
+
+ proc.stdin.write(buildCodexPrompt({ prompt, locale, candidates }))
+ proc.stdin.end()
+ })
+}
+
+function buildExperienceAiMessages({
+ prompt,
+ locale,
+ candidates,
+}: {
+ prompt: string
+ locale: string
+ candidates: VideoCandidate[]
+}) {
+ return [
+ {
+ role: "system" as const,
+ content: buildSystemPrompt(locale),
+ },
+ {
+ role: "user" as const,
+ content: JSON.stringify(
+ {
+ prompt: prompt.trim(),
+ locale,
+ videoCandidates: candidates.map((candidate) => ({
+ ref: candidate.ref,
+ title: candidate.title,
+ description: candidate.description,
+ label: candidate.label,
+ previewImageUrl: candidate.previewImageUrl,
+ previewStreamUrl: candidate.previewStreamUrl,
+ })),
+ },
+ null,
+ 2,
+ ),
+ },
+ ]
+}
+
+type StructuredDraftResult = {
+ payload: unknown
+ providerKind: "openrouter" | "openai" | "codex"
+}
+
+async function createStructuredDraft({
+ prompt,
+ locale,
+ candidates,
+}: {
+ prompt: string
+ locale: string
+ candidates: VideoCandidate[]
+}): Promise {
+ const provider = pickProvider()
+ if (!provider) {
+ throw new ExperienceAiGenerationError(
+ "NOT_CONFIGURED",
+ "OPENROUTER_API_KEY or OPENAI_API_KEY is required for AI drafting",
+ )
+ }
+
+ if (provider.kind === "codex") {
+ const payload = await createStructuredDraftWithCodex({
+ prompt,
+ locale,
+ candidates,
+ model: provider.model,
+ })
+ return { payload, providerKind: "codex" }
+ }
+
+ const controller = new AbortController()
+ const timeoutHandle = setTimeout(
+ () => controller.abort(),
+ GENERATION_REQUEST_TIMEOUT_MS,
+ )
+
+ try {
+ const response = await fetch(provider.url, {
+ method: "POST",
+ headers: {
+ authorization: `Bearer ${provider.apiKey}`,
+ "content-type": "application/json",
+ ...(provider.kind === "openrouter"
+ ? { "x-title": "forge-admin-experience-ai" }
+ : {}),
+ },
+ body: JSON.stringify({
+ model: provider.model,
+ messages: buildExperienceAiMessages({ prompt, locale, candidates }),
+ response_format: {
+ type: "json_schema",
+ json_schema: {
+ name: "experience_ai_draft",
+ strict: true,
+ schema: buildDraftExperienceJsonSchema(),
+ },
+ },
+ }),
+ signal: controller.signal,
+ })
+
+ if (!response.ok) {
+ throw new ExperienceAiGenerationError(
+ "UPSTREAM_ERROR",
+ `AI provider request failed with status ${response.status}`,
+ )
+ }
+
+ const body = (await response.json()) as unknown
+ const bodyParse = {
+ choices:
+ body && typeof body === "object" && "choices" in body
+ ? (body as { choices?: unknown }).choices
+ : undefined,
+ }
+ const choice = Array.isArray(bodyParse.choices)
+ ? bodyParse.choices[0]
+ : null
+ const message =
+ choice && typeof choice === "object" && choice !== null
+ ? (choice as { message?: unknown }).message
+ : null
+ const content =
+ message && typeof message === "object" && message !== null
+ ? (message as { content?: unknown }).content
+ : null
+
+ if (typeof content !== "string" || content.trim().length === 0) {
+ throw new ExperienceAiGenerationError(
+ "UPSTREAM_ERROR",
+ "AI provider returned an empty draft payload",
+ )
+ }
+
+ return {
+ payload: parseProviderDraftContent(content),
+ providerKind: provider.kind,
+ }
+ } catch (error) {
+ if (error instanceof ExperienceAiGenerationError) {
+ throw error
+ }
+ if (error instanceof Error && error.name === "AbortError") {
+ throw new ExperienceAiGenerationError(
+ "UPSTREAM_ERROR",
+ `AI provider request timed out after ${GENERATION_REQUEST_TIMEOUT_MS}ms`,
+ )
+ }
+ throw new ExperienceAiGenerationError(
+ "UPSTREAM_ERROR",
+ error instanceof Error ? error.message : "AI provider request failed",
+ )
+ } finally {
+ clearTimeout(timeoutHandle)
+ }
+}
+
+export async function loadExperienceAiVideoCandidates(
+ prisma: PrismaClient,
+ {
+ locale,
+ prompt,
+ limit = DEFAULT_CANDIDATE_LIMIT,
+ }: { locale: string; prompt: string; limit?: number },
+): Promise {
+ const safeLimit = Math.max(1, Math.min(limit, DEFAULT_CANDIDATE_LIMIT))
+ const fetchWindow = Math.min(
+ Math.max(safeLimit * 4, 24),
+ CANDIDATE_FETCH_WINDOW,
+ )
+ const tokens = tokenizePrompt(prompt)
+ const semanticVideoIds = await loadSemanticVideoCandidateIds(prisma, {
+ locale,
+ prompt,
+ limit: fetchWindow,
+ })
+
+ const videos = await prisma.video.findMany({
+ where:
+ semanticVideoIds.length > 0
+ ? { id: { in: semanticVideoIds }, deletedAt: null }
+ : { deletedAt: null },
+ select: {
+ id: true,
+ slug: true,
+ label: true,
+ updatedAt: true,
+ },
+ ...(semanticVideoIds.length > 0
+ ? {}
+ : { orderBy: { updatedAt: "desc" as const } }),
+ take: fetchWindow,
+ })
+
+ if (videos.length === 0) {
+ return []
+ }
+
+ const videoIds = videos.map((video) => video.id)
+ const [videoLocales, videoDubs, videoImages] = await Promise.all([
+ prisma.videoLocale.findMany({
+ where: { videoId: { in: videoIds } },
+ select: {
+ videoId: true,
+ locale: true,
+ title: true,
+ description: true,
+ status: true,
+ updatedAt: true,
+ },
+ orderBy: { updatedAt: "desc" },
+ }),
+ prisma.videoDub.findMany({
+ where: { videoId: { in: videoIds }, deletedAt: null },
+ select: {
+ videoId: true,
+ hls: true,
+ dash: true,
+ share: true,
+ language: {
+ select: {
+ bcp47: true,
+ iso3: true,
+ slug: true,
+ },
+ },
+ updatedAt: true,
+ },
+ orderBy: { updatedAt: "desc" },
+ }),
+ prisma.videoImage.findMany({
+ where: { videoId: { in: videoIds } },
+ select: {
+ videoId: true,
+ url: true,
+ createdAt: true,
+ },
+ orderBy: { createdAt: "asc" },
+ }),
+ ])
+
+ const localesByVideo = new Map()
+ for (const row of videoLocales) {
+ const current = localesByVideo.get(row.videoId) ?? []
+ current.push(row)
+ localesByVideo.set(row.videoId, current)
+ }
+
+ const dubsByVideo = new Map()
+ for (const row of videoDubs) {
+ const current = dubsByVideo.get(row.videoId) ?? []
+ current.push(row)
+ dubsByVideo.set(row.videoId, current)
+ }
+
+ const imagesByVideo = new Map()
+ for (const row of videoImages) {
+ const current = imagesByVideo.get(row.videoId) ?? []
+ current.push(row)
+ imagesByVideo.set(row.videoId, current)
+ }
+
+ const semanticRank = new Map(
+ semanticVideoIds.map((videoId, index) => [videoId, index]),
+ )
+
+ const ranked: RankedCandidate[] = videos.flatMap((video) => {
+ const localeRows = localesByVideo.get(video.id) ?? []
+ const preferredLocale =
+ localeRows.find(
+ (row) => row.locale === locale && row.status === "PUBLISHED",
+ ) ?? null
+
+ if (!preferredLocale) return []
+
+ const previewImageUrl =
+ imagesByVideo.get(video.id)?.find((row) => row.url)?.url ?? null
+ const preferredDub =
+ dubsByVideo
+ .get(video.id)
+ ?.find(
+ (row) =>
+ (row.hls || row.dash || row.share) &&
+ (row.language?.bcp47 === locale ||
+ row.language?.iso3 === locale ||
+ row.language?.slug === locale),
+ ) ?? null
+
+ const candidate = {
+ ref: "",
+ videoId: video.id,
+ slug: video.slug,
+ title: preferredLocale?.title?.trim() || video.slug,
+ description: preferredLocale?.description?.trim() || null,
+ previewImageUrl,
+ previewStreamUrl:
+ preferredDub?.hls ?? preferredDub?.dash ?? preferredDub?.share ?? null,
+ label: video.label ? String(video.label) : null,
+ score: 0,
+ updatedAt: video.updatedAt,
+ }
+
+ return [
+ {
+ ...candidate,
+ score: scoreCandidate(candidate, tokens),
+ },
+ ]
+ })
+
+ ranked.sort((left, right) => {
+ const leftSemanticRank = semanticRank.get(left.videoId)
+ const rightSemanticRank = semanticRank.get(right.videoId)
+ if (leftSemanticRank !== undefined || rightSemanticRank !== undefined) {
+ return (leftSemanticRank ?? Infinity) - (rightSemanticRank ?? Infinity)
+ }
+ if (right.score !== left.score) return right.score - left.score
+ if (right.updatedAt.getTime() !== left.updatedAt.getTime()) {
+ return right.updatedAt.getTime() - left.updatedAt.getTime()
+ }
+ return left.title.localeCompare(right.title)
+ })
+
+ const selected = ranked.slice(0, safeLimit)
+ if (selected.length === 0) {
+ return []
+ }
+
+ return selected.map((candidate, index) => ({
+ ref: `v${String(index + 1).padStart(2, "0")}` as const,
+ videoId: candidate.videoId,
+ slug: candidate.slug,
+ title: candidate.title,
+ description: candidate.description,
+ previewImageUrl: candidate.previewImageUrl,
+ previewStreamUrl: candidate.previewStreamUrl,
+ label: candidate.label,
+ }))
+}
+
+async function loadSemanticVideoCandidateIds(
+ prisma: PrismaClient,
+ {
+ locale,
+ prompt,
+ limit,
+ }: {
+ locale: string
+ prompt: string
+ limit: number
+ },
+): Promise {
+ let generated: Awaited>
+ try {
+ generated = await generateExperienceEmbedding(prompt)
+ } catch (error) {
+ console.warn(
+ "[experience-ai] primary semantic video candidate search unavailable; trying local Ollama index",
+ error instanceof Error ? error.message : String(error),
+ )
+ return loadLocalOllamaVideoCandidateIds(prisma, {
+ locale,
+ prompt,
+ limit,
+ })
+ }
+
+ const pgVector = toPgVector(generated.embedding)
+ const safeLimit = Math.max(1, Math.min(limit, CANDIDATE_FETCH_WINDOW))
+
+ const hits = await prisma.$transaction(async (tx) => {
+ await tx.$executeRawUnsafe(
+ `SET LOCAL hnsw.ef_search = ${VECTOR_SEARCH_EF_SEARCH}`,
+ )
+ return tx.$queryRaw`
+ WITH scene_hits AS (
+ SELECT
+ vs.video_id AS "videoId",
+ MIN(vsl.embedding <=> ${pgVector}::vector) AS distance
+ FROM video_scene_locale vsl
+ JOIN video_scene vs ON vs.id = vsl.video_scene_id
+ JOIN video v ON v.id = vs.video_id
+ WHERE vsl.embedding IS NOT NULL
+ AND vsl.locale = ${locale}
+ AND v.deleted_at IS NULL
+ GROUP BY vs.video_id
+ ),
+ transcript_hits AS (
+ SELECT
+ vt.video_id AS "videoId",
+ MIN(vtc.embedding <=> ${pgVector}::vector) AS distance
+ FROM video_transcript_chunk vtc
+ JOIN video_transcript vt ON vt.id = vtc.transcript_id
+ JOIN video v ON v.id = vt.video_id
+ WHERE vtc.embedding IS NOT NULL
+ AND vtc.language = ${locale}
+ AND v.deleted_at IS NULL
+ GROUP BY vt.video_id
+ ),
+ combined AS (
+ SELECT * FROM scene_hits
+ UNION ALL
+ SELECT * FROM transcript_hits
+ )
+ SELECT
+ "videoId",
+ MIN(distance)::float AS distance
+ FROM combined
+ GROUP BY "videoId"
+ ORDER BY distance ASC
+ LIMIT ${safeLimit}
+ `
+ })
+
+ return hits.map((hit) => hit.videoId)
+}
+
+async function loadLocalOllamaVideoCandidateIds(
+ prisma: PrismaClient,
+ {
+ locale,
+ prompt,
+ limit,
+ }: {
+ locale: string
+ prompt: string
+ limit: number
+ },
+): Promise {
+ let embedding: number[]
+ try {
+ embedding = await generateOllamaEmbedding(prompt)
+ } catch (error) {
+ console.warn(
+ "[experience-ai] local Ollama candidate search unavailable; falling back to catalog token ranking",
+ error instanceof Error ? error.message : String(error),
+ )
+ return []
+ }
+
+ const pgVector = toPgVector(embedding)
+ const safeLimit = Math.max(1, Math.min(limit, CANDIDATE_FETCH_WINDOW))
+
+ const hits = await prisma.$transaction(async (tx) => {
+ await tx.$executeRawUnsafe(
+ `SET LOCAL hnsw.ef_search = ${VECTOR_SEARCH_EF_SEARCH}`,
+ )
+ return tx.$queryRaw`
+ SELECT
+ vce.video_id AS "videoId",
+ (vce.embedding <=> ${pgVector}::vector)::float AS distance
+ FROM video_candidate_embedding vce
+ JOIN video v ON v.id = vce.video_id
+ WHERE vce.embedding IS NOT NULL
+ AND vce.locale = ${locale}
+ AND v.deleted_at IS NULL
+ ORDER BY distance ASC
+ LIMIT ${safeLimit}
+ `
+ })
+
+ return hits.map((hit) => hit.videoId)
+}
+
+export async function generateExperienceAiDraft(
+ prisma: PrismaClient,
+ input: ExperienceAiGenerationInput,
+): Promise {
+ if (!hasPermission(input.user, "write:experiences")) {
+ throw new ForbiddenError()
+ }
+
+ const localeRow = await prisma.experienceLocale.findUnique({
+ where: { id: input.experienceLocaleId },
+ select: {
+ id: true,
+ blocks: true,
+ status: true,
+ experience: {
+ select: {
+ ownerId: true,
+ archivedAt: true,
+ },
+ },
+ },
+ })
+
+ if (!localeRow) {
+ throw new NotFoundError("ExperienceLocale", input.experienceLocaleId)
+ }
+
+ if (!canEditExperienceLocale(input.user, localeRow)) {
+ throw new ForbiddenError()
+ }
+
+ if (Array.isArray(localeRow.blocks) && localeRow.blocks.length > 0) {
+ throw new ExperienceAiGenerationError(
+ "UPSTREAM_ERROR",
+ "AI drafting only supports empty canvases in v1",
+ )
+ }
+
+ const candidates = await loadExperienceAiVideoCandidates(prisma, {
+ locale: input.locale,
+ prompt: input.prompt,
+ limit: input.candidateLimit ?? DEFAULT_CANDIDATE_LIMIT,
+ })
+
+ if (candidates.length === 0) {
+ throw new ExperienceAiGenerationError(
+ "NO_CANDIDATES",
+ "No catalog-backed video candidates were available for this locale",
+ )
+ }
+
+ const startedAt = Date.now()
+ const { payload: providerDraft, providerKind } = await createStructuredDraft({
+ prompt: input.prompt,
+ locale: input.locale,
+ candidates,
+ })
+
+ const draftParse = DraftExperienceSchema.safeParse(providerDraft)
+ if (!draftParse.success) {
+ throw new ExperienceAiGenerationError(
+ "SCHEMA_MISMATCH",
+ "AI provider response did not match the draft schema",
+ )
+ }
+
+ let normalized: NormalizedExperienceDraft
+ try {
+ normalized = normalizeExperienceDraft(draftParse.data, candidates)
+ } catch (error) {
+ if (error instanceof ExperienceAiNormalizationError) {
+ throw new ExperienceAiGenerationError(
+ "NORMALIZATION_ERROR",
+ error.message,
+ )
+ }
+ throw error
+ }
+
+ // Rule-witness log — greppable invariant trail in Railway. MUST NOT
+ // include the operator prompt or any candidate metadata (titles,
+ // descriptions, URLs). Mirrors the workflow log shape used in
+ // src/workflows/transcriptEmbeddingBackfill.ts.
+ try {
+ console.log(
+ JSON.stringify({
+ service: "experience-ai",
+ event: "draft_generated",
+ experienceId: input.experienceId ?? null,
+ experienceLocaleId: input.experienceLocaleId,
+ locale: input.locale,
+ providerKind,
+ candidateCount: candidates.length,
+ blockCount: normalized.blocks.length,
+ rulesSatisfied: {
+ catalogOnly: true,
+ localeMatchedDubs: true,
+ blocksSchemaParsed: true,
+ ephemeralAction: true,
+ },
+ durationMs: Date.now() - startedAt,
+ }),
+ )
+ } catch {
+ // Never let a logging failure break a successful generation.
+ }
+
+ return normalized
+}
+
+export class ExperienceAiService {
+ constructor(private prisma: PrismaClient) {}
+
+ loadVideoCandidates(
+ input: Parameters[1],
+ ) {
+ return loadExperienceAiVideoCandidates(this.prisma, input)
+ }
+
+ generateDraft(input: ExperienceAiGenerationInput) {
+ return generateExperienceAiDraft(this.prisma, input)
+ }
+}
+
+export {
+ buildExperienceAiMessages,
+ createStructuredDraft,
+ pickProvider,
+ scoreCandidate,
+ tokenizePrompt,
+}
diff --git a/apps/admin/src/services/ollama-embedding.service.ts b/apps/admin/src/services/ollama-embedding.service.ts
new file mode 100644
index 000000000..05bdee3f0
--- /dev/null
+++ b/apps/admin/src/services/ollama-embedding.service.ts
@@ -0,0 +1,53 @@
+import { z } from "zod"
+import { env } from "@/config/env"
+
+export const OLLAMA_EMBEDDING_DIMENSIONS = 768
+
+const OllamaEmbedResponseSchema = z.object({
+ embeddings: z.array(z.array(z.number().finite())).min(1),
+})
+
+function ollamaEmbedEndpoint() {
+ return new URL(
+ "api/embed",
+ `${(env.OLLAMA_BASE_URL ?? "http://localhost:11434").replace(/\/$/, "")}/`,
+ ).toString()
+}
+
+export async function generateOllamaEmbedding(text: string): Promise {
+ const input = text.replace(/\s+/g, " ").trim()
+ if (!input) {
+ throw new Error("Ollama embedding input must not be empty")
+ }
+
+ const response = await fetch(ollamaEmbedEndpoint(), {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ model: env.OLLAMA_EMBEDDING_MODEL ?? "embeddinggemma",
+ input,
+ truncate: true,
+ }),
+ signal: AbortSignal.timeout(30_000),
+ })
+
+ if (!response.ok) {
+ throw new Error(
+ `Ollama embedding request failed with status ${response.status}`,
+ )
+ }
+
+ const parsed = OllamaEmbedResponseSchema.safeParse(await response.json())
+ if (!parsed.success) {
+ throw new Error("Ollama embedding response validation failed")
+ }
+
+ const embedding = parsed.data.embeddings[0]!
+ if (embedding.length !== OLLAMA_EMBEDDING_DIMENSIONS) {
+ throw new Error(
+ `Ollama embedding returned ${embedding.length} dimensions; expected ${OLLAMA_EMBEDDING_DIMENSIONS}`,
+ )
+ }
+
+ return embedding
+}
diff --git a/apps/cms/schema.graphql b/apps/cms/schema.graphql
index e18d89bd1..fbf28cf43 100644
--- a/apps/cms/schema.graphql
+++ b/apps/cms/schema.graphql
@@ -2626,6 +2626,7 @@ enum PublicationStatus {
type Query {
bibleBook(
documentId: ID!
+ hasPublishedVersion: Boolean
"""The locale to use for the query"""
locale: I18NLocaleCode
@@ -2633,6 +2634,7 @@ type Query {
): BibleBook
bibleBooks(
filters: BibleBookFiltersInput
+ hasPublishedVersion: Boolean
"""The locale to use for the query"""
locale: I18NLocaleCode
@@ -2642,6 +2644,7 @@ type Query {
): [BibleBook]!
bibleBooks_connection(
filters: BibleBookFiltersInput
+ hasPublishedVersion: Boolean
"""The locale to use for the query"""
locale: I18NLocaleCode
@@ -2649,14 +2652,15 @@ type Query {
sort: [String] = []
status: PublicationStatus = PUBLISHED
): BibleBookEntityResponseCollection
- bibleCitation(documentId: ID!, status: PublicationStatus = PUBLISHED): BibleCitation
- bibleCitations(filters: BibleCitationFiltersInput, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): [BibleCitation]!
- bibleCitations_connection(filters: BibleCitationFiltersInput, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): BibleCitationEntityResponseCollection
- cloudflareR2(documentId: ID!, status: PublicationStatus = PUBLISHED): CloudflareR2
- cloudflareR2S(filters: CloudflareR2FiltersInput, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): [CloudflareR2]!
- cloudflareR2S_connection(filters: CloudflareR2FiltersInput, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): CloudflareR2EntityResponseCollection
+ bibleCitation(documentId: ID!, hasPublishedVersion: Boolean, status: PublicationStatus = PUBLISHED): BibleCitation
+ bibleCitations(filters: BibleCitationFiltersInput, hasPublishedVersion: Boolean, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): [BibleCitation]!
+ bibleCitations_connection(filters: BibleCitationFiltersInput, hasPublishedVersion: Boolean, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): BibleCitationEntityResponseCollection
+ cloudflareR2(documentId: ID!, hasPublishedVersion: Boolean, status: PublicationStatus = PUBLISHED): CloudflareR2
+ cloudflareR2S(filters: CloudflareR2FiltersInput, hasPublishedVersion: Boolean, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): [CloudflareR2]!
+ cloudflareR2S_connection(filters: CloudflareR2FiltersInput, hasPublishedVersion: Boolean, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): CloudflareR2EntityResponseCollection
continent(
documentId: ID!
+ hasPublishedVersion: Boolean
"""The locale to use for the query"""
locale: I18NLocaleCode
@@ -2664,6 +2668,7 @@ type Query {
): Continent
continents(
filters: ContinentFiltersInput
+ hasPublishedVersion: Boolean
"""The locale to use for the query"""
locale: I18NLocaleCode
@@ -2673,6 +2678,7 @@ type Query {
): [Continent]!
continents_connection(
filters: ContinentFiltersInput
+ hasPublishedVersion: Boolean
"""The locale to use for the query"""
locale: I18NLocaleCode
@@ -2682,6 +2688,7 @@ type Query {
): ContinentEntityResponseCollection
countries(
filters: CountryFiltersInput
+ hasPublishedVersion: Boolean
"""The locale to use for the query"""
locale: I18NLocaleCode
@@ -2691,6 +2698,7 @@ type Query {
): [Country]!
countries_connection(
filters: CountryFiltersInput
+ hasPublishedVersion: Boolean
"""The locale to use for the query"""
locale: I18NLocaleCode
@@ -2700,28 +2708,30 @@ type Query {
): CountryEntityResponseCollection
country(
documentId: ID!
+ hasPublishedVersion: Boolean
"""The locale to use for the query"""
locale: I18NLocaleCode
status: PublicationStatus = PUBLISHED
): Country
- countryLanguage(documentId: ID!, status: PublicationStatus = PUBLISHED): CountryLanguage
- countryLanguages(filters: CountryLanguageFiltersInput, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): [CountryLanguage]!
- countryLanguages_connection(filters: CountryLanguageFiltersInput, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): CountryLanguageEntityResponseCollection
- coverageSnapshot(documentId: ID!, status: PublicationStatus = PUBLISHED): CoverageSnapshot
- coverageSnapshots(filters: CoverageSnapshotFiltersInput, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): [CoverageSnapshot]!
- coverageSnapshots_connection(filters: CoverageSnapshotFiltersInput, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): CoverageSnapshotEntityResponseCollection
- enrichmentAutomation(documentId: ID!, status: PublicationStatus = PUBLISHED): EnrichmentAutomation
- enrichmentAutomationRun(documentId: ID!, status: PublicationStatus = PUBLISHED): EnrichmentAutomationRun
- enrichmentAutomationRuns(filters: EnrichmentAutomationRunFiltersInput, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): [EnrichmentAutomationRun]!
- enrichmentAutomationRuns_connection(filters: EnrichmentAutomationRunFiltersInput, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): EnrichmentAutomationRunEntityResponseCollection
- enrichmentAutomations(filters: EnrichmentAutomationFiltersInput, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): [EnrichmentAutomation]!
- enrichmentAutomations_connection(filters: EnrichmentAutomationFiltersInput, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): EnrichmentAutomationEntityResponseCollection
- enrichmentJob(documentId: ID!, status: PublicationStatus = PUBLISHED): EnrichmentJob
- enrichmentJobs(filters: EnrichmentJobFiltersInput, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): [EnrichmentJob]!
- enrichmentJobs_connection(filters: EnrichmentJobFiltersInput, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): EnrichmentJobEntityResponseCollection
+ countryLanguage(documentId: ID!, hasPublishedVersion: Boolean, status: PublicationStatus = PUBLISHED): CountryLanguage
+ countryLanguages(filters: CountryLanguageFiltersInput, hasPublishedVersion: Boolean, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): [CountryLanguage]!
+ countryLanguages_connection(filters: CountryLanguageFiltersInput, hasPublishedVersion: Boolean, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): CountryLanguageEntityResponseCollection
+ coverageSnapshot(documentId: ID!, hasPublishedVersion: Boolean, status: PublicationStatus = PUBLISHED): CoverageSnapshot
+ coverageSnapshots(filters: CoverageSnapshotFiltersInput, hasPublishedVersion: Boolean, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): [CoverageSnapshot]!
+ coverageSnapshots_connection(filters: CoverageSnapshotFiltersInput, hasPublishedVersion: Boolean, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): CoverageSnapshotEntityResponseCollection
+ enrichmentAutomation(documentId: ID!, hasPublishedVersion: Boolean, status: PublicationStatus = PUBLISHED): EnrichmentAutomation
+ enrichmentAutomationRun(documentId: ID!, hasPublishedVersion: Boolean, status: PublicationStatus = PUBLISHED): EnrichmentAutomationRun
+ enrichmentAutomationRuns(filters: EnrichmentAutomationRunFiltersInput, hasPublishedVersion: Boolean, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): [EnrichmentAutomationRun]!
+ enrichmentAutomationRuns_connection(filters: EnrichmentAutomationRunFiltersInput, hasPublishedVersion: Boolean, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): EnrichmentAutomationRunEntityResponseCollection
+ enrichmentAutomations(filters: EnrichmentAutomationFiltersInput, hasPublishedVersion: Boolean, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): [EnrichmentAutomation]!
+ enrichmentAutomations_connection(filters: EnrichmentAutomationFiltersInput, hasPublishedVersion: Boolean, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): EnrichmentAutomationEntityResponseCollection
+ enrichmentJob(documentId: ID!, hasPublishedVersion: Boolean, status: PublicationStatus = PUBLISHED): EnrichmentJob
+ enrichmentJobs(filters: EnrichmentJobFiltersInput, hasPublishedVersion: Boolean, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): [EnrichmentJob]!
+ enrichmentJobs_connection(filters: EnrichmentJobFiltersInput, hasPublishedVersion: Boolean, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): EnrichmentJobEntityResponseCollection
experience(
documentId: ID!
+ hasPublishedVersion: Boolean
"""The locale to use for the query"""
locale: I18NLocaleCode
@@ -2729,6 +2739,7 @@ type Query {
): Experience
experiences(
filters: ExperienceFiltersInput
+ hasPublishedVersion: Boolean
"""The locale to use for the query"""
locale: I18NLocaleCode
@@ -2738,6 +2749,7 @@ type Query {
): [Experience]!
experiences_connection(
filters: ExperienceFiltersInput
+ hasPublishedVersion: Boolean
"""The locale to use for the query"""
locale: I18NLocaleCode
@@ -2745,24 +2757,26 @@ type Query {
sort: [String] = []
status: PublicationStatus = PUBLISHED
): ExperienceEntityResponseCollection
- i18NLocale(documentId: ID!, status: PublicationStatus = PUBLISHED): I18NLocale
- i18NLocales(filters: I18NLocaleFiltersInput, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): [I18NLocale]!
- i18NLocales_connection(filters: I18NLocaleFiltersInput, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): I18NLocaleEntityResponseCollection
- keyword(documentId: ID!, status: PublicationStatus = PUBLISHED): Keyword
- keywords(filters: KeywordFiltersInput, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): [Keyword]!
- keywords_connection(filters: KeywordFiltersInput, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): KeywordEntityResponseCollection
+ i18NLocale(documentId: ID!, hasPublishedVersion: Boolean, status: PublicationStatus = PUBLISHED): I18NLocale
+ i18NLocales(filters: I18NLocaleFiltersInput, hasPublishedVersion: Boolean, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): [I18NLocale]!
+ i18NLocales_connection(filters: I18NLocaleFiltersInput, hasPublishedVersion: Boolean, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): I18NLocaleEntityResponseCollection
+ keyword(documentId: ID!, hasPublishedVersion: Boolean, status: PublicationStatus = PUBLISHED): Keyword
+ keywords(filters: KeywordFiltersInput, hasPublishedVersion: Boolean, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): [Keyword]!
+ keywords_connection(filters: KeywordFiltersInput, hasPublishedVersion: Boolean, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): KeywordEntityResponseCollection
language(
documentId: ID!
+ hasPublishedVersion: Boolean
"""The locale to use for the query"""
locale: I18NLocaleCode
status: PublicationStatus = PUBLISHED
): Language
- languageAudioPreview(documentId: ID!, status: PublicationStatus = PUBLISHED): LanguageAudioPreview
- languageAudioPreviews(filters: LanguageAudioPreviewFiltersInput, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): [LanguageAudioPreview]!
- languageAudioPreviews_connection(filters: LanguageAudioPreviewFiltersInput, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): LanguageAudioPreviewEntityResponseCollection
+ languageAudioPreview(documentId: ID!, hasPublishedVersion: Boolean, status: PublicationStatus = PUBLISHED): LanguageAudioPreview
+ languageAudioPreviews(filters: LanguageAudioPreviewFiltersInput, hasPublishedVersion: Boolean, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): [LanguageAudioPreview]!
+ languageAudioPreviews_connection(filters: LanguageAudioPreviewFiltersInput, hasPublishedVersion: Boolean, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): LanguageAudioPreviewEntityResponseCollection
languages(
filters: LanguageFiltersInput
+ hasPublishedVersion: Boolean
"""The locale to use for the query"""
locale: I18NLocaleCode
@@ -2772,6 +2786,7 @@ type Query {
): [Language]!
languages_connection(
filters: LanguageFiltersInput
+ hasPublishedVersion: Boolean
"""The locale to use for the query"""
locale: I18NLocaleCode
@@ -2780,15 +2795,15 @@ type Query {
status: PublicationStatus = PUBLISHED
): LanguageEntityResponseCollection
me: UsersPermissionsMe
- muxVideo(documentId: ID!, status: PublicationStatus = PUBLISHED): MuxVideo
- muxVideos(filters: MuxVideoFiltersInput, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): [MuxVideo]!
- muxVideos_connection(filters: MuxVideoFiltersInput, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): MuxVideoEntityResponseCollection
- reviewWorkflowsWorkflow(documentId: ID!, status: PublicationStatus = PUBLISHED): ReviewWorkflowsWorkflow
- reviewWorkflowsWorkflowStage(documentId: ID!, status: PublicationStatus = PUBLISHED): ReviewWorkflowsWorkflowStage
- reviewWorkflowsWorkflowStages(filters: ReviewWorkflowsWorkflowStageFiltersInput, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): [ReviewWorkflowsWorkflowStage]!
- reviewWorkflowsWorkflowStages_connection(filters: ReviewWorkflowsWorkflowStageFiltersInput, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): ReviewWorkflowsWorkflowStageEntityResponseCollection
- reviewWorkflowsWorkflows(filters: ReviewWorkflowsWorkflowFiltersInput, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): [ReviewWorkflowsWorkflow]!
- reviewWorkflowsWorkflows_connection(filters: ReviewWorkflowsWorkflowFiltersInput, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): ReviewWorkflowsWorkflowEntityResponseCollection
+ muxVideo(documentId: ID!, hasPublishedVersion: Boolean, status: PublicationStatus = PUBLISHED): MuxVideo
+ muxVideos(filters: MuxVideoFiltersInput, hasPublishedVersion: Boolean, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): [MuxVideo]!
+ muxVideos_connection(filters: MuxVideoFiltersInput, hasPublishedVersion: Boolean, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): MuxVideoEntityResponseCollection
+ reviewWorkflowsWorkflow(documentId: ID!, hasPublishedVersion: Boolean, status: PublicationStatus = PUBLISHED): ReviewWorkflowsWorkflow
+ reviewWorkflowsWorkflowStage(documentId: ID!, hasPublishedVersion: Boolean, status: PublicationStatus = PUBLISHED): ReviewWorkflowsWorkflowStage
+ reviewWorkflowsWorkflowStages(filters: ReviewWorkflowsWorkflowStageFiltersInput, hasPublishedVersion: Boolean, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): [ReviewWorkflowsWorkflowStage]!
+ reviewWorkflowsWorkflowStages_connection(filters: ReviewWorkflowsWorkflowStageFiltersInput, hasPublishedVersion: Boolean, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): ReviewWorkflowsWorkflowStageEntityResponseCollection
+ reviewWorkflowsWorkflows(filters: ReviewWorkflowsWorkflowFiltersInput, hasPublishedVersion: Boolean, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): [ReviewWorkflowsWorkflow]!
+ reviewWorkflowsWorkflows_connection(filters: ReviewWorkflowsWorkflowFiltersInput, hasPublishedVersion: Boolean, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): ReviewWorkflowsWorkflowEntityResponseCollection
sceneRecommendations(limit: Int, locale: String!, sceneIndex: Int, slug: String, videoId: Int): [SceneRecommendation!]!
semanticSearch(
limit: Int
@@ -2801,33 +2816,35 @@ type Query {
"""
type: String
): SearchResponse!
- uploadFile(documentId: ID!, status: PublicationStatus = PUBLISHED): UploadFile
- uploadFiles(filters: UploadFileFiltersInput, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): [UploadFile]!
- uploadFiles_connection(filters: UploadFileFiltersInput, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): UploadFileEntityResponseCollection
- usersPermissionsRole(documentId: ID!, status: PublicationStatus = PUBLISHED): UsersPermissionsRole
- usersPermissionsRoles(filters: UsersPermissionsRoleFiltersInput, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): [UsersPermissionsRole]!
- usersPermissionsRoles_connection(filters: UsersPermissionsRoleFiltersInput, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): UsersPermissionsRoleEntityResponseCollection
- usersPermissionsUser(documentId: ID!, status: PublicationStatus = PUBLISHED): UsersPermissionsUser
- usersPermissionsUsers(filters: UsersPermissionsUserFiltersInput, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): [UsersPermissionsUser]!
- usersPermissionsUsers_connection(filters: UsersPermissionsUserFiltersInput, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): UsersPermissionsUserEntityResponseCollection
+ uploadFile(documentId: ID!, hasPublishedVersion: Boolean, status: PublicationStatus = PUBLISHED): UploadFile
+ uploadFiles(filters: UploadFileFiltersInput, hasPublishedVersion: Boolean, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): [UploadFile]!
+ uploadFiles_connection(filters: UploadFileFiltersInput, hasPublishedVersion: Boolean, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): UploadFileEntityResponseCollection
+ usersPermissionsRole(documentId: ID!, hasPublishedVersion: Boolean, status: PublicationStatus = PUBLISHED): UsersPermissionsRole
+ usersPermissionsRoles(filters: UsersPermissionsRoleFiltersInput, hasPublishedVersion: Boolean, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): [UsersPermissionsRole]!
+ usersPermissionsRoles_connection(filters: UsersPermissionsRoleFiltersInput, hasPublishedVersion: Boolean, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): UsersPermissionsRoleEntityResponseCollection
+ usersPermissionsUser(documentId: ID!, hasPublishedVersion: Boolean, status: PublicationStatus = PUBLISHED): UsersPermissionsUser
+ usersPermissionsUsers(filters: UsersPermissionsUserFiltersInput, hasPublishedVersion: Boolean, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): [UsersPermissionsUser]!
+ usersPermissionsUsers_connection(filters: UsersPermissionsUserFiltersInput, hasPublishedVersion: Boolean, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): UsersPermissionsUserEntityResponseCollection
video(
documentId: ID!
+ hasPublishedVersion: Boolean
"""The locale to use for the query"""
locale: I18NLocaleCode
status: PublicationStatus = PUBLISHED
): Video
- videoEdition(documentId: ID!, status: PublicationStatus = PUBLISHED): VideoEdition
- videoEditions(filters: VideoEditionFiltersInput, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): [VideoEdition]!
- videoEditions_connection(filters: VideoEditionFiltersInput, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): VideoEditionEntityResponseCollection
- videoImage(documentId: ID!, status: PublicationStatus = PUBLISHED): VideoImage
- videoImages(filters: VideoImageFiltersInput, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): [VideoImage]!
- videoImages_connection(filters: VideoImageFiltersInput, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): VideoImageEntityResponseCollection
- videoOrigin(documentId: ID!, status: PublicationStatus = PUBLISHED): VideoOrigin
- videoOrigins(filters: VideoOriginFiltersInput, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): [VideoOrigin]!
- videoOrigins_connection(filters: VideoOriginFiltersInput, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): VideoOriginEntityResponseCollection
+ videoEdition(documentId: ID!, hasPublishedVersion: Boolean, status: PublicationStatus = PUBLISHED): VideoEdition
+ videoEditions(filters: VideoEditionFiltersInput, hasPublishedVersion: Boolean, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): [VideoEdition]!
+ videoEditions_connection(filters: VideoEditionFiltersInput, hasPublishedVersion: Boolean, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): VideoEditionEntityResponseCollection
+ videoImage(documentId: ID!, hasPublishedVersion: Boolean, status: PublicationStatus = PUBLISHED): VideoImage
+ videoImages(filters: VideoImageFiltersInput, hasPublishedVersion: Boolean, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): [VideoImage]!
+ videoImages_connection(filters: VideoImageFiltersInput, hasPublishedVersion: Boolean, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): VideoImageEntityResponseCollection
+ videoOrigin(documentId: ID!, hasPublishedVersion: Boolean, status: PublicationStatus = PUBLISHED): VideoOrigin
+ videoOrigins(filters: VideoOriginFiltersInput, hasPublishedVersion: Boolean, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): [VideoOrigin]!
+ videoOrigins_connection(filters: VideoOriginFiltersInput, hasPublishedVersion: Boolean, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): VideoOriginEntityResponseCollection
videoStudyQuestion(
documentId: ID!
+ hasPublishedVersion: Boolean
"""The locale to use for the query"""
locale: I18NLocaleCode
@@ -2835,6 +2852,7 @@ type Query {
): VideoStudyQuestion
videoStudyQuestions(
filters: VideoStudyQuestionFiltersInput
+ hasPublishedVersion: Boolean
"""The locale to use for the query"""
locale: I18NLocaleCode
@@ -2844,6 +2862,7 @@ type Query {
): [VideoStudyQuestion]!
videoStudyQuestions_connection(
filters: VideoStudyQuestionFiltersInput
+ hasPublishedVersion: Boolean
"""The locale to use for the query"""
locale: I18NLocaleCode
@@ -2851,17 +2870,18 @@ type Query {
sort: [String] = []
status: PublicationStatus = PUBLISHED
): VideoStudyQuestionEntityResponseCollection
- videoSubtitle(documentId: ID!, status: PublicationStatus = PUBLISHED): VideoSubtitle
- videoSubtitles(filters: VideoSubtitleFiltersInput, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): [VideoSubtitle]!
- videoSubtitles_connection(filters: VideoSubtitleFiltersInput, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): VideoSubtitleEntityResponseCollection
- videoVariant(documentId: ID!, status: PublicationStatus = PUBLISHED): VideoVariant
- videoVariantDownload(documentId: ID!, status: PublicationStatus = PUBLISHED): VideoVariantDownload
- videoVariantDownloads(filters: VideoVariantDownloadFiltersInput, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): [VideoVariantDownload]!
- videoVariantDownloads_connection(filters: VideoVariantDownloadFiltersInput, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): VideoVariantDownloadEntityResponseCollection
- videoVariants(filters: VideoVariantFiltersInput, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): [VideoVariant]!
- videoVariants_connection(filters: VideoVariantFiltersInput, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): VideoVariantEntityResponseCollection
+ videoSubtitle(documentId: ID!, hasPublishedVersion: Boolean, status: PublicationStatus = PUBLISHED): VideoSubtitle
+ videoSubtitles(filters: VideoSubtitleFiltersInput, hasPublishedVersion: Boolean, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): [VideoSubtitle]!
+ videoSubtitles_connection(filters: VideoSubtitleFiltersInput, hasPublishedVersion: Boolean, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): VideoSubtitleEntityResponseCollection
+ videoVariant(documentId: ID!, hasPublishedVersion: Boolean, status: PublicationStatus = PUBLISHED): VideoVariant
+ videoVariantDownload(documentId: ID!, hasPublishedVersion: Boolean, status: PublicationStatus = PUBLISHED): VideoVariantDownload
+ videoVariantDownloads(filters: VideoVariantDownloadFiltersInput, hasPublishedVersion: Boolean, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): [VideoVariantDownload]!
+ videoVariantDownloads_connection(filters: VideoVariantDownloadFiltersInput, hasPublishedVersion: Boolean, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): VideoVariantDownloadEntityResponseCollection
+ videoVariants(filters: VideoVariantFiltersInput, hasPublishedVersion: Boolean, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): [VideoVariant]!
+ videoVariants_connection(filters: VideoVariantFiltersInput, hasPublishedVersion: Boolean, pagination: PaginationArg = {}, sort: [String] = [], status: PublicationStatus = PUBLISHED): VideoVariantEntityResponseCollection
videos(
filters: VideoFiltersInput
+ hasPublishedVersion: Boolean
"""The locale to use for the query"""
locale: I18NLocaleCode
@@ -2871,6 +2891,7 @@ type Query {
): [Video]!
videos_connection(
filters: VideoFiltersInput
+ hasPublishedVersion: Boolean
"""The locale to use for the query"""
locale: I18NLocaleCode
@@ -2879,6 +2900,8 @@ type Query {
status: PublicationStatus = PUBLISHED
): VideoEntityResponseCollection
watchSetting(
+ hasPublishedVersion: Boolean
+
"""The locale to use for the query"""
locale: I18NLocaleCode
status: PublicationStatus = PUBLISHED
@@ -4050,4 +4073,4 @@ input WatchSettingInput {
type WatchSettingRelationResponseCollection {
nodes: [WatchSetting!]!
-}
+}
\ No newline at end of file
diff --git a/apps/cms/src/api/seed-studio/controllers/seed-studio.ts b/apps/cms/src/api/seed-studio/controllers/seed-studio.ts
new file mode 100644
index 000000000..ad47ee8a5
--- /dev/null
+++ b/apps/cms/src/api/seed-studio/controllers/seed-studio.ts
@@ -0,0 +1,182 @@
+import type { Core } from "@strapi/strapi"
+import {
+ sanitizeSlug,
+ suggestAlternativeSlugs,
+} from "../../../lib/sanitize-slug"
+
+type StrapiContext = {
+ status: number
+ body: unknown
+ query: Record
+ request: {
+ header: Record
+ body?: Record
+ }
+}
+
+function validateToken(ctx: StrapiContext): boolean {
+ const token = ctx.request.header["x-seed-studio-token"]
+ const expected = process.env.SEED_STUDIO_API_TOKEN
+ if (!expected || token !== expected) {
+ ctx.status = 401
+ ctx.body = { error: "Invalid or missing X-Seed-Studio-Token" }
+ return false
+ }
+ return true
+}
+
+/**
+ * Heuristic for spotting the Strapi Document Service uniqueness error. The
+ * exception class is internal to @strapi/utils and is not stable across
+ * minor versions, so we match on the message content instead of `instanceof`.
+ */
+function isSlugUniquenessError(err: unknown): boolean {
+ if (!(err instanceof Error)) return false
+ const haystack = `${err.name} ${err.message}`.toLowerCase()
+ if (!haystack.includes("unique")) return false
+ // Must also mention the slug field — the lifecycle has a handful of other
+ // uniqueness constraints (e.g. homepage pins) that we shouldn't swallow.
+ return haystack.includes("slug") || haystack.includes("pathsegment")
+}
+
+export default ({ strapi }: { strapi: Core.Strapi }) => ({
+ async searchVideos(ctx: StrapiContext) {
+ if (!validateToken(ctx)) return
+
+ const body = ctx.request.body ?? {}
+ const query = typeof body.query === "string" ? body.query : ""
+ const tags =
+ Array.isArray(body.tags) &&
+ body.tags.every((t: unknown) => typeof t === "string")
+ ? (body.tags as string[])
+ : undefined
+ const locale = typeof body.locale === "string" ? body.locale : "en"
+
+ if (!query) {
+ ctx.status = 400
+ ctx.body = { error: "Missing required field: query" }
+ return
+ }
+
+ const service = strapi.service("api::seed-studio.seed-studio")
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const results = await (service as any).searchVideos(query, tags, locale)
+
+ ctx.status = 200
+ ctx.body = { videos: results }
+ },
+
+ async publishExperience(ctx: StrapiContext) {
+ if (!validateToken(ctx)) return
+
+ const body = ctx.request.body ?? {}
+ const { title, slug, metaDescription, blocks, platformOrdering, locale } =
+ body as Record
+
+ if (typeof title !== "string" || !title) {
+ ctx.status = 400
+ ctx.body = { error: "Missing required field: title" }
+ return
+ }
+ if (typeof slug !== "string" || !slug) {
+ ctx.status = 400
+ ctx.body = { error: "Missing required field: slug" }
+ return
+ }
+ if (!Array.isArray(blocks) || blocks.length === 0) {
+ ctx.status = 400
+ ctx.body = { error: "Missing required field: blocks (non-empty array)" }
+ return
+ }
+
+ const service = strapi.service("api::seed-studio.seed-studio")
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const svc = service as any
+
+ const resolvedLocale = typeof locale === "string" ? locale : "en"
+
+ try {
+ const result = await svc.publishExperience({
+ title,
+ slug,
+ metaDescription:
+ typeof metaDescription === "string" ? metaDescription : undefined,
+ blocks,
+ platformOrdering: platformOrdering ?? undefined,
+ locale: resolvedLocale,
+ })
+ ctx.status = 201
+ ctx.body = result
+ return
+ } catch (err) {
+ // Surface structured validation errors without treating them as 5xx.
+ if (err instanceof Error && err.name === "InvalidSlugError") {
+ const reason = (err as Error & { reason?: string }).reason
+ ctx.status = 400
+ ctx.body = {
+ error: {
+ message: err.message,
+ code: "INVALID_SLUG",
+ reason,
+ },
+ }
+ return
+ }
+
+ if (isSlugUniquenessError(err)) {
+ // Offer a handful of non-colliding suggestions based on the
+ // sanitized slug. If sanitize itself fails we still return the 409
+ // without suggestions — the author will already have been shown
+ // the 400 on a prior attempt, so hitting the uniqueness branch
+ // with an invalid slug is a best-effort corner case.
+ const sanitized = sanitizeSlug(slug)
+ const basis = sanitized.ok ? sanitized.slug : slug
+ let suggestions: string[] = []
+ try {
+ const taken: string[] = await svc.findSlugsStartingWith(
+ basis,
+ resolvedLocale,
+ 10,
+ )
+ suggestions = suggestAlternativeSlugs(basis, taken)
+ } catch {
+ suggestions = suggestAlternativeSlugs(basis, [basis])
+ }
+ ctx.status = 409
+ ctx.body = {
+ error: {
+ message: "Slug already exists",
+ code: "SLUG_TAKEN",
+ suggestions,
+ },
+ }
+ return
+ }
+
+ strapi.log.error(
+ `[seed-studio] publishExperience failed for slug="${slug}": ${
+ err instanceof Error ? err.message : String(err)
+ }`,
+ )
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const details = (err as any)?.details
+ if (details) {
+ strapi.log.error(
+ `[seed-studio] validation details: ${JSON.stringify(details)}`,
+ )
+ }
+ throw err
+ }
+ },
+
+ async videoCatalogStats(ctx: StrapiContext) {
+ if (!validateToken(ctx)) return
+
+ const service = strapi.service("api::seed-studio.seed-studio")
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const stats = await (service as any).getVideoCatalogStats()
+
+ ctx.status = 200
+ ctx.body = stats
+ },
+})
diff --git a/apps/cms/src/api/seed-studio/routes/seed-studio.ts b/apps/cms/src/api/seed-studio/routes/seed-studio.ts
new file mode 100644
index 000000000..bd2d85062
--- /dev/null
+++ b/apps/cms/src/api/seed-studio/routes/seed-studio.ts
@@ -0,0 +1,34 @@
+export default {
+ routes: [
+ {
+ method: "POST",
+ path: "/seed-studio/search-videos",
+ handler: "seed-studio.searchVideos",
+ config: {
+ auth: false,
+ policies: [],
+ middlewares: [],
+ },
+ },
+ {
+ method: "POST",
+ path: "/seed-studio/publish-experience",
+ handler: "seed-studio.publishExperience",
+ config: {
+ auth: false,
+ policies: [],
+ middlewares: [],
+ },
+ },
+ {
+ method: "GET",
+ path: "/seed-studio/video-catalog-stats",
+ handler: "seed-studio.videoCatalogStats",
+ config: {
+ auth: false,
+ policies: [],
+ middlewares: [],
+ },
+ },
+ ],
+}
diff --git a/apps/cms/src/api/seed-studio/services/seed-studio.test.ts b/apps/cms/src/api/seed-studio/services/seed-studio.test.ts
new file mode 100644
index 000000000..3cc274752
--- /dev/null
+++ b/apps/cms/src/api/seed-studio/services/seed-studio.test.ts
@@ -0,0 +1,179 @@
+import { describe, expect, it } from "vitest"
+import { collectVideoRelations } from "./seed-studio"
+
+/**
+ * Hand-rolled fixture shaped like the `/watch/easter` experience blocks:
+ * a top-level video-hero, a Section wrapper with nested content (including
+ * a container with slots), and a video-carousel with two items. Mirrors the
+ * real dynamic-zone payload shape so the walker's nested `sections.video`
+ * traversal is covered at depths 0 (dynamic zone), 1 (section.content), and
+ * 2 (container.slots). Carousel items stay present as a regression guard that
+ * they are ignored until the DB patch path supports them.
+ */
+const EASTER_LIKE_BLOCKS: Record[] = [
+ {
+ __component: "sections.video-hero",
+ sectionKey: "hero",
+ video: 10,
+ streamingUrl: "https://example.com/hero.m3u8",
+ },
+ {
+ __component: "sections.section",
+ sectionKey: "section-one",
+ content: [
+ {
+ __component: "sections.video",
+ sectionKey: "video-top-1",
+ video: 11,
+ streamingUrl: "https://example.com/v1.m3u8",
+ },
+ {
+ __component: "sections.container",
+ slots: [
+ {
+ gridSpan: 12,
+ content: [
+ {
+ __component: "sections.video",
+ sectionKey: "video-nested",
+ video: 12,
+ streamingUrl: "https://example.com/v2.m3u8",
+ },
+ {
+ __component: "sections.bible-quotes-carousel",
+ heading: "Q",
+ quotes: [],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ __component: "sections.video-carousel",
+ sectionKey: "carousel-one",
+ items: [
+ {
+ sectionKey: "carousel-item-1",
+ video: 13,
+ streamingUrl: "https://example.com/v3.m3u8",
+ },
+ {
+ sectionKey: "carousel-item-2",
+ video: 14,
+ streamingUrl: "https://example.com/v4.m3u8",
+ },
+ ],
+ },
+]
+
+describe("collectVideoRelations", () => {
+ it("returns an empty map for empty blocks", () => {
+ const { map, warnings } = collectVideoRelations([])
+ expect(map.size).toBe(0)
+ expect(warnings).toEqual([])
+ })
+
+ it("returns an empty map for null/undefined blocks", () => {
+ expect(collectVideoRelations(undefined).map.size).toBe(0)
+ expect(collectVideoRelations(null).map.size).toBe(0)
+ })
+
+ it("finds nested `sections.video` entries across section/container depths", () => {
+ const { map, warnings } = collectVideoRelations(EASTER_LIKE_BLOCKS)
+
+ // Top-level sections.video inside a section.content
+ expect(map.get("video-top-1")).toBe(11)
+ // Nested inside a container slot
+ expect(map.get("video-nested")).toBe(12)
+
+ // The walker only patches `sections.video` components (that's the only
+ // table `patchNestedVideoRelations` knows how to look up by section_key),
+ // so video-hero and carousel items are intentionally not in the map. Lock
+ // that in as a regression test — a change in policy here requires
+ // touching the patching helper and the DB as well.
+ expect(map.has("hero")).toBe(false)
+ expect(map.has("carousel-item-1")).toBe(false)
+ expect(map.has("carousel-item-2")).toBe(false)
+
+ expect(map.size).toBe(2)
+ expect(warnings).toEqual([])
+ })
+
+ it("skips `sections.video` entries that have no sectionKey but warns", () => {
+ const blocks: Record[] = [
+ {
+ __component: "sections.section",
+ content: [
+ {
+ __component: "sections.video",
+ // sectionKey intentionally omitted
+ video: 99,
+ streamingUrl: "https://example.com/orphan.m3u8",
+ },
+ {
+ __component: "sections.video",
+ sectionKey: "with-key",
+ video: 100,
+ streamingUrl: "https://example.com/keyed.m3u8",
+ },
+ ],
+ },
+ ]
+
+ const { map, warnings } = collectVideoRelations(blocks)
+
+ expect(map.get("with-key")).toBe(100)
+ expect(map.size).toBe(1)
+ expect(warnings.length).toBe(1)
+ expect(warnings[0]).toMatch(/missing sectionKey/)
+ expect(warnings[0]).toMatch(/video=99/)
+ })
+
+ it("ignores carousel items because the patch helper cannot repair them yet", () => {
+ const blocks: Record[] = [
+ {
+ __component: "sections.video-carousel",
+ sectionKey: "carousel",
+ items: [
+ { video: 200, streamingUrl: "https://example.com/a.m3u8" },
+ {
+ sectionKey: "item-b",
+ video: 201,
+ streamingUrl: "https://example.com/b.m3u8",
+ },
+ ],
+ },
+ ]
+
+ const { map, warnings } = collectVideoRelations(blocks)
+
+ expect(map.size).toBe(0)
+ expect(map.has("item-b")).toBe(false)
+ expect(warnings).toEqual([])
+ })
+
+ it("does not pick up arrays that are not video-shaped", () => {
+ const blocks: Record[] = [
+ {
+ __component: "sections.bible-quotes-carousel",
+ sectionKey: "quotes-1",
+ quotes: [
+ { reference: "John 3:16", text: "For God so loved..." },
+ { reference: "Rom 8:28", text: "All things work..." },
+ ],
+ },
+ ]
+
+ const { map, warnings } = collectVideoRelations(blocks)
+ expect(map.size).toBe(0)
+ expect(warnings).toEqual([])
+ })
+
+ it("is idempotent: same fixture twice yields the same map", () => {
+ const a = collectVideoRelations(EASTER_LIKE_BLOCKS)
+ const b = collectVideoRelations(EASTER_LIKE_BLOCKS)
+ expect([...a.map.entries()].sort()).toEqual([...b.map.entries()].sort())
+ })
+})
diff --git a/apps/cms/src/api/seed-studio/services/seed-studio.ts b/apps/cms/src/api/seed-studio/services/seed-studio.ts
new file mode 100644
index 000000000..4cca6a9ba
--- /dev/null
+++ b/apps/cms/src/api/seed-studio/services/seed-studio.ts
@@ -0,0 +1,323 @@
+import type { Core } from "@strapi/strapi"
+import {
+ DEFAULT_LOCALE,
+ getExperienceService,
+ patchNestedVideoRelations,
+} from "../../../bootstrap/seed-utils"
+import { sanitizeSlug } from "../../../lib/sanitize-slug"
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+type KnexInstance = any
+
+type VideoSearchResult = {
+ id: number
+ documentId: string
+ title: string
+ slug: string
+ description: string | null
+ streamingUrl: string | null
+ thumbnailUrl: string | null
+}
+
+type PublishExperienceInput = {
+ title: string
+ slug: string
+ metaDescription?: string
+ blocks: Record[]
+ platformOrdering?: unknown
+ locale?: string
+}
+
+type PublishExperienceResult = {
+ created: boolean
+ relationsPatched: boolean
+ documentId: string
+ slug: string
+}
+
+type VideoCatalogStats = {
+ totalVideos: number
+ labels: string[]
+ locales: string[]
+}
+
+/**
+ * Error thrown when `data.slug` fails validation. The controller catches this
+ * and returns 400 with a structured body so the seed-studio UI can surface
+ * the exact failure reason without parsing free-form strings.
+ */
+export class InvalidSlugError extends Error {
+ readonly reason:
+ | "empty"
+ | "too-short"
+ | "too-long"
+ | "invalid-chars"
+ | "reserved"
+ constructor(reason: InvalidSlugError["reason"]) {
+ super(`Invalid slug: ${reason}`)
+ this.name = "InvalidSlugError"
+ this.reason = reason
+ }
+}
+
+export default ({ strapi }: { strapi: Core.Strapi }) => ({
+ /**
+ * Search published videos by title/description/slug with ILIKE matching.
+ * Optionally filter by label tags. Returns max 20 results.
+ */
+ async searchVideos(
+ query: string,
+ tags?: string[],
+ locale: string = DEFAULT_LOCALE,
+ ): Promise {
+ const knex: KnexInstance = (strapi.db as KnexInstance).connection
+ const pattern = `%${query}%`
+
+ // streaming URL lives in video_variants.hls, thumbnail in video_images.url
+ let builder = knex("videos as v")
+ .distinctOn("v.id")
+ .select(
+ "v.id",
+ "v.document_id as documentId",
+ "v.title",
+ "v.slug",
+ "v.description",
+ "vv.hls as streamingUrl",
+ knex.raw(
+ 'COALESCE(vi.video_still, vi.url, vi.thumbnail) as "thumbnailUrl"',
+ ),
+ )
+ .leftJoin("video_variants_video_lnk as vvl", "vvl.video_id", "v.id")
+ .leftJoin("video_variants as vv", "vv.id", "vvl.video_variant_id")
+ .leftJoin("video_images_video_lnk as vil", "vil.video_id", "v.id")
+ .leftJoin("video_images as vi", "vi.id", "vil.video_image_id")
+ .where("v.locale", locale)
+ .whereNotNull("v.published_at")
+ .andWhere(function (this: KnexInstance) {
+ this.where("v.title", "ILIKE", pattern)
+ .orWhere("v.description", "ILIKE", pattern)
+ .orWhere("v.slug", "ILIKE", pattern)
+ })
+
+ if (tags && tags.length > 0) {
+ builder = builder.whereIn("v.label", tags)
+ }
+
+ const rows: VideoSearchResult[] = await builder.orderBy("v.id").limit(20)
+
+ // Filter out results without a streaming URL
+ return rows.filter((r) => r.streamingUrl != null)
+ },
+
+ /**
+ * Create (or re-create) an Experience with blocks, then patch nested
+ * video relations that Strapi v5 Document Service silently drops.
+ *
+ * The full delete -> create -> relation-patch sequence runs inside a
+ * single `strapi.db.transaction`. If the create or relation-patch fails,
+ * the delete is rolled back so the caller retains the prior Experience.
+ * Without this, a failed re-publish could leave the slug empty and
+ * break the /watch/ route until manual recovery.
+ *
+ * The slug is sanitized via the central `sanitizeSlug` util before any
+ * DB work; rejections throw `InvalidSlugError` so the controller can map
+ * them to a 400 response.
+ */
+ async publishExperience(
+ data: PublishExperienceInput,
+ ): Promise {
+ const sanitized = sanitizeSlug(data.slug)
+ if (sanitized.ok === false) {
+ throw new InvalidSlugError(sanitized.reason)
+ }
+ const slug = sanitized.slug
+
+ const locale = data.locale ?? DEFAULT_LOCALE
+ const experienceService = getExperienceService(strapi)
+
+ return await strapi.db.transaction(async () => {
+ // Delete existing experience with the same slug for clean re-creation.
+ // A throw later in this transaction rolls the delete back.
+ const existing = await experienceService.findFirst({
+ locale,
+ status: "published",
+ filters: { slug },
+ })
+
+ if (existing) {
+ await experienceService.delete({ documentId: existing.documentId })
+ strapi.log.info(
+ `[seed-studio] Deleted existing Experience "${slug}" for re-creation.`,
+ )
+ }
+
+ // Create via Document Service
+ const created = await experienceService.create({
+ locale,
+ status: "published",
+ data: {
+ slug,
+ title: data.title,
+ metaDescription: data.metaDescription,
+ pathSegment: slug,
+ blocks: data.blocks,
+ ...(data.platformOrdering != null
+ ? { platformOrdering: data.platformOrdering }
+ : {}),
+ },
+ })
+
+ strapi.log.info(
+ `[seed-studio] Created Experience "${slug}" (documentId=${created.documentId}).`,
+ )
+
+ // Build videoMap from blocks: collect all sections.video components
+ // with a sectionKey and numeric video id for relation patching.
+ const { map: videoMap, warnings } = collectVideoRelations(data.blocks)
+ for (const warning of warnings) {
+ strapi.log.warn(`[seed-studio] ${warning}`)
+ }
+
+ let relationsPatched = false
+ if (videoMap.size > 0) {
+ await patchNestedVideoRelations(strapi, videoMap)
+ relationsPatched = true
+ strapi.log.info(
+ `[seed-studio] Patched ${videoMap.size} video relation(s) for "${slug}".`,
+ )
+ }
+
+ return {
+ created: true,
+ relationsPatched,
+ documentId: created.documentId,
+ slug,
+ }
+ })
+ },
+
+ /**
+ * Return up to `limit` existing slugs whose text starts with `${prefix}-`.
+ * Used by the controller to feed `suggestAlternativeSlugs` when a publish
+ * fails due to a slug collision.
+ */
+ async findSlugsStartingWith(
+ prefix: string,
+ locale: string = DEFAULT_LOCALE,
+ limit: number = 10,
+ ): Promise {
+ const knex: KnexInstance = (strapi.db as KnexInstance).connection
+ const rows: { slug: string }[] = await knex("experiences")
+ .select("slug")
+ .where("locale", locale)
+ .andWhere(function (this: KnexInstance) {
+ this.where("slug", prefix).orWhere("slug", "ILIKE", `${prefix}-%`)
+ })
+ .limit(limit)
+ return rows.map((r) => r.slug)
+ },
+
+ /**
+ * Return catalog overview: total published videos, distinct labels, and locales.
+ */
+ async getVideoCatalogStats(): Promise {
+ const knex: KnexInstance = (strapi.db as KnexInstance).connection
+
+ const [countResult]: [{ count: string }] = await knex("videos")
+ .whereNotNull("published_at")
+ .count("id as count")
+
+ const labelRows: { label: string }[] = await knex("videos")
+ .distinct("label")
+ .whereNotNull("published_at")
+ .whereNotNull("label")
+ .where("label", "!=", "")
+ .orderBy("label")
+
+ const localeRows: { locale: string }[] = await knex("videos")
+ .distinct("locale")
+ .whereNotNull("published_at")
+ .whereNotNull("locale")
+ .orderBy("locale")
+
+ return {
+ totalVideos: parseInt(countResult.count, 10),
+ labels: labelRows.map((r) => r.label),
+ locales: localeRows.map((r) => r.locale),
+ }
+ },
+})
+
+/**
+ * Recursively walk blocks to find all `sections.video` components that have
+ * both a `sectionKey` and a numeric `video` id, building the map needed for
+ * patchNestedVideoRelations.
+ *
+ * Handles dynamic zones (arrays of blocks) and containers with nested slots.
+ * It intentionally does not collect `sections.video-carousel.items`, because
+ * `patchNestedVideoRelations` only knows how to patch the `sections.video`
+ * component table today.
+ *
+ * Returns the built map plus a list of warnings (e.g. video components that
+ * were skipped because they had no `sectionKey`). The caller decides what to
+ * do with warnings — the production service logs them via `strapi.log.warn`
+ * while the test suite asserts on them directly.
+ *
+ * Exported so unit tests can exercise it without spinning up Strapi.
+ */
+export function collectVideoRelations(
+ blocks: Record[] | undefined | null,
+): { map: Map; warnings: string[] } {
+ const map = new Map()
+ const warnings: string[] = []
+ if (!Array.isArray(blocks)) return { map, warnings }
+ walk(blocks, map, warnings)
+ return { map, warnings }
+}
+
+function walk(
+ blocks: Record[],
+ videoMap: Map,
+ warnings: string[],
+): void {
+ for (const block of blocks) {
+ if (block.__component === "sections.video") {
+ if (
+ typeof block.sectionKey === "string" &&
+ typeof block.video === "number"
+ ) {
+ videoMap.set(block.sectionKey, block.video)
+ } else if (typeof block.video === "number") {
+ // We have a numeric video id but no sectionKey to patch against —
+ // the nested relation will silently drop. Surface as a warning so
+ // the UI can force an author to fill in the key.
+ warnings.push(
+ `sections.video (video=${block.video}) missing sectionKey; relation will not be patched`,
+ )
+ }
+ }
+
+ // Recurse into container slots
+ if (Array.isArray(block.slots)) {
+ for (const slot of block.slots as Record[]) {
+ if (Array.isArray(slot.content)) {
+ walk(slot.content as Record[], videoMap, warnings)
+ }
+ }
+ }
+
+ // Recurse into dynamic-zone-like arrays only. Non-component arrays such
+ // as `sections.video-carousel.items` are intentionally ignored here until
+ // the patch helper knows their table/link layout too.
+ for (const [key, value] of Object.entries(block)) {
+ if (key === "slots") continue // already handled
+ if (!Array.isArray(value) || value.length === 0) continue
+ const first = value[0]
+ if (typeof first !== "object" || first === null) continue
+
+ if ("__component" in first) {
+ walk(value as Record[], videoMap, warnings)
+ }
+ }
+ }
+}
diff --git a/apps/cms/src/bootstrap/seed-utils.ts b/apps/cms/src/bootstrap/seed-utils.ts
index 4ed72f645..49ba38dd0 100644
--- a/apps/cms/src/bootstrap/seed-utils.ts
+++ b/apps/cms/src/bootstrap/seed-utils.ts
@@ -86,7 +86,9 @@ export async function findOrCreatePublishedVideo(
* After Experience creation, Strapi v5 Document Service silently drops
* relations in components nested 2+ levels deep in dynamic zones.
* This function patches the missing link table rows for `sections.video`
- * components by matching on `section_key`.
+ * components by matching on `section_key`. It does not patch
+ * `sections.video-carousel.items`; those live in different component/link
+ * tables and need a separate repair path.
*
* @param videoMap sectionKey → numeric video ID (same keys used in buildVideoSectionContent)
*/
diff --git a/apps/cms/src/lib/sanitize-slug.test.ts b/apps/cms/src/lib/sanitize-slug.test.ts
new file mode 100644
index 000000000..698575d44
--- /dev/null
+++ b/apps/cms/src/lib/sanitize-slug.test.ts
@@ -0,0 +1,112 @@
+import { describe, expect, it } from "vitest"
+import { sanitizeSlug, suggestAlternativeSlugs } from "./sanitize-slug"
+
+describe("sanitizeSlug", () => {
+ it("accepts a clean lowercase hyphenated slug", () => {
+ expect(sanitizeSlug("easter-story")).toEqual({
+ ok: true,
+ slug: "easter-story",
+ })
+ })
+
+ it("rejects an empty string", () => {
+ expect(sanitizeSlug("")).toEqual({ ok: false, reason: "empty" })
+ })
+
+ it("rejects a whitespace-only string as empty", () => {
+ expect(sanitizeSlug(" ")).toEqual({ ok: false, reason: "empty" })
+ })
+
+ it("rejects a single-character slug as too-short", () => {
+ expect(sanitizeSlug("a")).toEqual({ ok: false, reason: "too-short" })
+ })
+
+ it("rejects slugs longer than 80 characters", () => {
+ const long = "a".repeat(81)
+ expect(sanitizeSlug(long)).toEqual({ ok: false, reason: "too-long" })
+ })
+
+ it("lowercases and normalizes whitespace and underscores", () => {
+ expect(sanitizeSlug("Easter Story_Part 1")).toEqual({
+ ok: true,
+ slug: "easter-story-part-1",
+ })
+ })
+
+ it("strips unicode and other non [a-z0-9-] characters", () => {
+ expect(sanitizeSlug("café—story!")).toEqual({
+ ok: true,
+ slug: "caf-story",
+ })
+ })
+
+ it("collapses double hyphens produced by normalization", () => {
+ expect(sanitizeSlug("easter---story")).toEqual({
+ ok: true,
+ slug: "easter-story",
+ })
+ })
+
+ it("strips leading and trailing hyphens", () => {
+ expect(sanitizeSlug("---easter-story---")).toEqual({
+ ok: true,
+ slug: "easter-story",
+ })
+ })
+
+ it("treats all-symbol input as too-short after stripping", () => {
+ // `!!!!` → `` after strip, which is empty; we report too-short so the
+ // UI can differentiate from a literal blank submit.
+ expect(sanitizeSlug("!!!!")).toEqual({ ok: false, reason: "too-short" })
+ })
+
+ it("rejects reserved words (case insensitive)", () => {
+ expect(sanitizeSlug("admin")).toEqual({ ok: false, reason: "reserved" })
+ expect(sanitizeSlug("Watch")).toEqual({ ok: false, reason: "reserved" })
+ expect(sanitizeSlug("_next")).toEqual({ ok: false, reason: "reserved" })
+ })
+
+ it("coerces non-string input to string before processing", () => {
+ expect(sanitizeSlug(null)).toEqual({ ok: false, reason: "empty" })
+ expect(sanitizeSlug(undefined)).toEqual({ ok: false, reason: "empty" })
+ expect(sanitizeSlug(42)).toEqual({ ok: true, slug: "42" })
+ })
+
+ it("accepts numeric-only slugs of length >=2", () => {
+ expect(sanitizeSlug("2026")).toEqual({ ok: true, slug: "2026" })
+ })
+
+ it("accepts the max-length slug", () => {
+ const max = "a".repeat(80)
+ expect(sanitizeSlug(max)).toEqual({ ok: true, slug: max })
+ })
+})
+
+describe("suggestAlternativeSlugs", () => {
+ it("returns the slug first when it is not taken", () => {
+ expect(suggestAlternativeSlugs("easter", [])).toEqual([
+ "easter",
+ "easter-2",
+ "easter-3",
+ ])
+ })
+
+ it("returns only suffixed alternatives when the slug is taken", () => {
+ expect(suggestAlternativeSlugs("easter", ["easter"])).toEqual([
+ "easter-2",
+ "easter-3",
+ "easter-4",
+ ])
+ })
+
+ it("skips taken suffixed alternatives", () => {
+ expect(
+ suggestAlternativeSlugs("easter", ["easter", "easter-2", "easter-3"]),
+ ).toEqual(["easter-4", "easter-5", "easter-6"])
+ })
+
+ it("caps output at 3 entries", () => {
+ const result = suggestAlternativeSlugs("easter", [])
+ expect(result.length).toBe(3)
+ })
+})
diff --git a/apps/cms/src/lib/sanitize-slug.ts b/apps/cms/src/lib/sanitize-slug.ts
new file mode 100644
index 000000000..027f3f34b
--- /dev/null
+++ b/apps/cms/src/lib/sanitize-slug.ts
@@ -0,0 +1,143 @@
+/**
+ * Slug sanitization + reserved-word deny-list.
+ *
+ * A slug flows into three load-bearing places:
+ * 1. the URL path segment (e.g. `/watch/`)
+ * 2. the `pathSegment` column in the Experience table
+ * 3. the Strapi admin UI identifier
+ *
+ * Because all three share the same string, a malformed or reserved slug can
+ * collide with Next.js / Strapi routes (e.g. `/watch/admin`) or the admin
+ * surface. This module is the single source of truth for what we accept.
+ */
+
+export type SanitizeResult =
+ | { ok: true; slug: string }
+ | {
+ ok: false
+ reason: "empty" | "too-short" | "too-long" | "invalid-chars" | "reserved"
+ }
+
+/**
+ * Lowercased reserved words that must never be accepted as a slug. These
+ * collide with Next.js / Strapi route prefixes or common admin/system paths.
+ * Matching is case-insensitive.
+ */
+const RESERVED = new Set([
+ "admin",
+ "api",
+ "watch",
+ "_next",
+ // Post-normalization of `_next` (underscores -> hyphens, then trimmed)
+ // collapses to `next`, so reserve both forms so neither survives.
+ "next",
+ ".well-known",
+ // `.well-known` normalizes to `well-known` after the leading dot is
+ // stripped, so reserve that form too.
+ "well-known",
+ "default",
+ "home",
+ "index",
+ "new",
+ "edit",
+ "public",
+ "robots",
+ "sitemap",
+])
+
+/** Well-formed slug: lowercase alnum tokens joined by single hyphens. */
+const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
+
+const MIN_LEN = 2
+const MAX_LEN = 80
+
+/**
+ * Coerce, normalize, and validate a slug candidate.
+ *
+ * Normalization rules (in order):
+ * - trim
+ * - lowercase
+ * - replace whitespace and underscores with a single hyphen
+ * - strip any character outside [a-z0-9-]
+ * - collapse consecutive hyphens
+ * - strip leading/trailing hyphens
+ *
+ * Rejections (first failure wins):
+ * - empty after trim -> "empty"
+ * - pre-normalization under 2 chars -> "too-short"
+ * - pre-normalization over 80 chars -> "too-long"
+ * - post-normalization does not match SLUG_RE -> "invalid-chars"
+ * - post-normalization under 2 chars -> "too-short"
+ * - post-normalization in RESERVED -> "reserved"
+ */
+export function sanitizeSlug(input: unknown): SanitizeResult {
+ const raw = typeof input === "string" ? input : String(input ?? "")
+ const trimmed = raw.trim()
+
+ if (trimmed.length === 0) {
+ return { ok: false, reason: "empty" }
+ }
+ if (trimmed.length < MIN_LEN) {
+ return { ok: false, reason: "too-short" }
+ }
+ if (trimmed.length > MAX_LEN) {
+ return { ok: false, reason: "too-long" }
+ }
+
+ // Replace any run of non [a-z0-9] with a single hyphen, then collapse
+ // consecutive hyphens and trim. This preserves word boundaries when the
+ // caller pastes prose with unicode punctuation (e.g. `café—story`) which
+ // is friendlier than stripping non-alnum silently.
+ const normalized = trimmed
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/-+/g, "-")
+ .replace(/^-+|-+$/g, "")
+
+ if (normalized.length < MIN_LEN) {
+ // Either all characters were stripped or only 1 survived — treat both as
+ // too-short so the caller can message the user consistently.
+ return { ok: false, reason: "too-short" }
+ }
+
+ if (!SLUG_RE.test(normalized)) {
+ return { ok: false, reason: "invalid-chars" }
+ }
+
+ if (RESERVED.has(normalized)) {
+ return { ok: false, reason: "reserved" }
+ }
+
+ return { ok: true, slug: normalized }
+}
+
+/**
+ * Given a desired slug and the list of already-taken slugs, return up to
+ * three non-colliding alternatives.
+ *
+ * If `slug` itself is free, it is returned first so callers can offer it as
+ * a one-click suggestion; otherwise the suggestions are `slug-2`, `slug-3`,
+ * `slug-4`, … skipping any taken ones, up to 3 results.
+ */
+export function suggestAlternativeSlugs(
+ slug: string,
+ takenSlugs: string[],
+): string[] {
+ const taken = new Set(takenSlugs)
+ const out: string[] = []
+
+ if (!taken.has(slug)) {
+ out.push(slug)
+ }
+
+ let suffix = 2
+ while (out.length < 3 && suffix < 100) {
+ const candidate = `${slug}-${suffix}`
+ if (!taken.has(candidate)) {
+ out.push(candidate)
+ }
+ suffix += 1
+ }
+
+ return out
+}
diff --git a/apps/roadmap/tsconfig.json b/apps/roadmap/tsconfig.json
index 4b73c4bcc..705f5ce5e 100644
--- a/apps/roadmap/tsconfig.json
+++ b/apps/roadmap/tsconfig.json
@@ -11,7 +11,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
- "jsx": "preserve",
+ "jsx": "react-jsx",
"incremental": true,
"plugins": [
{
diff --git a/apps/seed-studio/christian_experience_ai_agents.md b/apps/seed-studio/christian_experience_ai_agents.md
new file mode 100644
index 000000000..62ab9ed9d
--- /dev/null
+++ b/apps/seed-studio/christian_experience_ai_agents.md
@@ -0,0 +1,573 @@
+# AI Experience Generation Agents for Christian Web Content
+
+## Purpose
+
+This document outlines an improved multi-agent workflow for generating Christian web content and Experiences.
+
+The goal is to move away from one-shot AI generation such as:
+
+```text
+theme → search videos → generate simple content
+```
+
+That approach often produces weak results, like a frozen search result.
+
+The improved direction is:
+
+```text
+keywords/theme
+→ brainstorm
+→ story frame
+→ content plan
+→ video selection
+→ design/layout
+→ platform tuning
+→ multilingual adaptation
+→ review
+→ final Experience
+```
+
+The Experience should feel like a guided Christian journey, not only a list of related videos.
+
+---
+
+## Main Improvement Areas
+
+### 1. Improve AI generation quality beyond one-shot generation
+
+Do not let one prompt do everything.
+
+Instead of:
+
+```text
+Generate an Experience about hope.
+```
+
+Use a multi-step generation workflow:
+
+```text
+Step 1: Understand the user input
+Step 2: Brainstorm possible angles
+Step 3: Choose the strongest story direction
+Step 4: Build the Experience frame
+Step 5: Generate content for each section
+Step 6: Select videos that support the story
+Step 7: Choose layout/design blocks
+Step 8: Adapt for web/mobile/TV
+Step 9: Review theological quality and user experience
+Step 10: Produce final Experience JSON/draft
+```
+
+---
+
+### 2. Add multi-step generation
+
+Recommended workflow:
+
+```text
+User input
+↓
+1. Idea / Brainstorm Agent
+↓
+2. Story Frame Agent
+↓
+3. Content Agent
+↓
+4. Video Selection Agent
+↓
+5. Design / Layout Agent
+↓
+6. Translation / Language Agent
+↓
+7. Platform Adaptation Agent
+↓
+8. Review Agent
+↓
+Final Experience Draft
+```
+
+---
+
+### 3. Consider mobile-specific fine-tuning
+
+One Experience can have the same main message, but different platform adjustments.
+
+```text
+One Experience idea, but platform-specific rendering.
+```
+
+Mobile may need:
+
+- Shorter text
+- Shorter headings
+- Simpler layout
+- Clearer CTA
+- Less scroll depth
+- Stronger visual hierarchy
+- Better video placement
+
+Web may support:
+
+- More text
+- Richer layout
+- More context
+- More supporting resources
+
+TV may need:
+
+- Simpler navigation
+- Bigger text
+- Fewer interactive elements
+- Video-first structure
+
+---
+
+### 4. Try faster/free models via OpenRouter for local testing
+
+Not every step needs the strongest or most expensive model.
+
+Suggested model strategy:
+
+| Workflow Step | Suggested Model Type |
+| -------------------------- | ------------------------------- |
+| Keyword grouping | Cheap/free model |
+| Brainstorming | Cheap or medium model |
+| Story frame | Better reasoning model |
+| Content writing | Good writing model |
+| Video matching explanation | Medium model |
+| Translation draft | Cheap/medium multilingual model |
+| Theological review | Stronger model or Apologist API |
+| Final review | Stronger model |
+
+Use cheaper/faster models for early draft work, and stronger models for final reasoning, theology, and quality review.
+
+---
+
+## Full Experience Generation Workflow
+
+```text
+1. User gives multiple keywords, audience, language, and purpose
+
+2. Brainstorm Agent
+ - Creates 5 possible directions
+
+3. Story Frame Agent
+ - Builds the Experience journey
+
+4. Content Agent
+ - Writes section content
+
+5. Video Selection Agent
+ - Selects videos based on story fit, not only keyword match
+
+6. Design Agent
+ - Chooses layout blocks and visual structure
+
+7. Translation / Language Agent
+ - Creates multilingual versions
+
+8. Platform Adaptation Agent
+ - Tunes for web, mobile, and TV
+
+9. Review Agent
+ - Checks quality, theology, UX, mobile, and final readiness
+
+10. Save as draft
+ - Human can review before publish
+```
+
+---
+
+## Agent 1: Idea / Brainstorm Agent
+
+### Purpose
+
+Take more than one keyword and turn it into possible Christian Experience directions.
+
+### Prompt
+
+```text
+You are a Christian Experience Brainstorm Agent.
+
+The user may give many rough keywords, Bible references, audience types, emotions, or ministry goals.
+
+Your job is to create several possible Experience directions.
+
+Input:
+[USER KEYWORDS / THEME / AUDIENCE]
+
+Create:
+1. Keyword interpretation
+2. Keyword grouping
+3. 5 possible Experience angles
+4. Best recommended angle
+5. Reason why this angle is strongest
+6. A clean content brief for the next agent
+
+Do not write the full Experience yet.
+Avoid generic Christian content.
+Focus on story, spiritual journey, and user transformation.
+```
+
+---
+
+## Agent 2: Story Frame Agent
+
+### Purpose
+
+Create the skeleton before content is written.
+
+### Prompt
+
+```text
+You are a Christian Experience Story Frame Agent.
+
+Use the content brief.
+
+Your job is to create the frame of the Experience before writing full content.
+
+Create:
+1. Experience title
+2. Main spiritual journey
+3. Opening hook
+4. Section-by-section frame
+5. Purpose of each section
+6. Suggested emotional flow
+7. Suggested video role for each section
+8. Suggested call to action
+
+The frame should feel like a guided journey, not a search result.
+
+Output example:
+- Section 1: The struggle
+- Section 2: The biblical truth
+- Section 3: The story/video connection
+- Section 4: Personal reflection
+- Section 5: Response / call to action
+```
+
+---
+
+## Agent 3: Content Agent
+
+### Purpose
+
+Fill the story frame with strong Christian content.
+
+### Prompt
+
+```text
+You are a Christian Experience Content Writer.
+
+Use the Story Frame.
+
+Write content for each section.
+
+Requirements:
+1. Warm and pastoral tone
+2. Clear Christian message
+3. Practical reflection
+4. Short enough for web/mobile experience
+5. Avoid long sermon-style paragraphs
+6. Avoid shallow clichés
+7. Avoid prosperity gospel language
+8. Use Bible references carefully
+9. Do not invent Bible quotations
+
+For each section, write:
+- Heading
+- Short body content
+- Reflection question
+- Optional prayer or response line
+```
+
+---
+
+## Agent 4: Video Selection Agent
+
+### Purpose
+
+Choose videos based on the story, not only keyword similarity.
+
+This is important because a video can match a keyword but still not fit the story.
+
+### Prompt
+
+```text
+You are a Christian Video Selection Agent.
+
+Use the Experience frame and generated content.
+
+Your job is to select videos that support the story journey.
+
+Do not choose videos only because they match keywords.
+
+For each candidate video, evaluate:
+1. Does it support the section purpose?
+2. Does it fit the audience?
+3. Does it fit the emotional tone?
+4. Does it move the story forward?
+5. Is it suitable for web, mobile, and TV?
+6. Should it be included, rejected, or replaced?
+
+Output:
+- Recommended videos
+- Section placement
+- Reason for selection
+- Videos to reject
+- Missing video needs
+```
+
+---
+
+## Agent 5: Design / Layout Agent
+
+### Purpose
+
+Decide how the Experience should look and which blocks should be used.
+
+### Prompt
+
+```text
+You are a Christian Experience Design Agent.
+
+Use the content and selected videos.
+
+Your job is to choose the best layout and blocks for the Experience.
+
+For each section, recommend:
+1. Block type
+2. Heading style
+3. Text length
+4. Video placement
+5. Image or background suggestion
+6. CTA placement
+7. Whether the section works better on web, mobile, or TV
+
+Keep the design simple, modern, readable, and emotionally appropriate.
+Avoid clutter.
+Avoid making the Experience feel like a blog article only.
+```
+
+---
+
+## Agent 6: Translation / Language Agent
+
+### Purpose
+
+Create multilingual versions of the Experience.
+
+The goal is not only direct translation, but natural adaptation.
+
+### Prompt
+
+```text
+You are a Christian Language Adaptation Agent.
+
+Your job is to adapt Christian content into multiple languages.
+
+Do not translate word-for-word. Adapt naturally.
+
+Input:
+Source content:
+[PASTE CONTENT]
+
+Target languages:
+[English, Thai, Lao, Chinese, Korean, etc.]
+
+Audience:
+[PASTE AUDIENCE]
+
+Tone:
+[PASTE TONE]
+
+For each language, produce:
+1. Natural translated/adapted version
+2. Notes about cultural or wording changes
+3. Suggested title
+4. Suggested call to action
+
+Rules:
+- Keep the same biblical message
+- Keep the same main idea
+- Make each language sound natural
+- Use Christian vocabulary appropriate for that language
+- Avoid awkward literal translation
+- Avoid changing the theology
+```
+
+---
+
+## Agent 7: Platform Adaptation Agent
+
+### Purpose
+
+Tune the Experience for web, mobile, and TV.
+
+### Prompt
+
+```text
+You are a Platform Adaptation Agent for Christian Experiences.
+
+Use the generated Experience.
+
+Your job is to adapt the Experience for different platforms:
+- Web
+- Mobile
+- TV
+
+Check each section for:
+1. Text length
+2. Heading length
+3. Video size and placement
+4. CTA visibility
+5. Scroll depth
+6. Readability
+7. Whether the section feels too heavy for mobile
+8. Whether TV needs simpler navigation
+
+Create:
+1. Web version recommendations
+2. Mobile version recommendations
+3. TV version recommendations
+4. Any fields that should be platform-specific
+5. Any content that should be shortened for mobile
+6. Any design blocks that should change per platform
+```
+
+---
+
+## Agent 8: Review Agent
+
+### Purpose
+
+Critique the Experience before publishing.
+
+### Prompt
+
+```text
+You are a Christian Experience Review Agent.
+
+Review the generated Experience before it is published.
+
+Check:
+1. Is the Experience a real story/journey, not just search results?
+2. Is the Christian message clear?
+3. Is the theology faithful?
+4. Are the videos suitable?
+5. Does each section have a purpose?
+6. Is the content useful for the audience?
+7. Is the mobile version readable?
+8. Is the call to action clear?
+9. Is anything too generic?
+10. What should be improved?
+
+Give:
+- Score out of 10
+- Main problems
+- Specific improvements
+- Final revised version
+```
+
+---
+
+## Master Orchestrator Prompt
+
+Use this if one agent controls the whole process.
+
+```text
+You are a Christian Experience Generation Orchestrator.
+
+Your job is not to generate everything in one step.
+
+You must create a high-quality Christian Experience through multiple steps:
+
+1. Interpret the user input
+2. Brainstorm 5 possible Experience directions
+3. Choose the strongest direction
+4. Create a story frame
+5. Write section content
+6. Select videos that support the story
+7. Recommend design/layout blocks
+8. Adapt the Experience for web, mobile, and TV
+9. Create multilingual versions if requested
+10. Review the final Experience for quality
+
+Important principles:
+- Do not create frozen search results.
+- Create a meaningful spiritual journey.
+- Video selection must support the story, not only match keywords.
+- Content must be biblically faithful and pastorally useful.
+- Mobile may need shorter text, simpler layout, and clearer CTA.
+- Use multiple improvement rounds before final output.
+- Avoid shallow clichés and prosperity gospel language.
+- Do not invent Bible quotations.
+- Save the result as a draft unless the user chooses publish.
+
+User input:
+[PASTE USER INPUT HERE]
+```
+
+---
+
+## Suggested User Input Form
+
+The user should be able to enter more than one keyword.
+
+```text
+Main keywords:
+[prayer, anxiety, hope]
+
+Bible references:
+[Psalm 27, John 15]
+
+Audience:
+[youth, young adults, Thai Christians overseas]
+
+Emotion/problem:
+[loneliness, fear, waiting, confusion]
+
+Purpose:
+[encourage, teach, invite, evangelize]
+
+Content type:
+[blog, devotional, homepage section, social post, sermon summary, Experience]
+
+Languages:
+[English, Thai, Lao, Chinese, Korean]
+
+Tone:
+[warm, pastoral, simple, deep, youth-friendly]
+
+Platform:
+[web, mobile, TV]
+
+Call to action:
+[pray, watch video, join group, contact church, read more]
+```
+
+---
+
+## Example Team Explanation
+
+You can explain this direction to the team like this:
+
+```text
+I want to move the AI Experience generation away from one-shot generation. Right now it feels too much like theme-based search results. I think we should make it a workflow: brainstorm, create a story frame, generate content, select videos based on the story, design the sections, adapt for mobile/web/TV, then review. I also want to test faster or free models through OpenRouter for local development, because not every step needs the strongest model.
+```
+
+---
+
+## Key Principle
+
+The final Experience should not simply answer:
+
+```text
+What videos match this theme?
+```
+
+It should answer:
+
+```text
+What spiritual journey should the user experience, and which content, videos, design, language, and platform choices best support that journey?
+```
diff --git a/apps/seed-studio/next.config.mjs b/apps/seed-studio/next.config.mjs
new file mode 100644
index 000000000..3da654a14
--- /dev/null
+++ b/apps/seed-studio/next.config.mjs
@@ -0,0 +1,21 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ images: {
+ remotePatterns: [
+ { protocol: "https", hostname: "image.mux.com" },
+ { protocol: "https", hostname: "imagedelivery.net" },
+ { protocol: "https", hostname: "images.unsplash.com" },
+ ...(process.env.NEXT_PUBLIC_CMS_HOSTNAME
+ ? [
+ {
+ protocol: process.env.NEXT_PUBLIC_CMS_PROTOCOL || "https",
+ hostname: process.env.NEXT_PUBLIC_CMS_HOSTNAME,
+ pathname: "/uploads/**",
+ },
+ ]
+ : []),
+ ],
+ },
+}
+
+export default nextConfig
diff --git a/apps/seed-studio/package.json b/apps/seed-studio/package.json
new file mode 100644
index 000000000..30e18da2b
--- /dev/null
+++ b/apps/seed-studio/package.json
@@ -0,0 +1,33 @@
+{
+ "name": "@forge/seed-studio",
+ "private": true,
+ "version": "0.0.1",
+ "scripts": {
+ "dev": "next dev --port 3200",
+ "build": "next build",
+ "start": "next start",
+ "lint": "eslint .",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@forge/experience-templates": "workspace:*",
+ "clsx": "^2.1.1",
+ "hls.js": "^1.6.15",
+ "lucide-react": "^0.577.0",
+ "next": "^16.1.6",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "tailwind-merge": "^3.5.0",
+ "zod": "^4.1.12"
+ },
+ "devDependencies": {
+ "@tailwindcss/postcss": "^4.1.18",
+ "@types/react": "^19.0.0",
+ "@types/react-dom": "^19.0.0",
+ "eslint": "^9.0.0",
+ "eslint-config-next": "^16.1.6",
+ "postcss": "^8.5.6",
+ "tailwindcss": "^4.1.18",
+ "typescript": "^5"
+ }
+}
diff --git a/apps/seed-studio/postcss.config.mjs b/apps/seed-studio/postcss.config.mjs
new file mode 100644
index 000000000..f6c75ff96
--- /dev/null
+++ b/apps/seed-studio/postcss.config.mjs
@@ -0,0 +1,8 @@
+/** @type {import('postcss-load-config').Config} */
+const config = {
+ plugins: {
+ "@tailwindcss/postcss": {},
+ },
+}
+
+export default config
diff --git a/apps/seed-studio/src/app/actions/publish.ts b/apps/seed-studio/src/app/actions/publish.ts
new file mode 100644
index 000000000..778663175
--- /dev/null
+++ b/apps/seed-studio/src/app/actions/publish.ts
@@ -0,0 +1,179 @@
+"use server"
+
+import type { GeneratedExperience } from "@/lib/ai/experience-schema"
+import {
+ publishExperience as publishToStrapi,
+ type PublishResult,
+} from "@/lib/strapi-client"
+
+type Block = Record
+
+/**
+ * Drop blocks or fields that the CMS schema would reject. LLMs (especially
+ * the free-form Gemini / Claude CLI paths) sometimes omit required strings
+ * like `bibleQuote.reference`. Rather than failing the whole publish, we
+ * quietly filter the bad quotes so the rest of the experience still lands.
+ */
+function sanitizeBlocksForPublish(blocks: unknown[]): Block[] {
+ const sanitized: Block[] = []
+ for (const raw of blocks) {
+ if (!raw || typeof raw !== "object") continue
+ const block = { ...(raw as Block) }
+
+ // bible-quotes-carousel: require `reference`, `text`, `imageUrl`,
+ // `backgroundColor` on each quote. Drop quotes that don't have them.
+ if (block.__component === "sections.bible-quotes-carousel") {
+ const rawQuotes = Array.isArray(block.quotes) ? block.quotes : []
+ const quotes = rawQuotes
+ .filter(
+ (q): q is Block => !!q && typeof q === "object" && !Array.isArray(q),
+ )
+ .map((q) => ({
+ ...q,
+ reference: typeof q.reference === "string" ? q.reference : "",
+ text: typeof q.text === "string" ? q.text : "",
+ imageUrl:
+ typeof q.imageUrl === "string" && q.imageUrl
+ ? q.imageUrl
+ : "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=900",
+ backgroundColor:
+ typeof q.backgroundColor === "string" && q.backgroundColor
+ ? q.backgroundColor
+ : "#1e3a5f",
+ }))
+ .filter((q) => q.reference && q.text)
+ if (quotes.length === 0) continue
+ block.quotes = quotes
+ }
+
+ // video sections: drop if no streamingUrl (prevents "HLS playlist error"
+ // for placeholder URLs on /watch).
+ if (
+ (block.__component === "sections.video" ||
+ block.__component === "sections.video-hero") &&
+ (typeof block.streamingUrl !== "string" || !block.streamingUrl)
+ ) {
+ continue
+ }
+
+ sanitized.push(block)
+ }
+ return sanitized
+}
+
+/**
+ * AI providers (Gemini / Claude CLI / Codex / Ollama) emit flat blocks by
+ * default — `[VideoHero, Text, Video, Video, BibleQuotes]` with no wrapping
+ * `sections.section`. Rendered verbatim, the child components (Text, Video,
+ * BibleQuotes) have no container padding and run edge-to-edge, unlike the
+ * hand-crafted `/watch/easter` reference which wraps every block in a
+ * `ComponentSectionsSection` with `backgroundColor` + padding.
+ *
+ * We wrap flat output in Section wrappers at publish time so the stored
+ * layout matches the easter template and the existing web renderer handles
+ * padding, background, and visual rhythm.
+ *
+ * Rule:
+ * - `sections.video-hero` stays at the top level (CMS does not allow it
+ * inside a Section's nested dynamic zone).
+ * - Every `sections.video` block anchors a new Section. Any preceding
+ * non-video blocks (e.g. a Text intro) are prepended to that Section's
+ * content.
+ * - Any trailing non-video blocks (bible quotes, related questions) form a
+ * final Section.
+ * - Pre-existing `sections.section` wrappers are passed through unchanged
+ * so the strict-schema path (OpenRouter) still works.
+ */
+const SECTION_BACKGROUNDS = [
+ "default",
+ "dark",
+ "primary",
+ "cosmic",
+ "light",
+] as const
+const SECTION_WRAPPER = "sections.section"
+const VIDEO_HERO = "sections.video-hero"
+const VIDEO = "sections.video"
+const NESTED_ALLOWED = new Set([
+ "sections.text",
+ "sections.video",
+ "sections.video-carousel",
+ "sections.related-questions",
+ "sections.bible-quotes-carousel",
+ "sections.quiz-button",
+ "sections.media-collection",
+ "sections.container",
+ "sections.navigation-carousel",
+ "sections.cta",
+ "sections.card",
+ "sections.info-blocks",
+ "sections.promo-banner",
+])
+
+function wrapFlatBlocksWithSections(slug: string, blocks: Block[]): Block[] {
+ // If the AI already produced wrappers, trust them.
+ if (blocks.some((b) => b.__component === SECTION_WRAPPER)) {
+ return blocks
+ }
+
+ const out: Block[] = []
+ let pending: Block[] = []
+ let sectionIndex = 0
+
+ const flushSection = () => {
+ if (pending.length === 0) return
+ const bg = SECTION_BACKGROUNDS[sectionIndex % SECTION_BACKGROUNDS.length]
+ out.push({
+ __component: SECTION_WRAPPER,
+ sectionKey: `${slug}-section-${sectionIndex + 1}`,
+ backgroundColor: bg,
+ content: pending,
+ })
+ pending = []
+ sectionIndex += 1
+ }
+
+ for (const block of blocks) {
+ const comp = block.__component
+ if (comp === VIDEO_HERO) {
+ // Hero always sits at the top level.
+ flushSection()
+ out.push(block)
+ continue
+ }
+ if (comp === SECTION_WRAPPER) {
+ flushSection()
+ out.push(block)
+ continue
+ }
+ if (typeof comp !== "string" || !NESTED_ALLOWED.has(comp)) {
+ // Unknown component: leave at top level (renderer will skip if
+ // unknown, but we don't want to lose possibly-valid blocks).
+ flushSection()
+ out.push(block)
+ continue
+ }
+ // A Video anchors a new section with any preceding blocks as intro.
+ if (comp === VIDEO && pending.length > 0) {
+ pending.push(block)
+ flushSection()
+ continue
+ }
+ pending.push(block)
+ }
+ flushSection()
+
+ return out
+}
+
+export async function publishExperience(
+ experience: GeneratedExperience,
+): Promise {
+ const sanitized = sanitizeBlocksForPublish(experience.blocks as unknown[])
+ const wrapped = wrapFlatBlocksWithSections(experience.slug, sanitized)
+ const clean: GeneratedExperience = {
+ ...experience,
+ blocks: wrapped as GeneratedExperience["blocks"],
+ }
+ return publishToStrapi(clean)
+}
diff --git a/apps/seed-studio/src/app/api/auth/route.ts b/apps/seed-studio/src/app/api/auth/route.ts
new file mode 100644
index 000000000..443cde550
--- /dev/null
+++ b/apps/seed-studio/src/app/api/auth/route.ts
@@ -0,0 +1,28 @@
+import { NextResponse } from "next/server"
+import type { NextRequest } from "next/server"
+
+const SESSION_COOKIE = "seed-studio-session"
+const SESSION_MAX_AGE = 60 * 60 * 24 // 24 hours
+
+export async function POST(request: NextRequest) {
+ const body = (await request.json()) as { password?: string }
+ const password = body.password ?? ""
+ const expected = process.env.SEED_STUDIO_PASSWORD ?? ""
+
+ if (!expected || password !== expected) {
+ return NextResponse.json({ error: "Invalid password" }, { status: 401 })
+ }
+
+ const token = crypto.randomUUID()
+ const response = NextResponse.json({ ok: true })
+
+ response.cookies.set(SESSION_COOKIE, token, {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === "production",
+ sameSite: "lax",
+ maxAge: SESSION_MAX_AGE,
+ path: "/",
+ })
+
+ return response
+}
diff --git a/apps/seed-studio/src/app/api/chat/route.ts b/apps/seed-studio/src/app/api/chat/route.ts
new file mode 100644
index 000000000..8a8cc78c1
--- /dev/null
+++ b/apps/seed-studio/src/app/api/chat/route.ts
@@ -0,0 +1,981 @@
+import { spawn } from "node:child_process"
+import { NextResponse } from "next/server"
+import type { NextRequest } from "next/server"
+
+import type { ChatMessage } from "@/lib/ai/experience-schema"
+import {
+ generateExperience,
+ type GeneratorCandidate,
+} from "@/lib/ai/generator.server"
+import {
+ DEFAULT_MODELS,
+ SUPPORTS_STRICT_JSON_SCHEMA,
+ type AIProvider,
+} from "@/lib/ai/providers"
+
+// -----------------------------------------------------------------------------
+// Video catalog: single /api/search call per request
+// -----------------------------------------------------------------------------
+
+type SearchVideo = {
+ id: number
+ documentId: string
+ title: string
+ slug: string
+ streamingUrl: string | null
+ thumbnailUrl: string | null
+}
+
+type VideoForPrompt = {
+ id: number
+ documentId: string
+ title: string
+ slug: string
+ streamingUrl: string
+ thumbnailUrl?: string
+}
+
+const SEARCH_DEFAULT_LOCALE = "en"
+const SEARCH_LIMIT = 20
+
+/**
+ * Call the CMS seed-studio search endpoint which returns fully-resolved
+ * video rows (streamingUrl + documentId) in one hop. We tried the public
+ * `/api/search` briefly — it exposes `playbackId` only on scene rows, not on
+ * video summaries, so every candidate had no streaming URL and the chat
+ * rejected every theme. The seed-studio endpoint already handles locale +
+ * ILIKE + streaming-url enrichment and is the right boundary.
+ */
+const SEARCH_STOP_WORDS = new Set([
+ "a",
+ "an",
+ "the",
+ "about",
+ "for",
+ "with",
+ "and",
+ "or",
+ "but",
+ "in",
+ "on",
+ "of",
+ "to",
+ "is",
+ "it",
+ "that",
+ "this",
+ "was",
+ "are",
+ "be",
+ "been",
+ "being",
+ "have",
+ "has",
+ "had",
+ "do",
+ "does",
+ "did",
+ "will",
+ "would",
+ "could",
+ "should",
+ "may",
+ "might",
+ "can",
+ "shall",
+ "create",
+ "make",
+ "build",
+ "generate",
+ "new",
+ "experience",
+ "theme",
+ "exploring",
+ "explore",
+ "through",
+ "stories",
+ "story",
+ "scripture",
+ "scriptures",
+ "verses",
+ "verse",
+ "families",
+ "children",
+ "people",
+])
+
+function extractSearchKeywords(query: string): string[] {
+ return Array.from(
+ new Set(
+ query
+ .toLowerCase()
+ .replace(/[^a-z0-9\s]/g, " ")
+ .split(/\s+/)
+ .filter((w) => w.length > 2 && !SEARCH_STOP_WORDS.has(w)),
+ ),
+ )
+}
+
+async function searchVideosOnce(
+ query: string,
+ signal?: AbortSignal,
+): Promise {
+ const strapiUrl = process.env.STRAPI_URL ?? "http://localhost:1337"
+ const token = process.env.STRAPI_SEED_STUDIO_TOKEN ?? ""
+ try {
+ const response = await fetch(`${strapiUrl}/api/seed-studio/search-videos`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-Seed-Studio-Token": token,
+ },
+ body: JSON.stringify({ query, locale: SEARCH_DEFAULT_LOCALE }),
+ signal,
+ })
+ if (!response.ok) return []
+ const payload = (await response.json().catch(() => null)) as {
+ videos?: SearchVideo[]
+ } | null
+ return payload?.videos ?? []
+ } catch {
+ return []
+ }
+}
+
+async function fetchCandidateVideos(
+ query: string,
+ signal?: AbortSignal,
+): Promise {
+ // The CMS search uses ILIKE on the whole query string. A free-form theme
+ // like "Exploring forgiveness through stories and scripture" never matches
+ // a video title verbatim, so we fan out across extracted keywords and
+ // fuse the results. Dedup by video id.
+ const keywords = extractSearchKeywords(query)
+ const probes = keywords.length > 0 ? keywords.slice(0, 4) : [query]
+
+ const results = await Promise.all(
+ probes.map((kw) => searchVideosOnce(kw, signal)),
+ )
+
+ const seen = new Map()
+ for (const batch of results) {
+ for (const v of batch) {
+ if (!v.streamingUrl) continue
+ if (seen.has(v.id)) continue
+ seen.set(v.id, {
+ id: v.id,
+ documentId: v.documentId,
+ title: v.title,
+ slug: v.slug,
+ streamingUrl: v.streamingUrl,
+ thumbnailUrl: v.thumbnailUrl ?? undefined,
+ })
+ if (seen.size >= SEARCH_LIMIT) break
+ }
+ if (seen.size >= SEARCH_LIMIT) break
+ }
+ return [...seen.values()]
+}
+
+// -----------------------------------------------------------------------------
+// Legacy free-form prompt (kept for non-strict providers)
+// -----------------------------------------------------------------------------
+
+function formatVideoCatalog(videos: VideoForPrompt[]): string {
+ if (videos.length === 0) {
+ return "No videos found in the Strapi catalog for this theme. Do NOT invent or use placeholder/external video URLs. Ask the user to refine the theme instead."
+ }
+ return videos
+ .map(
+ (v) =>
+ `- id: ${v.id} | "${v.title}" | streamingUrl: ${v.streamingUrl} | thumbnailUrl: ${v.thumbnailUrl ?? "none"} | slug: ${v.slug} | documentId: ${v.documentId}`,
+ )
+ .join("\n")
+}
+
+function buildPrompt(
+ history: ChatMessage[],
+ userMessage: string,
+ videos: VideoForPrompt[],
+): string {
+ const videoCatalog = formatVideoCatalog(videos)
+
+ const systemContext = `You are the Seed Studio Assistant — an expert at creating themed Christian experiences for JesusFilm.
+
+## Available Videos from Strapi Catalog
+${videoCatalog}
+
+IMPORTANT RULES:
+- You MUST pick videos from the catalog above. Do NOT invent streaming URLs or use external video URLs.
+- If the catalog above is empty or not relevant enough, ask the user to refine the theme. Do not generate an experience with fallback or placeholder videos.
+- For every video section, include a "videoRef" object with the real id, documentId, title, slug, streamingUrl, and thumbnailUrl from the catalog.
+- Text content (headings, paragraphs, bible quotes, Q&A) should be AI-generated to match the theme.
+- For bible quote imageUrl fields, use real Unsplash photo URLs (https://images.unsplash.com/photo-...) that match the quote mood.
+
+When asked to create an experience, you MUST include a JSON code block with the complete experience data in this exact format:
+
+\`\`\`experience
+{
+ "title": "Experience Title",
+ "slug": "experience-slug",
+ "metaDescription": "Brief description",
+ "blocks": [
+ {
+ "__component": "sections.video-hero",
+ "sectionKey": "hero/english",
+ "streamingUrl": "REAL_URL_FROM_CATALOG",
+ "heading": "Hero Heading",
+ "videoRef": {
+ "id": 123,
+ "documentId": "abc123",
+ "title": "Real Video Title",
+ "slug": "real-video-slug",
+ "streamingUrl": "REAL_URL_FROM_CATALOG",
+ "thumbnailUrl": "REAL_THUMBNAIL_FROM_CATALOG"
+ }
+ },
+ {
+ "__component": "sections.text",
+ "heading": "Section Heading",
+ "contentParagraphs": ["Paragraph 1", "Paragraph 2"]
+ },
+ {
+ "__component": "sections.video",
+ "sectionKey": "video-1/english",
+ "video": 123,
+ "streamingUrl": "REAL_URL_FROM_CATALOG",
+ "title": "Video Title",
+ "subtitle": "Video Subtitle",
+ "videoRef": {
+ "id": 123,
+ "documentId": "abc123",
+ "title": "Real Video Title",
+ "slug": "real-video-slug",
+ "streamingUrl": "REAL_URL_FROM_CATALOG",
+ "thumbnailUrl": "REAL_THUMBNAIL_FROM_CATALOG"
+ }
+ },
+ {
+ "__component": "sections.related-questions",
+ "heading": "Questions to Explore",
+ "questions": [
+ { "question": "Q1?", "answer": "A1." },
+ { "question": "Q2?", "answer": "A2." }
+ ]
+ },
+ {
+ "__component": "sections.bible-quotes-carousel",
+ "heading": "Scripture",
+ "sectionKey": "quotes/english",
+ "quotes": [
+ {
+ "reference": "John 3:16",
+ "text": "For God so loved the world...",
+ "imageUrl": "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=900",
+ "backgroundColor": "#1e3a5f"
+ }
+ ]
+ },
+ {
+ "__component": "sections.quiz-button",
+ "buttonText": "Take the Quiz",
+ "iframeSrc": "https://your.nextstep.is/embed/default?expand=false"
+ }
+ ],
+ "platformOrdering": {
+ "web": [1, 0, 2, 3, 4, 5],
+ "mobile": [0, 2, 1, 3, 4, 5]
+ }
+}
+\`\`\`
+
+Section types: sections.video-hero, sections.video, sections.video-carousel, sections.text, sections.container, sections.related-questions, sections.bible-quotes-carousel, sections.quiz-button.
+
+Platform ordering: mobile leads with video sections, web leads with text/context.
+
+Always end with suggestion chips:
+\`\`\`suggestions
+["Suggestion 1", "Suggestion 2", "Suggestion 3"]
+\`\`\``
+
+ const parts = [systemContext, ""]
+ for (const msg of history) {
+ const prefix = msg.role === "user" ? "User" : "Assistant"
+ parts.push(`${prefix}: ${msg.content}`)
+ }
+ parts.push(`User: ${userMessage}`)
+ return parts.join("\n\n")
+}
+
+function buildOllamaMessages(
+ history: ChatMessage[],
+ userMessage: string,
+ videos: VideoForPrompt[],
+): Array<{ role: string; content: string }> {
+ const videoCatalog = formatVideoCatalog(videos)
+
+ const systemPrompt = `You are the Seed Studio Assistant — an expert at creating themed Christian experiences for JesusFilm.
+
+## Available Videos from Strapi Catalog
+${videoCatalog}
+
+IMPORTANT RULES:
+- You MUST pick videos from the catalog above. Do NOT invent streaming URLs or use external video URLs.
+- If the catalog above is empty or not relevant enough, ask the user to refine the theme. Do not generate an experience with fallback or placeholder videos.
+- For every video section, include a "videoRef" object with the real id, documentId, title, slug, streamingUrl, and thumbnailUrl from the catalog.
+- Text content (headings, paragraphs, bible quotes, Q&A) should be AI-generated to match the theme.
+- For bible quote imageUrl fields, use real Unsplash photo URLs (https://images.unsplash.com/photo-...) that match the quote mood.
+
+When asked to create an experience, include a JSON code block with experience data using \`\`\`experience ... \`\`\` format.
+Section types: sections.video-hero, sections.video, sections.video-carousel, sections.text, sections.container, sections.related-questions, sections.bible-quotes-carousel, sections.quiz-button.
+Always end with suggestion chips in \`\`\`suggestions ... \`\`\` format.`
+
+ const messages: Array<{ role: string; content: string }> = [
+ { role: "system", content: systemPrompt },
+ ]
+ for (const msg of history) {
+ messages.push({ role: msg.role, content: msg.content })
+ }
+ messages.push({ role: "user", content: userMessage })
+ return messages
+}
+
+// -----------------------------------------------------------------------------
+// SSE helpers
+// -----------------------------------------------------------------------------
+
+function safeEnqueue(
+ controller: ReadableStreamDefaultController,
+ data: Uint8Array,
+) {
+ try {
+ controller.enqueue(data)
+ } catch {
+ // controller already closed
+ }
+}
+
+function safeClose(controller: ReadableStreamDefaultController) {
+ try {
+ controller.close()
+ } catch {
+ // controller already closed
+ }
+}
+
+/**
+ * 15-second heartbeat — emits an SSE comment (`: ping\n\n`) that browsers
+ * ignore but keeps the Railway gateway from closing an idle stream. Returns
+ * a cleanup callback the route must call when the stream ends.
+ */
+function startHeartbeat(
+ controller: ReadableStreamDefaultController,
+ encoder: TextEncoder,
+): () => void {
+ const timer = setInterval(() => {
+ safeEnqueue(controller, encoder.encode(`: ping\n\n`))
+ }, 15_000)
+ return () => clearInterval(timer)
+}
+
+// -----------------------------------------------------------------------------
+// Theme slug derivation
+// -----------------------------------------------------------------------------
+
+/**
+ * Produce a URL-safe theme slug from the user's query. V1 does not depend on
+ * the CMS' sanitizeSlug deny-list — collisions are resolved at publish time
+ * by the seed-studio service, which runs the authoritative sanitizer.
+ */
+function deriveThemeSlug(query: string): string {
+ const base = query
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-|-$/g, "")
+ .slice(0, 40)
+ return base || `experience-${Date.now().toString(36)}`
+}
+
+// -----------------------------------------------------------------------------
+// Legacy provider shims (streaming)
+// -----------------------------------------------------------------------------
+
+function streamClaude(
+ prompt: string,
+ model: string,
+ controller: ReadableStreamDefaultController,
+ encoder: TextEncoder,
+) {
+ const container = process.env.CLAUDE_CONTAINER ?? "devcontainer-app-1"
+ // Security: the previous implementation passed the user-controlled prompt
+ // as a single argv element, which meant a long prompt risked hitting the
+ // OS ARG_MAX and any shell metacharacters in it sat in `/proc/$$/cmdline`.
+ // We now spawn claude with `-p -` (read from stdin), write the prompt to
+ // the child's stdin, and close it — no shell, no argv injection surface.
+ const proc = spawn(
+ "docker",
+ [
+ "exec",
+ "-i",
+ container,
+ "claude",
+ "-p",
+ "-",
+ "--output-format",
+ "text",
+ "--model",
+ model,
+ ],
+ {
+ env: { ...process.env, LANG: "en_US.UTF-8" },
+ stdio: ["pipe", "pipe", "pipe"],
+ },
+ )
+
+ try {
+ proc.stdin.write(prompt)
+ proc.stdin.end()
+ } catch (err) {
+ safeEnqueue(
+ controller,
+ encoder.encode(
+ `data: ${JSON.stringify({ type: "error", text: err instanceof Error ? err.message : "stdin write failed" })}\n\n`,
+ ),
+ )
+ }
+
+ proc.stdout.on("data", (chunk: Buffer) => {
+ const text = chunk.toString("utf-8")
+ safeEnqueue(
+ controller,
+ encoder.encode(`data: ${JSON.stringify({ type: "chunk", text })}\n\n`),
+ )
+ })
+
+ proc.stderr.on("data", (chunk: Buffer) => {
+ const text = chunk.toString("utf-8")
+ safeEnqueue(
+ controller,
+ encoder.encode(
+ `data: ${JSON.stringify({ type: "status", text: text.trim() })}\n\n`,
+ ),
+ )
+ })
+
+ proc.on("close", (code) => {
+ safeEnqueue(
+ controller,
+ encoder.encode(`data: ${JSON.stringify({ type: "done", code })}\n\n`),
+ )
+ safeClose(controller)
+ })
+
+ proc.on("error", (err) => {
+ safeEnqueue(
+ controller,
+ encoder.encode(
+ `data: ${JSON.stringify({ type: "error", text: err.message })}\n\n`,
+ ),
+ )
+ safeClose(controller)
+ })
+}
+
+function streamCodex(
+ prompt: string,
+ model: string,
+ controller: ReadableStreamDefaultController,
+ encoder: TextEncoder,
+) {
+ const isFast = model.endsWith(":fast")
+ const actualModel = model.replace(/:fast$/, "")
+ const args = ["exec", "-m", actualModel, "--sandbox", "read-only"]
+ if (isFast) args.push("-c", 'service_tier="fast"')
+ args.push("-")
+ const proc = spawn("codex", args, {
+ env: { ...process.env, LANG: "en_US.UTF-8" },
+ stdio: ["pipe", "pipe", "pipe"],
+ })
+ proc.stdin.write(prompt)
+ proc.stdin.end()
+
+ proc.stdout.on("data", (chunk: Buffer) => {
+ const text = chunk.toString("utf-8")
+ safeEnqueue(
+ controller,
+ encoder.encode(`data: ${JSON.stringify({ type: "chunk", text })}\n\n`),
+ )
+ })
+
+ proc.stderr.on("data", (chunk: Buffer) => {
+ const text = chunk.toString("utf-8")
+ safeEnqueue(
+ controller,
+ encoder.encode(
+ `data: ${JSON.stringify({ type: "status", text: text.trim() })}\n\n`,
+ ),
+ )
+ })
+
+ proc.on("close", (code) => {
+ safeEnqueue(
+ controller,
+ encoder.encode(`data: ${JSON.stringify({ type: "done", code })}\n\n`),
+ )
+ safeClose(controller)
+ })
+
+ proc.on("error", (err) => {
+ safeEnqueue(
+ controller,
+ encoder.encode(
+ `data: ${JSON.stringify({ type: "error", text: err.message })}\n\n`,
+ ),
+ )
+ safeClose(controller)
+ })
+}
+
+async function streamGemini(
+ messages: Array<{ role: string; content: string }>,
+ model: string,
+ controller: ReadableStreamDefaultController,
+ encoder: TextEncoder,
+) {
+ const apiKey = process.env.GEMINI_API_KEY
+ if (!apiKey) {
+ safeEnqueue(
+ controller,
+ encoder.encode(
+ `data: ${JSON.stringify({ type: "error", text: "GEMINI_API_KEY not set in .env.local" })}\n\n`,
+ ),
+ )
+ safeClose(controller)
+ return
+ }
+
+ const systemMsg = messages.find((m) => m.role === "system")
+ const chatMsgs = messages
+ .filter((m) => m.role !== "system")
+ .map((m) => ({
+ role: m.role === "assistant" ? "model" : "user",
+ parts: [{ text: m.content }],
+ }))
+
+ const body: Record = {
+ contents: chatMsgs,
+ generationConfig: { temperature: 0.7 },
+ }
+ if (systemMsg) {
+ body.systemInstruction = { parts: [{ text: systemMsg.content }] }
+ }
+
+ try {
+ const response = await fetch(
+ `https://generativelanguage.googleapis.com/v1beta/models/${model}:streamGenerateContent?alt=sse&key=${apiKey}`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ },
+ )
+
+ if (!response.ok || !response.body) {
+ const errorText = await response.text().catch(() => "unknown error")
+ throw new Error(`Gemini API error ${response.status}: ${errorText}`)
+ }
+
+ const reader = response.body.getReader()
+ const decoder = new TextDecoder()
+ let buffer = ""
+
+ while (true) {
+ const { done, value } = await reader.read()
+ if (done) break
+
+ buffer += decoder.decode(value, { stream: true })
+ const lines = buffer.split("\n")
+ buffer = lines.pop() ?? ""
+
+ for (const line of lines) {
+ if (!line.startsWith("data: ")) continue
+ const jsonStr = line.slice(6).trim()
+ if (!jsonStr) continue
+ try {
+ const parsed = JSON.parse(jsonStr) as {
+ candidates?: Array<{
+ content?: { parts?: Array<{ text?: string }> }
+ }>
+ }
+ const text = parsed.candidates?.[0]?.content?.parts?.[0]?.text
+ if (text) {
+ safeEnqueue(
+ controller,
+ encoder.encode(
+ `data: ${JSON.stringify({ type: "chunk", text })}\n\n`,
+ ),
+ )
+ }
+ } catch {
+ // skip malformed lines
+ }
+ }
+ }
+
+ safeEnqueue(
+ controller,
+ encoder.encode(`data: ${JSON.stringify({ type: "done", code: 0 })}\n\n`),
+ )
+ safeClose(controller)
+ } catch (err) {
+ safeEnqueue(
+ controller,
+ encoder.encode(
+ `data: ${JSON.stringify({ type: "error", text: err instanceof Error ? err.message : "Gemini connection failed" })}\n\n`,
+ ),
+ )
+ safeClose(controller)
+ }
+}
+
+async function streamExo(
+ messages: Array<{ role: string; content: string }>,
+ model: string,
+ controller: ReadableStreamDefaultController,
+ encoder: TextEncoder,
+) {
+ const exoUrl = process.env.EXO_API_URL ?? "http://localhost:52415"
+
+ try {
+ const response = await fetch(`${exoUrl}/v1/chat/completions`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model, messages, stream: true }),
+ })
+
+ if (!response.ok || !response.body) {
+ const errorText = await response.text().catch(() => "unknown error")
+ throw new Error(`Exo API error ${response.status}: ${errorText}`)
+ }
+
+ const reader = response.body.getReader()
+ const decoder = new TextDecoder()
+ let buffer = ""
+
+ while (true) {
+ const { done, value } = await reader.read()
+ if (done) break
+
+ buffer += decoder.decode(value, { stream: true })
+ const lines = buffer.split("\n")
+ buffer = lines.pop() ?? ""
+
+ for (const line of lines) {
+ if (!line.startsWith("data: ")) continue
+ const jsonStr = line.slice(6).trim()
+ if (!jsonStr || jsonStr === "[DONE]") continue
+ try {
+ const parsed = JSON.parse(jsonStr) as {
+ choices?: Array<{ delta?: { content?: string } }>
+ }
+ const text = parsed.choices?.[0]?.delta?.content
+ if (text) {
+ safeEnqueue(
+ controller,
+ encoder.encode(
+ `data: ${JSON.stringify({ type: "chunk", text })}\n\n`,
+ ),
+ )
+ }
+ } catch {
+ // skip malformed lines
+ }
+ }
+ }
+
+ safeEnqueue(
+ controller,
+ encoder.encode(`data: ${JSON.stringify({ type: "done", code: 0 })}\n\n`),
+ )
+ safeClose(controller)
+ } catch (err) {
+ safeEnqueue(
+ controller,
+ encoder.encode(
+ `data: ${JSON.stringify({ type: "error", text: err instanceof Error ? err.message : "Exo connection failed" })}\n\n`,
+ ),
+ )
+ safeClose(controller)
+ }
+}
+
+async function streamOllama(
+ messages: Array<{ role: string; content: string }>,
+ model: string,
+ controller: ReadableStreamDefaultController,
+ encoder: TextEncoder,
+) {
+ const ollamaUrl = process.env.OLLAMA_URL ?? "http://localhost:11434"
+
+ try {
+ const response = await fetch(`${ollamaUrl}/api/chat`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ model, messages, stream: true }),
+ })
+
+ if (!response.ok || !response.body) {
+ throw new Error(`Ollama request failed: ${response.status}`)
+ }
+
+ const reader = response.body.getReader()
+ const decoder = new TextDecoder()
+ let buffer = ""
+
+ while (true) {
+ const { done, value } = await reader.read()
+ if (done) break
+
+ buffer += decoder.decode(value, { stream: true })
+ const lines = buffer.split("\n")
+ buffer = lines.pop() ?? ""
+
+ for (const line of lines) {
+ if (!line.trim()) continue
+ try {
+ const parsed = JSON.parse(line) as {
+ message?: { content?: string }
+ done?: boolean
+ }
+ if (parsed.message?.content) {
+ safeEnqueue(
+ controller,
+ encoder.encode(
+ `data: ${JSON.stringify({ type: "chunk", text: parsed.message.content })}\n\n`,
+ ),
+ )
+ }
+ } catch {
+ // skip malformed lines
+ }
+ }
+ }
+
+ safeEnqueue(
+ controller,
+ encoder.encode(`data: ${JSON.stringify({ type: "done", code: 0 })}\n\n`),
+ )
+ safeClose(controller)
+ } catch (err) {
+ safeEnqueue(
+ controller,
+ encoder.encode(
+ `data: ${JSON.stringify({ type: "error", text: err instanceof Error ? err.message : "Ollama connection failed" })}\n\n`,
+ ),
+ )
+ safeClose(controller)
+ }
+}
+
+// -----------------------------------------------------------------------------
+// Strict JSON-schema route (OpenRouter)
+// -----------------------------------------------------------------------------
+
+async function runStrictGenerator(
+ query: string,
+ videos: VideoForPrompt[],
+ model: string,
+ signal: AbortSignal,
+ controller: ReadableStreamDefaultController,
+ encoder: TextEncoder,
+) {
+ const themeSlug = deriveThemeSlug(query)
+ const candidates: GeneratorCandidate[] = videos.map((v) => ({
+ id: v.id,
+ documentId: v.documentId,
+ title: v.title,
+ slug: v.slug,
+ streamingUrl: v.streamingUrl,
+ thumbnailUrl: v.thumbnailUrl,
+ }))
+
+ const result = await generateExperience({
+ query,
+ themeSlug,
+ candidates,
+ model,
+ signal,
+ })
+
+ if (result.ok) {
+ safeEnqueue(
+ controller,
+ encoder.encode(
+ `event: patch\ndata: ${JSON.stringify({ path: ["experience"], value: result.experience })}\n\n`,
+ ),
+ )
+ safeEnqueue(controller, encoder.encode(`event: done\ndata: {}\n\n`))
+ } else {
+ const { code, message } = result.error
+ safeEnqueue(
+ controller,
+ encoder.encode(
+ `event: patch\ndata: ${JSON.stringify({ path: ["error"], value: { code, message } })}\n\n`,
+ ),
+ )
+ safeEnqueue(controller, encoder.encode(`event: done\ndata: {}\n\n`))
+ }
+ safeClose(controller)
+}
+
+// -----------------------------------------------------------------------------
+// POST
+// -----------------------------------------------------------------------------
+
+export async function POST(request: NextRequest) {
+ const body = (await request.json()) as {
+ messages: ChatMessage[]
+ userMessage: string
+ provider?: AIProvider
+ model?: string
+ }
+
+ const provider: AIProvider = body.provider ?? "openrouter"
+ const model = body.model ?? DEFAULT_MODELS[provider]
+ const useStrict = SUPPORTS_STRICT_JSON_SCHEMA[provider]
+
+ // Single search call — replaces the previous keyword-loop fanout which
+ // starved the CMS search rate-limit bucket.
+ const videos = await fetchCandidateVideos(body.userMessage, request.signal)
+ console.log(
+ `[api/chat] query=${JSON.stringify(body.userMessage)} provider=${body.provider} candidates=${videos.length}`,
+ )
+
+ const stream = new ReadableStream({
+ start(controller) {
+ const encoder = new TextEncoder()
+ const stopHeartbeat = startHeartbeat(controller, encoder)
+
+ safeEnqueue(
+ controller,
+ encoder.encode(
+ `data: ${JSON.stringify({ type: "status", text: `Using ${provider} (${model})...` })}\n\n`,
+ ),
+ )
+ const wrapClose = () => {
+ stopHeartbeat()
+ }
+
+ safeEnqueue(
+ controller,
+ encoder.encode(
+ `event: patch\ndata: ${JSON.stringify({ path: ["catalog"], value: videos })}\n\n`,
+ ),
+ )
+
+ if (videos.length === 0) {
+ safeEnqueue(
+ controller,
+ encoder.encode(
+ `event: patch\ndata: ${JSON.stringify({
+ path: ["error"],
+ value: {
+ code: "NO_CANDIDATES",
+ message:
+ "No Strapi videos matched this query. Refine the theme instead of using external videos.",
+ },
+ })}\n\n`,
+ ),
+ )
+ safeEnqueue(controller, encoder.encode(`event: done\ndata: {}\n\n`))
+ wrapClose()
+ safeClose(controller)
+ return
+ }
+
+ if (useStrict) {
+ // Single-shot OpenRouter call with strict-JSON-Schema; emit one
+ // `event: patch` frame.
+ runStrictGenerator(
+ body.userMessage,
+ videos,
+ model,
+ request.signal,
+ controller,
+ encoder,
+ )
+ .catch((err) => {
+ safeEnqueue(
+ controller,
+ encoder.encode(
+ `event: patch\ndata: ${JSON.stringify({
+ path: ["error"],
+ value: {
+ code: "UPSTREAM_ERROR",
+ message:
+ err instanceof Error ? err.message : "Generator failed",
+ },
+ })}\n\n`,
+ ),
+ )
+ safeClose(controller)
+ })
+ .finally(wrapClose)
+ return
+ }
+
+ // Legacy free-form streaming providers.
+ const prompt = buildPrompt(body.messages, body.userMessage, videos)
+ const messages = buildOllamaMessages(
+ body.messages,
+ body.userMessage,
+ videos,
+ )
+
+ let fired: Promise | undefined
+ if (provider === "exo") {
+ fired = streamExo(messages, model, controller, encoder)
+ } else if (provider === "ollama") {
+ fired = streamOllama(messages, model, controller, encoder)
+ } else if (provider === "gemini") {
+ fired = streamGemini(messages, model, controller, encoder)
+ } else if (provider === "codex") {
+ streamCodex(prompt, model, controller, encoder)
+ } else {
+ // Default to claude (legacy docker-exec CLI). `openrouter` was
+ // handled above in the useStrict branch, so reaching this path
+ // means an unrecognized provider slipped through — fall back to
+ // claude which is the closest "remote chat" analog for now.
+ streamClaude(prompt, model, controller, encoder)
+ }
+
+ if (fired) {
+ fired.finally(wrapClose)
+ } else {
+ // child-process streamers close the controller inside their event
+ // handlers, so we hook the heartbeat cleanup to the controller via
+ // a best-effort check on close. If the child never emits close
+ // (e.g. process crash), the 60s Vercel gateway timeout will tear
+ // the connection down and finalize cleanup with the response.
+ const originalClose = controller.close.bind(controller)
+ controller.close = () => {
+ wrapClose()
+ originalClose()
+ }
+ }
+ },
+ cancel() {
+ // Client aborted — no-op; the underlying fetch/process already saw
+ // the shared AbortSignal.
+ },
+ })
+
+ return new NextResponse(stream, {
+ headers: {
+ "Content-Type": "text/event-stream",
+ "Cache-Control": "no-cache",
+ Connection: "keep-alive",
+ "X-Accel-Buffering": "no",
+ },
+ })
+}
diff --git a/apps/seed-studio/src/app/api/ollama-models/route.ts b/apps/seed-studio/src/app/api/ollama-models/route.ts
new file mode 100644
index 000000000..ffaa56053
--- /dev/null
+++ b/apps/seed-studio/src/app/api/ollama-models/route.ts
@@ -0,0 +1,28 @@
+import { NextResponse } from "next/server"
+
+export async function GET() {
+ const ollamaUrl = process.env.OLLAMA_URL ?? "http://localhost:11434"
+
+ try {
+ const response = await fetch(`${ollamaUrl}/api/tags`, {
+ signal: AbortSignal.timeout(3000),
+ })
+
+ if (!response.ok) {
+ return NextResponse.json([])
+ }
+
+ const data = (await response.json()) as {
+ models?: Array<{ name: string }>
+ }
+
+ const models = (data.models ?? []).map((m) => ({
+ id: m.name,
+ label: m.name,
+ }))
+
+ return NextResponse.json(models)
+ } catch {
+ return NextResponse.json([])
+ }
+}
diff --git a/apps/seed-studio/src/app/globals.css b/apps/seed-studio/src/app/globals.css
new file mode 100644
index 000000000..022f88f0c
--- /dev/null
+++ b/apps/seed-studio/src/app/globals.css
@@ -0,0 +1,34 @@
+@import "tailwindcss";
+
+@theme {
+ --font-sans: "Geist", ui-sans-serif, system-ui, sans-serif;
+ --font-mono: "Geist Mono", ui-monospace, monospace;
+
+ --color-primary-50: #eef2ff;
+ --color-primary-100: #e0e7ff;
+ --color-primary-200: #c7d2fe;
+ --color-primary-300: #a5b4fc;
+ --color-primary-400: #818cf8;
+ --color-primary-500: #6366f1;
+ --color-primary-600: #4f46e5;
+ --color-primary-700: #4338ca;
+ --color-primary-800: #3730a3;
+ --color-primary-900: #312e81;
+
+ --color-neutral-50: #fafafa;
+ --color-neutral-100: #f5f5f5;
+ --color-neutral-200: #e5e5e5;
+ --color-neutral-300: #d4d4d4;
+ --color-neutral-400: #a3a3a3;
+ --color-neutral-500: #737373;
+ --color-neutral-600: #525252;
+ --color-neutral-700: #404040;
+ --color-neutral-800: #262626;
+ --color-neutral-900: #171717;
+}
+
+body {
+ font-family: var(--font-sans);
+ background-color: var(--color-neutral-50);
+ color: var(--color-neutral-900);
+}
diff --git a/apps/seed-studio/src/app/layout.tsx b/apps/seed-studio/src/app/layout.tsx
new file mode 100644
index 000000000..e398ed430
--- /dev/null
+++ b/apps/seed-studio/src/app/layout.tsx
@@ -0,0 +1,26 @@
+import type { Metadata } from "next"
+import "./globals.css"
+
+export const metadata: Metadata = {
+ title: "Seed Studio — AI Experience Creator",
+ description:
+ "Create themed experiences for JesusFilm using AI-powered content generation",
+}
+
+export default function RootLayout({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ return (
+
+
+
+
+ {children}
+
+ )
+}
diff --git a/apps/seed-studio/src/app/login/login-form.tsx b/apps/seed-studio/src/app/login/login-form.tsx
new file mode 100644
index 000000000..c6daa5f13
--- /dev/null
+++ b/apps/seed-studio/src/app/login/login-form.tsx
@@ -0,0 +1,72 @@
+"use client"
+
+import { useState } from "react"
+import { useRouter } from "next/navigation"
+import { LogIn } from "lucide-react"
+import { cn } from "@/lib/cn"
+
+export function LoginForm() {
+ const router = useRouter()
+ const [password, setPassword] = useState("")
+ const [error, setError] = useState("")
+ const [isPending, setIsPending] = useState(false)
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault()
+ setError("")
+ setIsPending(true)
+
+ try {
+ const res = await fetch("/api/auth", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ password }),
+ })
+
+ if (!res.ok) {
+ setError("Invalid password")
+ return
+ }
+
+ router.push("/")
+ router.refresh()
+ } catch {
+ setError("Something went wrong")
+ } finally {
+ setIsPending(false)
+ }
+ }
+
+ return (
+
+ )
+}
diff --git a/apps/seed-studio/src/app/login/page.tsx b/apps/seed-studio/src/app/login/page.tsx
new file mode 100644
index 000000000..635e95216
--- /dev/null
+++ b/apps/seed-studio/src/app/login/page.tsx
@@ -0,0 +1,19 @@
+import { LoginForm } from "./login-form"
+
+export default function LoginPage() {
+ return (
+
+
+
+
+ Seed Studio
+
+
+ Enter the password to access the experience creator
+
+
+
+
+
+ )
+}
diff --git a/apps/seed-studio/src/app/page.tsx b/apps/seed-studio/src/app/page.tsx
new file mode 100644
index 000000000..fcdfdfa99
--- /dev/null
+++ b/apps/seed-studio/src/app/page.tsx
@@ -0,0 +1,5 @@
+import { Studio } from "@/components/studio"
+
+export default function HomePage() {
+ return
+}
diff --git a/apps/seed-studio/src/components/ProviderSelect.tsx b/apps/seed-studio/src/components/ProviderSelect.tsx
new file mode 100644
index 000000000..3a1f4a751
--- /dev/null
+++ b/apps/seed-studio/src/components/ProviderSelect.tsx
@@ -0,0 +1,94 @@
+"use client"
+
+import { useEffect, useState } from "react"
+import { cn } from "@/lib/cn"
+import {
+ AI_PROVIDERS,
+ PROVIDER_LABELS,
+ PROVIDER_MODELS,
+ DEFAULT_MODELS,
+ type AIProvider,
+ type ModelOption,
+} from "@/lib/ai/providers"
+
+type ProviderSelectProps = {
+ provider: AIProvider
+ model: string
+ onProviderChange: (provider: AIProvider) => void
+ onModelChange: (model: string) => void
+ disabled: boolean
+}
+
+const selectClasses = cn(
+ "rounded-lg border border-neutral-200 bg-white px-2.5 py-1.5",
+ "text-xs font-medium text-neutral-700",
+ "transition-colors hover:border-neutral-300",
+ "focus:border-primary-300 focus:outline-none focus:ring-2 focus:ring-primary-100",
+ "disabled:cursor-not-allowed disabled:opacity-50",
+)
+
+export function ProviderSelect({
+ provider,
+ model,
+ onProviderChange,
+ onModelChange,
+ disabled,
+}: ProviderSelectProps) {
+ const [ollamaModels, setOllamaModels] = useState([])
+
+ useEffect(() => {
+ if (provider !== "ollama") return
+ fetch("/api/ollama-models")
+ .then((r) => r.json())
+ .then((models: ModelOption[]) => setOllamaModels(models))
+ .catch(() => setOllamaModels([]))
+ }, [provider])
+
+ const models =
+ provider === "ollama" ? ollamaModels : PROVIDER_MODELS[provider]
+
+ return (
+
+ {
+ const next = e.target.value as AIProvider
+ onProviderChange(next)
+ onModelChange(DEFAULT_MODELS[next])
+ }}
+ disabled={disabled}
+ className={selectClasses}
+ >
+ {AI_PROVIDERS.map((p) => (
+
+ {PROVIDER_LABELS[p]}
+
+ ))}
+
+
+ {models.length > 0 ? (
+ onModelChange(e.target.value)}
+ disabled={disabled}
+ className={selectClasses}
+ >
+ {models.map((m) => (
+
+ {m.label}
+
+ ))}
+
+ ) : provider === "ollama" ? (
+ onModelChange(e.target.value)}
+ disabled={disabled}
+ placeholder="model name"
+ className={cn(selectClasses, "w-28")}
+ />
+ ) : null}
+
+ )
+}
diff --git a/apps/seed-studio/src/components/chat/ChatInput.tsx b/apps/seed-studio/src/components/chat/ChatInput.tsx
new file mode 100644
index 000000000..0ae4e168f
--- /dev/null
+++ b/apps/seed-studio/src/components/chat/ChatInput.tsx
@@ -0,0 +1,85 @@
+"use client"
+
+import { useCallback, useRef, useState } from "react"
+import { SendHorizontal } from "lucide-react"
+
+import { cn } from "@/lib/cn"
+
+type ChatInputProps = {
+ onSend: (message: string) => void
+ disabled: boolean
+}
+
+export function ChatInput({ onSend, disabled }: ChatInputProps) {
+ const [value, setValue] = useState("")
+ const textareaRef = useRef(null)
+
+ const adjustHeight = useCallback(() => {
+ const textarea = textareaRef.current
+ if (!textarea) return
+ textarea.style.height = "auto"
+ textarea.style.height = `${Math.min(textarea.scrollHeight, 160)}px`
+ }, [])
+
+ const handleSubmit = useCallback(() => {
+ const trimmed = value.trim()
+ if (!trimmed || disabled) return
+ onSend(trimmed)
+ setValue("")
+ if (textareaRef.current) {
+ textareaRef.current.style.height = "auto"
+ }
+ }, [value, disabled, onSend])
+
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault()
+ handleSubmit()
+ }
+ },
+ [handleSubmit],
+ )
+
+ return (
+
+
+ )
+}
diff --git a/apps/seed-studio/src/components/chat/ChatMessage.tsx b/apps/seed-studio/src/components/chat/ChatMessage.tsx
new file mode 100644
index 000000000..ee3189a3c
--- /dev/null
+++ b/apps/seed-studio/src/components/chat/ChatMessage.tsx
@@ -0,0 +1,42 @@
+import type { ChatMessage as ChatMessageType } from "@/lib/ai/experience-schema"
+import { cn } from "@/lib/cn"
+
+type ChatMessageProps = {
+ message: ChatMessageType
+}
+
+function StreamingIndicator() {
+ return (
+
+
+
+
+
+ )
+}
+
+export function ChatMessage({ message }: ChatMessageProps) {
+ const isUser = message.role === "user"
+ const isStreaming = message.role === "assistant" && message.content === ""
+
+ return (
+
+
+ {isStreaming ? (
+
+ ) : (
+
+ {message.content}
+
+ )}
+
+
+ )
+}
diff --git a/apps/seed-studio/src/components/chat/ChatPanel.tsx b/apps/seed-studio/src/components/chat/ChatPanel.tsx
new file mode 100644
index 000000000..9b51d01c3
--- /dev/null
+++ b/apps/seed-studio/src/components/chat/ChatPanel.tsx
@@ -0,0 +1,174 @@
+"use client"
+
+import { useEffect, useRef } from "react"
+import { MessageSquare, Square } from "lucide-react"
+
+import type { ChatMessage as ChatMessageType } from "@/lib/ai/experience-schema"
+import { cn } from "@/lib/cn"
+
+import { ChatInput } from "./ChatInput"
+import { ChatMessage } from "./ChatMessage"
+import { SuggestionChips } from "./SuggestionChips"
+
+type ChatPanelProps = {
+ messages: ChatMessageType[]
+ isLoading: boolean
+ error: string | null
+ streamingText: string
+ statusText: string
+ onSendMessage: (content: string) => void
+ onStopGenerating: () => void
+}
+
+const EXAMPLE_PROMPTS = [
+ "Create an Easter experience about hope and resurrection",
+ "A Christmas experience for families with children",
+ "Exploring forgiveness through stories and scripture",
+]
+
+export function ChatPanel({
+ messages,
+ isLoading,
+ error,
+ streamingText,
+ statusText,
+ onSendMessage,
+ onStopGenerating,
+}: ChatPanelProps) {
+ const scrollRef = useRef(null)
+
+ useEffect(() => {
+ const container = scrollRef.current
+ if (!container) return
+ container.scrollTop = container.scrollHeight
+ }, [messages, streamingText])
+
+ const lastAssistantMessage = [...messages]
+ .reverse()
+ .find((m) => m.role === "assistant")
+
+ const isEmpty = messages.length === 0 && !isLoading
+
+ return (
+
+
+ {isEmpty ? (
+
+
+
+
+
+
+ Describe your experience theme to get started
+
+
Try something like:
+
+
+ {EXAMPLE_PROMPTS.map((prompt) => (
+ onSendMessage(prompt)}
+ className={cn(
+ "rounded-xl border border-neutral-200 px-4 py-2.5",
+ "text-left text-sm text-neutral-600",
+ "transition-colors hover:border-primary-200 hover:bg-primary-50",
+ )}
+ >
+ {prompt}
+
+ ))}
+
+
+ ) : (
+
+ {messages.map((message) => (
+
+ ))}
+
+ {/* Streaming response */}
+ {isLoading ? (
+
+
+ {streamingText ? (
+
+ {streamingText}
+
+
+ ) : statusText ? (
+
+
+
+
+
+
+
+ {statusText}
+
+
+ ) : (
+
+
+
+
+
+ )}
+
+
+ ) : null}
+
+ {/* Error message */}
+ {error ? (
+
+ ) : null}
+
+ {/* Suggestion chips after last message */}
+ {!isLoading &&
+ lastAssistantMessage?.suggestions &&
+ lastAssistantMessage.suggestions.length > 0 ? (
+
+
+
+ ) : null}
+
+ )}
+
+
+
+ {isLoading ? (
+
+
+ Stop generating
+
+ ) : (
+
+ )}
+
+
+ )
+}
diff --git a/apps/seed-studio/src/components/chat/SuggestionChips.tsx b/apps/seed-studio/src/components/chat/SuggestionChips.tsx
new file mode 100644
index 000000000..ec929e6f0
--- /dev/null
+++ b/apps/seed-studio/src/components/chat/SuggestionChips.tsx
@@ -0,0 +1,32 @@
+import { cn } from "@/lib/cn"
+
+type SuggestionChipsProps = {
+ suggestions: string[]
+ onSelect: (suggestion: string) => void
+}
+
+export function SuggestionChips({
+ suggestions,
+ onSelect,
+}: SuggestionChipsProps) {
+ if (suggestions.length === 0) return null
+
+ return (
+
+ {suggestions.map((suggestion) => (
+ onSelect(suggestion)}
+ className={cn(
+ "cursor-pointer rounded-full border border-primary-200 px-3 py-1.5",
+ "bg-primary-50 text-sm text-primary-700",
+ "transition-colors hover:bg-primary-100",
+ )}
+ >
+ {suggestion}
+
+ ))}
+
+ )
+}
diff --git a/apps/seed-studio/src/components/preview/PlatformToggle.tsx b/apps/seed-studio/src/components/preview/PlatformToggle.tsx
new file mode 100644
index 000000000..a75f9596b
--- /dev/null
+++ b/apps/seed-studio/src/components/preview/PlatformToggle.tsx
@@ -0,0 +1,45 @@
+"use client"
+
+import { Monitor, Smartphone } from "lucide-react"
+
+import { cn } from "@/lib/cn"
+
+type PlatformToggleProps = {
+ platform: "web" | "mobile"
+ onChange: (p: "web" | "mobile") => void
+}
+
+export function PlatformToggle({ platform, onChange }: PlatformToggleProps) {
+ return (
+
+ onChange("web")}
+ className={cn(
+ "flex items-center gap-1.5 rounded-md px-3 py-1.5",
+ "text-sm font-medium transition-colors",
+ platform === "web"
+ ? "bg-primary-500 text-white shadow-sm"
+ : "text-neutral-500 hover:text-neutral-700",
+ )}
+ >
+
+ Web
+
+ onChange("mobile")}
+ className={cn(
+ "flex items-center gap-1.5 rounded-md px-3 py-1.5",
+ "text-sm font-medium transition-colors",
+ platform === "mobile"
+ ? "bg-primary-500 text-white shadow-sm"
+ : "text-neutral-500 hover:text-neutral-700",
+ )}
+ >
+
+ Mobile
+
+
+ )
+}
diff --git a/apps/seed-studio/src/components/preview/PreviewPanel.tsx b/apps/seed-studio/src/components/preview/PreviewPanel.tsx
new file mode 100644
index 000000000..5ebed19d8
--- /dev/null
+++ b/apps/seed-studio/src/components/preview/PreviewPanel.tsx
@@ -0,0 +1,82 @@
+"use client"
+
+import { useMemo, useState } from "react"
+import { LayoutTemplate } from "lucide-react"
+
+import type {
+ GeneratedExperience,
+ Platform,
+ SectionBlock,
+} from "@/lib/ai/experience-schema"
+import { cn } from "@/lib/cn"
+
+import { PlatformToggle } from "./PlatformToggle"
+import { SectionCard } from "./SectionCard"
+
+type PreviewPanelProps = {
+ experience: GeneratedExperience | null
+}
+
+export function PreviewPanel({ experience }: PreviewPanelProps) {
+ const [platform, setPlatform] = useState("web")
+
+ const orderedBlocks = useMemo(() => {
+ if (!experience?.blocks?.length) return []
+ const ordering = experience.platformOrdering?.[platform]
+ if (!ordering || !Array.isArray(ordering) || ordering.length === 0)
+ return experience.blocks.filter(Boolean)
+ return ordering
+ .filter(
+ (idx) =>
+ typeof idx === "number" && idx >= 0 && idx < experience.blocks.length,
+ )
+ .map((idx) => experience.blocks[idx])
+ .filter(Boolean)
+ }, [experience, platform])
+
+ if (!experience) {
+ return (
+
+
+
+
+
+ Your experience preview will appear here
+
+
+ )
+ }
+
+ return (
+
+
+
+
+ {experience.title}
+
+ {experience.metaDescription ? (
+
+ {experience.metaDescription}
+
+ ) : null}
+
+
+
+
+ {orderedBlocks.map((block, i) => (
+
+ ))}
+
+
+ )
+}
diff --git a/apps/seed-studio/src/components/preview/SectionCard.tsx b/apps/seed-studio/src/components/preview/SectionCard.tsx
new file mode 100644
index 000000000..2413ad660
--- /dev/null
+++ b/apps/seed-studio/src/components/preview/SectionCard.tsx
@@ -0,0 +1,89 @@
+import {
+ BookOpen,
+ HelpCircle,
+ LayoutGrid,
+ Play,
+ Sparkles,
+ Type,
+ Video,
+} from "lucide-react"
+
+import type { SectionBlock } from "@/lib/ai/experience-schema"
+import { cn } from "@/lib/cn"
+
+import { SectionRenderer } from "./SectionRenderer"
+
+type SectionCardProps = {
+ block: SectionBlock
+ index: number
+}
+
+type SectionMeta = {
+ label: string
+ icon: React.ReactNode
+}
+
+function getSectionMeta(block: SectionBlock): SectionMeta {
+ switch (block.__component) {
+ case "sections.video":
+ return { label: "Video", icon: }
+ case "sections.video-hero":
+ return { label: "Video Hero", icon: }
+ case "sections.video-carousel":
+ return {
+ label: "Video Carousel",
+ icon: ,
+ }
+ case "sections.text":
+ return { label: "Text", icon: }
+ case "sections.bible-quotes-carousel":
+ return {
+ label: "Bible Quotes",
+ icon: ,
+ }
+ case "sections.related-questions":
+ return {
+ label: "Related Questions",
+ icon: ,
+ }
+ case "sections.quiz-button":
+ return {
+ label: "Quiz Button",
+ icon: ,
+ }
+ case "sections.container":
+ return {
+ label: "Container",
+ icon: ,
+ }
+ default:
+ return { label: "Section", icon: }
+ }
+}
+
+export function SectionCard({ block, index }: SectionCardProps) {
+ const meta = getSectionMeta(block)
+
+ return (
+
+
+
+ {meta.icon}
+
+
+ {index + 1}. {meta.label}
+
+
+
+
+ )
+}
diff --git a/apps/seed-studio/src/components/preview/SectionRenderer.tsx b/apps/seed-studio/src/components/preview/SectionRenderer.tsx
new file mode 100644
index 000000000..0bf2c196a
--- /dev/null
+++ b/apps/seed-studio/src/components/preview/SectionRenderer.tsx
@@ -0,0 +1,100 @@
+import type {
+ CardSection as SharedCardSection,
+ CTASection as SharedCTASection,
+ InfoBlocksSection as SharedInfoBlocksSection,
+ MediaCollectionSection as SharedMediaCollectionSection,
+ NavigationCarouselSection as SharedNavigationCarouselSection,
+ PromoBannerSection as SharedPromoBannerSection,
+ SectionBlock as SharedSectionBlock,
+ SectionWrapper as SharedSectionWrapper,
+} from "@forge/experience-templates"
+
+import type { SectionBlock as LocalSectionBlock } from "@/lib/ai/experience-schema"
+
+import { BibleQuotesPreview } from "./sections/BibleQuotesPreview"
+import { CardPreview } from "./sections/CardPreview"
+import { ContainerPreview } from "./sections/ContainerPreview"
+import { CtaPreview } from "./sections/CtaPreview"
+import { InfoBlocksPreview } from "./sections/InfoBlocksPreview"
+import { MediaCollectionPreview } from "./sections/MediaCollectionPreview"
+import { NavigationCarouselPreview } from "./sections/NavigationCarouselPreview"
+import { PromoBannerPreview } from "./sections/PromoBannerPreview"
+import { QuizButtonPreview } from "./sections/QuizButtonPreview"
+import { RelatedQuestionsPreview } from "./sections/RelatedQuestionsPreview"
+import { SectionWrapperPreview } from "./sections/SectionWrapperPreview"
+import { TextSectionPreview } from "./sections/TextSectionPreview"
+import { VideoCarouselPreview } from "./sections/VideoCarouselPreview"
+import { VideoHeroPreview } from "./sections/VideoHeroPreview"
+import { VideoSectionPreview } from "./sections/VideoSectionPreview"
+
+type RenderableBlock =
+ | LocalSectionBlock
+ | SharedSectionBlock
+ | { __component?: string }
+
+type SectionRendererProps = {
+ block: RenderableBlock
+}
+
+export function SectionRenderer({ block }: SectionRendererProps) {
+ if (!block || !block.__component) return null
+
+ switch (block.__component) {
+ case "sections.video":
+ return
+ case "sections.video-hero":
+ return
+ case "sections.video-carousel":
+ return
+ case "sections.text":
+ return
+ case "sections.bible-quotes-carousel":
+ return
+ case "sections.related-questions":
+ return
+ case "sections.quiz-button":
+ return
+ case "sections.container":
+ return
+ case "sections.section":
+ return (
+
+ )
+ case "sections.media-collection":
+ return (
+
+ )
+ case "sections.navigation-carousel":
+ return (
+
+ )
+ case "sections.cta":
+ return
+ case "sections.card":
+ return
+ case "sections.info-blocks":
+ return (
+
+ )
+ case "sections.promo-banner":
+ return (
+
+ )
+ default: {
+ if (process.env.NODE_ENV === "development") {
+ console.warn(`Unknown __component: ${block.__component}`)
+ }
+ return null
+ }
+ }
+}
diff --git a/apps/seed-studio/src/components/preview/VideoPlayer.tsx b/apps/seed-studio/src/components/preview/VideoPlayer.tsx
new file mode 100644
index 000000000..765e44e03
--- /dev/null
+++ b/apps/seed-studio/src/components/preview/VideoPlayer.tsx
@@ -0,0 +1,185 @@
+"use client"
+
+import { useEffect, useId, useRef, useState } from "react"
+import Hls from "hls.js"
+
+import {
+ registerPlayer,
+ touchPlayer,
+ unregisterPlayer,
+} from "@/lib/lazy-video/registry"
+
+type VideoPlayerProps = {
+ src: string
+ onClose: () => void
+ poster?: string
+ /**
+ * When true, the player should start as soon as media is ready after an
+ * explicit user action that mounted the component.
+ */
+ playOnMount?: boolean
+ /**
+ * When true, the player will attempt to autoplay (muted) as soon as
+ * it enters the viewport. Only VideoHero should pass this — every
+ * other consumer must require a user tap to kick off playback.
+ */
+ autoplayOnViewport?: boolean
+}
+
+export function VideoPlayer({
+ src,
+ onClose,
+ poster,
+ playOnMount = false,
+ autoplayOnViewport = false,
+}: VideoPlayerProps) {
+ const containerRef = useRef(null)
+ const videoRef = useRef(null)
+ // Stable unique id for the LRU registry — scoped to this mounted
+ // VideoPlayer instance. Including `src` keeps it unique across
+ // re-mounts with a new source.
+ const reactId = useId()
+ const registryId = `${reactId}:${src}`
+
+ // Gate HLS init behind IntersectionObserver. Starts false; flips to
+ // true the first time the container enters the viewport (with 400px
+ // of slop so we start fetching the manifest just before the user
+ // scrolls to the video).
+ const [initialized, setInitialized] = useState(false)
+
+ // Watch the viewport: set `initialized = true` on first intersection.
+ useEffect(() => {
+ const container = containerRef.current
+ if (!container) return
+ if (initialized) return
+
+ if (typeof IntersectionObserver === "undefined") {
+ // SSR / old browser fallback: just init immediately.
+ setInitialized(true)
+ return
+ }
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ for (const entry of entries) {
+ if (entry.isIntersecting) {
+ setInitialized(true)
+ observer.disconnect()
+ break
+ }
+ }
+ },
+ { rootMargin: "400px" },
+ )
+ observer.observe(container)
+
+ return () => observer.disconnect()
+ }, [initialized])
+
+ // Attach HLS once we're initialized. The element is always
+ // in the DOM (so the poster renders immediately), but we don't pay
+ // the cost of a MediaSource + segment parser until needed.
+ useEffect(() => {
+ if (!initialized) return
+
+ const video = videoRef.current
+ if (!video) return
+ const shouldStartWhenReady = playOnMount || autoplayOnViewport
+
+ let hls: Hls | null = null
+ let cleanedUp = false
+
+ const destroy = () => {
+ if (cleanedUp) return
+ cleanedUp = true
+ if (hls) {
+ try {
+ hls.destroy()
+ } catch {
+ // Already torn down.
+ }
+ hls = null
+ }
+ // Stop any in-flight native HLS playback.
+ try {
+ video.pause()
+ video.removeAttribute("src")
+ video.load()
+ } catch {
+ // Ignore — element may already be detached.
+ }
+ }
+
+ // Register with the LRU registry. If this pushes us past the cap,
+ // the oldest other player gets evicted (its destroy() is called).
+ registerPlayer(registryId, destroy)
+
+ if (video.canPlayType("application/vnd.apple.mpegurl")) {
+ // Safari: native HLS, no hls.js needed.
+ video.src = src
+ if (shouldStartWhenReady) {
+ video.play().catch(() => {})
+ }
+ } else if (Hls.isSupported()) {
+ hls = new Hls()
+ hls.loadSource(src)
+ hls.attachMedia(video)
+ if (shouldStartWhenReady) {
+ hls.on(Hls.Events.MANIFEST_PARSED, () => {
+ video.play().catch(() => {})
+ })
+ }
+ }
+
+ return () => {
+ unregisterPlayer(registryId)
+ destroy()
+ }
+ }, [initialized, src, playOnMount, autoplayOnViewport, registryId])
+
+ // Bump LRU recency on user interaction so that whichever player the
+ // user is actively watching doesn't get evicted by a newly scrolled-
+ // in sibling.
+ useEffect(() => {
+ if (!initialized) return
+
+ const video = videoRef.current
+ if (!video) return
+
+ const bump = () => touchPlayer(registryId)
+ video.addEventListener("play", bump)
+ video.addEventListener("pause", bump)
+ video.addEventListener("seeking", bump)
+
+ return () => {
+ video.removeEventListener("play", bump)
+ video.removeEventListener("pause", bump)
+ video.removeEventListener("seeking", bump)
+ }
+ }, [initialized, registryId])
+
+ return (
+
+ e.stopPropagation()}
+ />
+
+ {"✕"}
+
+
+ )
+}
diff --git a/apps/seed-studio/src/components/preview/sections/BibleQuotesPreview.tsx b/apps/seed-studio/src/components/preview/sections/BibleQuotesPreview.tsx
new file mode 100644
index 000000000..bac9b710f
--- /dev/null
+++ b/apps/seed-studio/src/components/preview/sections/BibleQuotesPreview.tsx
@@ -0,0 +1,43 @@
+import { BookOpen } from "lucide-react"
+
+import type { BibleQuotesCarouselSection } from "@/lib/ai/experience-schema"
+import { cn } from "@/lib/cn"
+
+type BibleQuotesPreviewProps = {
+ section: BibleQuotesCarouselSection
+}
+
+export function BibleQuotesPreview({ section }: BibleQuotesPreviewProps) {
+ return (
+
+
+ {section.heading}
+
+
+ {(section.quotes ?? []).map((quote, i) => (
+
+
+
+ “{quote.text}”
+
+
+ {quote.reference}
+
+ {quote.attribution ? (
+
{quote.attribution}
+ ) : null}
+
+ ))}
+
+
+ )
+}
diff --git a/apps/seed-studio/src/components/preview/sections/CardPreview.tsx b/apps/seed-studio/src/components/preview/sections/CardPreview.tsx
new file mode 100644
index 000000000..1c9956f7b
--- /dev/null
+++ b/apps/seed-studio/src/components/preview/sections/CardPreview.tsx
@@ -0,0 +1,63 @@
+import type { CardSection } from "@forge/experience-templates"
+
+import { cn } from "@/lib/cn"
+import { fixImageUrl } from "@/lib/mux"
+
+type CardPreviewProps = {
+ section: CardSection
+}
+
+type MediaShape = { url?: string | null } | null | undefined
+
+function getMediaUrl(media: unknown): string | undefined {
+ if (!media || typeof media !== "object") return undefined
+ const shape = media as MediaShape
+ return fixImageUrl(shape?.url ?? undefined)
+}
+
+export function CardPreview({ section }: CardPreviewProps) {
+ const variant = section.variant ?? "default"
+ const mediaUrl = getMediaUrl(section.media)
+
+ const containerClass =
+ variant === "featured"
+ ? "p-6 border-l-4 border-blue-600 shadow-md"
+ : "p-4 shadow-sm"
+
+ const Wrapper = (section.link ? "a" : "article") as "a" | "article"
+
+ return (
+
+ {mediaUrl ? (
+
+
+
+ ) : null}
+ {section.title ? (
+
+ {section.title}
+
+ ) : null}
+ {section.description ? (
+
+ {section.description}
+
+ ) : null}
+
+ )
+}
diff --git a/apps/seed-studio/src/components/preview/sections/ContainerPreview.tsx b/apps/seed-studio/src/components/preview/sections/ContainerPreview.tsx
new file mode 100644
index 000000000..7015242c9
--- /dev/null
+++ b/apps/seed-studio/src/components/preview/sections/ContainerPreview.tsx
@@ -0,0 +1,49 @@
+import type { ContainerSection, SectionBlock } from "@/lib/ai/experience-schema"
+import { cn } from "@/lib/cn"
+
+import { SectionRenderer } from "../SectionRenderer"
+
+type ContainerPreviewProps = {
+ section: ContainerSection
+}
+
+function gridColsClass(span: number): string {
+ const map: Record = {
+ 1: "col-span-1",
+ 2: "col-span-2",
+ 3: "col-span-3",
+ 4: "col-span-4",
+ 5: "col-span-5",
+ 6: "col-span-6",
+ 7: "col-span-7",
+ 8: "col-span-8",
+ 9: "col-span-9",
+ 10: "col-span-10",
+ 11: "col-span-11",
+ 12: "col-span-12",
+ }
+ return map[span] ?? "col-span-12"
+}
+
+export function ContainerPreview({ section }: ContainerPreviewProps) {
+ return (
+
+ {(section.slots ?? []).map((slot, i) => (
+
+
+ Slot {i + 1} ({slot.gridSpan}-col)
+
+ {(slot.content ?? []).map((block: SectionBlock, j: number) => (
+
+ ))}
+
+ ))}
+
+ )
+}
diff --git a/apps/seed-studio/src/components/preview/sections/CtaPreview.tsx b/apps/seed-studio/src/components/preview/sections/CtaPreview.tsx
new file mode 100644
index 000000000..b49f98c15
--- /dev/null
+++ b/apps/seed-studio/src/components/preview/sections/CtaPreview.tsx
@@ -0,0 +1,41 @@
+import type { CTASection } from "@forge/experience-templates"
+
+import { cn } from "@/lib/cn"
+
+type CtaPreviewProps = {
+ section: CTASection
+}
+
+export function CtaPreview({ section }: CtaPreviewProps) {
+ const variant = section.variant ?? "primary"
+
+ const buttonClass =
+ variant === "primary"
+ ? "bg-blue-600 text-white hover:bg-blue-700"
+ : "border border-neutral-900 text-neutral-900 hover:bg-neutral-900 hover:text-white"
+
+ return (
+
+ {section.heading ? (
+
+ {section.heading}
+
+ ) : null}
+ {section.body ? (
+
{section.body}
+ ) : null}
+ {section.buttonLabel ? (
+
+ {section.buttonLabel}
+
+ ) : null}
+
+ )
+}
diff --git a/apps/seed-studio/src/components/preview/sections/InfoBlocksPreview.tsx b/apps/seed-studio/src/components/preview/sections/InfoBlocksPreview.tsx
new file mode 100644
index 000000000..4871e837f
--- /dev/null
+++ b/apps/seed-studio/src/components/preview/sections/InfoBlocksPreview.tsx
@@ -0,0 +1,109 @@
+import {
+ BookOpen,
+ CalendarDays,
+ Church,
+ Cross,
+ Film,
+ Flame,
+ Heart,
+ HelpCircle,
+ Info,
+ type LucideIcon,
+ MapPin,
+ MessageCircle,
+ Play,
+ Sparkles,
+ Star,
+ Sun,
+ Users,
+} from "lucide-react"
+
+import type { InfoBlocksSection } from "@forge/experience-templates"
+
+const ICON_MAP: Record = {
+ book: BookOpen,
+ bookopen: BookOpen,
+ calendar: CalendarDays,
+ calendardays: CalendarDays,
+ church: Church,
+ cross: Cross,
+ film: Film,
+ flame: Flame,
+ heart: Heart,
+ help: HelpCircle,
+ helpcircle: HelpCircle,
+ info: Info,
+ location: MapPin,
+ mappin: MapPin,
+ message: MessageCircle,
+ messagecircle: MessageCircle,
+ play: Play,
+ sparkles: Sparkles,
+ star: Star,
+ sun: Sun,
+ users: Users,
+}
+
+function resolveIcon(name?: string): LucideIcon {
+ if (!name) return Info
+ const key = name
+ .trim()
+ .toLowerCase()
+ .replace(/[-_\s]/g, "")
+ return ICON_MAP[key] ?? Info
+}
+
+type InfoBlocksPreviewProps = {
+ section: InfoBlocksSection
+}
+
+export function InfoBlocksPreview({ section }: InfoBlocksPreviewProps) {
+ const blocks = (section.blocks ?? []).filter(
+ (b): b is NonNullable => b != null,
+ )
+
+ return (
+
+
+ {section.intro ? (
+
+ {section.intro}
+
+ ) : null}
+ {section.heading ? (
+
+ {section.heading}
+
+ ) : null}
+ {section.description ? (
+
{section.description}
+ ) : null}
+
+ {blocks.length > 0 ? (
+
+ {blocks.map((block, i) => {
+ const Icon = resolveIcon(block.icon)
+ return (
+
+
+ {block.title ? (
+
+ {block.title}
+
+ ) : null}
+ {block.description ? (
+
+ {block.description}
+
+ ) : null}
+
+ )
+ })}
+
+ ) : null}
+
+ )
+}
diff --git a/apps/seed-studio/src/components/preview/sections/MediaCollectionPreview.tsx b/apps/seed-studio/src/components/preview/sections/MediaCollectionPreview.tsx
new file mode 100644
index 000000000..2247a2ad2
--- /dev/null
+++ b/apps/seed-studio/src/components/preview/sections/MediaCollectionPreview.tsx
@@ -0,0 +1,208 @@
+import type { MediaCollectionSection } from "@forge/experience-templates"
+
+import { cn } from "@/lib/cn"
+import { fixImageUrl } from "@/lib/mux"
+
+type MediaCollectionPreviewProps = {
+ section: MediaCollectionSection
+}
+
+type NormalizedItem = {
+ title: string
+ subtitle?: string
+ label?: string
+ collectionSize?: string
+ imageUrl?: string
+}
+
+function normalizeItem(
+ raw: NonNullable[number],
+): NormalizedItem {
+ const imageUrl = fixImageUrl(raw.imageUrl) ?? undefined
+ return {
+ title: raw.titleOverride ?? "Untitled",
+ subtitle: raw.subtitleOverride,
+ label: raw.labelOverride,
+ collectionSize: raw.collectionSize,
+ imageUrl,
+ }
+}
+
+export function MediaCollectionPreview({
+ section,
+}: MediaCollectionPreviewProps) {
+ const variant = section.variant ?? "grid"
+ const items = (section.items ?? [])
+ .filter((i): i is NonNullable => i != null)
+ .map(normalizeItem)
+
+ const header = (
+
+ {section.categoryLabel ? (
+
+ {section.categoryLabel}
+
+ ) : null}
+ {section.title ? (
+
+ {section.title}
+
+ ) : null}
+ {section.subtitle ? (
+
{section.subtitle}
+ ) : null}
+ {section.description ? (
+
{section.description}
+ ) : null}
+
+ )
+
+ if (items.length === 0) {
+ return (
+
+ )
+ }
+
+ if (variant === "hero") {
+ const [featured] = items
+ return (
+
+ {header}
+ {featured ? (
+
+ {featured.imageUrl ? (
+
+ ) : null}
+
+
+
+ {featured.title}
+
+ {featured.subtitle ? (
+
{featured.subtitle}
+ ) : null}
+
+
+ ) : null}
+
+ )
+ }
+
+ if (variant === "player") {
+ const [featured] = items
+ return (
+
+ {header}
+ {featured ? (
+
+ {featured.imageUrl ? (
+
+ ) : null}
+
+ ) : null}
+
+ )
+ }
+
+ if (variant === "carousel") {
+ return (
+
+ {header}
+
+ {items.map((item, i) => (
+
+ ))}
+
+
+ )
+ }
+
+ const gridClass =
+ variant === "grid"
+ ? "grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-4"
+ : "grid grid-cols-1 gap-3 md:grid-cols-2"
+
+ return (
+
+ {header}
+
+ {items.map((item, i) => (
+
+ ))}
+
+ {section.ctaLink ? (
+
+ {section.ctaLabel ?? "View all"}
+
+ ) : null}
+ {section.footerText ? (
+
{section.footerText}
+ ) : null}
+
+ )
+}
+
+function MediaCard({
+ item,
+ className,
+}: {
+ item: NormalizedItem
+ className?: string
+}) {
+ return (
+
+
+ {item.imageUrl ? (
+
+ ) : null}
+ {item.collectionSize ? (
+
+ {item.collectionSize}
+
+ ) : null}
+
+
+ {item.label ? (
+
+ {item.label}
+
+ ) : null}
+
+ {item.title}
+
+ {item.subtitle ? (
+
+ {item.subtitle}
+
+ ) : null}
+
+
+ )
+}
diff --git a/apps/seed-studio/src/components/preview/sections/NavigationCarouselPreview.tsx b/apps/seed-studio/src/components/preview/sections/NavigationCarouselPreview.tsx
new file mode 100644
index 000000000..e10616e2c
--- /dev/null
+++ b/apps/seed-studio/src/components/preview/sections/NavigationCarouselPreview.tsx
@@ -0,0 +1,77 @@
+"use client"
+
+import type { NavigationCarouselSection } from "@forge/experience-templates"
+
+import { cn } from "@/lib/cn"
+import { fixImageUrl } from "@/lib/mux"
+
+type NavigationCarouselPreviewProps = {
+ section: NavigationCarouselSection
+}
+
+function handleNavigationClick(contentId: string) {
+ if (typeof document === "undefined") return
+ const element = document.getElementById(contentId)
+ element?.scrollIntoView({ behavior: "smooth", block: "start" })
+}
+
+export function NavigationCarouselPreview({
+ section,
+}: NavigationCarouselPreviewProps) {
+ const items = (section.items ?? []).filter(
+ (i): i is NonNullable => i != null,
+ )
+
+ if (items.length === 0) return null
+
+ return (
+
+ {items.map((item, i) => {
+ const imageUrl = fixImageUrl(item.imageUrl)
+ const bg = item.backgroundColor ?? "#1A1815"
+ return (
+
handleNavigationClick(item.contentId)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault()
+ handleNavigationClick(item.contentId)
+ }
+ }}
+ aria-label={`Scroll to ${item.title}`}
+ className={cn(
+ "relative flex h-40 w-36 shrink-0 snap-center flex-col justify-end overflow-hidden",
+ "rounded-lg text-left text-white shadow-sm transition hover:scale-[1.02]",
+ !imageUrl && "bg-neutral-800",
+ )}
+ style={{ backgroundColor: bg }}
+ >
+ {imageUrl ? (
+
+ ) : null}
+
+
+ {item.category ? (
+
+ {item.category}
+
+ ) : null}
+
+ {item.title}
+
+
+
+ )
+ })}
+
+ )
+}
diff --git a/apps/seed-studio/src/components/preview/sections/PromoBannerPreview.tsx b/apps/seed-studio/src/components/preview/sections/PromoBannerPreview.tsx
new file mode 100644
index 000000000..1a312033c
--- /dev/null
+++ b/apps/seed-studio/src/components/preview/sections/PromoBannerPreview.tsx
@@ -0,0 +1,36 @@
+import type { PromoBannerSection } from "@forge/experience-templates"
+
+type PromoBannerPreviewProps = {
+ section: PromoBannerSection
+}
+
+export function PromoBannerPreview({ section }: PromoBannerPreviewProps) {
+ return (
+
+ {section.intro ? (
+
+ {section.intro}
+
+ ) : null}
+ {section.heading ? (
+
+ {section.heading}
+
+ ) : null}
+ {section.description ? (
+
+ {section.description}
+
+ ) : null}
+ {section.ctaLink ? (
+
+ Learn more
+
+ ) : null}
+
+ )
+}
diff --git a/apps/seed-studio/src/components/preview/sections/QuizButtonPreview.tsx b/apps/seed-studio/src/components/preview/sections/QuizButtonPreview.tsx
new file mode 100644
index 000000000..2f9ab369c
--- /dev/null
+++ b/apps/seed-studio/src/components/preview/sections/QuizButtonPreview.tsx
@@ -0,0 +1,26 @@
+import { Sparkles } from "lucide-react"
+
+import type { QuizButtonSection } from "@/lib/ai/experience-schema"
+import { cn } from "@/lib/cn"
+
+type QuizButtonPreviewProps = {
+ section: QuizButtonSection
+}
+
+export function QuizButtonPreview({ section }: QuizButtonPreviewProps) {
+ return (
+
+
+
+ {section.buttonText}
+
+
+ )
+}
diff --git a/apps/seed-studio/src/components/preview/sections/RelatedQuestionsPreview.tsx b/apps/seed-studio/src/components/preview/sections/RelatedQuestionsPreview.tsx
new file mode 100644
index 000000000..08e2489ac
--- /dev/null
+++ b/apps/seed-studio/src/components/preview/sections/RelatedQuestionsPreview.tsx
@@ -0,0 +1,67 @@
+"use client"
+
+import { useState } from "react"
+import { ChevronDown, ChevronRight } from "lucide-react"
+
+import type { RelatedQuestionsSection } from "@/lib/ai/experience-schema"
+import { cn } from "@/lib/cn"
+
+type RelatedQuestionsPreviewProps = {
+ section: RelatedQuestionsSection
+}
+
+export function RelatedQuestionsPreview({
+ section,
+}: RelatedQuestionsPreviewProps) {
+ const [expandedIndex, setExpandedIndex] = useState(null)
+
+ return (
+
+
+ {section.heading}
+
+
+ {(section.questions ?? []).map((item, i) => {
+ const isExpanded = expandedIndex === i
+ return (
+
setExpandedIndex(isExpanded ? null : i)}
+ className="w-full text-left"
+ >
+
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+
+ {item.question}
+
+ {isExpanded ? (
+ item.answer ? (
+
+ {item.answer}
+
+ ) : (
+
+ No answer provided by the model.
+
+ )
+ ) : null}
+
+
+
+ )
+ })}
+
+
+ )
+}
diff --git a/apps/seed-studio/src/components/preview/sections/SectionWrapperPreview.tsx b/apps/seed-studio/src/components/preview/sections/SectionWrapperPreview.tsx
new file mode 100644
index 000000000..3a852267a
--- /dev/null
+++ b/apps/seed-studio/src/components/preview/sections/SectionWrapperPreview.tsx
@@ -0,0 +1,69 @@
+import type {
+ BackgroundColor,
+ SectionBlock,
+ SectionWrapper,
+} from "@forge/experience-templates"
+
+import { cn } from "@/lib/cn"
+
+import { SectionRenderer } from "../SectionRenderer"
+
+type SectionWrapperPreviewProps = {
+ section: SectionWrapper
+}
+
+const SECTION_BG_CLASSES: Record = {
+ default: "bg-white",
+ light: "bg-neutral-50",
+ dark: "bg-stone-900 text-white",
+ primary: "bg-blue-900 text-white",
+ cosmic: "bg-indigo-950 text-white",
+ purple: "bg-purple-900 text-white",
+}
+
+export function SectionWrapperPreview({ section }: SectionWrapperPreviewProps) {
+ const bgKey: BackgroundColor = section.backgroundColor ?? "default"
+ const bgClass = SECTION_BG_CLASSES[bgKey] ?? SECTION_BG_CLASSES.default
+
+ const content = (section.content ?? []).filter(
+ (block): block is NonNullable => block != null,
+ )
+
+ if (content.length === 0) return null
+
+ const opacity = section.backgroundOpacity
+ const hasOverlay = section.staticOverlay === true
+
+ return (
+ = 0 && opacity <= 1
+ ? { opacity }
+ : undefined
+ }
+ >
+ {hasOverlay ? (
+
+ ) : null}
+
+ {content.map((block, i) => (
+
+ ))}
+
+
+ )
+}
diff --git a/apps/seed-studio/src/components/preview/sections/TextSectionPreview.tsx b/apps/seed-studio/src/components/preview/sections/TextSectionPreview.tsx
new file mode 100644
index 000000000..4cd5d4c71
--- /dev/null
+++ b/apps/seed-studio/src/components/preview/sections/TextSectionPreview.tsx
@@ -0,0 +1,29 @@
+import type { TextSection } from "@/lib/ai/experience-schema"
+
+type TextSectionPreviewProps = {
+ section: TextSection
+}
+
+export function TextSectionPreview({ section }: TextSectionPreviewProps) {
+ return (
+
+ {section.heading ? (
+
+ {section.heading}
+
+ ) : null}
+ {section.subtitle ? (
+
+ {section.subtitle}
+
+ ) : null}
+
+ {(section.contentParagraphs ?? []).map((paragraph, i) => (
+
+ {paragraph}
+
+ ))}
+
+
+ )
+}
diff --git a/apps/seed-studio/src/components/preview/sections/VideoCarouselPreview.tsx b/apps/seed-studio/src/components/preview/sections/VideoCarouselPreview.tsx
new file mode 100644
index 000000000..4a9dc38cb
--- /dev/null
+++ b/apps/seed-studio/src/components/preview/sections/VideoCarouselPreview.tsx
@@ -0,0 +1,66 @@
+import { Play } from "lucide-react"
+
+import type { VideoCarouselSection } from "@/lib/ai/experience-schema"
+import { cn } from "@/lib/cn"
+import { fixImageUrl, getMuxThumbnail } from "@/lib/mux"
+
+type VideoCarouselPreviewProps = {
+ section: VideoCarouselSection
+}
+
+export function VideoCarouselPreview({ section }: VideoCarouselPreviewProps) {
+ return (
+
+
+
+ {section.title}
+
+ {section.subtitle ? (
+
{section.subtitle}
+ ) : null}
+
+
+ {(section.items ?? []).filter(Boolean).map((item) => (
+
+
+ {(fixImageUrl(item.videoRef?.thumbnailUrl) ??
+ getMuxThumbnail(
+ item.streamingUrl ?? item.videoRef?.streamingUrl,
+ )) ? (
+
+ ) : null}
+
+
+
+ {item.title}
+
+
+ ))}
+
+
+ )
+}
diff --git a/apps/seed-studio/src/components/preview/sections/VideoHeroPreview.tsx b/apps/seed-studio/src/components/preview/sections/VideoHeroPreview.tsx
new file mode 100644
index 000000000..eb5b01469
--- /dev/null
+++ b/apps/seed-studio/src/components/preview/sections/VideoHeroPreview.tsx
@@ -0,0 +1,74 @@
+"use client"
+
+import { useState } from "react"
+import { Play } from "lucide-react"
+
+import type { VideoHeroSection } from "@/lib/ai/experience-schema"
+import { cn } from "@/lib/cn"
+import { fixImageUrl, getMuxThumbnail } from "@/lib/mux"
+import { VideoPlayer } from "../VideoPlayer"
+
+type VideoHeroPreviewProps = {
+ section: VideoHeroSection
+}
+
+export function VideoHeroPreview({ section }: VideoHeroPreviewProps) {
+ const [playing, setPlaying] = useState(false)
+ const streamingUrl = section.streamingUrl ?? section.videoRef?.streamingUrl
+ const thumbnail =
+ fixImageUrl(section.videoRef?.thumbnailUrl) ?? getMuxThumbnail(streamingUrl)
+
+ if (playing && streamingUrl) {
+ return (
+ setPlaying(false)}
+ />
+ )
+ }
+
+ return (
+
+ {thumbnail ? (
+
+ ) : null}
+
+
+
{section.heading}
+
+ {section.ctaLabel ? (
+
+ {section.ctaLabel}
+
+ ) : null}
+
setPlaying(true)}
+ className={cn(
+ "flex h-9 w-9 items-center justify-center",
+ "rounded-full bg-white/20 backdrop-blur-sm transition hover:bg-white/40",
+ )}
+ >
+
+
+
+
+
+ )
+}
diff --git a/apps/seed-studio/src/components/preview/sections/VideoSectionPreview.tsx b/apps/seed-studio/src/components/preview/sections/VideoSectionPreview.tsx
new file mode 100644
index 000000000..6d37cbeb0
--- /dev/null
+++ b/apps/seed-studio/src/components/preview/sections/VideoSectionPreview.tsx
@@ -0,0 +1,66 @@
+"use client"
+
+import { useState } from "react"
+import { Play } from "lucide-react"
+
+import type { VideoSection } from "@/lib/ai/experience-schema"
+import { cn } from "@/lib/cn"
+import { fixImageUrl, getMuxThumbnail } from "@/lib/mux"
+import { VideoPlayer } from "../VideoPlayer"
+
+type VideoSectionPreviewProps = {
+ section: VideoSection
+}
+
+export function VideoSectionPreview({ section }: VideoSectionPreviewProps) {
+ const [playing, setPlaying] = useState(false)
+ const streamingUrl = section.streamingUrl ?? section.videoRef?.streamingUrl
+ const thumbnail =
+ fixImageUrl(section.videoRef?.thumbnailUrl) ?? getMuxThumbnail(streamingUrl)
+
+ return (
+
+ {playing && streamingUrl ? (
+
setPlaying(false)}
+ />
+ ) : (
+ setPlaying(true)}
+ className={cn(
+ "relative flex w-full aspect-video items-center justify-center",
+ "rounded-lg bg-neutral-200 cursor-pointer",
+ )}
+ >
+ {thumbnail ? (
+
+ ) : null}
+
+
+ )}
+
+
+ {section.title}
+
+ {section.subtitle ? (
+
{section.subtitle}
+ ) : null}
+
+
+ )
+}
diff --git a/apps/seed-studio/src/components/publish/PublishButton.tsx b/apps/seed-studio/src/components/publish/PublishButton.tsx
new file mode 100644
index 000000000..53954da75
--- /dev/null
+++ b/apps/seed-studio/src/components/publish/PublishButton.tsx
@@ -0,0 +1,76 @@
+"use client"
+
+import { useState } from "react"
+import { ExternalLink, Save } from "lucide-react"
+
+import type { GeneratedExperience } from "@/lib/ai/experience-schema"
+import { cn } from "@/lib/cn"
+
+import { PublishDialog } from "./PublishDialog"
+
+const WEB_BASE_URL =
+ process.env.NEXT_PUBLIC_WEB_BASE_URL ?? "http://localhost:3000/watch"
+
+type PublishButtonProps = {
+ experience: GeneratedExperience | null
+ savedSlug: string | null
+ onSaved: (slug: string) => void
+}
+
+export function PublishButton({
+ experience,
+ savedSlug,
+ onSaved,
+}: PublishButtonProps) {
+ const [dialogOpen, setDialogOpen] = useState(false)
+
+ return (
+ <>
+ setDialogOpen(true)}
+ disabled={!experience}
+ className={cn(
+ "flex items-center gap-2 rounded-lg px-4 py-2",
+ "border border-neutral-200 bg-white text-sm font-medium",
+ "text-neutral-700 transition-colors hover:bg-neutral-50",
+ "disabled:cursor-not-allowed disabled:opacity-40",
+ )}
+ >
+
+ Save to Strapi
+
+
+ {
+ if (!savedSlug) e.preventDefault()
+ }}
+ className={cn(
+ "flex items-center gap-2 rounded-lg px-4 py-2",
+ "bg-primary-500 text-sm font-medium text-white transition-colors",
+ "hover:bg-primary-600",
+ !savedSlug && "cursor-not-allowed opacity-40",
+ )}
+ >
+
+ Preview
+
+
+ {experience && dialogOpen ? (
+ setDialogOpen(false)}
+ onPublished={(slug) => {
+ onSaved(slug)
+ }}
+ />
+ ) : null}
+ >
+ )
+}
diff --git a/apps/seed-studio/src/components/publish/PublishDialog.tsx b/apps/seed-studio/src/components/publish/PublishDialog.tsx
new file mode 100644
index 000000000..a03959285
--- /dev/null
+++ b/apps/seed-studio/src/components/publish/PublishDialog.tsx
@@ -0,0 +1,274 @@
+"use client"
+
+import { useCallback, useState } from "react"
+import { AlertCircle, CheckCircle2, Loader2, X } from "lucide-react"
+
+import type { GeneratedExperience } from "@/lib/ai/experience-schema"
+import { cn } from "@/lib/cn"
+
+type PublishDialogProps = {
+ experience: GeneratedExperience
+ initialSlug: string
+ open: boolean
+ onClose: () => void
+ onPublished: (slug: string) => void
+}
+
+type PublishError = {
+ message: string
+ code?: string
+ reason?: string
+ suggestions?: string[]
+}
+
+type PublishState =
+ | { status: "idle" }
+ | { status: "loading" }
+ | { status: "success"; documentId: string; slug: string; warning?: string }
+ | { status: "error"; error: PublishError }
+
+export function PublishDialog({
+ experience,
+ initialSlug,
+ open,
+ onClose,
+ onPublished,
+}: PublishDialogProps) {
+ const [slug, setSlug] = useState(initialSlug)
+ const [publishState, setPublishState] = useState({
+ status: "idle",
+ })
+
+ const setSlugValue = useCallback((value: string) => {
+ setSlug(value)
+ setPublishState((current) =>
+ current.status === "error" ? { status: "idle" } : current,
+ )
+ }, [])
+
+ const applySuggestedSlug = useCallback((value: string) => {
+ setSlug(value)
+ setPublishState({ status: "idle" })
+ }, [])
+
+ const handlePublish = useCallback(async () => {
+ setPublishState({ status: "loading" })
+ try {
+ const { publishExperience } = await import("@/app/actions/publish")
+ const result = await publishExperience({
+ ...experience,
+ slug,
+ })
+ if (result.success) {
+ setSlug(result.slug)
+ setPublishState({
+ status: "success",
+ documentId: result.documentId,
+ slug: result.slug,
+ warning: result.warning,
+ })
+ onPublished(result.slug)
+ } else {
+ setPublishState({
+ status: "error",
+ error: result.error,
+ })
+ }
+ } catch (err) {
+ setPublishState({
+ status: "error",
+ error: {
+ message: err instanceof Error ? err.message : "An error occurred",
+ },
+ })
+ }
+ }, [experience, slug, onPublished])
+
+ if (!open) return null
+
+ return (
+
+
{
+ if (e.key === "Escape") onClose()
+ }}
+ role="button"
+ tabIndex={-1}
+ aria-label="Close dialog"
+ />
+
+
+
+
+
+ {publishState.status === "success" ? (
+
+
+
+
+
+
Saved!
+
+ Your experience is saved in Strapi. Click Preview to see it on
+ the web.
+
+
+ {publishState.documentId ? (
+
+
Document ID: {publishState.documentId}
+
Slug: {publishState.slug}
+
+ ) : null}
+ {publishState.warning ? (
+
+ {publishState.warning}
+
+ ) : null}
+
+ Done
+
+
+ ) : (
+
+
+
+ Save to Strapi
+
+
+ Set a name (slug) for this experience before saving.
+
+
+
+
+
+
+ Slug
+
+ setSlugValue(e.target.value)}
+ className={cn(
+ "w-full rounded-lg border border-neutral-200 px-3 py-2",
+ "text-sm text-neutral-900 outline-none",
+ "focus:border-primary-300 focus:ring-2 focus:ring-primary-100",
+ )}
+ />
+
+
+
+
+ {experience.blocks.length}{" "}
+ {experience.blocks.length === 1 ? "section" : "sections"}
+
+ Locale: en
+
+
+
+ {publishState.status === "error" ? (
+
+
+
+
+ {publishState.error.message}
+
+ {publishState.error.suggestions &&
+ publishState.error.suggestions.length > 0 ? (
+
+
+ Try one of these slugs:
+
+
+ {publishState.error.suggestions.map((suggestion) => (
+ applySuggestedSlug(suggestion)}
+ className={cn(
+ "rounded-full border border-red-200 bg-white px-3 py-1",
+ "text-xs font-medium text-red-700 transition-colors",
+ "hover:border-red-300 hover:bg-red-100",
+ )}
+ >
+ {suggestion}
+
+ ))}
+
+
+ ) : null}
+
+
+ ) : null}
+
+
+
+ Cancel
+
+
+ {publishState.status === "loading" ? (
+ <>
+
+ Saving...
+ >
+ ) : publishState.status === "error" ? (
+ "Retry"
+ ) : (
+ "Save"
+ )}
+
+
+
+ )}
+
+
+ )
+}
diff --git a/apps/seed-studio/src/components/studio.tsx b/apps/seed-studio/src/components/studio.tsx
new file mode 100644
index 000000000..8ebc98b6a
--- /dev/null
+++ b/apps/seed-studio/src/components/studio.tsx
@@ -0,0 +1,102 @@
+"use client"
+
+import { useState } from "react"
+import { Sparkles } from "lucide-react"
+import { cn } from "@/lib/cn"
+import { useChat } from "@/lib/chat/use-chat"
+import {
+ DEFAULT_PROVIDER,
+ DEFAULT_MODELS,
+ type AIProvider,
+} from "@/lib/ai/providers"
+
+import { ChatPanel } from "./chat/ChatPanel"
+import { PreviewPanel } from "./preview/PreviewPanel"
+import { ProviderSelect } from "./ProviderSelect"
+import { PublishButton } from "./publish/PublishButton"
+
+export function Studio() {
+ const [provider, setProvider] = useState
(DEFAULT_PROVIDER)
+ const [model, setModel] = useState(DEFAULT_MODELS[DEFAULT_PROVIDER])
+ const [savedSlug, setSavedSlug] = useState(null)
+ const {
+ messages,
+ experience,
+ isLoading,
+ error,
+ streamingText,
+ statusText,
+ sendMessage,
+ stopGenerating,
+ clearChat,
+ } = useChat(provider, model)
+
+ const handleClearChat = () => {
+ setSavedSlug(null)
+ clearChat()
+ }
+
+ return (
+
+ )
+}
diff --git a/apps/seed-studio/src/lib/ai/experience-schema.ts b/apps/seed-studio/src/lib/ai/experience-schema.ts
new file mode 100644
index 000000000..0202026a9
--- /dev/null
+++ b/apps/seed-studio/src/lib/ai/experience-schema.ts
@@ -0,0 +1,132 @@
+export type Platform = "web" | "mobile"
+
+export type PlatformOrdering = {
+ web: number[]
+ mobile: number[]
+}
+
+export type VideoRef = {
+ id: number
+ documentId: string
+ title: string
+ slug: string
+ streamingUrl: string
+ thumbnailUrl?: string
+}
+
+export type VideoSection = {
+ __component: "sections.video"
+ sectionKey: string
+ video: number
+ streamingUrl: string
+ title: string
+ subtitle: string
+ videoRef?: VideoRef
+}
+
+export type VideoHeroSection = {
+ __component: "sections.video-hero"
+ sectionKey: string
+ streamingUrl: string
+ heading: string
+ ctaLabel?: string
+ ctaLink?: string
+ videoRef?: VideoRef
+}
+
+export type VideoCarouselItem = {
+ sectionKey: string
+ video: number
+ streamingUrl: string
+ title: string
+ subtitle?: string
+ videoRef?: VideoRef
+}
+
+export type VideoCarouselSection = {
+ __component: "sections.video-carousel"
+ title: string
+ subtitle?: string
+ description?: string
+ sectionKey: string
+ items: VideoCarouselItem[]
+}
+
+export type TextSection = {
+ __component: "sections.text"
+ heading?: string
+ subtitle?: string
+ contentParagraphs: string[]
+}
+
+export type ContainerSlot = {
+ gridSpan: number
+ content: SectionBlock[]
+}
+
+export type ContainerSection = {
+ __component: "sections.container"
+ slots: ContainerSlot[]
+}
+
+export type RelatedQuestion = {
+ question: string
+ answer: string
+}
+
+export type RelatedQuestionsSection = {
+ __component: "sections.related-questions"
+ heading: string
+ ctaLabel?: string
+ ctaLink?: string
+ questions: RelatedQuestion[]
+}
+
+export type BibleQuote = {
+ reference: string
+ text: string
+ attribution?: string
+ imageUrl: string
+ backgroundColor: string
+ ctaLabel?: string
+ ctaLink?: string
+}
+
+export type BibleQuotesCarouselSection = {
+ __component: "sections.bible-quotes-carousel"
+ heading: string
+ sectionKey: string
+ quotes: BibleQuote[]
+}
+
+export type QuizButtonSection = {
+ __component: "sections.quiz-button"
+ buttonText: string
+ iframeSrc: string
+}
+
+export type SectionBlock =
+ | VideoSection
+ | VideoHeroSection
+ | VideoCarouselSection
+ | TextSection
+ | ContainerSection
+ | RelatedQuestionsSection
+ | BibleQuotesCarouselSection
+ | QuizButtonSection
+
+export type GeneratedExperience = {
+ title: string
+ slug: string
+ metaDescription?: string
+ blocks: SectionBlock[]
+ platformOrdering: PlatformOrdering
+}
+
+export type ChatMessage = {
+ id: string
+ role: "user" | "assistant"
+ content: string
+ experienceSnapshot?: GeneratedExperience
+ suggestions?: string[]
+}
diff --git a/apps/seed-studio/src/lib/ai/generator.server.ts b/apps/seed-studio/src/lib/ai/generator.server.ts
new file mode 100644
index 000000000..6f55e526f
--- /dev/null
+++ b/apps/seed-studio/src/lib/ai/generator.server.ts
@@ -0,0 +1,1015 @@
+import "server-only"
+
+import {
+ ARCHETYPE_SHAPES,
+ buildSectionKey,
+ computePlatformOrdering,
+ EASTER_SHAPED_TEMPLATE_LAYOUT,
+ generatedExperienceSchema,
+ type ArchetypeName,
+ type GeneratedExperience,
+ type TemplateLayoutEntry,
+ type VideoRef,
+} from "@forge/experience-templates"
+
+const DEFAULT_QUIZ_IFRAME_SRC =
+ "https://your.nextstep.is/embed/default?expand=false"
+
+// -----------------------------------------------------------------------------
+// Public shapes
+// -----------------------------------------------------------------------------
+
+export type GeneratorCandidate = {
+ id: number
+ documentId: string
+ title: string
+ slug: string
+ streamingUrl: string
+ thumbnailUrl?: string
+ similarityScore?: number
+}
+
+export type GenerateInput = {
+ query: string
+ themeSlug: string
+ candidates: GeneratorCandidate[]
+ model: string
+ signal?: AbortSignal
+}
+
+export type GeneratorError =
+ | { code: "NOT_CONFIGURED"; message: string }
+ | { code: "INSUFFICIENT_CANDIDATES"; message: string }
+ | { code: "UPSTREAM_ERROR"; message: string }
+ | { code: "SCHEMA_MISMATCH"; message: string; details?: string }
+ | { code: "ABORTED"; message: string }
+
+export type GenerateResult =
+ | { ok: true; experience: GeneratedExperience }
+ | { ok: false; error: GeneratorError }
+
+// -----------------------------------------------------------------------------
+// Constants
+// -----------------------------------------------------------------------------
+
+const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
+const REQUEST_TIMEOUT_MS = 45_000
+const RETRY_DELAY_MS = 500
+const MIN_CANDIDATES = 4
+const CAROUSEL_ITEMS_PER_SLOT = 3
+
+// -----------------------------------------------------------------------------
+// Archetype → slot manifest
+// -----------------------------------------------------------------------------
+
+type SlotAssignment = {
+ /** Position in the final blocks[] array. */
+ index: number
+ layoutEntry: TemplateLayoutEntry
+ archetype: ArchetypeName
+ sectionKey: string
+ /** Candidate ids allowed for this slot's videoId field (JSON Schema enum). */
+ allowedVideoIds: number[]
+ /** For carousels, multiple video assignments. */
+ carouselSlotCount?: number
+ /** Carousel slot allowed id pools (length === carouselSlotCount). */
+ carouselAllowedVideoIds?: number[][]
+ /** The primary candidate chosen for this slot (used for videoRef). */
+ primaryCandidate: GeneratorCandidate
+ /** For carousels, one candidate per carousel slot. */
+ carouselCandidates?: GeneratorCandidate[]
+}
+
+/**
+ * Greedy round-robin partition of candidates across the layout. Hero gets
+ * candidate[0]; each non-hero VIDEO_CENTRIC gets one distinct candidate from
+ * the remainder; carousels get `CAROUSEL_ITEMS_PER_SLOT` each.
+ *
+ * Unused candidates are not wasted — we still expose 3–5 ids as the enum for
+ * each slot so the model has some freedom while staying bounded.
+ */
+function assignCandidates(
+ themeSlug: string,
+ candidates: GeneratorCandidate[],
+): SlotAssignment[] {
+ const slots: SlotAssignment[] = []
+ let cursor = 0
+ const take = (): GeneratorCandidate => {
+ const pick = candidates[cursor % candidates.length]!
+ cursor += 1
+ return pick
+ }
+
+ const enumFor = (primary: GeneratorCandidate, extraCount = 4): number[] => {
+ const ids = new Set([primary.id])
+ let probe = 0
+ while (
+ ids.size < Math.min(extraCount + 1, candidates.length) &&
+ probe < candidates.length * 2
+ ) {
+ const c = candidates[probe % candidates.length]!
+ ids.add(c.id)
+ probe += 1
+ }
+ return [...ids]
+ }
+
+ EASTER_SHAPED_TEMPLATE_LAYOUT.forEach((entry, index) => {
+ const sectionKey = buildSectionKey(themeSlug, entry.sectionKeySuffix)
+ if (entry.archetype === "VIDEO_HERO") {
+ const primary = candidates[0]!
+ slots.push({
+ index,
+ layoutEntry: entry,
+ archetype: entry.archetype,
+ sectionKey,
+ allowedVideoIds: enumFor(primary),
+ primaryCandidate: primary,
+ })
+ // consume one position of the cursor so subsequent picks don't clash
+ cursor = Math.max(cursor, 1)
+ return
+ }
+
+ if (entry.archetype === "VIDEO_CAROUSEL") {
+ const carouselCandidates: GeneratorCandidate[] = []
+ const carouselAllowed: number[][] = []
+ for (let i = 0; i < CAROUSEL_ITEMS_PER_SLOT; i++) {
+ const pick = take()
+ carouselCandidates.push(pick)
+ carouselAllowed.push(enumFor(pick))
+ }
+ slots.push({
+ index,
+ layoutEntry: entry,
+ archetype: entry.archetype,
+ sectionKey,
+ allowedVideoIds: enumFor(carouselCandidates[0]!),
+ carouselSlotCount: CAROUSEL_ITEMS_PER_SLOT,
+ carouselAllowedVideoIds: carouselAllowed,
+ primaryCandidate: carouselCandidates[0]!,
+ carouselCandidates,
+ })
+ return
+ }
+
+ // VIDEO_CENTRIC / INTRODUCTION / MEDIA_COLLECTION — single primary video
+ const pick = take()
+ slots.push({
+ index,
+ layoutEntry: entry,
+ archetype: entry.archetype,
+ sectionKey,
+ allowedVideoIds: enumFor(pick),
+ primaryCandidate: pick,
+ })
+ })
+
+ return slots
+}
+
+// -----------------------------------------------------------------------------
+// Dynamic JSON Schema builder
+// -----------------------------------------------------------------------------
+
+type JsonSchema = Record
+
+/**
+ * Strict JSON schemas (additionalProperties: false everywhere) for each
+ * archetype. `videoId` fields are constrained to an enum per slot so the
+ * model cannot invent ids.
+ */
+function schemaForArchetype(slot: SlotAssignment): JsonSchema {
+ const base: JsonSchema = {
+ type: "object",
+ additionalProperties: false,
+ }
+
+ switch (slot.archetype) {
+ case "VIDEO_HERO":
+ return {
+ ...base,
+ required: ["heading", "videoId"],
+ properties: {
+ heading: { type: "string" },
+ subtitle: { type: "string" },
+ ctaLabel: { type: "string" },
+ ctaLink: { type: "string" },
+ videoId: { type: "integer", enum: slot.allowedVideoIds },
+ },
+ }
+
+ case "VIDEO_CENTRIC":
+ return {
+ ...base,
+ required: [
+ "heading",
+ "videoTitle",
+ "videoSubtitle",
+ "videoId",
+ "intro",
+ ],
+ properties: {
+ heading: { type: "string" },
+ intro: { type: "string" },
+ videoTitle: { type: "string" },
+ videoSubtitle: { type: "string" },
+ videoId: { type: "integer", enum: slot.allowedVideoIds },
+ quizButtonText: { type: "string" },
+ },
+ }
+
+ case "VIDEO_CAROUSEL": {
+ const itemsSchema = (slot.carouselAllowedVideoIds ?? []).map((ids) => ({
+ type: "object",
+ additionalProperties: false,
+ required: ["title", "videoId"],
+ properties: {
+ title: { type: "string" },
+ subtitle: { type: "string" },
+ videoId: { type: "integer", enum: ids },
+ },
+ }))
+ return {
+ ...base,
+ required: ["title", "items"],
+ properties: {
+ title: { type: "string" },
+ subtitle: { type: "string" },
+ description: { type: "string" },
+ items: {
+ type: "array",
+ minItems: itemsSchema.length,
+ maxItems: itemsSchema.length,
+ prefixItems: itemsSchema,
+ items: false,
+ },
+ },
+ }
+ }
+
+ case "INTRODUCTION":
+ return {
+ ...base,
+ required: ["heading", "intro", "videoId", "questions", "quotes"],
+ properties: {
+ heading: { type: "string" },
+ intro: { type: "string" },
+ videoTitle: { type: "string" },
+ videoSubtitle: { type: "string" },
+ videoId: { type: "integer", enum: slot.allowedVideoIds },
+ questions: {
+ type: "array",
+ minItems: 2,
+ maxItems: 5,
+ items: {
+ type: "object",
+ additionalProperties: false,
+ required: ["question", "answer"],
+ properties: {
+ question: { type: "string" },
+ answer: { type: "string" },
+ },
+ },
+ },
+ quotes: {
+ type: "array",
+ minItems: 1,
+ maxItems: 4,
+ items: {
+ type: "object",
+ additionalProperties: false,
+ required: ["reference", "text"],
+ properties: {
+ reference: { type: "string" },
+ text: { type: "string" },
+ attribution: { type: "string" },
+ },
+ },
+ },
+ quizButtonText: { type: "string" },
+ },
+ }
+
+ case "MEDIA_COLLECTION":
+ return {
+ ...base,
+ required: ["title", "videoId"],
+ properties: {
+ title: { type: "string" },
+ subtitle: { type: "string" },
+ description: { type: "string" },
+ videoId: { type: "integer", enum: slot.allowedVideoIds },
+ ctaLabel: { type: "string" },
+ ctaLink: { type: "string" },
+ },
+ }
+
+ default:
+ return { ...base, properties: {} }
+ }
+}
+
+function buildResponseSchema(slots: SlotAssignment[]): JsonSchema {
+ const blocksSchema = slots.map(schemaForArchetype)
+ return {
+ type: "object",
+ additionalProperties: false,
+ required: ["title", "metaDescription", "blocks"],
+ properties: {
+ title: { type: "string" },
+ metaDescription: { type: "string" },
+ blocks: {
+ type: "array",
+ minItems: blocksSchema.length,
+ maxItems: blocksSchema.length,
+ prefixItems: blocksSchema,
+ items: false,
+ },
+ },
+ }
+}
+
+// -----------------------------------------------------------------------------
+// Prompt
+// -----------------------------------------------------------------------------
+
+function formatCatalog(candidates: GeneratorCandidate[]): string {
+ return candidates
+ .map(
+ (c, i) =>
+ `[${i}] id=${c.id} title="${c.title}" slug="${c.slug}"` +
+ (c.similarityScore != null
+ ? ` similarity=${c.similarityScore.toFixed(3)}`
+ : ""),
+ )
+ .join("\n")
+}
+
+function formatLayout(slots: SlotAssignment[]): string {
+ return slots
+ .map((s) => {
+ const enumStr = s.carouselAllowedVideoIds
+ ? `items=[${s.carouselAllowedVideoIds.map((ids) => `[${ids.join(",")}]`).join(", ")}]`
+ : `videoId∈{${s.allowedVideoIds.join(",")}}`
+ return `[${s.index}] ${s.archetype} (sectionKey="${s.sectionKey}", bg=${s.layoutEntry.backgroundColor ?? "default"}) ${enumStr}`
+ })
+ .join("\n")
+}
+
+function buildSystemPrompt(): string {
+ return [
+ "You are a content curator for JesusFilm, a Christian ministry.",
+ "You compose themed experiences from an existing catalog of videos.",
+ "",
+ "RULES:",
+ "- You MUST only choose videoId values from the enum allowed for each slot.",
+ "- Never invent numeric ids. Never invent video metadata.",
+ "- Fill every required field. Keep copy short, warm, and inviting.",
+ "- Return JSON that matches the schema exactly. No prose, no code fences.",
+ ].join("\n")
+}
+
+function buildUserPrompt(
+ query: string,
+ candidates: GeneratorCandidate[],
+ slots: SlotAssignment[],
+): string {
+ return [
+ `${query} `,
+ "",
+ "",
+ formatCatalog(candidates),
+ " ",
+ "",
+ "",
+ formatLayout(slots),
+ " ",
+ "",
+ "Return the JSON object matching the supplied schema. Use `blocks[i].videoId`",
+ "values drawn only from the enum for that slot. Populate `items[]` for the",
+ "carousel slots matching the given per-item enums. Populate `questions[]` and",
+ "`quotes[]` for the INTRODUCTION block.",
+ ].join("\n")
+}
+
+// -----------------------------------------------------------------------------
+// HTTP call
+// -----------------------------------------------------------------------------
+
+async function callOpenRouter(
+ model: string,
+ systemPrompt: string,
+ userPrompt: string,
+ schema: JsonSchema,
+ signal: AbortSignal,
+): Promise<
+ { ok: true; content: string } | { ok: false; status: number; body: string }
+> {
+ const apiKey = process.env.OPENROUTER_API_KEY
+ const body = {
+ model,
+ messages: [
+ { role: "system", content: systemPrompt },
+ { role: "user", content: userPrompt },
+ ],
+ response_format: {
+ type: "json_schema",
+ json_schema: {
+ name: "experience",
+ strict: true,
+ schema,
+ },
+ },
+ provider: { require_parameters: true },
+ temperature: 0.4,
+ max_tokens: 4000,
+ }
+
+ const response = await fetch(OPENROUTER_URL, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${apiKey}`,
+ "Content-Type": "application/json",
+ "HTTP-Referer": "http://localhost:3200",
+ "X-Title": "Seed Studio",
+ },
+ body: JSON.stringify(body),
+ signal,
+ })
+
+ if (!response.ok) {
+ const text = await response.text().catch(() => "")
+ return { ok: false, status: response.status, body: text }
+ }
+
+ const json = (await response.json()) as {
+ choices?: Array<{ message?: { content?: string } }>
+ }
+ const content = json.choices?.[0]?.message?.content
+ if (typeof content !== "string" || content.length === 0) {
+ return { ok: false, status: 502, body: "Upstream returned empty content" }
+ }
+ return { ok: true, content }
+}
+
+/**
+ * Combine the caller's AbortSignal (if any) with a request-level timeout so a
+ * hung OpenRouter response can't leak compute. Uses AbortSignal.any() —
+ * available in Node 20+ / Next.js edge runtimes.
+ */
+function combineSignals(external?: AbortSignal): AbortSignal {
+ const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS)
+ if (external == null) return timeout
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const anyFn = (AbortSignal as any).any as
+ | ((signals: AbortSignal[]) => AbortSignal)
+ | undefined
+ if (typeof anyFn === "function") {
+ return anyFn([external, timeout])
+ }
+ // Fallback — unify via a manual controller.
+ const ctrl = new AbortController()
+ const onAbort = () => ctrl.abort()
+ external.addEventListener("abort", onAbort, { once: true })
+ timeout.addEventListener("abort", onAbort, { once: true })
+ return ctrl.signal
+}
+
+// -----------------------------------------------------------------------------
+// Assemble the GeneratedExperience from the model's compressed JSON
+// -----------------------------------------------------------------------------
+
+type ModelBlock = {
+ heading?: string
+ intro?: string
+ subtitle?: string
+ videoTitle?: string
+ videoSubtitle?: string
+ videoId?: number
+ ctaLabel?: string
+ ctaLink?: string
+ quizButtonText?: string
+ title?: string
+ description?: string
+ items?: Array<{ title?: string; subtitle?: string; videoId?: number }>
+ questions?: Array<{ question: string; answer: string }>
+ quotes?: Array<{ reference: string; text: string; attribution?: string }>
+}
+
+type ModelPayload = {
+ title: string
+ metaDescription: string
+ blocks: ModelBlock[]
+}
+
+function navigationTitleForSlot(
+ slot: SlotAssignment,
+ block: ModelBlock,
+ primary: GeneratorCandidate,
+): string {
+ switch (slot.archetype) {
+ case "VIDEO_CAROUSEL":
+ case "MEDIA_COLLECTION":
+ return block.title ?? primary.title
+ case "VIDEO_CENTRIC":
+ case "INTRODUCTION":
+ return block.videoTitle ?? block.heading ?? primary.title
+ case "VIDEO_HERO":
+ return block.heading ?? primary.title
+ default:
+ return primary.title
+ }
+}
+
+function navigationCategoryForSlot(slot: SlotAssignment): string {
+ switch (slot.archetype) {
+ case "VIDEO_CAROUSEL":
+ return "Playlist"
+ case "MEDIA_COLLECTION":
+ return "Collection"
+ default:
+ return "Video"
+ }
+}
+
+function buildIntroductionNavigationItems(
+ candidates: GeneratorCandidate[],
+ slots: SlotAssignment[],
+ payload: ModelPayload,
+ afterIndex: number,
+): Array<{
+ contentId: string
+ title: string
+ category: string
+ imageUrl?: string
+}> {
+ return slots
+ .filter((slot) => slot.index > afterIndex)
+ .map((slot) => {
+ const block = payload.blocks[slot.index] ?? ({} as ModelBlock)
+ const primary = candidateById(
+ candidates,
+ block.videoId,
+ slot.primaryCandidate,
+ )
+
+ return {
+ contentId: slot.sectionKey,
+ title: navigationTitleForSlot(slot, block, primary),
+ category: navigationCategoryForSlot(slot),
+ imageUrl: primary.thumbnailUrl,
+ }
+ })
+}
+
+function candidateById(
+ candidates: GeneratorCandidate[],
+ id: number | undefined,
+ fallback: GeneratorCandidate,
+): GeneratorCandidate {
+ if (typeof id !== "number") return fallback
+ return candidates.find((c) => c.id === id) ?? fallback
+}
+
+function toVideoRef(c: GeneratorCandidate): VideoRef {
+ return {
+ id: c.id,
+ documentId: c.documentId,
+ title: c.title,
+ slug: c.slug,
+ streamingUrl: c.streamingUrl,
+ thumbnailUrl: c.thumbnailUrl,
+ }
+}
+
+function assembleExperience(
+ input: GenerateInput,
+ slots: SlotAssignment[],
+ payload: ModelPayload,
+): GeneratedExperience {
+ const candidates = input.candidates
+ const themeSlug = input.themeSlug
+
+ const blocks: GeneratedExperience["blocks"] = slots.map((slot, i) => {
+ const block = payload.blocks[i] ?? ({} as ModelBlock)
+ const primary = candidateById(
+ candidates,
+ block.videoId,
+ slot.primaryCandidate,
+ )
+ const primaryRef = toVideoRef(primary)
+
+ switch (slot.archetype) {
+ case "VIDEO_HERO": {
+ return {
+ __component: "sections.video-hero",
+ sectionKey: slot.sectionKey,
+ streamingUrl: primary.streamingUrl,
+ heading: block.heading ?? primary.title,
+ ctaLabel: block.ctaLabel,
+ ctaLink: block.ctaLink,
+ videoRef: primaryRef,
+ }
+ }
+
+ case "VIDEO_CENTRIC": {
+ return {
+ __component: "sections.section",
+ sectionKey: slot.sectionKey,
+ backgroundColor: slot.layoutEntry.backgroundColor,
+ content: [
+ {
+ __component: "sections.video",
+ sectionKey: `${slot.sectionKey}-video`,
+ video: primary.id,
+ streamingUrl: primary.streamingUrl,
+ title: block.videoTitle ?? primary.title,
+ subtitle: block.videoSubtitle ?? "",
+ videoRef: primaryRef,
+ },
+ {
+ __component: "sections.container",
+ slots: [
+ {
+ gridSpan: 12,
+ content: [
+ {
+ __component: "sections.text",
+ heading: block.heading ?? "",
+ contentParagraphs: block.intro ? [block.intro] : [],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ __component: "sections.quiz-button",
+ buttonText: block.quizButtonText ?? "Take the quiz",
+ iframeSrc: DEFAULT_QUIZ_IFRAME_SRC,
+ },
+ ],
+ } as unknown as GeneratedExperience["blocks"][number]
+ }
+
+ case "VIDEO_CAROUSEL": {
+ const rawItems = block.items ?? []
+ const carouselCandidates = slot.carouselCandidates ?? []
+ const items = rawItems.map((item, idx) => {
+ const itemCandidate = candidateById(
+ candidates,
+ item.videoId,
+ carouselCandidates[idx] ?? slot.primaryCandidate,
+ )
+ return {
+ sectionKey: `${slot.sectionKey}-item-${idx}`,
+ video: itemCandidate.id,
+ streamingUrl: itemCandidate.streamingUrl,
+ title: item.title ?? itemCandidate.title,
+ subtitle: item.subtitle,
+ videoRef: toVideoRef(itemCandidate),
+ }
+ })
+ return {
+ __component: "sections.section",
+ sectionKey: slot.sectionKey,
+ backgroundColor: slot.layoutEntry.backgroundColor,
+ content: [
+ {
+ __component: "sections.video-carousel",
+ sectionKey: `${slot.sectionKey}-carousel`,
+ title: block.title ?? "Explore more",
+ subtitle: block.subtitle,
+ description: block.description,
+ items,
+ },
+ ],
+ } as unknown as GeneratedExperience["blocks"][number]
+ }
+
+ case "INTRODUCTION": {
+ const navigationItems = buildIntroductionNavigationItems(
+ candidates,
+ slots,
+ payload,
+ slot.index,
+ )
+
+ return {
+ __component: "sections.section",
+ sectionKey: slot.sectionKey,
+ backgroundColor: slot.layoutEntry.backgroundColor,
+ content: [
+ {
+ __component: "sections.navigation-carousel",
+ sectionKey: `${slot.sectionKey}-navigation`,
+ items: navigationItems,
+ },
+ {
+ __component: "sections.container",
+ slots: [
+ {
+ gridSpan: 12,
+ content: [
+ {
+ __component: "sections.text",
+ heading: block.heading ?? "",
+ contentParagraphs: block.intro ? [block.intro] : [],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ __component: "sections.video",
+ sectionKey: `${slot.sectionKey}-video`,
+ video: primary.id,
+ streamingUrl: primary.streamingUrl,
+ title: block.videoTitle ?? primary.title,
+ subtitle: block.videoSubtitle ?? "",
+ videoRef: primaryRef,
+ },
+ {
+ __component: "sections.container",
+ slots: [
+ {
+ gridSpan: 12,
+ content: [
+ {
+ __component: "sections.related-questions",
+ heading: "Related questions",
+ questions: block.questions ?? [],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ __component: "sections.bible-quotes-carousel",
+ sectionKey: `${slot.sectionKey}-quotes`,
+ heading: "Scripture",
+ quotes: (block.quotes ?? []).map((q) => ({
+ reference: q.reference,
+ text: q.text,
+ attribution: q.attribution,
+ imageUrl: "",
+ backgroundColor: "#1e3a5f",
+ })),
+ },
+ {
+ __component: "sections.quiz-button",
+ buttonText: block.quizButtonText ?? "Take the quiz",
+ iframeSrc: DEFAULT_QUIZ_IFRAME_SRC,
+ },
+ ],
+ } as unknown as GeneratedExperience["blocks"][number]
+ }
+
+ case "MEDIA_COLLECTION": {
+ return {
+ __component: "sections.section",
+ sectionKey: slot.sectionKey,
+ backgroundColor: slot.layoutEntry.backgroundColor,
+ content: [
+ {
+ __component: "sections.media-collection",
+ sectionKey: `${slot.sectionKey}-media`,
+ variant: "carousel",
+ title: block.title ?? "More to explore",
+ subtitle: block.subtitle,
+ description: block.description,
+ ctaLabel: block.ctaLabel,
+ ctaLink: block.ctaLink,
+ items: [
+ {
+ video: {
+ id: primary.id,
+ documentId: primary.documentId,
+ slug: primary.slug,
+ },
+ },
+ ],
+ },
+ ],
+ } as unknown as GeneratedExperience["blocks"][number]
+ }
+
+ default:
+ // Never reached — ARCHETYPE_SHAPES covers every case. Included to keep
+ // the switch exhaustive for TS without an @ts-expect-error.
+ void ARCHETYPE_SHAPES
+ return {
+ __component: "sections.video-hero",
+ sectionKey: slot.sectionKey,
+ streamingUrl: primary.streamingUrl,
+ heading: primary.title,
+ videoRef: primaryRef,
+ }
+ }
+ })
+
+ return {
+ title: payload.title,
+ slug: themeSlug,
+ metaDescription: payload.metaDescription,
+ blocks,
+ platformOrdering: computePlatformOrdering(blocks.length),
+ }
+}
+
+// -----------------------------------------------------------------------------
+// Public entry point
+// -----------------------------------------------------------------------------
+
+function sleep(ms: number, signal?: AbortSignal): Promise {
+ return new Promise((resolve, reject) => {
+ if (signal?.aborted) {
+ reject(new DOMException("Aborted", "AbortError"))
+ return
+ }
+ const t = setTimeout(resolve, ms)
+ signal?.addEventListener(
+ "abort",
+ () => {
+ clearTimeout(t)
+ reject(new DOMException("Aborted", "AbortError"))
+ },
+ { once: true },
+ )
+ })
+}
+
+function zodIssuePaths(error: {
+ issues: Array<{ path: PropertyKey[]; message: string }>
+}): string {
+ return JSON.stringify(
+ error.issues.slice(0, 8).map((i) => ({
+ path: i.path.map((p) => String(p)),
+ message: i.message,
+ })),
+ )
+}
+
+export async function generateExperience(
+ input: GenerateInput,
+): Promise {
+ const apiKey = process.env.OPENROUTER_API_KEY
+ if (!apiKey || apiKey.length === 0) {
+ return {
+ ok: false,
+ error: {
+ code: "NOT_CONFIGURED",
+ message: "OPENROUTER_API_KEY is not set",
+ },
+ }
+ }
+
+ if (input.candidates.length < MIN_CANDIDATES) {
+ return {
+ ok: false,
+ error: {
+ code: "INSUFFICIENT_CANDIDATES",
+ message: `Need at least ${MIN_CANDIDATES} candidate videos, got ${input.candidates.length}.`,
+ },
+ }
+ }
+
+ const slots = assignCandidates(input.themeSlug, input.candidates)
+ const schema = buildResponseSchema(slots)
+ const systemPrompt = buildSystemPrompt()
+ let userPrompt = buildUserPrompt(input.query, input.candidates, slots)
+ const combinedSignal = combineSignals(input.signal)
+
+ // ---- First HTTP call (with one retry on 5xx / network) --------------------
+ let upstream = await callOpenRouter(
+ input.model,
+ systemPrompt,
+ userPrompt,
+ schema,
+ combinedSignal,
+ ).catch((err: unknown) => ({
+ ok: false as const,
+ status: 0,
+ body: err instanceof Error ? err.message : String(err),
+ }))
+
+ if (!upstream.ok && upstream.status >= 500) {
+ try {
+ await sleep(RETRY_DELAY_MS, combinedSignal)
+ } catch {
+ return {
+ ok: false,
+ error: { code: "ABORTED", message: "Request aborted before retry" },
+ }
+ }
+ upstream = await callOpenRouter(
+ input.model,
+ systemPrompt,
+ userPrompt,
+ schema,
+ combinedSignal,
+ ).catch((err: unknown) => ({
+ ok: false as const,
+ status: 0,
+ body: err instanceof Error ? err.message : String(err),
+ }))
+ }
+
+ if (!upstream.ok) {
+ if (combinedSignal.aborted) {
+ return {
+ ok: false,
+ error: { code: "ABORTED", message: "Request aborted" },
+ }
+ }
+ return {
+ ok: false,
+ error: {
+ code: "UPSTREAM_ERROR",
+ message: `OpenRouter returned ${upstream.status}: ${upstream.body.slice(0, 200)}`,
+ },
+ }
+ }
+
+ // ---- Parse + Zod validate (with one JSON-only retry) ---------------------
+ const tryParse = (
+ raw: string,
+ ):
+ | { ok: true; payload: ModelPayload; experience: GeneratedExperience }
+ | { ok: false; reason: "not-json" | "schema"; details: string } => {
+ let parsed: unknown
+ try {
+ parsed = JSON.parse(raw)
+ } catch (err) {
+ return {
+ ok: false,
+ reason: "not-json",
+ details: err instanceof Error ? err.message : String(err),
+ }
+ }
+ const assembled = assembleExperience(input, slots, parsed as ModelPayload)
+ const zod = generatedExperienceSchema.safeParse(assembled)
+ if (!zod.success) {
+ return {
+ ok: false,
+ reason: "schema",
+ details: zodIssuePaths(zod.error),
+ }
+ }
+ return {
+ ok: true,
+ payload: parsed as ModelPayload,
+ experience: zod.data as GeneratedExperience,
+ }
+ }
+
+ let parseResult = tryParse(upstream.content)
+ if (!parseResult.ok) {
+ // Retry once with the validation error appended to the user message.
+ userPrompt = `${userPrompt}\n\nPrevious response was invalid: ${parseResult.details}. Return only JSON matching the schema.`
+ const retry = await callOpenRouter(
+ input.model,
+ systemPrompt,
+ userPrompt,
+ schema,
+ combinedSignal,
+ ).catch((err: unknown) => ({
+ ok: false as const,
+ status: 0,
+ body: err instanceof Error ? err.message : String(err),
+ }))
+
+ if (!retry.ok) {
+ if (combinedSignal.aborted) {
+ return {
+ ok: false,
+ error: { code: "ABORTED", message: "Request aborted" },
+ }
+ }
+ return {
+ ok: false,
+ error: {
+ code: "UPSTREAM_ERROR",
+ message: `OpenRouter returned ${retry.status}: ${retry.body.slice(0, 200)}`,
+ },
+ }
+ }
+ parseResult = tryParse(retry.content)
+ }
+
+ if (!parseResult.ok) {
+ return {
+ ok: false,
+ error: {
+ code: "SCHEMA_MISMATCH",
+ message:
+ parseResult.reason === "not-json"
+ ? "Response was not valid JSON"
+ : "Response did not match the expected schema",
+ details: parseResult.details,
+ },
+ }
+ }
+
+ return { ok: true, experience: parseResult.experience }
+}
diff --git a/apps/seed-studio/src/lib/ai/providers.ts b/apps/seed-studio/src/lib/ai/providers.ts
new file mode 100644
index 000000000..a971e3d19
--- /dev/null
+++ b/apps/seed-studio/src/lib/ai/providers.ts
@@ -0,0 +1,80 @@
+export const AI_PROVIDERS = [
+ "openrouter",
+ "exo",
+ "claude",
+ "codex",
+ "gemini",
+ "ollama",
+] as const
+export type AIProvider = (typeof AI_PROVIDERS)[number]
+
+export const PROVIDER_LABELS: Record = {
+ openrouter: "OpenRouter",
+ exo: "Exo",
+ claude: "Claude",
+ codex: "Codex",
+ gemini: "Gemini",
+ ollama: "Ollama",
+}
+
+/**
+ * Providers with a `true` value here are routed through the strict-JSON-Schema
+ * generator (`generator.server.ts`) — a single-shot OpenRouter call that
+ * returns a validated `GeneratedExperience`. The `/api/chat` route emits a
+ * single `event: patch` SSE event with the parsed object.
+ *
+ * Providers with `false` stay on the legacy free-form streaming path (chunk
+ * SSE events + code-block extraction on the client).
+ */
+export const SUPPORTS_STRICT_JSON_SCHEMA: Record = {
+ openrouter: true,
+ exo: false,
+ claude: false,
+ codex: false,
+ gemini: false,
+ ollama: false,
+}
+
+export type ModelOption = {
+ id: string
+ label: string
+}
+
+export const PROVIDER_MODELS: Record = {
+ openrouter: [
+ { id: "anthropic/claude-opus-4.7", label: "Claude Opus 4.7" },
+ { id: "anthropic/claude-sonnet-4.6", label: "Claude Sonnet 4.6" },
+ { id: "openai/gpt-5.4", label: "GPT-5.4" },
+ { id: "google/gemini-2.5-pro", label: "Gemini 2.5 Pro" },
+ ],
+ exo: [
+ { id: "mlx-community/GLM-4.7-Flash-8bit", label: "GLM-4.7 Flash 8bit" },
+ ],
+ claude: [
+ { id: "claude-opus-4-7", label: "Opus 4.7" },
+ { id: "claude-sonnet-4-6", label: "Sonnet 4.6" },
+ { id: "claude-haiku-4-5-20251001", label: "Haiku 4.5" },
+ ],
+ codex: [
+ { id: "gpt-5.4", label: "GPT-5.4" },
+ { id: "gpt-5.4:fast", label: "GPT-5.4 Fast" },
+ ],
+ gemini: [
+ { id: "gemini-2.5-flash", label: "2.5 Flash" },
+ { id: "gemini-2.5-pro", label: "2.5 Pro" },
+ { id: "gemini-2.0-flash", label: "2.0 Flash" },
+ { id: "gemini-2.0-flash-lite", label: "2.0 Flash Lite" },
+ ],
+ ollama: [],
+}
+
+export const DEFAULT_MODELS: Record = {
+ openrouter: "anthropic/claude-sonnet-4.6",
+ exo: "mlx-community/GLM-4.7-Flash-8bit",
+ claude: "claude-sonnet-4-6",
+ codex: "gpt-5.4:fast",
+ gemini: "gemini-2.5-flash",
+ ollama: "gemma4:26b",
+}
+
+export const DEFAULT_PROVIDER: AIProvider = "codex"
diff --git a/apps/seed-studio/src/lib/chat/use-chat.ts b/apps/seed-studio/src/lib/chat/use-chat.ts
new file mode 100644
index 000000000..e04747b4c
--- /dev/null
+++ b/apps/seed-studio/src/lib/chat/use-chat.ts
@@ -0,0 +1,946 @@
+"use client"
+
+import { useState, useCallback, useRef } from "react"
+import {
+ COMPONENT_ALIASES,
+ generatedExperienceSchema,
+ normalizeComponent,
+} from "@forge/experience-templates"
+import type {
+ ChatMessage,
+ GeneratedExperience,
+} from "@/lib/ai/experience-schema"
+import { DEFAULT_PROVIDER, type AIProvider } from "@/lib/ai/providers"
+
+// Re-export to preserve existing `import { COMPONENT_ALIASES } from ".../use-chat"`
+// call sites, should any exist. The local copy is removed — `COMPONENT_ALIASES`
+// now lives in @forge/experience-templates.
+export { COMPONENT_ALIASES }
+
+/**
+ * SSE events emitted by /api/chat. Two streams coexist:
+ *
+ * - Legacy "chunk" events: free-form text deltas from Claude CLI / Codex /
+ * Ollama / Gemini / Exo. Concatenated and parsed once the stream closes.
+ * - Strict "patch" events: a single structured payload from the OpenRouter
+ * strict-JSON-Schema generator. Shape `{ path: string[], value: unknown }`.
+ *
+ * Any unknown event type is ignored so clients survive a server upgrade
+ * without crashing.
+ */
+type SSEEvent =
+ | { type: "chunk"; text: string }
+ | { type: "status"; text: string }
+ | { type: "done"; code: number }
+ | { type: "error"; text: string }
+
+type PatchEvent = {
+ path: string[]
+ value: unknown
+}
+
+type CatalogVideo = {
+ id: number
+ documentId: string
+ title: string
+ slug: string
+ streamingUrl: string
+ thumbnailUrl?: string
+}
+
+type ExtractedExperienceResult = {
+ experience?: GeneratedExperience
+ error?: string
+}
+
+function generateId(): string {
+ return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
+}
+
+function canonicalComponent(raw: unknown): string | undefined {
+ if (typeof raw !== "string") return undefined
+ return normalizeComponent(raw) ?? undefined
+}
+
+function normalizeBlock(
+ block: Record,
+): Record | undefined {
+ const rawComponent =
+ block.__component ?? block.type ?? block.component ?? block.kind
+ const component = canonicalComponent(rawComponent)
+ if (!component) return undefined
+
+ const out: Record = { ...block, __component: component }
+ delete out.type
+ delete out.component
+ delete out.kind
+
+ // heading aliases for sections that need a heading field
+ if (
+ !out.heading &&
+ typeof out.title === "string" &&
+ (component === "sections.video-hero" ||
+ component === "sections.related-questions" ||
+ component === "sections.bible-quotes-carousel")
+ ) {
+ out.heading = out.title
+ }
+
+ // contentParagraphs aliases for text sections
+ if (component === "sections.text") {
+ if (!Array.isArray(out.contentParagraphs)) {
+ if (Array.isArray(out.paragraphs)) {
+ out.contentParagraphs = out.paragraphs
+ } else if (typeof out.content === "string") {
+ out.contentParagraphs = [out.content]
+ } else if (typeof out.body === "string") {
+ out.contentParagraphs = [out.body]
+ } else {
+ out.contentParagraphs = []
+ }
+ }
+ }
+
+ // video sections: copy videoRef.streamingUrl / thumbnailUrl to top level if missing
+ const videoRef = out.videoRef as Record | undefined
+ if (videoRef && typeof videoRef === "object") {
+ if (!out.streamingUrl && typeof videoRef.streamingUrl === "string") {
+ out.streamingUrl = videoRef.streamingUrl
+ }
+ }
+
+ // related-questions: coerce each entry to { question, answer } strings
+ if (
+ component === "sections.related-questions" &&
+ Array.isArray(out.questions)
+ ) {
+ out.questions = (out.questions as unknown[])
+ .map((q) => {
+ if (typeof q === "string") return { question: q, answer: "" }
+ if (q && typeof q === "object") {
+ const obj = q as Record
+ const question =
+ (typeof obj.question === "string" && obj.question) ||
+ (typeof obj.q === "string" && obj.q) ||
+ (typeof obj.title === "string" && obj.title) ||
+ ""
+ const answer =
+ (typeof obj.answer === "string" && obj.answer) ||
+ (typeof obj.a === "string" && obj.a) ||
+ (typeof obj.response === "string" && obj.response) ||
+ ""
+ return question ? { question, answer } : undefined
+ }
+ return undefined
+ })
+ .filter(Boolean)
+ }
+
+ return out
+}
+
+function normalizeBlocks(
+ blocks: Array>,
+): GeneratedExperience["blocks"] {
+ return blocks
+ .map(normalizeBlock)
+ .filter((b): b is Record =>
+ Boolean(b),
+ ) as unknown as GeneratedExperience["blocks"]
+}
+
+function unwrapExperience(
+ parsed: Record,
+): Record {
+ const WRAPPERS = ["experience", "data", "result", "output"]
+ for (const key of WRAPPERS) {
+ const inner = parsed[key]
+ if (
+ inner &&
+ typeof inner === "object" &&
+ !Array.isArray(inner) &&
+ Object.keys(parsed).length === 1
+ ) {
+ return unwrapExperience(inner as Record)
+ }
+ }
+ return parsed
+}
+
+function normalizeStreamingUrl(value: unknown): string | undefined {
+ if (typeof value !== "string") return undefined
+ const trimmed = value.trim()
+ return trimmed.length > 0 ? trimmed : undefined
+}
+
+function toVideoRef(video: CatalogVideo): Record {
+ return {
+ id: video.id,
+ documentId: video.documentId,
+ title: video.title,
+ slug: video.slug,
+ streamingUrl: video.streamingUrl,
+ ...(video.thumbnailUrl ? { thumbnailUrl: video.thumbnailUrl } : {}),
+ }
+}
+
+function buildCatalogLookups(catalog: CatalogVideo[]) {
+ const byId = new Map()
+ const byDocumentId = new Map()
+ const bySlug = new Map()
+ const byStreamingUrl = new Map()
+
+ for (const video of catalog) {
+ byId.set(video.id, video)
+ byDocumentId.set(video.documentId, video)
+ bySlug.set(video.slug.toLowerCase(), video)
+ byStreamingUrl.set(video.streamingUrl, video)
+ }
+
+ return { byId, byDocumentId, bySlug, byStreamingUrl }
+}
+
+function matchCatalogVideo(
+ node: Record,
+ lookups: ReturnType,
+): CatalogVideo | undefined {
+ const videoRef =
+ node.videoRef && typeof node.videoRef === "object"
+ ? (node.videoRef as Record)
+ : undefined
+
+ const numericIds = [node.video, videoRef?.id]
+ for (const rawId of numericIds) {
+ if (typeof rawId === "number") {
+ const match = lookups.byId.get(rawId)
+ if (match) return match
+ }
+ }
+
+ const documentIds = [videoRef?.documentId]
+ for (const rawDocumentId of documentIds) {
+ if (typeof rawDocumentId === "string") {
+ const match = lookups.byDocumentId.get(rawDocumentId.trim())
+ if (match) return match
+ }
+ }
+
+ const slugs = [node.slug, videoRef?.slug]
+ for (const rawSlug of slugs) {
+ if (typeof rawSlug === "string") {
+ const match = lookups.bySlug.get(rawSlug.trim().toLowerCase())
+ if (match) return match
+ }
+ }
+
+ const streamingUrls = [node.streamingUrl, videoRef?.streamingUrl]
+ for (const rawStreamingUrl of streamingUrls) {
+ const normalized = normalizeStreamingUrl(rawStreamingUrl)
+ if (!normalized) continue
+ const match = lookups.byStreamingUrl.get(normalized)
+ if (match) return match
+ }
+
+ return undefined
+}
+
+function pathString(path: Array): string {
+ if (path.length === 0) return "experience"
+
+ return path
+ .map((part, index) =>
+ typeof part === "number" ? `[${part}]` : index === 0 ? part : `.${part}`,
+ )
+ .join("")
+}
+
+function reconcileExperienceWithCatalog(
+ candidate: Record,
+ catalog: CatalogVideo[],
+): ExtractedExperienceResult {
+ if (catalog.length === 0) {
+ return {
+ error:
+ "No Strapi videos matched this query. Refine the theme instead of using external videos.",
+ }
+ }
+
+ const lookups = buildCatalogLookups(catalog)
+ let mismatchPath: string | undefined
+
+ const visit = (node: unknown, path: Array): boolean => {
+ if (!node || typeof node !== "object") return true
+
+ if (Array.isArray(node)) {
+ for (const [index, child] of node.entries()) {
+ if (!visit(child, [...path, index])) return false
+ }
+ return true
+ }
+
+ const obj = node as Record
+ const rawComponent =
+ obj.__component ?? obj.type ?? obj.component ?? obj.kind
+ const component = canonicalComponent(rawComponent)
+ if (component) {
+ obj.__component = component
+ delete obj.type
+ delete obj.component
+ delete obj.kind
+ }
+
+ if (component === "sections.video" || component === "sections.video-hero") {
+ const match = matchCatalogVideo(obj, lookups)
+ if (!match) {
+ mismatchPath = pathString(path)
+ return false
+ }
+ if (component === "sections.video") {
+ obj.video = match.id
+ }
+ obj.streamingUrl = match.streamingUrl
+ obj.videoRef = toVideoRef(match)
+ }
+
+ if (component === "sections.video-carousel" && Array.isArray(obj.items)) {
+ for (const [index, rawItem] of obj.items.entries()) {
+ if (!rawItem || typeof rawItem !== "object") continue
+ const item = rawItem as Record
+ const match = matchCatalogVideo(item, lookups)
+ if (!match) {
+ mismatchPath = pathString([...path, "items", index])
+ return false
+ }
+ item.video = match.id
+ item.streamingUrl = match.streamingUrl
+ item.videoRef = toVideoRef(match)
+ }
+ }
+
+ if (
+ Array.isArray(obj.content) &&
+ !visit(obj.content, [...path, "content"])
+ ) {
+ return false
+ }
+
+ if (Array.isArray(obj.slots)) {
+ for (const [index, rawSlot] of obj.slots.entries()) {
+ if (!rawSlot || typeof rawSlot !== "object") continue
+ const slot = rawSlot as Record
+ if (
+ Array.isArray(slot.content) &&
+ !visit(slot.content, [...path, "slots", index, "content"])
+ ) {
+ return false
+ }
+ }
+ }
+
+ if (Array.isArray(obj.blocks) && !visit(obj.blocks, [...path, "blocks"])) {
+ return false
+ }
+
+ return true
+ }
+
+ if (!visit(candidate.blocks, ["blocks"])) {
+ return {
+ error: mismatchPath
+ ? `Generated experience referenced a video outside the Strapi catalog at ${mismatchPath}.`
+ : "Generated experience referenced a video outside the Strapi catalog.",
+ }
+ }
+
+ return { experience: candidate as GeneratedExperience }
+}
+
+/**
+ * Deterministically backfill missing sectionKey on video-shaped nodes.
+ *
+ * The CMS publish path keys nested `sections.video` relation repair by
+ * sectionKey, and deterministic keys also keep generated trees stable for
+ * preview/parity work. We walk the whole tree (top-level blocks,
+ * `sections.section.content[]`, `sections.container.slots[].content[]`, and
+ * `sections.video-carousel.items`) assigning `${slug}-video-${i}` keys as a
+ * fallback.
+ *
+ * Runs mutating because the tree comes from JSON.parse and has no shared
+ * references that would make mutation unsafe.
+ */
+function backfillSectionKeys(
+ experience: GeneratedExperience,
+): GeneratedExperience {
+ const slug = experience.slug || "experience"
+ let counter = 0
+ const nextKey = () => `${slug}-video-${counter++}`
+
+ const visit = (node: unknown): void => {
+ if (!node || typeof node !== "object") return
+ if (Array.isArray(node)) {
+ for (const child of node) visit(child)
+ return
+ }
+ const obj = node as Record
+ const component = typeof obj.__component === "string" ? obj.__component : ""
+
+ if (component === "sections.video" || component === "sections.video-hero") {
+ if (typeof obj.sectionKey !== "string" || obj.sectionKey.length === 0) {
+ obj.sectionKey = nextKey()
+ }
+ }
+
+ // Carousel items carry sectionKey but not __component
+ if (component === "sections.video-carousel" && Array.isArray(obj.items)) {
+ for (const item of obj.items as Array>) {
+ if (
+ item &&
+ typeof item === "object" &&
+ (typeof item.sectionKey !== "string" || item.sectionKey.length === 0)
+ ) {
+ item.sectionKey = nextKey()
+ }
+ }
+ }
+
+ // Recurse into known container shapes
+ if (Array.isArray(obj.content)) visit(obj.content)
+ if (Array.isArray(obj.slots)) {
+ for (const slot of obj.slots as Array>) {
+ if (slot && typeof slot === "object" && Array.isArray(slot.content)) {
+ visit(slot.content)
+ }
+ }
+ }
+ if (Array.isArray(obj.blocks)) visit(obj.blocks)
+ }
+
+ visit(experience.blocks)
+ return experience
+}
+
+/**
+ * Validate the parsed object against the shared Zod schema. On failure, log
+ * the Zod issues (never the raw user input — see
+ * docs/solutions/security-issues/zod-validation-errors-must-not-echo-user-
+ * controlled-input-20260420.md) and return the best-effort object anyway so
+ * partial previews still render during dev.
+ */
+function validateExperience(
+ candidate: Record,
+): GeneratedExperience | undefined {
+ const result = generatedExperienceSchema.safeParse(candidate)
+ if (result.success) {
+ return result.data as GeneratedExperience
+ }
+ // Log the Zod issue paths only — echoing the raw input would bounce
+ // user-controlled data back to client logs and any error reporter.
+ const issues = result.error.issues.map((i) => ({
+ path: i.path,
+ message: i.message,
+ code: i.code,
+ }))
+
+ console.warn("[use-chat] generatedExperienceSchema validation failed", issues)
+ // Best-effort fall-through: return the candidate as a partial experience so
+ // the preview pane still renders something useful while the model retries.
+ return candidate as unknown as GeneratedExperience
+}
+
+function parseExperienceJson(
+ json: string,
+ catalog: CatalogVideo[] = [],
+): ExtractedExperienceResult {
+ try {
+ const raw = JSON.parse(json) as Record
+ const parsed = unwrapExperience(raw)
+ const blocksRaw = (parsed.blocks ?? parsed.sections ?? parsed.pages) as
+ | Array>
+ | undefined
+ if (!Array.isArray(blocksRaw)) return {}
+ const blocks = normalizeBlocks(blocksRaw)
+ if (blocks.length === 0) return {}
+ const title =
+ (typeof parsed.title === "string" && parsed.title) ||
+ (typeof parsed.name === "string" && parsed.name) ||
+ "Untitled Experience"
+ const slug =
+ (typeof parsed.slug === "string" && parsed.slug) ||
+ (typeof title === "string" &&
+ title
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-|-$/g, "")) ||
+ "experience"
+ const metaDescription =
+ (typeof parsed.metaDescription === "string" && parsed.metaDescription) ||
+ (typeof parsed.description === "string" && parsed.description) ||
+ undefined
+
+ const candidate: Record = {
+ ...parsed,
+ title,
+ slug,
+ metaDescription,
+ blocks,
+ }
+ const reconciled =
+ catalog.length > 0
+ ? reconcileExperienceWithCatalog(candidate, catalog)
+ : { experience: candidate as GeneratedExperience }
+ if (!reconciled.experience) return reconciled
+
+ const validated = validateExperience(
+ reconciled.experience as Record,
+ )
+ if (!validated) return {}
+ return { experience: backfillSectionKeys(validated) }
+ } catch {
+ return {}
+ }
+}
+
+/** Find the outermost balanced JSON object starting from a { */
+function extractBalancedJson(
+ text: string,
+ startIdx: number,
+): string | undefined {
+ let depth = 0
+ let inString = false
+ let escape = false
+ for (let i = startIdx; i < text.length; i++) {
+ const ch = text[i]
+ if (escape) {
+ escape = false
+ continue
+ }
+ if (ch === "\\") {
+ escape = true
+ continue
+ }
+ if (ch === '"' && !escape) {
+ inString = !inString
+ continue
+ }
+ if (inString) continue
+ if (ch === "{") depth++
+ if (ch === "}") {
+ depth--
+ if (depth === 0) return text.slice(startIdx, i + 1)
+ }
+ }
+ return undefined
+}
+
+function findRawExperienceJson(text: string): string | undefined {
+ // Find { that likely starts an experience object (contains "title" nearby)
+ let idx = 0
+ while (idx < text.length) {
+ const braceIdx = text.indexOf("{", idx)
+ if (braceIdx === -1) break
+ const json = extractBalancedJson(text, braceIdx)
+ if (
+ json &&
+ json.includes('"title"') &&
+ (json.includes('"blocks"') || json.includes('"sections"'))
+ ) {
+ return json
+ }
+ idx = braceIdx + 1
+ }
+ return undefined
+}
+
+function extractExperience(
+ text: string,
+ catalog: CatalogVideo[] = [],
+): ExtractedExperienceResult {
+ // Try ```experience ... ``` first
+ const fenced = text.match(/```experience\n([\s\S]*?)\n```/)
+ if (fenced) {
+ const result = parseExperienceJson(fenced[1], catalog)
+ if (result.experience || result.error) return result
+ }
+
+ // Try ```json ... ``` blocks
+ const jsonBlock = text.match(/```json\n([\s\S]*?)\n```/)
+ if (jsonBlock) {
+ const result = parseExperienceJson(jsonBlock[1], catalog)
+ if (result.experience || result.error) return result
+ }
+
+ // Try raw JSON with bracket counting
+ const rawJson = findRawExperienceJson(text)
+ if (rawJson) {
+ return parseExperienceJson(rawJson, catalog)
+ }
+
+ return {}
+}
+
+function coerceSuggestion(raw: unknown): string | undefined {
+ if (typeof raw === "string") {
+ const s = raw.trim()
+ return s.length > 0 ? s : undefined
+ }
+ if (raw && typeof raw === "object") {
+ const obj = raw as Record
+ for (const key of [
+ "text",
+ "label",
+ "title",
+ "suggestion",
+ "content",
+ "prompt",
+ ]) {
+ const v = obj[key]
+ if (typeof v === "string" && v.trim().length > 0) return v.trim()
+ }
+ }
+ return undefined
+}
+
+function normalizeSuggestions(arr: unknown): string[] {
+ if (!Array.isArray(arr)) return []
+ return arr
+ .map(coerceSuggestion)
+ .filter((s): s is string => typeof s === "string")
+ .slice(0, 6)
+}
+
+function extractSuggestions(text: string): string[] {
+ const defaults = ["Add more sections", "Change the theme", "Publish"]
+
+ // Try ```suggestions ... ```
+ const fenced = text.match(/```suggestions\n([\s\S]*?)\n```/)
+ if (fenced) {
+ try {
+ const normalized = normalizeSuggestions(JSON.parse(fenced[1]))
+ if (normalized.length > 0) return normalized
+ } catch {
+ // fall through
+ }
+ }
+
+ // Try "suggestions": [...] inside JSON
+ const inJson = text.match(/"suggestions"\s*:\s*\[([\s\S]*?)\]/)
+ if (inJson) {
+ try {
+ const normalized = normalizeSuggestions(JSON.parse(`[${inJson[1]}]`))
+ if (normalized.length > 0) return normalized
+ } catch {
+ // fall through
+ }
+ }
+
+ return defaults
+}
+
+function cleanMessage(text: string): string {
+ // Remove fenced code blocks
+ let cleaned = text
+ .replace(/```experience\n[\s\S]*?\n```/g, "")
+ .replace(/```json\n[\s\S]*?\n```/g, "")
+ .replace(/```suggestions\n[\s\S]*?\n```/g, "")
+
+ // Remove raw JSON experience object
+ const rawJson = findRawExperienceJson(cleaned)
+ if (rawJson) {
+ cleaned = cleaned.replace(rawJson, "")
+ }
+
+ return cleaned.trim()
+}
+
+function extractCodexVisibleMessage(text: string): string {
+ const rolePattern = /(?:^|\n)codex\r?\n/gi
+ let match: RegExpExecArray | null
+ let bodyStart = -1
+
+ while ((match = rolePattern.exec(text)) !== null) {
+ bodyStart = match.index + match[0].length
+ }
+
+ if (bodyStart === -1) return ""
+
+ let body = text.slice(bodyStart)
+ const tokensUsed = body.search(/\n+tokens used\r?\n/i)
+ if (tokensUsed !== -1) {
+ body = body.slice(0, tokensUsed)
+ }
+
+ const cleaned = cleanMessage(body)
+ if (
+ cleaned.includes("") ||
+ cleaned.includes("") ||
+ cleaned.includes("OpenAI Codex") ||
+ cleaned.includes("User:")
+ ) {
+ return ""
+ }
+
+ return cleaned
+}
+
+function getVisibleAssistantMessage(
+ text: string,
+ provider: AIProvider,
+ opts?: { streaming?: boolean },
+): string {
+ if (provider === "codex") {
+ if (opts?.streaming) {
+ return ""
+ }
+ return extractCodexVisibleMessage(text)
+ }
+ return cleanMessage(text)
+}
+
+export function useChat(
+ provider: AIProvider = DEFAULT_PROVIDER,
+ model?: string,
+) {
+ const [messages, setMessages] = useState([])
+ const [experience, setExperience] = useState(null)
+ const [isLoading, setIsLoading] = useState(false)
+ const [error, setError] = useState(null)
+ const [streamingText, setStreamingText] = useState("")
+ const [statusText, setStatusText] = useState("")
+ const abortRef = useRef(null)
+ const sendingRef = useRef(false)
+
+ const sendMessage = useCallback(
+ async (content: string) => {
+ if (sendingRef.current) return
+ sendingRef.current = true
+
+ setError(null)
+ setStreamingText("")
+ setStatusText(`Connecting to ${provider}...`)
+
+ const userMessage: ChatMessage = {
+ id: generateId(),
+ role: "user",
+ content,
+ }
+ setMessages((prev) => [...prev, userMessage])
+ setIsLoading(true)
+
+ abortRef.current = new AbortController()
+ let fullText = ""
+ let currentCatalog: CatalogVideo[] = []
+ let strictExperience: GeneratedExperience | undefined
+ let strictError: string | undefined
+
+ try {
+ const response = await fetch("/api/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ messages,
+ userMessage: content,
+ provider,
+ model,
+ }),
+ signal: abortRef.current.signal,
+ })
+ if (!response.ok || !response.body) {
+ throw new Error(`Request failed: ${response.status}`)
+ }
+
+ setStatusText(`${provider} is thinking...`)
+
+ const reader = response.body.getReader()
+ const decoder = new TextDecoder()
+ let buffer = ""
+ let pendingEvent: string | undefined
+
+ while (true) {
+ const { done, value } = await reader.read()
+ if (done) break
+
+ buffer += decoder.decode(value, { stream: true })
+ const frames = buffer.split("\n\n")
+ buffer = frames.pop() ?? ""
+
+ for (const frame of frames) {
+ // Parse SSE frame — an event name line and/or a data line.
+ let eventName: string | undefined
+ let dataLine: string | undefined
+ for (const rawLine of frame.split("\n")) {
+ if (rawLine.startsWith(":")) continue // comment / heartbeat
+ if (rawLine.startsWith("event: ")) {
+ eventName = rawLine.slice(7).trim()
+ } else if (rawLine.startsWith("data: ")) {
+ // Multi-line data: concatenate per SSE spec.
+ const chunk = rawLine.slice(6)
+ dataLine =
+ dataLine === undefined ? chunk : `${dataLine}\n${chunk}`
+ }
+ }
+
+ if (dataLine === undefined) continue
+ if (eventName === undefined && pendingEvent !== undefined) {
+ eventName = pendingEvent
+ pendingEvent = undefined
+ }
+
+ // Strict-JSON-Schema path: server emits `event: patch` + JSON body.
+ if (eventName === "patch") {
+ let patch: PatchEvent | undefined
+ try {
+ patch = JSON.parse(dataLine) as PatchEvent
+ } catch {
+ continue
+ }
+ if (!patch || !Array.isArray(patch.path)) continue
+ const [head] = patch.path
+ if (head === "catalog") {
+ if (Array.isArray(patch.value)) {
+ currentCatalog = patch.value.filter(
+ (video): video is CatalogVideo =>
+ Boolean(video) &&
+ typeof video === "object" &&
+ typeof (video as CatalogVideo).id === "number" &&
+ typeof (video as CatalogVideo).documentId === "string" &&
+ typeof (video as CatalogVideo).title === "string" &&
+ typeof (video as CatalogVideo).slug === "string" &&
+ typeof (video as CatalogVideo).streamingUrl === "string",
+ )
+ }
+ } else if (head === "experience") {
+ // The generator already ran Zod validation + sectionKey
+ // backfill, but we defensively re-run here so a misbehaving
+ // provider can't bypass it.
+ const candidate = patch.value as Record
+ const validated = validateExperience(candidate)
+ if (validated) {
+ strictExperience = backfillSectionKeys(validated)
+ setExperience(strictExperience)
+ setStatusText("")
+ }
+ } else if (head === "error") {
+ const err = patch.value as { code?: string; message?: string }
+ strictError = err.message ?? "Generator failed"
+ }
+ continue
+ }
+
+ if (eventName === "done") {
+ // Stream will close naturally; nothing to do per-event.
+ continue
+ }
+
+ // Legacy path: default event name, JSON body with `type` field.
+ let event: SSEEvent
+ try {
+ event = JSON.parse(dataLine) as SSEEvent
+ } catch {
+ continue
+ }
+
+ if (event.type === "chunk") {
+ fullText += event.text
+ const visibleText = getVisibleAssistantMessage(
+ fullText,
+ provider,
+ {
+ streaming: true,
+ },
+ )
+ setStreamingText(visibleText)
+ setStatusText(
+ visibleText.length > 0
+ ? ""
+ : `Using ${provider} (${model ?? "default"})...`,
+ )
+
+ // Try to extract experience as it streams in
+ const parsed = extractExperience(fullText, currentCatalog)
+ if (parsed.experience) {
+ setExperience(parsed.experience)
+ }
+ } else if (event.type === "status") {
+ if (event.text) setStatusText(event.text)
+ } else if (event.type === "error") {
+ throw new Error(event.text)
+ }
+ // "done" is handled by the loop ending
+ }
+ }
+
+ if (strictError) {
+ throw new Error(strictError)
+ }
+
+ // Finalize the assistant message. Prefer strict-path experience when
+ // present (it already ran through Zod). Fall back to the legacy
+ // free-form extractor.
+ const parsedExperience = strictExperience
+ ? ({
+ experience: strictExperience,
+ } satisfies ExtractedExperienceResult)
+ : extractExperience(fullText, currentCatalog)
+ if (parsedExperience.error) {
+ throw new Error(parsedExperience.error)
+ }
+ const exp = parsedExperience.experience
+ const suggestions = extractSuggestions(fullText)
+ const cleanText = getVisibleAssistantMessage(fullText, provider)
+ const fallbackContent =
+ provider === "codex" && !exp
+ ? "Generation finished. Refine the theme and try again."
+ : "Experience generated! Check the preview panel."
+
+ const assistantMessage: ChatMessage = {
+ id: generateId(),
+ role: "assistant",
+ content: cleanText || fallbackContent,
+ experienceSnapshot: exp,
+ suggestions,
+ }
+
+ setMessages((prev) => [...prev, assistantMessage])
+ if (exp) setExperience(exp)
+ } catch (err) {
+ if ((err as Error).name !== "AbortError") {
+ const msg =
+ err instanceof Error ? err.message : "Failed to send message"
+ setError(msg)
+ }
+ } finally {
+ setIsLoading(false)
+ setStreamingText("")
+ setStatusText("")
+ abortRef.current = null
+ sendingRef.current = false
+ }
+ },
+ [messages, provider, model],
+ )
+
+ const stopGenerating = useCallback(() => {
+ abortRef.current?.abort()
+ }, [])
+
+ const clearChat = useCallback(() => {
+ abortRef.current?.abort()
+ setMessages([])
+ setExperience(null)
+ setError(null)
+ setStreamingText("")
+ setStatusText("")
+ }, [])
+
+ return {
+ messages,
+ experience,
+ isLoading,
+ error,
+ streamingText,
+ statusText,
+ sendMessage,
+ stopGenerating,
+ clearChat,
+ } as const
+}
diff --git a/apps/seed-studio/src/lib/cn.ts b/apps/seed-studio/src/lib/cn.ts
new file mode 100644
index 000000000..bd0c391dd
--- /dev/null
+++ b/apps/seed-studio/src/lib/cn.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx"
+import { twMerge } from "tailwind-merge"
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
diff --git a/apps/seed-studio/src/lib/lazy-video/registry.ts b/apps/seed-studio/src/lib/lazy-video/registry.ts
new file mode 100644
index 000000000..61f7280b4
--- /dev/null
+++ b/apps/seed-studio/src/lib/lazy-video/registry.ts
@@ -0,0 +1,86 @@
+/**
+ * LRU registry for live HLS player instances in the seed-studio preview.
+ *
+ * HLS.js players hold ~25MB each (MediaSource buffers, parsed segments,
+ * decryption workers). With 20 video blocks on a preview page this adds
+ * up to ~500MB, enough to get us OOM-killed by Railway and enough to
+ * make the studio unusable on lower-spec laptops.
+ *
+ * The registry enforces a cap of `MAX_ACTIVE` concurrent players. When
+ * a new player registers and the cap is already reached, the oldest
+ * (least-recently-touched) entry is destroyed first. Players must call
+ * `touchPlayer` on user interaction (play/pause/seek) to stay recent.
+ *
+ * On viewport-exit or unmount the player calls `unregisterPlayer` and
+ * owns its own cleanup; the registry only calls `destroy` when evicting.
+ */
+
+const MAX_ACTIVE = 2
+
+type Entry = {
+ id: string
+ destroy: () => void
+ lastActive: number
+}
+
+// Module-level state; the registry is a singleton per window.
+let entries: Entry[] = []
+
+/**
+ * Register a new active player. If the registry is at cap, the oldest
+ * entry is evicted (its `destroy()` is called) before the new entry is
+ * added.
+ *
+ * Callers should use a stable, unique `id` (e.g. sectionKey + src) so
+ * that the registry never holds two entries for the same DOM node.
+ */
+export function registerPlayer(id: string, destroy: () => void): void {
+ // Defensive: if an entry with this id already exists (hot reload,
+ // re-render race), drop the stale one without calling destroy —
+ // the caller is replacing it.
+ entries = entries.filter((entry) => entry.id !== id)
+
+ if (entries.length >= MAX_ACTIVE) {
+ // Find the oldest lastActive and evict it.
+ let oldestIndex = 0
+ for (let i = 1; i < entries.length; i++) {
+ if (entries[i].lastActive < entries[oldestIndex].lastActive) {
+ oldestIndex = i
+ }
+ }
+ const evicted = entries[oldestIndex]
+ entries.splice(oldestIndex, 1)
+ try {
+ evicted.destroy()
+ } catch {
+ // Player already torn down; swallow.
+ }
+ }
+
+ entries.push({ id, destroy, lastActive: Date.now() })
+}
+
+/**
+ * Mark a player as most-recently-active. Called on play/pause/seek.
+ */
+export function touchPlayer(id: string): void {
+ const entry = entries.find((candidate) => candidate.id === id)
+ if (!entry) return
+ entry.lastActive = Date.now()
+}
+
+/**
+ * Remove a player from the registry without calling destroy. The
+ * caller is expected to own the teardown (e.g. the React cleanup
+ * effect already disposed HLS).
+ */
+export function unregisterPlayer(id: string): void {
+ entries = entries.filter((entry) => entry.id !== id)
+}
+
+/**
+ * Test/debug helper: how many players are currently registered.
+ */
+export function getActivePlayerCount(): number {
+ return entries.length
+}
diff --git a/apps/seed-studio/src/lib/mux.ts b/apps/seed-studio/src/lib/mux.ts
new file mode 100644
index 000000000..c362486b1
--- /dev/null
+++ b/apps/seed-studio/src/lib/mux.ts
@@ -0,0 +1,30 @@
+/**
+ * Derive a thumbnail URL from a Mux streaming URL.
+ * stream.mux.com/{playbackId}.m3u8 → image.mux.com/{playbackId}/thumbnail.jpg
+ */
+export function getMuxThumbnail(
+ streamingUrl: string | undefined | null,
+): string | undefined {
+ if (!streamingUrl) return undefined
+ const match = streamingUrl.match(
+ /stream\.mux\.com\/([A-Za-z0-9_-]+)(?:\.m3u8)?/,
+ )
+ if (!match) return undefined
+ return `https://image.mux.com/${match[1]}/thumbnail.jpg?width=640`
+}
+
+/**
+ * Cloudflare Image Delivery URLs require a variant suffix.
+ * Append /public if no variant is present.
+ */
+export function fixImageUrl(
+ url: string | undefined | null,
+): string | undefined {
+ if (!url) return undefined
+ if (!url.includes("imagedelivery.net")) return url
+ // Already has a variant (e.g. /public, /f=jpg,w=...)
+ const parts = url.split("/")
+ const last = parts[parts.length - 1]
+ if (last.includes("=") || last === "public") return url
+ return `${url}/public`
+}
diff --git a/apps/seed-studio/src/lib/strapi-client.ts b/apps/seed-studio/src/lib/strapi-client.ts
new file mode 100644
index 000000000..0c0072943
--- /dev/null
+++ b/apps/seed-studio/src/lib/strapi-client.ts
@@ -0,0 +1,214 @@
+import type { GeneratedExperience } from "@/lib/ai/experience-schema"
+
+const STRAPI_URL = process.env.STRAPI_URL ?? "http://localhost:1337"
+const TOKEN = process.env.STRAPI_SEED_STUDIO_TOKEN ?? ""
+
+type VideoSearchResult = {
+ id: number
+ documentId: string
+ title: string
+ slug: string
+ streamingUrl: string
+ thumbnailUrl?: string
+ tags?: string[]
+}
+
+type VideoCatalogStats = {
+ totalVideos: number
+ labels: string[]
+}
+
+export type PublishError = {
+ message: string
+ code?: string
+ reason?: string
+ suggestions?: string[]
+}
+
+export type PublishResult =
+ | {
+ success: true
+ documentId: string
+ slug: string
+ warning?: string
+ }
+ | {
+ success: false
+ error: PublishError
+ }
+
+type StrapiErrorResponse = {
+ error?:
+ | string
+ | {
+ message?: string
+ code?: string
+ reason?: string
+ suggestions?: string[]
+ details?: {
+ errors?: Array<{ path?: (string | number)[]; message?: string }>
+ }
+ }
+}
+
+class StrapiRequestError extends Error {
+ status: number
+ body?: StrapiErrorResponse
+
+ constructor(status: number, message: string, body?: StrapiErrorResponse) {
+ super(message)
+ this.name = "StrapiRequestError"
+ this.status = status
+ this.body = body
+ }
+}
+
+function getStrapiErrorMessage(
+ body: StrapiErrorResponse | undefined,
+ fallback: string,
+): string {
+ if (!body?.error) return fallback
+ if (typeof body.error === "string") return body.error
+
+ const detailMessages = body.error.details?.errors
+ ?.map((entry) => {
+ const path = entry.path?.join(".") ?? ""
+ return path ? `${path}: ${entry.message}` : entry.message
+ })
+ .filter((message): message is string => Boolean(message))
+
+ if (detailMessages && detailMessages.length > 0) {
+ return detailMessages.join("\n")
+ }
+
+ return body.error.message ?? fallback
+}
+
+function normalizePublishError(error: unknown, fallback: string): PublishError {
+ if (error instanceof StrapiRequestError) {
+ const body = error.body
+ const payload = body?.error
+
+ if (payload && typeof payload !== "string") {
+ return {
+ message: getStrapiErrorMessage(body, error.message),
+ code: typeof payload.code === "string" ? payload.code : undefined,
+ reason: typeof payload.reason === "string" ? payload.reason : undefined,
+ suggestions: Array.isArray(payload.suggestions)
+ ? payload.suggestions.filter(
+ (suggestion): suggestion is string =>
+ typeof suggestion === "string" && suggestion.length > 0,
+ )
+ : undefined,
+ }
+ }
+
+ return { message: getStrapiErrorMessage(body, error.message) }
+ }
+
+ if (error instanceof Error) {
+ return { message: error.message }
+ }
+
+ return { message: fallback }
+}
+
+async function strapiRequest(
+ path: string,
+ options: RequestInit = {},
+): Promise {
+ const url = `${STRAPI_URL}${path}`
+ const response = await fetch(url, {
+ ...options,
+ headers: {
+ "Content-Type": "application/json",
+ "X-Seed-Studio-Token": TOKEN,
+ ...options.headers,
+ },
+ })
+
+ if (!response.ok) {
+ const fallback = `Strapi request failed: ${response.status} ${response.statusText}`
+ let body: StrapiErrorResponse | undefined
+ try {
+ body = (await response.json()) as StrapiErrorResponse
+ } catch {
+ // Use default message
+ }
+
+ throw new StrapiRequestError(
+ response.status,
+ getStrapiErrorMessage(body, fallback),
+ body,
+ )
+ }
+
+ return response.json() as Promise
+}
+
+export async function searchVideos(
+ query: string,
+ tags?: string[],
+ locale?: string,
+): Promise {
+ try {
+ const result = await strapiRequest<{ videos: VideoSearchResult[] }>(
+ "/api/seed-studio/search-videos",
+ {
+ method: "POST",
+ body: JSON.stringify({ query, tags, locale }),
+ },
+ )
+ return result.videos
+ } catch (error) {
+ console.error("Failed to search videos:", error)
+ return []
+ }
+}
+
+export async function publishExperience(
+ experience: GeneratedExperience,
+): Promise {
+ try {
+ const result = await strapiRequest<{
+ created: boolean
+ relationsPatched: boolean
+ documentId: string
+ slug: string
+ }>("/api/seed-studio/publish-experience", {
+ method: "POST",
+ body: JSON.stringify(experience),
+ })
+ if (!result.created) {
+ return {
+ success: false,
+ error: { message: "Failed to publish experience" },
+ }
+ }
+
+ return {
+ success: true,
+ documentId: result.documentId,
+ slug: result.slug,
+ warning: !result.relationsPatched
+ ? "Published but video relations could not be linked. Try re-publishing."
+ : undefined,
+ }
+ } catch (error) {
+ return {
+ success: false,
+ error: normalizePublishError(error, "Failed to publish experience"),
+ }
+ }
+}
+
+export async function getVideoCatalogStats(): Promise {
+ try {
+ return await strapiRequest(
+ "/api/seed-studio/video-catalog-stats",
+ )
+ } catch (error) {
+ console.error("Failed to get video catalog stats:", error)
+ return { totalVideos: 0, labels: [] }
+ }
+}
diff --git a/apps/seed-studio/src/middleware.ts b/apps/seed-studio/src/middleware.ts
new file mode 100644
index 000000000..5bd256336
--- /dev/null
+++ b/apps/seed-studio/src/middleware.ts
@@ -0,0 +1,29 @@
+import { NextResponse } from "next/server"
+import type { NextRequest } from "next/server"
+
+const LOGIN_PATH = "/login"
+const SESSION_COOKIE = "seed-studio-session"
+
+export function middleware(request: NextRequest) {
+ const { pathname } = request.nextUrl
+
+ if (pathname === LOGIN_PATH) {
+ return NextResponse.next()
+ }
+
+ if (pathname.startsWith("/api/auth")) {
+ return NextResponse.next()
+ }
+
+ const session = request.cookies.get(SESSION_COOKIE)
+ if (!session?.value) {
+ const loginUrl = new URL(LOGIN_PATH, request.url)
+ return NextResponse.redirect(loginUrl)
+ }
+
+ return NextResponse.next()
+}
+
+export const config = {
+ matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
+}
diff --git a/apps/seed-studio/tsconfig.json b/apps/seed-studio/tsconfig.json
new file mode 100644
index 000000000..9044aacc6
--- /dev/null
+++ b/apps/seed-studio/tsconfig.json
@@ -0,0 +1,32 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "strict": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "jsx": "react-jsx",
+ "allowJs": false,
+ "noEmit": true,
+ "incremental": true,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": [
+ "src/**/*.ts",
+ "src/**/*.tsx",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts"
+ ],
+ "exclude": ["node_modules"]
+}
diff --git a/apps/web/.env.example b/apps/web/.env.example
index 9594bca5a..7ed5e4f79 100644
--- a/apps/web/.env.example
+++ b/apps/web/.env.example
@@ -15,8 +15,11 @@ REVALIDATION_SECRET=
# Reuse the same key as apps/cms OPENROUTER_API_KEY — no separate account needed.
OPENROUTER_API_KEY=
+# Optional: render admin-published Experience blocks instead of Strapi content.
+FORGE_CONTENT_API=strapi
+ADMIN_GRAPHQL_URL=http://localhost:3003/api/graphql
+
## Production ##
# CMS image hostname (e.g. api.example.com)
NEXT_PUBLIC_CMS_HOSTNAME=
NEXT_PUBLIC_CMS_PROTOCOL=https
-
diff --git a/apps/web/src/components/sections/VideoHero.tsx b/apps/web/src/components/sections/VideoHero.tsx
index 7ac7cf144..9cd7a583f 100644
--- a/apps/web/src/components/sections/VideoHero.tsx
+++ b/apps/web/src/components/sections/VideoHero.tsx
@@ -83,7 +83,7 @@ function VideoHeroPlayer({
return (
{
+ it("maps generated admin experience blocks into watch renderer blocks", () => {
+ const result = normalizeAdminExperience({
+ id: "loc-1",
+ locale: "en",
+ slug: "jesus",
+ title: "Jesus",
+ metaDescription: "Generated page.",
+ ogTitle: null,
+ ogDescription: null,
+ ogImageUrl: null,
+ referencedVideos: [
+ {
+ id: "video-1",
+ slug: "jesus-vision",
+ label: "SHORT_FILM",
+ locales: [
+ {
+ locale: "en",
+ title: "Hydrated Jesus Vision",
+ description: "Hydrated description",
+ snippet: null,
+ },
+ ],
+ images: [{ url: "https://images.example/hydrated.jpg" }],
+ dubs: [
+ {
+ hls: "https://stream.example/spanish.m3u8",
+ published: true,
+ language: { bcp47: "es", iso3: "spa", slug: "spanish" },
+ },
+ {
+ hls: "https://stream.example/hydrated.m3u8",
+ published: true,
+ language: { bcp47: "en", iso3: "eng", slug: "english" },
+ },
+ ],
+ },
+ ],
+ blocks: [
+ {
+ t: "videoHero",
+ sectionKey: "ai-s01",
+ videoId: "video-1",
+ heading: "Jesus Vision - John",
+ subheading: "Vision image of Jesus from John",
+ },
+ {
+ t: "mediaCollection",
+ sectionKey: "ai-s02",
+ title: "Featured Stories About Jesus",
+ variant: "collection",
+ items: [
+ {
+ videoId: "video-1",
+ titleOverride: "What Was Jesus Really Like?",
+ subtitleOverride: "A short story.",
+ },
+ ],
+ },
+ {
+ t: "videoCarousel",
+ sectionKey: "ai-s03",
+ title: "Watch More",
+ description: "A quick set of videos.",
+ items: [
+ {
+ videoId: "video-1",
+ titleOverride: "Jesus Prays to be Glorified",
+ },
+ ],
+ },
+ {
+ t: "section",
+ sectionKey: "ai-s04",
+ backgroundColor: "dark",
+ content: [
+ {
+ t: "text",
+ sectionKey: "ai-s05",
+ heading: "Reflect",
+ contentParagraphs: ["A real generated paragraph."],
+ },
+ {
+ t: "navigationCarousel",
+ items: [{ contentId: "ai-s05", title: "Reflect" }],
+ },
+ ],
+ },
+ {
+ t: "video",
+ sectionKey: "ai-s06",
+ videoId: "video-1",
+ },
+ ],
+ })
+
+ expect(result.blocks).toEqual([
+ expect.objectContaining({
+ __typename: "ComponentSectionsVideoHero",
+ heading: "Jesus Vision - John",
+ streamingUrl: "https://stream.example/hydrated.m3u8",
+ }),
+ expect.objectContaining({
+ __typename: "ComponentSectionsMediaCollection",
+ title: "Featured Stories About Jesus",
+ mediaCollectionVariant: "collection",
+ items: [
+ expect.objectContaining({
+ titleOverride: "What Was Jesus Really Like?",
+ subtitleOverride: "A short story.",
+ imageUrl: "https://images.example/hydrated.jpg",
+ video: expect.objectContaining({ slug: "jesus-vision" }),
+ }),
+ ],
+ }),
+ expect.objectContaining({
+ __typename: "ComponentSectionsVideoCarousel",
+ title: "Watch More",
+ carouselDescription: "A quick set of videos.",
+ items: [
+ expect.objectContaining({
+ titleOverride: "Jesus Prays to be Glorified",
+ streamingUrl: "https://stream.example/hydrated.m3u8",
+ imageUrl: "https://images.example/hydrated.jpg",
+ }),
+ ],
+ }),
+ expect.objectContaining({
+ __typename: "ComponentSectionsSection",
+ sectionContent: [
+ expect.objectContaining({
+ __typename: "ComponentSectionsText",
+ heading: "Reflect",
+ }),
+ expect.objectContaining({
+ __typename: "ComponentSectionsNavigationCarousel",
+ items: [expect.objectContaining({ contentId: "ai-s05" })],
+ }),
+ ],
+ }),
+ expect.objectContaining({
+ __typename: "ComponentSectionsVideo",
+ streamingUrl: "https://stream.example/hydrated.m3u8",
+ title: "Hydrated Jesus Vision",
+ subtitle: "Hydrated description",
+ }),
+ ])
+ })
+
+ it("drops unsupported generated blocks without dropping the whole page", () => {
+ const result = normalizeAdminExperience({
+ id: "loc-1",
+ locale: "en",
+ slug: "jesus",
+ title: "Jesus",
+ metaDescription: "Generated page.",
+ ogTitle: null,
+ ogDescription: null,
+ ogImageUrl: null,
+ referencedVideos: [],
+ blocks: [
+ { t: "unknown", title: "Ignore me" },
+ { t: "text", heading: "Keep this", contentParagraphs: ["Hello"] },
+ ],
+ })
+
+ expect(result.blocks).toHaveLength(1)
+ expect(result.blocks?.[0]).toEqual(
+ expect.objectContaining({ __typename: "ComponentSectionsText" }),
+ )
+ })
+
+ it("does not hydrate preview streams from another language", () => {
+ const result = normalizeAdminExperience({
+ id: "loc-1",
+ locale: "en",
+ slug: "jesus",
+ title: "Jesus",
+ metaDescription: "Generated page.",
+ ogTitle: null,
+ ogDescription: null,
+ ogImageUrl: null,
+ referencedVideos: [
+ {
+ id: "video-1",
+ slug: "jesus-vision",
+ label: "SHORT_FILM",
+ locales: [
+ {
+ locale: "en",
+ title: "Hydrated Jesus Vision",
+ description: "Hydrated description",
+ snippet: null,
+ },
+ ],
+ images: [],
+ dubs: [
+ {
+ hls: "https://stream.example/spanish.m3u8",
+ published: true,
+ language: { bcp47: "es", iso3: "spa", slug: "spanish" },
+ },
+ ],
+ },
+ ],
+ blocks: [{ t: "videoHero", videoId: "video-1", heading: "Jesus" }],
+ })
+
+ expect(result.blocks?.[0]).toEqual(
+ expect.objectContaining({
+ __typename: "ComponentSectionsVideoHero",
+ streamingUrl: null,
+ }),
+ )
+ })
+})
diff --git a/apps/web/src/lib/admin-content.ts b/apps/web/src/lib/admin-content.ts
new file mode 100644
index 000000000..7a06a8799
--- /dev/null
+++ b/apps/web/src/lib/admin-content.ts
@@ -0,0 +1,805 @@
+import { z } from "zod"
+import { env } from "@/env"
+import type { WatchExperience } from "@/lib/content"
+
+const HeadingLevelSchema = z.enum(["h1", "h2", "h3", "h4", "h5", "h6"])
+
+const AdminInfoBlockItemSchema = z.object({
+ icon: z.string().optional(),
+ title: z.string().optional(),
+ description: z.string().optional(),
+})
+
+const AdminMediaCollectionItemSchema = z.object({
+ videoId: z.string().optional(),
+ imageOverrideUrl: z.string().optional(),
+ titleOverride: z.string().optional(),
+ subtitleOverride: z.string().optional(),
+ labelOverride: z.string().optional(),
+ collectionSize: z.string().optional(),
+ imageUrl: z.string().optional(),
+ linkToSectionKey: z.string().optional(),
+})
+
+const AdminVideoCarouselItemSchema = z.object({
+ videoId: z.string().optional(),
+ streamingUrl: z.string().optional(),
+ imageUrl: z.string().optional(),
+ imageOverrideUrl: z.string().optional(),
+ titleOverride: z.string().optional(),
+ subtitleOverride: z.string().optional(),
+ backgroundColor: z.string().optional(),
+})
+
+const AdminNavigationCarouselItemSchema = z.object({
+ contentId: z.string(),
+ title: z.string(),
+ category: z.string().optional(),
+ imageUrl: z.string().optional(),
+ backgroundColor: z.string().optional(),
+})
+
+const AdminRelatedQuestionItemSchema = z.object({
+ question: z.string(),
+ answer: z.string(),
+})
+
+const AdminBibleQuoteItemSchema = z.object({
+ reference: z.string(),
+ text: z.string(),
+ attribution: z.string().optional(),
+ imageUrl: z.string().optional(),
+ backgroundColor: z.string().optional(),
+ ctaLabel: z.string().optional(),
+ ctaLink: z.string().optional(),
+})
+
+const AdminTextBlockSchema = z.object({
+ t: z.literal("text"),
+ sectionKey: z.string().optional(),
+ heading: z.string().optional(),
+ headingLevel: HeadingLevelSchema.optional(),
+ subtitle: z.string().optional(),
+ contentParagraphs: z.array(z.string()).optional(),
+ variant: z.enum(["default", "lead", "small"]).optional(),
+})
+
+const AdminVideoHeroBlockSchema = z.object({
+ t: z.literal("videoHero"),
+ sectionKey: z.string().optional(),
+ useRouteVideo: z.boolean().optional(),
+ videoId: z.string().optional(),
+ heading: z.string().optional(),
+ subheading: z.string().optional(),
+ ctaLabel: z.string().optional(),
+ ctaLink: z.string().optional(),
+ streamingUrl: z.string().optional(),
+})
+
+const AdminVideoBlockSchema = z.object({
+ t: z.literal("video"),
+ sectionKey: z.string().optional(),
+ useRouteVideo: z.boolean().optional(),
+ videoId: z.string().optional(),
+ streamingUrl: z.string().optional(),
+ mediaUrl: z.string().optional(),
+ title: z.string().optional(),
+ subtitle: z.string().optional(),
+})
+
+const AdminMediaCollectionBlockSchema = z.object({
+ t: z.literal("mediaCollection"),
+ sectionKey: z.string().optional(),
+ categoryLabel: z.string().optional(),
+ variant: z.enum(["carousel", "grid", "collection", "hero", "player"]),
+ itemsSource: z.enum(["manual", "routeVideoChildren"]).optional(),
+ title: z.string().optional(),
+ subtitle: z.string().optional(),
+ description: z.string().optional(),
+ ctaLink: z.string().optional(),
+ ctaLabel: z.string().optional(),
+ showItemNumbers: z.boolean().optional(),
+ footerText: z.string().optional(),
+ items: z.array(AdminMediaCollectionItemSchema).optional(),
+})
+
+const AdminVideoCarouselBlockSchema = z.object({
+ t: z.literal("videoCarousel"),
+ sectionKey: z.string().optional(),
+ itemsSource: z.enum(["manual", "routeVideoChildren"]).optional(),
+ title: z.string().optional(),
+ subtitle: z.string().optional(),
+ description: z.string().optional(),
+ items: z.array(AdminVideoCarouselItemSchema).optional(),
+})
+
+const AdminInfoBlocksBlockSchema = z.object({
+ t: z.literal("infoBlocks"),
+ sectionKey: z.string().optional(),
+ heading: z.string().optional(),
+ intro: z.string().optional(),
+ description: z.string().optional(),
+ blocks: z.array(AdminInfoBlockItemSchema).optional(),
+})
+
+const AdminCtaBlockSchema = z.object({
+ t: z.literal("cta"),
+ sectionKey: z.string().optional(),
+ heading: z.string().optional(),
+ body: z.string().optional(),
+ buttonLabel: z.string().optional(),
+ buttonLink: z.string().optional(),
+})
+
+const AdminPromoBannerBlockSchema = z.object({
+ t: z.literal("promoBanner"),
+ sectionKey: z.string().optional(),
+ intro: z.string().optional(),
+ heading: z.string(),
+ description: z.string(),
+ ctaLink: z.string().optional(),
+})
+
+const AdminRelatedQuestionsBlockSchema = z.object({
+ t: z.literal("relatedQuestions"),
+ sectionKey: z.string().optional(),
+ heading: z.string().optional(),
+ ctaLabel: z.string().optional(),
+ ctaLink: z.string().optional(),
+ questions: z.array(AdminRelatedQuestionItemSchema).optional(),
+})
+
+const AdminNavigationCarouselBlockSchema = z.object({
+ t: z.literal("navigationCarousel"),
+ sectionKey: z.string().optional(),
+ items: z.array(AdminNavigationCarouselItemSchema).optional(),
+})
+
+const AdminBibleQuotesCarouselBlockSchema = z.object({
+ t: z.literal("bibleQuotesCarousel"),
+ sectionKey: z.string().optional(),
+ heading: z.string().optional(),
+ quotes: z.array(AdminBibleQuoteItemSchema).optional(),
+})
+
+const AdminQuizButtonBlockSchema = z.object({
+ t: z.literal("quizButton"),
+ buttonText: z.string(),
+ iframeSrc: z.string(),
+})
+
+type NormalizedAdminBlock = Record & {
+ __typename: string
+ id: string
+}
+
+type AdminBlock = z.infer
+type AdminSectionContentBlock = z.infer
+type AdminContainerContentBlock = z.infer<
+ typeof AdminContainerContentBlockSchema
+>
+
+const AdminContainerSlotBlockSchema = z.object({
+ t: z.literal("containerSlot"),
+ gridSpan: z.number().optional(),
+ spans: z.record(z.string(), z.number()).optional(),
+})
+
+const AdminContainerContentBlockSchema = z.discriminatedUnion("t", [
+ AdminContainerSlotBlockSchema,
+ AdminTextBlockSchema,
+ AdminMediaCollectionBlockSchema,
+ AdminRelatedQuestionsBlockSchema,
+ AdminCtaBlockSchema,
+ AdminBibleQuotesCarouselBlockSchema,
+ AdminVideoBlockSchema,
+ AdminInfoBlocksBlockSchema,
+])
+
+const AdminContainerBlockSchema = z.object({
+ t: z.literal("container"),
+ sectionKey: z.string().optional(),
+ content: z.array(AdminContainerContentBlockSchema).optional(),
+})
+
+const AdminSectionContentBlockSchema = z.discriminatedUnion("t", [
+ AdminTextBlockSchema,
+ AdminMediaCollectionBlockSchema,
+ AdminPromoBannerBlockSchema,
+ AdminInfoBlocksBlockSchema,
+ AdminCtaBlockSchema,
+ AdminContainerBlockSchema,
+ AdminRelatedQuestionsBlockSchema,
+ AdminBibleQuotesCarouselBlockSchema,
+ AdminVideoBlockSchema,
+ AdminQuizButtonBlockSchema,
+ AdminVideoCarouselBlockSchema,
+ AdminNavigationCarouselBlockSchema,
+])
+
+const AdminSectionBlockSchema = z.object({
+ t: z.literal("section"),
+ sectionKey: z.string().optional(),
+ backgroundColor: z.string().optional(),
+ backgroundImageUrl: z.string().optional(),
+ blurHash: z.string().optional(),
+ backgroundOpacity: z.number().optional(),
+ dynamicBackgroundImage: z.boolean().optional(),
+ staticOverlay: z.boolean().optional(),
+ content: z.array(AdminSectionContentBlockSchema).optional(),
+})
+
+const AdminBlockSchema = z.discriminatedUnion("t", [
+ AdminTextBlockSchema,
+ AdminVideoHeroBlockSchema,
+ AdminVideoBlockSchema,
+ AdminMediaCollectionBlockSchema,
+ AdminVideoCarouselBlockSchema,
+ AdminInfoBlocksBlockSchema,
+ AdminCtaBlockSchema,
+ AdminPromoBannerBlockSchema,
+ AdminRelatedQuestionsBlockSchema,
+ AdminNavigationCarouselBlockSchema,
+ AdminBibleQuotesCarouselBlockSchema,
+ AdminContainerBlockSchema,
+ AdminSectionBlockSchema,
+])
+
+const AdminExperienceSchema = z.object({
+ id: z.string(),
+ locale: z.string(),
+ slug: z.string(),
+ title: z.string().nullable().optional(),
+ metaDescription: z.string().nullable().optional(),
+ ogTitle: z.string().nullable().optional(),
+ ogDescription: z.string().nullable().optional(),
+ ogImageUrl: z.string().nullable().optional(),
+ blocks: z.array(z.unknown()).default([]),
+ referencedVideos: z
+ .array(
+ z.object({
+ id: z.string(),
+ slug: z.string().nullable().optional(),
+ label: z.string().nullable().optional(),
+ locales: z
+ .array(
+ z.object({
+ locale: z.string(),
+ title: z.string().nullable().optional(),
+ description: z.string().nullable().optional(),
+ snippet: z.string().nullable().optional(),
+ }),
+ )
+ .nullable()
+ .optional(),
+ images: z
+ .array(z.object({ url: z.string().nullable().optional() }).nullable())
+ .nullable()
+ .optional(),
+ dubs: z
+ .array(
+ z
+ .object({
+ hls: z.string().nullable().optional(),
+ published: z.boolean().nullable().optional(),
+ language: z
+ .object({
+ bcp47: z.string().nullable().optional(),
+ iso3: z.string().nullable().optional(),
+ slug: z.string().nullable().optional(),
+ })
+ .nullable()
+ .optional(),
+ })
+ .nullable(),
+ )
+ .nullable()
+ .optional(),
+ }),
+ )
+ .nullable()
+ .optional(),
+})
+
+type AdminExperience = z.infer
+type AdminReferencedVideo = NonNullable<
+ NonNullable[number]
+>
+type VideoMap = Map
+
+function blockId(sectionKey: string | undefined, fallback: string) {
+ return sectionKey ? `admin-${sectionKey}` : fallback
+}
+
+function videoTitle(video: AdminReferencedVideo | undefined, locale: string) {
+ return (
+ video?.locales?.find((entry) => entry?.locale === locale)?.title ?? null
+ )
+}
+
+function videoDescription(
+ video: AdminReferencedVideo | undefined,
+ locale: string,
+) {
+ const videoLocale = video?.locales?.find((entry) => entry?.locale === locale)
+ return videoLocale?.description ?? videoLocale?.snippet ?? null
+}
+
+function videoImage(video: AdminReferencedVideo | undefined) {
+ return video?.images?.find((image) => image?.url)?.url ?? null
+}
+
+function dubMatchesLocale(
+ dub: NonNullable[number],
+ locale: string,
+) {
+ return (
+ dub?.language?.bcp47 === locale ||
+ dub?.language?.iso3 === locale ||
+ dub?.language?.slug === locale
+ )
+}
+
+function videoStream(video: AdminReferencedVideo | undefined, locale: string) {
+ return (
+ video?.dubs?.find(
+ (dub) =>
+ dub?.published === true && dub.hls && dubMatchesLocale(dub, locale),
+ )?.hls ??
+ video?.dubs?.find((dub) => dub?.hls && dubMatchesLocale(dub, locale))
+ ?.hls ??
+ null
+ )
+}
+
+function mediaItem(
+ item: z.infer,
+ id: string,
+ videos: VideoMap,
+ locale: string,
+) {
+ const video = item.videoId ? videos.get(item.videoId) : undefined
+ return {
+ id,
+ titleOverride: item.titleOverride ?? videoTitle(video, locale),
+ subtitleOverride: item.subtitleOverride ?? videoDescription(video, locale),
+ labelOverride: item.labelOverride ?? null,
+ collectionSize: item.collectionSize ?? null,
+ imageUrl: item.imageUrl ?? item.imageOverrideUrl ?? videoImage(video),
+ imageOverride: item.imageOverrideUrl
+ ? { url: item.imageOverrideUrl }
+ : null,
+ video: video
+ ? {
+ documentId: video.id,
+ title: videoTitle(video, locale),
+ slug: video.slug ?? null,
+ images: (video.images ?? []).filter(Boolean),
+ }
+ : null,
+ }
+}
+
+function videoCarouselItem(
+ item: z.infer,
+ id: string,
+ videos: VideoMap,
+ locale: string,
+) {
+ const video = item.videoId ? videos.get(item.videoId) : undefined
+ return {
+ id,
+ streamingUrl: item.streamingUrl ?? videoStream(video, locale),
+ imageUrl: item.imageUrl ?? item.imageOverrideUrl ?? videoImage(video),
+ titleOverride: item.titleOverride ?? videoTitle(video, locale),
+ backgroundColor: item.backgroundColor ?? null,
+ video: video
+ ? {
+ documentId: video.id,
+ title: videoTitle(video, locale),
+ slug: video.slug ?? null,
+ images: (video.images ?? []).filter(Boolean),
+ }
+ : null,
+ }
+}
+
+function normalizeContainerContent(
+ blocks: AdminContainerContentBlock[] | undefined,
+ parentId: string,
+ videos: VideoMap,
+ locale: string,
+) {
+ const slots: Array> = []
+ let currentSlot: Record | null = null
+
+ for (const [index, block] of (blocks ?? []).entries()) {
+ if (block.t === "containerSlot") {
+ currentSlot = {
+ id: `${parentId}-slot-${slots.length}`,
+ gridSpan: block.gridSpan ?? 6,
+ spans: block.spans ?? null,
+ content: [],
+ }
+ slots.push(currentSlot)
+ continue
+ }
+
+ if (!currentSlot) {
+ currentSlot = {
+ id: `${parentId}-slot-0`,
+ gridSpan: 12,
+ spans: null,
+ content: [],
+ }
+ slots.push(currentSlot)
+ }
+
+ const normalized = normalizeAdminBlock(
+ block,
+ `${parentId}-slot-${index}`,
+ videos,
+ locale,
+ )
+ if (normalized) {
+ ;(currentSlot.content as NormalizedAdminBlock[]).push(normalized)
+ }
+ }
+
+ return slots
+}
+
+function normalizeSectionContent(
+ blocks: AdminSectionContentBlock[] | undefined,
+ parentId: string,
+ videos: VideoMap,
+ locale: string,
+) {
+ return (blocks ?? [])
+ .map((block, index) =>
+ normalizeAdminBlock(block, `${parentId}-${index}`, videos, locale),
+ )
+ .filter((block): block is NormalizedAdminBlock => block != null)
+}
+
+function normalizeAdminBlock(
+ block: AdminBlock | AdminSectionContentBlock | AdminContainerContentBlock,
+ fallbackId: string,
+ videos: VideoMap,
+ locale: string,
+): NormalizedAdminBlock | null {
+ switch (block.t) {
+ case "text":
+ return {
+ __typename: "ComponentSectionsText",
+ id: blockId(block.sectionKey, fallbackId),
+ sectionKey: block.sectionKey ?? null,
+ heading: block.heading ?? null,
+ headingLevel: block.headingLevel ?? null,
+ subtitle: block.subtitle ?? null,
+ contentParagraphs: block.contentParagraphs ?? [],
+ textVariant: block.variant ?? null,
+ }
+ case "videoHero": {
+ const heroVideo =
+ "videoId" in block && typeof block.videoId === "string"
+ ? videos.get(block.videoId)
+ : undefined
+ return {
+ __typename: "ComponentSectionsVideoHero",
+ id: blockId(block.sectionKey, fallbackId),
+ sectionKey: block.sectionKey ?? null,
+ useRouteVideo: block.useRouteVideo ?? false,
+ heading: block.heading ?? null,
+ subheading: block.subheading ?? null,
+ ctaLabel: block.ctaLabel ?? null,
+ ctaLink: block.ctaLink ?? null,
+ streamingUrl: block.streamingUrl ?? videoStream(heroVideo, locale),
+ video: heroVideo
+ ? {
+ documentId: heroVideo.id,
+ title: videoTitle(heroVideo, locale),
+ slug: heroVideo.slug ?? null,
+ images: (heroVideo.images ?? []).filter(Boolean),
+ }
+ : null,
+ }
+ }
+ case "video": {
+ const video =
+ "videoId" in block && typeof block.videoId === "string"
+ ? videos.get(block.videoId)
+ : undefined
+ return {
+ __typename: "ComponentSectionsVideo",
+ id: blockId(block.sectionKey, fallbackId),
+ sectionKey: block.sectionKey ?? null,
+ useRouteVideo: block.useRouteVideo ?? false,
+ streamingUrl: block.streamingUrl ?? videoStream(video, locale),
+ title: block.title ?? videoTitle(video, locale),
+ subtitle: block.subtitle ?? videoDescription(video, locale),
+ media:
+ block.mediaUrl || videoImage(video)
+ ? { url: block.mediaUrl ?? videoImage(video) }
+ : null,
+ videoRef: video
+ ? {
+ documentId: video.id,
+ title: videoTitle(video, locale),
+ slug: video.slug ?? null,
+ images: (video.images ?? []).filter(Boolean),
+ }
+ : null,
+ }
+ }
+ case "mediaCollection":
+ return {
+ __typename: "ComponentSectionsMediaCollection",
+ id: blockId(block.sectionKey, fallbackId),
+ title: block.title ?? null,
+ subtitle: block.subtitle ?? null,
+ mediaDescription: block.description ?? null,
+ categoryLabel: block.categoryLabel ?? null,
+ itemsSource: block.itemsSource ?? "manual",
+ mediaCtaLink: block.ctaLink ?? null,
+ mediaCtaLabel: block.ctaLabel ?? null,
+ showItemNumbers: block.showItemNumbers ?? false,
+ mediaCollectionVariant: block.variant,
+ footerText: block.footerText ?? null,
+ items: (block.items ?? []).map((item, index) =>
+ mediaItem(item, `${fallbackId}-item-${index}`, videos, locale),
+ ),
+ }
+ case "videoCarousel":
+ return {
+ __typename: "ComponentSectionsVideoCarousel",
+ id: blockId(block.sectionKey, fallbackId),
+ sectionKey: block.sectionKey ?? null,
+ title: block.title ?? null,
+ subtitle: block.subtitle ?? null,
+ carouselDescription: block.description ?? null,
+ items: (block.items ?? []).map((item, index) =>
+ videoCarouselItem(
+ item,
+ `${fallbackId}-item-${index}`,
+ videos,
+ locale,
+ ),
+ ),
+ }
+ case "infoBlocks":
+ return {
+ __typename: "ComponentSectionsInfoBlocks",
+ id: blockId(block.sectionKey, fallbackId),
+ infoHeading: block.heading ?? null,
+ intro: block.intro ?? null,
+ infoDescription: block.description ?? null,
+ blocks: (block.blocks ?? []).map((item, index) => ({
+ id: `${fallbackId}-item-${index}`,
+ title: item.title ?? null,
+ description: item.description ?? null,
+ icon: item.icon ?? null,
+ })),
+ }
+ case "cta":
+ return {
+ __typename: "ComponentSectionsCta",
+ id: blockId(block.sectionKey, fallbackId),
+ ctaHeading: block.heading ?? null,
+ body: block.body ?? null,
+ buttonLabel: block.buttonLabel ?? "",
+ buttonLink: block.buttonLink ?? null,
+ }
+ case "promoBanner":
+ return {
+ __typename: "ComponentSectionsPromoBanner",
+ id: blockId(block.sectionKey, fallbackId),
+ promoHeading: block.heading,
+ promoDescription: block.description,
+ intro: block.intro ?? null,
+ promoCtaLink: block.ctaLink ?? null,
+ }
+ case "relatedQuestions":
+ return {
+ __typename: "ComponentSectionsRelatedQuestions",
+ id: blockId(block.sectionKey, fallbackId),
+ sectionKey: block.sectionKey ?? null,
+ heading: block.heading ?? null,
+ ctaLabel: block.ctaLabel ?? null,
+ ctaLink: block.ctaLink ?? null,
+ questions: (block.questions ?? []).map((question, index) => ({
+ id: `${fallbackId}-question-${index}`,
+ question: question.question,
+ answer: question.answer,
+ })),
+ }
+ case "navigationCarousel":
+ return {
+ __typename: "ComponentSectionsNavigationCarousel",
+ id: blockId(block.sectionKey, fallbackId),
+ sectionKey: block.sectionKey ?? null,
+ items: (block.items ?? []).map((item, index) => ({
+ id: `${fallbackId}-nav-${index}`,
+ contentId: item.contentId,
+ title: item.title,
+ category: item.category ?? null,
+ imageUrl: item.imageUrl ?? null,
+ backgroundColor: item.backgroundColor ?? null,
+ })),
+ }
+ case "bibleQuotesCarousel":
+ return {
+ __typename: "ComponentSectionsBibleQuotesCarousel",
+ id: blockId(block.sectionKey, fallbackId),
+ sectionKey: block.sectionKey ?? null,
+ heading: block.heading ?? null,
+ quotes: (block.quotes ?? []).map((quote, index) => ({
+ id: `${fallbackId}-quote-${index}`,
+ reference: quote.reference,
+ text: quote.text,
+ attribution: quote.attribution ?? null,
+ imageUrl: quote.imageUrl ?? null,
+ backgroundColor: quote.backgroundColor ?? null,
+ ctaLabel: quote.ctaLabel ?? null,
+ ctaLink: quote.ctaLink ?? null,
+ })),
+ }
+ case "container":
+ return {
+ __typename: "ComponentSectionsContainer",
+ id: blockId(block.sectionKey, fallbackId),
+ sectionKey: block.sectionKey ?? null,
+ slots: normalizeContainerContent(
+ block.content,
+ fallbackId,
+ videos,
+ locale,
+ ),
+ }
+ case "section":
+ return {
+ __typename: "ComponentSectionsSection",
+ id: blockId(block.sectionKey, fallbackId),
+ sectionKey: block.sectionKey ?? null,
+ backgroundColor: block.backgroundColor ?? null,
+ backgroundImageUrl: block.backgroundImageUrl ?? null,
+ backgroundOpacity: block.backgroundOpacity ?? null,
+ dynamicBackgroundImage: block.dynamicBackgroundImage ?? false,
+ staticOverlay: block.staticOverlay ?? false,
+ blurHash: block.blurHash ?? null,
+ sectionContent: normalizeSectionContent(
+ block.content,
+ fallbackId,
+ videos,
+ locale,
+ ),
+ }
+ case "quizButton":
+ return {
+ __typename: "ComponentSectionsQuizButton",
+ id: fallbackId,
+ buttonText: block.buttonText,
+ iframeSrc: block.iframeSrc,
+ }
+ case "containerSlot":
+ return null
+ }
+}
+
+export function normalizeAdminExperience(
+ experience: AdminExperience,
+): NonNullable {
+ const videos = new Map(
+ (experience.referencedVideos ?? []).map((video) => [video.id, video]),
+ )
+ const blocks = experience.blocks
+ .map((entry) => AdminBlockSchema.safeParse(entry))
+ .flatMap((result, index) =>
+ result.success
+ ? [
+ normalizeAdminBlock(
+ result.data,
+ `admin-block-${index}`,
+ videos,
+ experience.locale,
+ ),
+ ]
+ : [],
+ )
+ .filter((block): block is NormalizedAdminBlock => block != null)
+
+ return {
+ documentId: experience.id,
+ slug: experience.slug,
+ isTemplate: false,
+ title: experience.title ?? null,
+ metaDescription: experience.metaDescription ?? null,
+ ogTitle: experience.ogTitle || experience.title || null,
+ ogDescription:
+ experience.ogDescription || experience.metaDescription || null,
+ pathSegment: null,
+ ogImage: experience.ogImageUrl
+ ? {
+ url: experience.ogImageUrl,
+ width: null,
+ height: null,
+ alternativeText: experience.title ?? "",
+ }
+ : null,
+ blocks,
+ } as NonNullable
+}
+
+export async function getAdminExperienceBySlug(
+ locale: string,
+ slug: string,
+): Promise | null> {
+ if (!env.ADMIN_GRAPHQL_URL) return null
+
+ const response = await fetch(env.ADMIN_GRAPHQL_URL, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ query: `query AdminExperienceBySlug($locale: String!, $slug: String!) {
+ experienceBySlug(locale: $locale, slug: $slug) {
+ id
+ locale
+ slug
+ title
+ metaDescription
+ ogTitle
+ ogDescription
+ ogImageUrl
+ blocks
+ referencedVideos {
+ id
+ slug
+ label
+ locales {
+ locale
+ title
+ description
+ snippet
+ }
+ images {
+ url
+ }
+ dubs {
+ hls
+ published
+ language {
+ bcp47
+ iso3
+ slug
+ }
+ }
+ }
+ }
+ }`,
+ variables: { locale, slug },
+ }),
+ cache: "no-store",
+ })
+
+ if (!response.ok) {
+ throw new Error(`Admin content API returned ${response.status}`)
+ }
+
+ const payload = (await response.json()) as {
+ data?: { experienceBySlug?: unknown | null }
+ errors?: Array<{ message?: string }>
+ }
+
+ if (payload.errors?.length) {
+ throw new Error(
+ payload.errors
+ .map((entry) => entry.message ?? "Unknown error")
+ .join("; "),
+ )
+ }
+
+ const parsed = AdminExperienceSchema.safeParse(payload.data?.experienceBySlug)
+ if (!parsed.success) return null
+
+ return normalizeAdminExperience(parsed.data)
+}
diff --git a/apps/web/src/lib/content.ts b/apps/web/src/lib/content.ts
index e9df66b02..ec228dcca 100644
--- a/apps/web/src/lib/content.ts
+++ b/apps/web/src/lib/content.ts
@@ -2,6 +2,8 @@ import type { ErrorLike } from "@apollo/client"
import { cache } from "react"
import { unstable_cache } from "next/cache"
import { graphql, type ResultOf } from "@forge/graphql"
+import { env } from "@/env"
+import { getAdminExperienceBySlug } from "@/lib/admin-content"
import client from "@/lib/client"
import type { EnrichedMediaItem } from "@/lib/enrichment"
import { enrichRouteRelatedVideo } from "@/lib/enrichment"
@@ -363,6 +365,13 @@ async function resolveSlugPage(
locale: string,
slug: string,
): Promise {
+ if (env.FORGE_CONTENT_API === "admin") {
+ const adminExperience = await getAdminExperienceBySlug(locale, slug)
+ if (adminExperience) {
+ return { kind: "experience", experience: adminExperience }
+ }
+ }
+
const explicitExperience = asNonTemplateExperience(
await getExperienceByFilters(locale, {
slug: { eq: slug },
@@ -431,6 +440,29 @@ const fetchResolvedWatchPage = unstable_cache(
/** Shared watch-page resolver for page rendering and metadata generation. */
export const resolveWatchPage = cache(
async (locale: string, slug?: string): Promise => {
+ if (env.FORGE_CONTENT_API === "admin") {
+ try {
+ const resolved =
+ slug == null
+ ? await resolveHomepage(locale)
+ : await resolveSlugPage(locale, slug)
+
+ if (!resolved) {
+ return { data: null, error: new Error(NO_EXPERIENCE_FOUND_MESSAGE) }
+ }
+
+ return {
+ data: JSON.parse(JSON.stringify(resolved)) as ResolvedWatchPage,
+ error: null,
+ }
+ } catch (error) {
+ return {
+ data: null,
+ error: error instanceof Error ? error : new Error(String(error)),
+ }
+ }
+ }
+
return fetchResolvedWatchPage(locale, slug ?? null)
},
)
diff --git a/docs/brainstorms/2026-04-08-nested-component-video-relations-requirements.md b/docs/brainstorms/2026-04-08-nested-component-video-relations-requirements.md
new file mode 100644
index 000000000..7ee879921
--- /dev/null
+++ b/docs/brainstorms/2026-04-08-nested-component-video-relations-requirements.md
@@ -0,0 +1,58 @@
+---
+date: 2026-04-08
+topic: nested-component-video-relations
+---
+
+# Fix Video Relations in Deeply Nested Seed Components
+
+## Problem Frame
+
+After PR #679 fixed locale-aware video resolution in Easter & Christmas seeds, a new issue surfaced: **all 30 `sections.video` components have NULL video relations** in the database, while `sections.video-hero` components (same seed, same `video: numericId` pattern) work correctly.
+
+The difference is nesting depth:
+
+- `sections.video-hero`: Experience → blocks (depth 1) — **relations created correctly**
+- `sections.video`: Experience → blocks → `sections.section` → content (depth 2+) — **relations silently dropped**
+
+Strapi v5 Document Service does not propagate relation fields in components nested more than one level deep. This affects all video sections in both Easter and Christmas seeds — the mobile app shows video cards without thumbnails because the video relation (which carries `video_images`) is missing.
+
+## Requirements
+
+- R1. Every `sections.video` component created by the seed must have its `video` relation populated in the `components_sections_videos_video_lnk` table after seeding.
+- R2. The fix must work for both Easter and Christmas seeds (30 affected components total).
+- R3. `sections.video-hero` and `sections.video-carousel-item` relations must continue working (no regression).
+- R4. The fix must work on both fresh databases and databases with production data imported via `pnpm data-import`.
+
+## Success Criteria
+
+- `SELECT * FROM components_sections_videos_video_lnk` returns a row for every `sections.video` component after seeding.
+- Mobile app renders video thumbnails on all video sections (Easter Explained, My Last Day, etc.).
+- No changes to Strapi content type schemas.
+
+## Scope Boundaries
+
+- Not fixing Strapi's Document Service itself — working around the limitation in seed scripts.
+- Not changing how `sections.video-hero` works (it already works).
+- Not adding new component types or relations.
+
+## Key Decisions
+
+- **Post-create SQL patch**: Since Strapi Document Service silently drops nested relations, the seed should insert the link table rows directly after Experience creation. This is the pragmatic approach — the alternative (restructuring nesting to be shallower) would require content type schema changes.
+- **Reuse existing `findOrCreatePublishedVideo` pattern**: The video lookup logic from PR #679 is correct; only the link insertion is missing.
+
+## Dependencies / Assumptions
+
+- The `components_sections_videos_video_lnk` table uses `inv_video_id` (component FK) and `video_id` (video FK).
+- Video components are created by Strapi during Experience create — they get IDs assigned. The patch runs after creation to fill in the missing link rows.
+
+## Outstanding Questions
+
+### Deferred to Planning
+
+- [Affects R1][Technical] Exact timing of the post-create patch: query component IDs by section_key after Experience creation, or track them from the create response?
+- [Affects R1][Needs research] Does Strapi v5 Document Service return nested component IDs in the create response, or do we need to query the DB?
+- [Affects R2][Technical] Should the patch be a shared utility in seed-utils.ts or inline in each seed?
+
+## Next Steps
+
+-> `/ce:plan` for structured implementation planning
diff --git a/docs/brainstorms/2026-04-09-seed-studio-requirements.md b/docs/brainstorms/2026-04-09-seed-studio-requirements.md
new file mode 100644
index 000000000..3c0dcb29b
--- /dev/null
+++ b/docs/brainstorms/2026-04-09-seed-studio-requirements.md
@@ -0,0 +1,75 @@
+---
+date: 2026-04-09
+topic: seed-studio
+---
+
+# Seed Studio — AI-Powered Experience Creator
+
+## Problem Frame
+
+Creating new themed experiences (like Easter and Christmas) currently requires a developer to write hundreds of lines of TypeScript seed code, manually selecting videos, crafting bible quotes, and hardcoding section ordering. This makes it impossible for the content team to create or iterate on experiences without developer involvement. The content team needs a self-service tool to create rich, multi-section experiences using JesusFilm's existing video catalog.
+
+## Requirements
+
+- R1. **Chat-first creation flow**: Users describe a theme or idea in a chat interface (e.g., "Create a Thanksgiving experience about gratitude") and AI generates a complete experience with all sections.
+- R2. **AI generates full experience**: Given a theme, AI selects videos from the Strapi video catalog, writes bible quotes, generates discussion questions, composes descriptive text, and arranges sections — all matching the theme.
+- R3. **Live preview panel**: A split-screen layout shows the generated experience preview alongside the chat, updating in real-time as AI generates or user edits content.
+- R4. **Per-platform section ordering**: AI produces separate section orderings for web and mobile. Web may lead with text/context, mobile may lead with video. Both orderings are stored in the Experience document.
+- R5. **Iterative refinement via chat**: Users can request changes through chat (e.g., "swap the first video for something shorter", "add a quiz after the third section", "reorder bible quotes") and AI applies the edits.
+- R6. **Direct Strapi publish**: Completed experiences are published as `api::experience.experience` documents in Strapi via the Document Service API, with all video relations, dynamic zone blocks, and media properly linked.
+- R7. **Video catalog search**: AI searches the Strapi video catalog by theme, tags, and content to find relevant videos. Uses existing video data (titles, descriptions, tags) for matching.
+- R8. **Section type support**: Supports all existing dynamic zone block types: `sections.video`, `sections.video-hero`, `sections.video-carousel`, `sections.text`, `sections.container`, `sections.related-questions`, `sections.bible-quotes-carousel`, `sections.quiz-button`.
+- R9. **Clean, modern UI**: Minimal, polished interface with clear visual hierarchy. Chat on the left, preview on the right. Designed using Stitch MCP for consistent, production-quality design.
+- R10. **Suggestion chips**: AI provides quick-action suggestions (e.g., "Add more videos", "Include a quiz", "Try a different theme") as clickable chips below the chat.
+
+## Success Criteria
+
+- Content team member can create a complete themed experience in under 10 minutes without writing code.
+- Generated experience renders correctly in both the web app and mobile app.
+- Per-platform ordering produces meaningfully different section arrangements for web vs mobile.
+- All video relations in the generated experience are valid and playable.
+
+## Scope Boundaries
+
+- **Not building a general CMS editor** — this is specifically for creating themed experiences.
+- **Not replacing Strapi admin** — users still use Strapi for editing individual content types.
+- **Not adding new content types** — uses existing Experience schema and section components.
+- **Not building user auth in v1** — relies on internal network access or simple shared secret. Auth can come later.
+- **Not handling video uploads** — only selects from existing video catalog in Strapi.
+- **Per-platform ordering is about section arrangement only** — not about showing/hiding sections per platform.
+
+## Key Decisions
+
+- **Standalone app (`apps/seed-studio`)**: Separate from `apps/web` because it's an internal tool with different auth, deployment, and UX needs. Avoids bloating the public-facing app.
+- **Chat-first, not form-first**: Matches the Lovable.com mental model. Lower learning curve for content team. AI does the heavy lifting.
+- **AI generates everything, user reviews**: Rather than user assembling pieces, AI creates a complete draft and user iterates. Faster for the content team.
+- **Direct Strapi publish (not seed files)**: Eliminates the developer bottleneck entirely. Content goes live without a code review cycle.
+- **Stitch MCP for UI design**: Ensures clean, consistent design system from the start.
+
+## Dependencies / Assumptions
+
+- Strapi video catalog has enough tagged/searchable videos to make AI selection useful.
+- Strapi GraphQL API or Document Service API is accessible from the seed-studio app.
+- AI model (Claude) has access to the video catalog data for making content decisions.
+- Experience schema supports storing per-platform ordering (may need a new field or convention).
+
+## Outstanding Questions
+
+### Resolve Before Planning
+
+(All resolved)
+
+### Resolved
+
+- [R4] Per-platform ordering: Platform tag on each section block. Each section gets a `platforms` array with `{ platform: "web" | "mobile", order: number }` entries. Apps query sections and sort by their platform's order value.
+
+### Deferred to Planning
+
+- [Affects R2][Needs research] Which AI model/API to use for content generation and how to structure the prompts for theme-to-experience generation.
+- [Affects R7][Needs research] Whether existing video metadata (tags, descriptions) is rich enough for AI to make good selections, or if we need to add embeddings/semantic search.
+- [Affects R6][Technical] How to handle the Strapi v5 nested component relation bug (currently patched by `patchNestedVideoRelations`) when publishing from seed-studio.
+- [Affects R9][Design] Detailed UI component design — to be explored with Stitch MCP during planning.
+
+## Next Steps
+
+→ `/ce:plan` for structured implementation planning
diff --git a/docs/brainstorms/2026-04-10-seed-studio-real-video-data-requirements.md b/docs/brainstorms/2026-04-10-seed-studio-real-video-data-requirements.md
new file mode 100644
index 000000000..39f0f2218
--- /dev/null
+++ b/docs/brainstorms/2026-04-10-seed-studio-real-video-data-requirements.md
@@ -0,0 +1,36 @@
+---
+date: 2026-04-10
+topic: seed-studio-real-video-data
+---
+
+# Seed Studio: Real Video Data in Preview
+
+## Problem Frame
+
+When Seed Studio's AI generates an experience, it produces placeholder streaming URLs and no thumbnails because it has no access to the actual Strapi video catalog. The preview panel shows empty gray boxes instead of real video thumbnails. This makes the preview useless for evaluating the experience before publishing.
+
+## Requirements
+
+- R1. AI must select videos from the real Strapi video catalog (title, streamingUrl, thumbnailUrl) when building an experience
+- R2. Preview panel must display actual video thumbnails from the selected videos
+- R3. AI-generated text content (headings, paragraphs, bible quotes, Q&A) remains AI-generated — only video/image references come from Strapi
+- R4. Bible quote `imageUrl` fields should use real image URLs that will render in preview (Unsplash or similar publicly accessible URLs are acceptable)
+
+## Success Criteria
+
+- Preview shows real video thumbnails for all video sections (video, video-hero, video-carousel)
+- Published experience contains valid video references that match actual Strapi records
+
+## Scope Boundaries
+
+- No changes to Strapi endpoints (video search already exists)
+- No changes to the preview component rendering logic beyond using the data that's now available
+- No new video upload or management features
+
+## Key Decisions
+
+- **Inject catalog data into prompt**: Fetch video catalog from Strapi before calling Claude CLI, include real video data in the system prompt so Claude can reference actual videos with real URLs and thumbnails
+
+## Next Steps
+
+→ Proceed directly to work — scope is small and well-bounded
diff --git a/docs/brainstorms/2026-05-04-admin-ai-experience-editorial-quality-requirements.md b/docs/brainstorms/2026-05-04-admin-ai-experience-editorial-quality-requirements.md
new file mode 100644
index 000000000..096972498
--- /dev/null
+++ b/docs/brainstorms/2026-05-04-admin-ai-experience-editorial-quality-requirements.md
@@ -0,0 +1,293 @@
+---
+date: 2026-05-04
+updated: 2026-05-05
+topic: admin-ai-experience-editorial-quality
+---
+
+# Admin AI Experience — Editorial Quality & Compliance Pass
+
+## Problem Frame
+
+`feat-107` (Admin AI Experience Drafting) shipped the end-to-end generation
+flow: prompt → server action → catalog candidates → LLM → normalizer →
+`BlocksSchema`-valid draft in editor state. The pipeline works, but two
+gaps undercut the operator experience:
+
+1. **Editorial quality is weak.** When an operator hits the Preview button
+ on the dashboard (which opens the published `//` page in
+ `apps/web`), the rendered AI draft looks markedly less editorial than
+ the curated Easter (`feat-029`) and Christmas (`feat-034`) experiences.
+ The schema supports every block kind the editor exposes, yet generated
+ pages tend toward flat sequences of `text` + `videoBlock` rather than
+ the layered `videoHero` → themed `section` → nested
+ `navigationCarousel` / `mediaCollection` / `bibleQuotesCarousel`
+ composition that gives Easter and Christmas their feel.
+
+2. **Rule compliance is unverified.** The plan (R1–R9 in
+ `docs/plans/2026-04-23-002-feat-admin-ai-experience-drafting-plan.md`)
+ defines safety and product invariants the generator must hold every
+ time. Several of those invariants have unit-level coverage but no
+ end-to-end guard, so silent regressions are possible — especially
+ around ephemeral state (R5), empty-canvas-only (R4), and the
+ provider posture (R7/R8).
+
+Both gaps share a root cause: the prompt builder in
+`apps/admin/src/services/experience-ai/experience-ai-prompts.ts` is the
+primary place where editorial judgment is expressed, but it does not yet
+carry the full "guided spiritual journey" discipline from the Christian
+Experience agent brief. Every other lever (schema, normalizer, action)
+was built carefully; the prompt needs to do more product-quality work
+before introducing extra agents or a durable async pipeline.
+
+The Christian Experience agent brief in
+`apps/seed-studio/christian_experience_ai_agents.md` is useful as product
+direction, but the full multi-agent workflow is too large for this pass.
+Eight LLM calls, intermediate persisted state, progress UI, retry/resume,
+and per-agent error handling would move the work into a new async
+generation architecture. This brainstorm therefore adopts the best parts
+immediately as prompt discipline, then defers richer input controls to a
+second phase and durable background generation to future roadmap work.
+
+## Requirements
+
+### Editorial composition (visual quality)
+
+- **R1.** Every successful generation must produce a draft that includes,
+ at minimum, one `videoHero`-shaped opener, two or more `section`-level
+ blocks, and at least one nested cross-block construct (a
+ `navigationCarousel`, `mediaCollection`, or `videoCarousel`). A flat
+ list of `text` + `videoBlock` only is a quality regression.
+- **R2.** The system prompt must carry an explicit structural template
+ for what a "good first draft" looks like, including ordering hints
+ (hero opens, themed sections in the middle, a closer block such as
+ `quizButton` or `cta`).
+- **R3.** The system prompt must include at least one few-shot reference
+ draft modelled on the editorial shape of the Easter / Christmas
+ experiences (using the model-facing draft AST, not raw `BlocksSchema`).
+- **R4.** Locale-aware copy guidance must be present in the system
+ prompt: tone, voice, and length expectations appropriate to the
+ requested experience locale. Copy length should not be uniform across
+ block kinds (heroes get short headings; sections can carry richer
+ prose).
+
+### Presentation defaults (visual quality)
+
+- **R5.** The normalizer must fill safe presentation defaults when the
+ model omits optional fields that materially affect the rendered look:
+ - `section.backgroundOpacity` when `dynamicBackgroundImage` is true
+ - `videoHero.clipStartSeconds` / `clipEndSeconds` derived from a
+ sensible default trim window
+ - container slot `spans` for multi-slot layouts
+ - `section.dynamicBackgroundImage` enabled when the section's first
+ video-bearing slot has a `previewImageUrl`
+- **R6.** Defaults must be data-derived where possible (e.g., use the
+ candidate's actual `previewImageUrl` rather than a hardcoded asset)
+ and must never inject fields the saved `BlocksSchema` rejects.
+
+### Rule compliance verification (audit + regression guard)
+
+- **R7.** Add an integration-level test that runs the full
+ `generateDraftAction` path with a stubbed provider and asserts:
+ - every `videoId` in the normalized output appears in the candidate
+ set returned by `loadVideoCandidates` for that prompt + locale
+ (plan R6: catalog-only)
+ - every persisted streaming reference resolves through a `VideoDub`
+ whose `language.bcp47 / iso3 / slug` matches the requested locale
+ (plan R6: locale-matched dubs — see solution doc
+ `docs/solutions/integration-issues/admin-ai-experience-preview-videodub-language-selection-20260501.md`)
+ - the action does not call any service that writes to Prisma during
+ generation (plan R5: ephemeral)
+ - the normalized output passes `BlocksSchema.parse` (plan R3 / R9)
+- **R8.** Add a UI-level test that confirms the empty-canvas guard:
+ the AI entry point is hidden or disabled when the locale already has
+ blocks (plan R4).
+- **R9.** Server-side, every successful generation must emit a single
+ structured "rule witness" log line listing which invariants the
+ service explicitly satisfied (provider used, candidate count, locale
+ match status, normalize success, schema parse success). The log
+ format must be greppable in Railway and must not include user prompt
+ text or candidate metadata that would amount to PII / catalog leak.
+
+### Phase A — prompt-only journey discipline
+
+- **R10.** Phase A must improve generation quality without changing the
+ authoring UI, request shape, database model, provider call count, or
+ background execution model.
+- **R11.** The system prompt must require the model to frame the draft as
+ a guided spiritual journey before composing blocks. The default journey
+ should move through a coherent arc such as struggle → biblical truth →
+ story/video connection → reflection → response, while still adapting to
+ the operator's prompt.
+- **R12.** Every section in the generated draft must have a clear purpose
+ in the journey. A section that only repeats the theme or holds a
+ keyword-matched video without advancing the experience is a quality
+ regression.
+- **R13.** Video selection guidance must prioritize story fit above raw
+ keyword overlap. Candidate videos should be chosen because they support
+ the section purpose, fit the audience's emotional state, and move the
+ draft forward.
+- **R14.** Pastoral guardrails must be explicit in the prompt: avoid
+ prosperity-gospel language, shallow clichés, invented Bible quotations,
+ and scripture references unless the model is confident about the
+ chapter and verse.
+- **R15.** The draft must close with one clear invitation to respond, not
+ a stack of competing CTAs. Valid invitations include pray, reflect,
+ watch, read more, contact, or join, selected according to the prompt.
+
+### Phase B — structured generation intent
+
+- **R16.** Phase B may add structured input fields to the existing
+ Generate with AI surface so an operator can steer audience,
+ emotion/problem, purpose, tone, preferred CTA, and target language
+ without writing all of that into one free-text prompt.
+- **R17.** Structured fields must compile into a server-owned generation
+ brief consumed by the prompt builder. Empty fields should be omitted,
+ not filled with generic defaults that make the output feel less
+ intentional.
+- **R18.** The original free-text prompt remains the primary input.
+ Structured fields refine the prompt; they do not replace it or create a
+ multi-step wizard.
+- **R19.** Phase B must keep generation as one provider call unless a
+ separate background-job phase has already landed. Review Agent,
+ critique/regenerate loops, and visible per-agent progress are out of
+ scope for Phase B.
+
+## Success Criteria
+
+- Phase A produces visibly more journey-shaped drafts from the same
+ single prompt flow, without increasing provider latency/cost beyond the
+ existing one-call path.
+- A blind test of three operator prompts ("Easter for teens",
+ "Christmas family devotional", "What is forgiveness?") returns drafts
+ that, on visual review against the existing Easter / Christmas pages,
+ read as comparably editorial: hero present, multiple themed sections,
+ at least one cross-block carousel, locale-matched copy.
+- For the same blind prompts, video placement reads as story-driven:
+ videos support the purpose of their sections rather than appearing as a
+ topical search-result list.
+- Phase B lets operators steer audience, emotional problem, tone, purpose,
+ CTA, and language more reliably than the textarea-only flow while still
+ returning an editable admin draft through the existing editor.
+- The compliance test in R7 runs in CI and is a hard gate on the next
+ PR that touches the AI generation path.
+- The empty-canvas guard test in R8 prevents regressions where the AI
+ entry point appears on a non-empty draft.
+- The rule witness log in R9 is visible in Railway logs and can be
+ filtered by experience id and locale.
+
+## Scope Boundaries
+
+- No changes to the canonical saved `BlocksSchema` in
+ `apps/admin/src/domain/blocks.ts`.
+- No new embedding or semantic-ranking pipeline for candidate
+ retrieval. Lexical / topical ranking from the current admin video
+ catalog stays.
+- No AI behavior on a non-empty canvas. Merge / append / rewrite are
+ v2 work.
+- No slug or path mutation by AI.
+- No client-side provider calls. Provider remains server-only.
+- No Seed Studio runtime cross-import.
+- No general-purpose chat UX in admin. This is still one-shot
+ generation, not conversational editing.
+- No new image asset upload or generation. Default presentation fields
+ reuse existing catalog imagery only.
+- Phase A does not add structured input fields, another provider call,
+ review/regenerate loops, background jobs, progress tracking, or
+ persisted intermediate agent state.
+- Phase B does not add Review Agent, multi-agent orchestration,
+ progress UI, retry/resume semantics, auto-save, or auto-publish.
+- Durable async generation is a future phase because it requires a
+ persisted job/proposal model and list-level progress, especially if
+ generation grows beyond one provider call.
+
+## Key Decisions
+
+- **Do not fold the full Christian multi-agent workflow into the current
+ quality pass.** The agent brief is strategically right, but the full
+ version changes runtime architecture. The immediate adoption path is to
+ encode its core product discipline into the existing Admin AI prompt,
+ then evaluate whether richer input controls or async orchestration are
+ justified.
+- **Keep normalizer defaults and compliance tests as the broader
+ editorial-quality backlog, not prerequisites for Phase A.** They remain
+ valid requirements for making the generator production-harder, but the
+ Christian journey improvement can land as a smaller prompt-only slice
+ first.
+- **Treat the system prompt as the primary visual lever.** The schema
+ already supports every block kind. The bottleneck is editorial
+ bias, which is best expressed in the prompt, not in tighter Zod
+ constraints.
+- **Treat the normalizer as the secondary visual lever, with a strict
+ "fill, never override" rule.** Defaults only land on optional fields
+ the model omitted; the model's explicit choices stay authoritative.
+- **Compliance tests at the action boundary, not the service
+ boundary.** R7's invariants describe end-to-end behavior; testing
+ them at the service level alone misses the action layer's
+ responsibility for never writing to Prisma during generation.
+- **Phase A is prompt-only.** The fastest low-risk improvement is to
+ encode the "spiritual journey, section purpose, pastoral guardrails,
+ story-fit video selection, single CTA" discipline into the existing
+ prompt builder. This should improve output quality without changing
+ product flow, latency, persistence, or data contracts.
+- **Phase B is structured intent capture, not multi-agent generation.**
+ Audience, emotion/problem, purpose, tone, CTA, and target language are
+ useful operator controls, but they change the authoring UX and action
+ input shape. They belong in a follow-up PR after prompt-only quality is
+ proven.
+- **Review Agent waits for durable async generation.** A second LLM call
+ can add 15–25 seconds and introduces retry/failure behavior. If review,
+ critique/regenerate, or full multi-agent orchestration is added, it
+ should ride on a background job/progress model rather than blocking the
+ editor page.
+
+## Dependencies / Assumptions
+
+- The existing locale-aware dub matching in `experience-ai.service.ts`
+ (per the 1-May solution doc) is in place and stays in place.
+- Catalog candidate retrieval continues to return at least 4–6
+ candidates for typical prompts, which is the floor needed to
+ populate one `videoHero` plus a handful of section-level video
+ references.
+- The empty-canvas guard logic in `experience-editor.tsx` continues
+ to be the single source of truth for hiding the AI entry point.
+- No provider account / billing changes are required; this work fits
+ within current OpenRouter / OpenAI usage.
+- Phase A assumes the existing prompt builder can be extended without
+ widening the action payload.
+- Phase B assumes the editor's Generate with AI surface has enough room
+ for compact optional controls without turning into a wizard.
+
+## Outstanding Questions
+
+### Resolve Before Planning
+
+_(none — the brainstorm settled scope, levers, and boundaries.)_
+
+### Deferred to Planning
+
+- [Affects R3][Technical] What is the smallest few-shot example that
+ carries the editorial shape without bloating the prompt context?
+ Probably a truncated AST mirroring the Christmas seed's first
+ `videoHero` + first `section` only.
+- [Affects R5][Technical] Which presentation defaults are best
+ expressed as constants in the normalizer vs derived from candidate
+ metadata? The normalizer's existing test surface should make this
+ evident during planning.
+- [Affects R7][Needs research] What is the cheapest stub provider
+ shape for the integration test — a hand-written fixture, or a
+ recorded real-provider response? Either works; planning should pick
+ based on test runtime cost.
+- [Affects R9][Technical] What is the canonical log shape elsewhere
+ in admin (e.g., the embedding-backfill workflow) so the rule
+ witness log matches existing Railway log conventions?
+- [Affects R16-R18][Design] What is the smallest structured input UI
+ that improves operator control without making Generate with AI feel
+ like a form-heavy CMS workflow?
+- [Affects R19][Technical] If a later phase adds Review Agent or
+ multi-agent orchestration, should completed output land as an AI
+ proposal requiring explicit apply, or as a draft revision in the
+ editor?
+
+## Next Steps
+
+→ `/ce:plan` for structured implementation planning
diff --git a/docs/plans/2026-04-08-002-fix-nested-video-component-relations-plan.md b/docs/plans/2026-04-08-002-fix-nested-video-component-relations-plan.md
new file mode 100644
index 000000000..0513bd433
--- /dev/null
+++ b/docs/plans/2026-04-08-002-fix-nested-video-component-relations-plan.md
@@ -0,0 +1,160 @@
+---
+title: "fix: Patch video relations in deeply nested seed components"
+type: fix
+status: completed
+date: 2026-04-08
+origin: docs/brainstorms/2026-04-08-nested-component-video-relations-requirements.md
+---
+
+# fix: Patch video relations in deeply nested seed components
+
+## Overview
+
+After PR #679 fixed locale-aware video resolution, a second Strapi v5 bug surfaced: the Document Service silently drops relation data for components nested 2+ levels deep in dynamic zones. All 30 `sections.video` components across Easter and Christmas seeds have NULL video relations, while `sections.video-hero` (depth 1) works correctly.
+
+The fix: insert missing link table rows directly via Knex after Experience creation.
+
+## Problem Statement
+
+**Nesting depth determines whether relations are persisted:**
+
+| Component | Nesting path | Depth | Video relation |
+| --------------------- | -------------------------------------------------- | ----- | -------------- |
+| `sections.video-hero` | Experience → blocks | 1 | **Works** |
+| `sections.video` | Experience → blocks → `sections.section` → content | 2+ | **NULL** |
+
+Both use identical `video: numericId` syntax. The Document Service `create()` succeeds without error, but the `components_sections_videos_video_lnk` rows are never created for depth 2+ components.
+
+This is a **distinct bug from strapi/strapi#22611** (which throws "Invalid relations"). Here, no error is thrown — the relation is silently discarded during persistence.
+
+(see origin: docs/brainstorms/2026-04-08-nested-component-video-relations-requirements.md)
+
+## Proposed Solution
+
+Add a `patchNestedVideoRelations()` utility to `seed-utils.ts` that runs after `experienceService.create()`. It:
+
+1. Queries `components_sections_videos` by `section_key` to find all newly created video component rows
+2. Parses each `section_key` to extract the video slug (the part before `/`)
+3. Looks up the correct video ID from the `videos` table by slug + locale
+4. Inserts the missing rows into `components_sections_videos_video_lnk`
+
+Both Easter and Christmas seeds call this function after Experience creation.
+
+## Implementation Units
+
+### Unit 1: Add `patchNestedVideoRelations` to seed-utils.ts
+
+**Goal:** Shared utility that patches the `components_sections_videos_video_lnk` table after Experience creation.
+
+**Files:**
+
+- `apps/cms/src/bootstrap/seed-utils.ts`
+
+**Approach:**
+
+1. Accept a map of `sectionKey → videoId` (the seed already has this info from `findOrCreatePublishedVideo`)
+2. Query `components_sections_videos` for rows matching those section keys
+3. Check which component IDs already have a link row (idempotent — safe to re-run)
+4. Insert missing link rows: `{ inv_video_id: componentId, video_id: videoId }`
+
+**Patterns to follow:**
+
+- `apps/cms/src/api/core-sync/services/bulk-upsert.ts:363-407` — batch link table insertion pattern
+- `apps/cms/src/bootstrap/seed-utils.ts:56-63` — existing Knex query pattern
+
+**Verification:**
+
+- `SELECT COUNT(*) FROM components_sections_videos_video_lnk` returns a row for every `components_sections_videos` row after seeding
+
+**Execution note:** Implementation-first. The function signature should be:
+
+```typescript
+export async function patchNestedVideoRelations(
+ strapi: Core.Strapi,
+ videoMap: Map, // sectionKey → video numeric ID
+): Promise
+```
+
+### Unit 2: Call patch from Easter seed
+
+**Goal:** Wire up the patch after Easter Experience creation.
+
+**Files:**
+
+- `apps/cms/src/bootstrap/seed-easter.ts`
+
+**Approach:**
+
+1. Build a `Map` from the existing `buildVideoSectionContent` calls (sectionKey → videoId)
+2. Call `patchNestedVideoRelations(strapi, videoMap)` after `experienceService.create()` succeeds
+3. Place the call inside the try block, after create but before any success logging
+
+**Patterns to follow:**
+
+- `apps/cms/src/bootstrap/seed-easter.ts:462-464` — existing buildVideoSectionContent calls with sectionKey and videoId
+- The map entries come from the same data already used for buildVideoSectionContent
+
+**Verification:**
+
+- After seeding, `SELECT * FROM components_sections_videos_video_lnk WHERE inv_video_id IN (SELECT id FROM components_sections_videos WHERE section_key LIKE 'easter%')` returns 8 rows (one per video section)
+
+### Unit 3: Call patch from Christmas seed
+
+**Goal:** Wire up the patch after Christmas Experience creation.
+
+**Files:**
+
+- `apps/cms/src/bootstrap/seed-christmas.ts`
+
+**Approach:**
+
+- Same pattern as Unit 2: build videoMap from existing Christmas video section data, call patch after create
+
+**Patterns to follow:**
+
+- `apps/cms/src/bootstrap/seed-christmas.ts` — mirrors Easter seed structure
+
+**Verification:**
+
+- After seeding, Christmas video components have link rows for all 7 video sections
+
+### Unit 4: End-to-end verification
+
+**Goal:** Verify the fix works on both fresh and production-imported databases.
+
+**Files:** None (testing only)
+
+**Approach:**
+
+1. Wipe local DB, start Strapi (creates schema), stop, import production data, restart with `SEED_ON_BOOT=true`
+2. Verify DB: `SELECT sv.section_key, v.title FROM components_sections_videos sv JOIN components_sections_videos_video_lnk svl ON svl.inv_video_id = sv.id JOIN videos v ON v.id = svl.video_id`
+3. Verify mobile app: video thumbnails appear on Easter Explained and all other video sections
+4. Verify no regression: video-hero still works
+
+**Verification:**
+
+- All 30 `components_sections_videos` rows have corresponding link table entries
+- Mobile app renders video thumbnails on all sections
+- Strapi admin shows video relations populated
+
+## Acceptance Criteria
+
+- [ ] Every `sections.video` component has a populated video relation after seeding (R1, R2)
+- [ ] `sections.video-hero` and `sections.video-carousel-item` continue working (R3)
+- [ ] Works on fresh databases and production-imported databases (R4)
+- [ ] Patch is idempotent — running seed twice doesn't create duplicate link rows
+- [ ] No changes to Strapi content type schemas
+
+## Dependencies & Risks
+
+- **Dependency:** PR #679 must be merged first (provides the `findOrCreatePublishedVideo` and shared utils)
+- **Risk:** If Strapi upstream fixes the nested relation bug, the patch becomes redundant (but harmless — idempotent check prevents duplicates)
+- **Risk:** Component IDs may differ between fresh and imported databases — the patch uses section_key lookup, not hardcoded IDs
+
+## Sources & References
+
+- **Origin document:** [docs/brainstorms/2026-04-08-nested-component-video-relations-requirements.md](docs/brainstorms/2026-04-08-nested-component-video-relations-requirements.md) — key decisions: post-create SQL patch approach, reuse findOrCreatePublishedVideo pattern
+- **Strapi v5 nested relation bug:** [docs/solutions/integration-issues/strapi-v5-nested-component-relation-ids-2026-03-31.md](docs/solutions/integration-issues/strapi-v5-nested-component-relation-ids-2026-03-31.md)
+- **Bulk link table insertion pattern:** `apps/cms/src/api/core-sync/services/bulk-upsert.ts:363-407`
+- **Related PR:** #679 (locale-aware video resolution)
+- **Upstream issues:** strapi/strapi#22611, strapi/strapi#24850, strapi/strapi#23909
diff --git a/docs/plans/2026-04-09-003-feat-seed-studio-ai-experience-creator-plan.md b/docs/plans/2026-04-09-003-feat-seed-studio-ai-experience-creator-plan.md
new file mode 100644
index 000000000..d56742beb
--- /dev/null
+++ b/docs/plans/2026-04-09-003-feat-seed-studio-ai-experience-creator-plan.md
@@ -0,0 +1,394 @@
+---
+title: "feat: Seed Studio — AI-Powered Experience Creator"
+type: feat
+status: completed
+date: 2026-04-09
+origin: docs/brainstorms/2026-04-09-seed-studio-requirements.md
+---
+
+# feat: Seed Studio — AI-Powered Experience Creator
+
+## Overview
+
+Build `apps/seed-studio`, a standalone Next.js app with a chat-first UI where the content team describes a theme and AI generates a complete Experience document — selecting videos, writing bible quotes, composing discussion questions, and arranging sections — then publishes directly to Strapi. This eliminates the developer bottleneck of manually coding seed files like `seed-easter.ts` and `seed-christmas.ts`.
+
+## Problem Statement
+
+Creating themed experiences (Easter, Christmas) currently requires a developer to write 500+ lines of TypeScript seed code, manually selecting Mux streaming URLs, crafting bible quotes, and hardcoding section ordering. The content team cannot create or iterate on experiences without developer involvement. Each new experience takes days of developer time. (see origin: `docs/brainstorms/2026-04-09-seed-studio-requirements.md`)
+
+## Proposed Solution
+
+A chat-first web tool inspired by Lovable.com where:
+
+1. User types a theme (e.g., "Create a Thanksgiving experience about gratitude")
+2. AI generates all sections — video selections from Strapi catalog, text blocks, bible quotes, Q&A, quizzes
+3. Live preview shows the experience in real-time alongside the chat
+4. User iterates via chat ("swap the first video", "add a quiz after section 3")
+5. User publishes directly to Strapi when satisfied
+
+**Key architectural decisions (see origin):**
+
+- **Standalone app** (`apps/seed-studio`): Separate from `apps/web` — different auth, deployment, UX needs
+- **Chat-first, not form-first**: Lower learning curve, AI does heavy lifting
+- **Direct Strapi publish**: No developer bottleneck, no seed file generation
+- **Per-platform ordering**: Platform tags on section blocks for web vs mobile arrangement
+
+## Technical Approach
+
+### Architecture
+
+```
+┌─────────────────────────────────────────────────────┐
+│ apps/seed-studio (Next.js 16 + React 19) │
+│ │
+│ ┌──────────────┐ ┌──────────────────────────┐ │
+│ │ Chat Panel │ │ Preview Panel │ │
+│ │ (Client) │ │ (Client) │ │
+│ │ │ │ │ │
+│ │ Messages │ │ Web / Mobile toggle │ │
+│ │ Suggestions │ │ Section cards │ │
+│ │ Input │ │ Drag-to-reorder │ │
+│ └──────┬───────┘ └──────────┬───────────────┘ │
+│ │ │ │
+│ ┌──────▼────────────────────────▼───────────────┐ │
+│ │ Server Actions │ │
+│ │ ├─ generateExperience(theme) │ │
+│ │ ├─ refineExperience(conversationId, message) │ │
+│ │ ├─ searchVideos(query) │ │
+│ │ └─ publishExperience(experience) │ │
+│ └──────┬────────────────────────┬───────────────┘ │
+│ │ │ │
+└─────────┼────────────────────────┼──────────────────┘
+ │ │
+ ┌─────▼─────┐ ┌───────▼──────┐
+ │ Claude AI │ │ Strapi CMS │
+ │ (Anthropic│ │ REST API │
+ │ SDK) │ │ + custom │
+ │ │ │ endpoints │
+ └───────────┘ └──────────────┘
+```
+
+**Communication pattern**: Next.js Server Actions call Claude API (for AI generation) and Strapi REST API (for video search and experience publishing). The `patchNestedVideoRelations` workaround requires a custom Strapi endpoint since Seed Studio cannot access Strapi's internal Knex instance.
+
+### Implementation Phases
+
+#### Phase 1: Foundation — App Scaffold + Strapi Endpoints (2-3 days)
+
+**Goal**: Standalone Next.js app running in monorepo + Strapi API endpoints for video search and experience publishing.
+
+**Tasks:**
+
+1. **Scaffold `apps/seed-studio`**
+ - Follow `apps/web` patterns: `@forge/seed-studio`, Next.js 16, React 19, Tailwind v4
+ - `package.json` with `@forge/graphql: workspace:*`, `@anthropic-ai/sdk`
+ - `tsconfig.json` extending root, paths `@/* -> ./src/*`
+ - `next.config.mjs` with Strapi URL env vars
+ - `turbo.json` entry for seed-studio tasks
+ - Reference: `apps/web/package.json`, `apps/web/tsconfig.json` for conventions
+
+2. **Create Strapi custom endpoints** in `apps/cms/`
+ - `src/api/seed-studio/routes/seed-studio.ts` — routes
+ - `src/api/seed-studio/controllers/seed-studio.ts` — controller
+ - `src/api/seed-studio/services/seed-studio.ts` — service
+ - **Endpoints:**
+ - `POST /api/seed-studio/search-videos` — search videos by query, tags, locale. Returns `{ id, documentId, title, slug, description, streamingUrl, thumbnailUrl }`
+ - `POST /api/seed-studio/publish-experience` — create Experience document with all blocks + run `patchNestedVideoRelations` internally
+ - `GET /api/seed-studio/video-catalog-stats` — return available tags, locales, video count for AI context
+ - Auth: API token header (`X-Seed-Studio-Token`) validated against env var
+ - Reference: `apps/cms/src/bootstrap/seed-utils.ts` for `findOrCreatePublishedVideo()` and `patchNestedVideoRelations()` patterns
+ - Reference: `docs/solutions/runtime-errors/cms-easter-seed-not-called-2026-03-30.md` for endpoint pattern
+
+3. **Environment setup**
+ - `.env.local`: `STRAPI_URL`, `STRAPI_SEED_STUDIO_TOKEN`, `ANTHROPIC_API_KEY`
+ - Railway service config (deferred to Phase 4)
+
+**Gotchas:**
+
+- Use numeric entity IDs for all video relations inside dynamic zone components (see `docs/solutions/integration-issues/strapi-v5-nested-component-relation-ids-2026-03-31.md`)
+- Strapi v5 GraphQL truncates nested relations to 10 items — use REST API for publish, not GraphQL mutation (see `docs/solutions/performance-issues/strapi-nested-relation-truncation-and-n-plus-one-manager-20260328.md`)
+
+**Acceptance criteria:**
+
+- [ ] `pnpm --filter @forge/seed-studio dev` starts the app on localhost
+- [ ] `POST /api/seed-studio/search-videos` returns video results from Strapi DB
+- [ ] `POST /api/seed-studio/publish-experience` creates an Experience with video relations intact
+
+---
+
+#### Phase 2: AI Chat Engine (2-3 days)
+
+**Goal**: Working chat interface that generates experiences via Claude API.
+
+**Tasks:**
+
+1. **AI prompt system** — `src/lib/ai/`
+ - `system-prompt.ts` — System prompt defining AI's role as an experience creator. Includes:
+ - Available section types and their schemas
+ - Video catalog summary (from `/video-catalog-stats`)
+ - Output format: JSON structure matching Experience blocks schema
+ - Per-platform ordering guidelines (mobile: video-first, web: context-first)
+ - `tools.ts` — Claude tool definitions:
+ - `search_videos(query, tags?, locale?)` — searches Strapi video catalog
+ - `generate_section(type, content)` — creates a typed section block
+ - `reorder_sections(platform, newOrder)` — adjusts ordering for a platform
+ - `experience-schema.ts` — TypeScript types for the Experience JSON structure matching Strapi's dynamic zone format
+
+2. **Server Actions** — `src/app/actions/`
+ - `chat.ts` — `generateExperience(theme: string)` and `refineExperience(messages: Message[], userMessage: string)`
+ - Uses Anthropic SDK with streaming (`stream: true`) for real-time preview updates
+ - Tool use loop: AI calls `search_videos` → Server Action fetches from Strapi → returns results to AI → AI incorporates into experience
+ - Returns structured experience JSON + chat message
+
+3. **Chat state management** — `src/lib/chat/`
+ - `use-chat.ts` — Custom hook managing conversation history + current experience state
+ - Messages: `{ role, content, experienceSnapshot? }` — each AI message optionally carries the experience state at that point
+ - Ephemeral sessions (no persistence in v1)
+
+**Key design decisions:**
+
+- **Streaming**: AI streams its reasoning/explanation text. The experience JSON is extracted from the final tool call result, not from streamed text. This avoids partial JSON parsing issues.
+- **Tool use pattern**: AI uses `search_videos` tool to query Strapi, receives results, then uses `generate_section` tools to build each section. This keeps the AI grounded in real catalog data.
+- **Per-platform ordering**: AI generates a single set of sections, then assigns `platforms: [{ platform: "web", order: N }, { platform: "mobile", order: N }]` to each block based on content-type heuristics (video-heavy → earlier on mobile, text-heavy → earlier on web).
+
+**Acceptance criteria:**
+
+- [ ] User types a theme → AI streams a response + generates Experience JSON
+- [ ] AI searches video catalog via tool calls and selects relevant videos
+- [ ] User sends follow-up message → AI modifies the experience
+- [ ] Experience JSON matches Strapi's `ExperienceBlocksDynamicZone` format
+
+---
+
+#### Phase 3: UI — Chat + Preview Split Screen (2-3 days)
+
+**Goal**: Polished split-screen UI with chat and live preview.
+
+**Reference designs**: Stitch MCP project `3497475446795838271` — 4 screen variations generated. Screen 1 (sidebar + chat + preview) and Screen 4 (header with Publish button) are the primary references.
+
+**Tasks:**
+
+1. **Layout** — `src/app/page.tsx` (Server Component) + `src/app/studio.tsx` (Client Component)
+ - Split-screen: Chat panel (40% width) | Preview panel (60% width)
+ - Responsive: On mobile, tabs to switch between chat and preview
+ - Header bar: "Seed Studio" branding + "Publish to Strapi" button + "Save Draft" (disabled v1)
+
+2. **Chat panel** — `src/components/chat/`
+ - `ChatPanel.tsx` — Container with message list + input
+ - `ChatMessage.tsx` — User messages (indigo bg, right-aligned) and AI messages (gray bg, left-aligned)
+ - `ChatInput.tsx` — Large text input with send button, placeholder "Describe your experience theme..."
+ - `SuggestionChips.tsx` — Soft-rounded pills below last AI message. Hybrid: fixed chips ("Add more videos", "Include a quiz", "Preview on mobile") + AI-generated contextual suggestions
+ - Streaming indicator: Animated dots while AI is generating
+
+3. **Preview panel** — `src/components/preview/`
+ - `PreviewPanel.tsx` — Container with platform toggle + section list
+ - `PlatformToggle.tsx` — "Web" | "Mobile" tab toggle that re-sorts sections by platform ordering
+ - `SectionCard.tsx` — Generic card wrapper with section type icon + drag handle + edit icon on hover
+ - Section type renderers (simplified card previews, not full web components):
+ - `VideoSectionPreview.tsx` — Video thumbnail + title + subtitle
+ - `VideoHeroPreview.tsx` — Large hero image/thumbnail + heading + CTA
+ - `VideoCarouselPreview.tsx` — Horizontal scroll of video thumbnail cards
+ - `TextSectionPreview.tsx` — Heading + paragraph text
+ - `BibleQuotesPreview.tsx` — Quote card with reference + background
+ - `RelatedQuestionsPreview.tsx` — Expandable Q&A items
+ - `QuizButtonPreview.tsx` — CTA button preview
+ - `ContainerPreview.tsx` — Grid layout with nested content
+ - Drag-to-reorder: Users can drag sections to manually override AI ordering (updates platform-specific order)
+
+4. **Publish flow** — `src/components/publish/`
+ - `PublishButton.tsx` — "Publish to Strapi" button with loading state
+ - `PublishDialog.tsx` — Confirmation dialog showing: experience title, slug (editable), locale, section count
+ - Slug auto-generated from theme, user can edit. If collision detected, append `-2`, `-3`, etc.
+ - Success state: "Published! View in Strapi" link
+ - Error state: Clear message + "Retry" button. If relation patch fails, show warning "Published with incomplete video links — Retry linking"
+
+**Styling:**
+
+- Tailwind v4 with Geist font (from Stitch design system: indigo `#6366f1` primary, neutral `#1e1e2e`)
+- Clean, minimal, generous whitespace
+- Subtle borders and shadows, no heavy visual noise
+
+**Acceptance criteria:**
+
+- [ ] Split-screen layout renders correctly on desktop
+- [ ] Chat messages stream in real-time
+- [ ] Suggestion chips are clickable and inject into chat
+- [ ] Preview updates as AI generates sections
+- [ ] Platform toggle re-sorts sections by web/mobile ordering
+- [ ] Publish flow creates Experience in Strapi with success/error feedback
+
+---
+
+#### Phase 4: Polish + Deployment (1-2 days)
+
+**Goal**: Production-ready deployment on Railway.
+
+**Tasks:**
+
+1. **Error handling**
+ - Strapi unreachable: "Cannot connect to CMS. Please try again later."
+ - AI timeout: "AI took too long. Try a simpler theme or retry."
+ - Video search returns 0 results: AI responds in chat suggesting broader theme or manual video selection
+ - Publish validation: Check all required fields before calling Strapi
+ - Partial publish failure (relation patch fails): Show warning + retry button
+
+2. **Auth (simple v1)**
+ - Basic auth middleware (`src/middleware.ts`) with shared password from env var `SEED_STUDIO_PASSWORD`
+ - Simple login page with password input
+ - Session cookie (httpOnly, secure) lasting 24 hours
+
+3. **Railway deployment**
+ - `railway.toml` in `apps/seed-studio/`
+ - Do NOT use `output: "standalone"` (see `docs/solutions/deployment/nextjs-pnpm-monorepo-railway-standalone.md`)
+ - Set `HOSTNAME=0.0.0.0` via Railway CLI, not toml
+ - Pin pnpm version in build command
+ - Env vars: `STRAPI_URL`, `STRAPI_SEED_STUDIO_TOKEN`, `ANTHROPIC_API_KEY`, `SEED_STUDIO_PASSWORD`
+
+4. **Smoke tests**
+ - Manual test: create an experience via chat → verify in Strapi admin → verify renders in `apps/web`
+ - Verify video relations are populated on all video section types
+
+**Acceptance criteria:**
+
+- [ ] App deploys to Railway and is accessible behind Cloudflare
+- [ ] Basic auth prevents unauthorized access
+- [ ] Full end-to-end flow: chat → generate → preview → publish → visible in Strapi
+
+## Scope Decisions for v1
+
+These decisions narrow the v1 scope based on SpecFlow analysis findings (see origin):
+
+| Decision | v1 Scope | Future |
+| ------------------------- | ------------------------------------------------ | --------------------------------- |
+| Edit existing experiences | Create-only. "Edit in Strapi" link after publish | v2: load + edit |
+| Locale | English (`en`) only | v2: locale selector |
+| Section types | 8 types from R8 | Add more as content team requests |
+| Conversation persistence | Ephemeral sessions | v2: save/resume conversations |
+| Undo/redo | No undo. User can re-describe in chat | v2: version history |
+| Preview fidelity | Simplified card-based previews | v2: import web components |
+| Draft workflow | No drafts. Publish or abandon | v2: save as Strapi draft |
+| Video stream validation | Check `published_at IS NOT NULL` only | v2: validate Mux playback |
+
+## Per-Platform Ordering — Schema Approach
+
+**Decision**: Store ordering as a JSON field on the Experience itself, NOT as component-level attributes.
+
+```typescript
+// New field on Experience content type:
+// platformOrdering: JSON
+// Value:
+{
+ "web": [0, 2, 1, 3, 4, 5], // indices into blocks array
+ "mobile": [1, 0, 3, 2, 5, 4] // indices into blocks array
+}
+```
+
+**Rationale**:
+
+- No schema change to existing section components (avoids breaking GraphQL contract)
+- No changes needed in `apps/web` or `apps/mobile-v2` consumers until they opt-in to reading this field
+- Backward compatible: existing experiences have `null` → consumers use default blocks array order
+- Single field addition to Experience content type via Strapi admin
+- Consumer apps read `platformOrdering.web` or `platformOrdering.mobile` and sort `blocks` accordingly
+
+**Note**: This differs from the brainstorm decision of "platform tag on each section block" — the JSON field approach was chosen to avoid breaking the existing GraphQL schema contract. The end result is equivalent: each platform has its own section ordering.
+
+## System-Wide Impact
+
+### Interaction Graph
+
+- Seed Studio → Strapi REST API (create Experience, search videos)
+- Seed Studio → Claude API (generate content)
+- New Strapi endpoints → Document Service → PostgreSQL (create Experience + blocks)
+- New Strapi endpoints → Knex (patchNestedVideoRelations for link table rows)
+- `apps/web` reads Experience via GraphQL (unchanged, optionally reads `platformOrdering`)
+- `apps/mobile-v2` reads Experience via GraphQL (unchanged, optionally reads `platformOrdering`)
+
+### Error Propagation
+
+- AI API failure → Server Action catches → returns error message to chat UI
+- Strapi API failure → Server Action catches → shows user-friendly error in publish dialog
+- Relation patch failure → Strapi endpoint catches → returns partial success status → UI shows warning + retry
+- Video search failure → AI receives empty results → AI asks user to try different keywords
+
+### State Lifecycle Risks
+
+- **Partial publish**: `create()` succeeds but `patchNestedVideoRelations()` fails → Experience exists with broken video links. Mitigation: the custom endpoint wraps both in a try/catch and returns `{ created: true, relationsPatched: false }`. UI shows retry button.
+- **Orphaned experiences**: User publishes, then wants to redo. No auto-cleanup. Mitigation: v1 shows "Edit in Strapi" link; v2 adds update/delete.
+
+### API Surface Parity
+
+- New Strapi endpoints: `POST /api/seed-studio/search-videos`, `POST /api/seed-studio/publish-experience`, `GET /api/seed-studio/video-catalog-stats`
+- No changes to existing GraphQL schema (Experience type gains one optional JSON field)
+- `apps/web` and `apps/mobile-v2` are NOT modified in this feature
+
+## Acceptance Criteria
+
+### Functional Requirements
+
+- [ ] Content team member can type a theme and receive a full AI-generated experience
+- [ ] AI selects videos from Strapi catalog that match the theme
+- [ ] Live preview shows all generated sections with correct section type rendering
+- [ ] Web/Mobile toggle shows different section orderings
+- [ ] User can iteratively refine via chat ("swap video", "add quiz", "reorder")
+- [ ] Suggestion chips provide context-aware quick actions
+- [ ] Publish creates a valid Experience in Strapi with all video relations populated
+- [ ] Published experience renders correctly in `apps/web`
+
+### Non-Functional Requirements
+
+- [ ] AI generation completes within 30 seconds for a typical 8-section experience
+- [ ] Simple password auth prevents unauthorized access
+- [ ] App deploys to Railway as a separate service
+
+### Quality Gates
+
+- [ ] TypeScript strict mode, no `any`
+- [ ] All Strapi API calls have error handling with user-friendly messages
+- [ ] Publish flow validates Experience structure before calling Strapi
+
+## Dependencies & Prerequisites
+
+1. **Strapi running locally** with video data imported (`pnpm data-import`)
+2. **Anthropic API key** for Claude access
+3. **`platformOrdering` JSON field** added to Experience content type in Strapi admin
+4. **New Strapi endpoints** (Phase 1) must be built before the AI engine (Phase 2)
+
+## Risk Analysis & Mitigation
+
+| Risk | Likelihood | Impact | Mitigation |
+| -------------------------------------------- | ---------- | ------ | ----------------------------------------------------------------- |
+| Video catalog metadata too sparse for AI | Medium | High | Start with tag-based search; add pgvector embeddings if needed |
+| Strapi nested relation bug affects publish | High | High | Custom endpoint runs `patchNestedVideoRelations` server-side |
+| AI generates poor content for niche themes | Medium | Medium | Iterative refinement via chat; user always reviews before publish |
+| Per-platform ordering adds schema complexity | Low | Medium | JSON field on Experience avoids component-level changes |
+
+## Sources & References
+
+### Origin
+
+- **Origin document:** [docs/brainstorms/2026-04-09-seed-studio-requirements.md](docs/brainstorms/2026-04-09-seed-studio-requirements.md) — Key decisions: chat-first UX, AI generates everything, direct Strapi publish, standalone app, per-platform ordering via platform tags
+
+### Internal References
+
+- Seed script patterns: `apps/cms/src/bootstrap/seed-easter.ts`, `seed-christmas.ts`, `seed-utils.ts`
+- Experience schema: `apps/cms/schema.graphql:1584`
+- Next.js app conventions: `apps/web/package.json`, `apps/web/tsconfig.json`
+- Video relation workaround: `docs/solutions/integration-issues/strapi-v5-nested-component-relation-ids-2026-03-31.md`
+- Endpoint pattern: `docs/solutions/runtime-errors/cms-easter-seed-not-called-2026-03-30.md`
+- Railway deployment: `docs/solutions/deployment/nextjs-pnpm-monorepo-railway-standalone.md`
+- pgvector search: `docs/solutions/best-practices/pgvector-recommendation-query-locale-graphql-strapi-v5.md`
+- GraphQL relation truncation: `docs/solutions/performance-issues/strapi-nested-relation-truncation-and-n-plus-one-manager-20260328.md`
+
+### Design References
+
+- Stitch MCP project: `3497475446795838271` — 4 desktop screen variations
+- Design system: Geist font, indigo `#6366f1` primary, `ROUND_TWELVE` corners, light mode
+
+### Stitch Screen Designs
+
+| Screen | Title | Description |
+| --------------------- | ------------------ | --------------------------------------------------------------------------- |
+| Screen 1 (`4fa4ce71`) | Seed Studio Editor | Sidebar + chat + preview with Easter content. Best for navigation structure |
+| Screen 2 (`9cbc8b8d`) | Seed Studio Editor | Chat above + article-style preview below |
+| Screen 3 (`3070eafc`) | AI Studio Chat | Clean split-screen with suggestion chips |
+| Screen 4 (`b5e200b2`) | AI Studio Chat | Split-screen with "Publish to Strapi" header button. Best for publish flow |
diff --git a/docs/plans/2026-04-22-001-feat-seed-studio-watch-parity-plan.md b/docs/plans/2026-04-22-001-feat-seed-studio-watch-parity-plan.md
new file mode 100644
index 000000000..a12e01e9f
--- /dev/null
+++ b/docs/plans/2026-04-22-001-feat-seed-studio-watch-parity-plan.md
@@ -0,0 +1,489 @@
+---
+title: "feat: seed-studio generator matches /watch/ visual quality"
+type: feat
+status: active
+date: 2026-04-22
+---
+
+# Seed Studio → /watch Parity Generator
+
+## Enhancement Summary
+
+**Deepened on:** 2026-04-22
+**Sections enhanced:** architecture, pipeline shape, performance, security, unit list
+**Review agents used:** architecture-strategist, code-simplicity-reviewer, performance-oracle, security-sentinel, framework-docs-researcher (OpenRouter/SSE/fan-out), repo explorer (semantic-search), learnings-researcher
+
+### Key Improvements From Deepening
+
+1. **Pipeline simplified:** single strict-JSON-Schema call against the fixed template replaces the proposed two-stage plan+fill. Template rigidity (not orchestration complexity) is doing the work. Fan-out is retained only as a measured fallback if the single-shot fails quality benchmarks.
+2. **Shared template package:** `packages/experience-templates/` becomes the single source of truth for the Section/Archetype types, the `EASTER_SHAPED_TEMPLATE` constant, the `backgroundColor` enum, and the alias map. `apps/web`, `apps/seed-studio`, and future `apps/mobile` consume from it.
+3. **No new search endpoint:** the existing `GET /api/search` (already rate-limited) is consumed directly by seed-studio's server code; a single semantic call replaces the per-keyword loop in `extractKeywords`.
+4. **SSE collapsed to a single `patch` event:** `{ path, value }` tuple applied to a single `experienceSnapshot` atom — gives progressive reveal without a per-feature event-type explosion.
+5. **Parity check becomes a CLI script** (`apps/seed-studio/scripts/parity-check.ts`) plus the solutions doc — dev tooling, not product UI.
+6. **Performance fixes baked in:**
+ - Lazy-init HLS with `` and IntersectionObserver; cap 2 concurrent players via LRU-destroy; autoplay only for VideoHero.
+ - Batched `patchNestedVideoRelations` with a single `UPDATE … FROM (VALUES …)` inside a Strapi transaction (closes TOCTOU and collapses N round-trips).
+ - SSE heartbeat every 15s to survive Railway gateway idle-timeouts.
+ - `p-limit(4)` on any fan-out path; 6s per-fill timeout + boilerplate fallback.
+7. **Security fixes baked in:**
+ - `streamClaude` switched from `-p` flag to stdin (removes flag-injection surface).
+ - `import "server-only"` on generator module + ESLint rule forbidding `OPENROUTER` in `components/`.
+ - Central `sanitizeSlug(input)` with reserved-word deny-list, applied at every slug boundary.
+ - Moderation pass on generated `heading`/`contentParagraphs`/`bibleQuote.text` before public publish (or gate behind a Draft state).
+ - Constant-time `X-Seed-Studio-Token` comparison; scrub from URLs/logs.
+ - Per-IP/per-day cost counter replaces the per-hour rate limiter (theatre).
+
+### New Considerations Discovered
+
+- HLS player memory footprint at 20 videos/page (~500MB) is the biggest single risk and would be unreleasable without the lazy-init + LRU cap above.
+- `streamClaude` spawns `docker exec claude -p "$prompt"` — the plan amplifies an existing flag-injection surface. Fix gates this feature.
+- Strapi v5 has a per-connection GUC pattern (`config/database.ts` afterCreate) already in place for pgvector; we don't need to wrap every query in a transaction unless we later add `SET LOCAL hnsw.ef_search` tuning.
+- `/api/search` already exists with rate limiting (`/Users/up/Projects/forge/apps/cms/src/api/search/routes/search.ts:5`). No new search endpoint.
+
+---
+
+## Overview
+
+Redesign `apps/seed-studio`'s AI experience generator so that its published output on `/watch/` renders with the same visual richness and layout fidelity as the hand-crafted `/watch/easter` reference. Today the generator emits a flat list of sections (`sections.video-hero`, `sections.text`, `sections.video`, `sections.bible-quotes-carousel`, etc.); `/watch/easter` instead uses a 13-block template dominated by `ComponentSectionsSection` wrappers, each containing a nested `content[]` dynamic zone (Video + Container-with-BibleQuotes + QuizButton), interspersed with `ComponentSectionsVideoCarousel` and `ComponentSectionsMediaCollection`. The seed-studio prompt, schema, parser, preview, and publish path all lack `sections.section`, `backgroundColor`, `sectionKey` enforcement, nested relation patching, and semantic video ranking. This plan closes those gaps with a **single strict-JSON-Schema call against a fixed code template** plus **semantic video pre-ranking**.
+
+The result: a user types "forgiveness" in the studio chat, sees a skeleton within ~2s and the full preview within ~10s, clicks Save to Strapi, then Preview, and lands on `http://localhost:3000/watch/forgiveness` where the hero plays, every video section has a functioning Mux player, BibleQuotes carry the chosen scripture with backgrounds, and Container/MediaCollection/NavigationCarousel render correctly — matching the shape of `/watch/easter`.
+
+## Problem Statement
+
+The current seed-studio generator diverges from the production `/watch` templates in four load-bearing ways:
+
+1. **Structural mismatch.** `ComponentSectionsSection` (the wrapper with `backgroundColor`, `sectionKey`, and a nested `content` dynamic zone) is the primary visual unit on `/watch/easter` (12 of 13 blocks). Seed-studio's schema has no wrapper type at all. Generated experiences render as visually flat pages lacking the alternating background tones and chapter-like anchoring that define the easter experience. Preview SectionRenderer has no case for `sections.section` and returns `null`.
+
+2. **Component coverage gap.** Production pages use `MediaCollection` (variant: collection/grid/hero/carousel/player), `NavigationCarousel` (chapter anchor links), `CTA`, `PromoBanner`, `InfoBlocks`, `Card`. Seed-studio's prompt doesn't mention them, the TypeScript schema doesn't define them, and the preview SectionRenderer doesn't render them. The AI is simply unable to produce them.
+
+3. **Weak model contract.** The prompt hands the model a free-form code-block (` ```experience ... ``` `) and hopes for valid JSON. There is no JSON Schema, no retry, and no enum constraint on `video` IDs — so the LLM routinely (a) omits `sectionKey` (which breaks `patchNestedVideoRelations` on save, leaving `video: null` on `ComponentSectionsVideo` entries even after streamingUrl is set), (b) makes up videoIds not in the catalog, and (c) writes thin copy because the schema doesn't specify minimum lengths or counts.
+
+4. **Keyword-only video search.** `searchVideos` in `apps/cms/src/api/seed-studio/services/seed-studio.ts:48` uses PostgreSQL `ILIKE` on title/description/slug. A query for "forgiveness" misses clearly relevant content like "Talk with Nicodemus" unless the word literally appears in the title. The forge codebase already runs a hybrid `semanticSearch` service (RRF-fused pgvector + FTS) at `GET /api/search` (`apps/cms/src/api/search/routes/search.ts:5`) that produces dramatically better candidate ranking — but seed-studio doesn't call it.
+
+The compounded effect: users generate an experience, publish it, open the preview, and see a visually thin page where the videos either don't play (missing relation → `useRouteVideo: false` and `video: null` → web `Video.tsx` logs "Missing streaming URL") or are weakly-matched to the theme. The studio output is demonstrably inferior to `/watch/easter`.
+
+## Proposed Solution
+
+Adopt a **template-slot architecture** plus a **single strict-JSON-Schema call** plus **hybrid semantic video pre-ranking**:
+
+1. **Fixed template skeleton in a shared package.** Introduce `packages/experience-templates/` exporting a typed `EASTER_SHAPED_TEMPLATE` constant that describes the exact layout (1 VideoHero + N×Section wrappers, each Section predeclaring its nested content recipe). The LLM never sees or generates the structural outline — it only fills `{title, heading, contentParagraphs, videoId, bibleQuote, Q&A}` into named slots. Same package exports the `SectionBlock` discriminated union, `backgroundColor` enum, alias map, and a pure `parityDiff(expected, actual)` helper. Both `apps/web` (for a future generic renderer) and `apps/seed-studio` (generator + preview + CLI parity check) consume from this package.
+
+2. **Single LLM call, strict JSON Schema, enum-constrained videoIds.** The request body builds a full schema where every `videoId` slot is `{ type: "integer", enum: [candidateIds…] }` — mathematically eliminates hallucination. One call per generation. OpenRouter `response_format.type: "json_schema"`, `strict: true`, `provider.require_parameters: true`. One retry with Zod-error feedback (~1.3–1.8× token cost, 85–95% recovery on enum/type violations per 2026 industry data). Legacy free-form path stays for Ollama/Codex/Exo but with the same sanitization layer around generated text.
+
+3. **Semantic video pre-ranking via existing `/api/search`.** Before the LLM call, seed-studio hits the existing `GET /api/search?q=&locale=en&type=video&limit=20` endpoint (already rate-limited, no new endpoint needed). Gets a top-20 RRF-fused candidate list with similarity scores. Partitions candidates per Section in the generator based on archetype (carousel gets top-5, each video-centric gets a bucket of 3–5). Embeds the user query once (~$0.0000004, 120–240ms total including FTS).
+
+4. **Section wrapper + missing-component rendering parity.** The shared package's types include `SectionWrapper`, `MediaCollection`, `NavigationCarousel`, `Card`, `CTA`, `InfoBlocks`, `PromoBanner`. Preview components for each. `use-chat.ts` parser normalizes aliases (or drops them once strict mode is the only path). Server-side publisher's `collectVideoRelations` walker in `apps/cms/src/api/seed-studio/services/seed-studio.ts:199` gets unit-tested against the real `/watch/easter` GraphQL payload (it already recurses into any array-valued `__component`-bearing property; just needs proof). A batched `UPDATE … FROM (VALUES …)` replaces per-row UPDATEs.
+
+5. **Progressive UX via a single `patch` SSE event.** One event type: `{ path: string[], value: unknown }`. The generator emits patches as it produces the tree (skeleton first, then fills). Client applies patches to a single `experienceSnapshot` atom. Heartbeat comment (`: ping\n\n`) every 15s keeps Railway's gateway happy. Avoids proliferating `plan`/`section-filled`/`done`/`chunk` events into a protocol.
+
+6. **Dev-tooling parity check as a CLI script.** `apps/seed-studio/scripts/parity-check.ts` fetches the published experience from Strapi GraphQL, runs `parityDiff(EASTER_SHAPED_TEMPLATE, experience)` from the shared package, and prints a structural diff. Not shipped into the React app. Used during implementation to close the prompt-tuning loop.
+
+## Technical Approach
+
+### Architecture
+
+```
+User chat query
+ ▼
+/api/chat (apps/seed-studio server route)
+ │
+ ├─► GET /api/search?q=&locale=en&type=video (cms, already exists)
+ │ └─► pgvector HNSW + FTS RRF fusion → top-20 candidates with scores
+ │
+ ├─► Build strict JSON Schema from EASTER_SHAPED_TEMPLATE
+ │ where every videoId slot is enum([ids from top-20 partitioned per section])
+ │
+ ├─► POST https://openrouter.ai/api/v1/chat/completions
+ │ response_format: { type: "json_schema", strict: true, ... }
+ │ provider: { require_parameters: true }
+ │ signal: AbortSignal.any([req.signal, timeout(25000)])
+ │ (1 retry with Zod error feedback on 5xx / schema failure)
+ │
+ └─► SSE stream
+ {patch: ["skeleton"], value: templateSkeleton} after search
+ {patch: ["experience"], value: filledExperience} after LLM returns
+ heartbeats every 15s (`: ping\n\n` comments)
+
+Preview renders via shared SectionRenderer (progressive merge via JSON Patch)
+
+Save → Strapi /api/seed-studio/publish-experience (unchanged external contract)
+ └─► DB TRANSACTION:
+ create Experience via Document Service
+ collectVideoRelations (recursive walker; proven by unit tests)
+ single batched SQL: UPDATE components_sections_videos
+ SET video_id = v.video_id
+ FROM (VALUES (id1, vid1), (id2, vid2), ...) AS v(comp_id, video_id)
+ WHERE id = v.comp_id
+ └─► moderate generated text (OpenAI mod endpoint) → on flag, publish as Draft
+```
+
+### Template Shape (derived from `/watch/easter`)
+
+The easter reference is 1 VideoHero + 12 Sections, where Sections follow three archetypes:
+
+- **Introduction** (1×): content = `[NavigationCarousel, Container, Video, Container, BibleQuotesCarousel, QuizButton]`
+- **Video-centric** (8× recurring): content = `[Video, Container (with BibleQuotes inside a slot), QuizButton]`
+- **Carousel / Collection** (3× interspersed): content = `[VideoCarousel]` or `[MediaCollection]`
+
+Our V1 template generalizes to: 1 VideoHero + 1 Introduction Section + 2–3 Carousel/Collection Sections + 4–6 Video-centric Sections. Count flexes based on how many distinct video candidates `/api/search` returns (floor 4 video-centrics, ceil 6). `platformOrdering` is computed deterministically from the generated order. **Editors can override `platformOrdering` in Strapi admin** and republish preserves overrides unless the user explicitly regenerates (tracked via a `generatedAt` field on the Experience).
+
+### Implementation Phases
+
+#### Phase 1: Shared package + schema + preview coverage
+
+- Create `packages/experience-templates/` with: `types.ts` (SectionBlock union, SectionWrapper, MediaCollection, NavigationCarousel, Card, CTA, InfoBlocks, PromoBanner, AdventCountdown, EasterDates, backgroundColor enum), `template.ts` (EASTER_SHAPED_TEMPLATE + ARCHETYPES constants), `aliases.ts` (COMPONENT_ALIASES), `parity.ts` (pure structural diff helper).
+- Update `apps/seed-studio/package.json` to depend on the new package.
+- Migrate seed-studio's `experience-schema.ts` to re-export from the package.
+- Extend `SectionRenderer.tsx` in preview to render `sections.section` (recursive into `content`), `sections.media-collection`, `sections.navigation-carousel`, `sections.cta`, and the other missing types. Mirror styling tokens from `apps/web/src/components/sections/*.tsx` so preview matches web.
+- Update `use-chat.ts` to: normalize `section` / `wrapper` aliases to `sections.section`; backfill `sectionKey` deterministically from position (`--`); Zod-validate the final experience; emit inline error on failure.
+
+**Success criteria:** The `/watch/easter` GraphQL payload, pasted into the studio preview as-is, renders identically in shape (block count, nesting, sectionKeys preserved) to what `/watch/easter` produces on the web.
+
+#### Phase 2: Batched + transactional publish + walker proof
+
+- Unit-test `collectVideoRelations` against the real `/watch/easter` GraphQL fixture; assert every `sections.video` is reachable. Add a warning branch when any is missing `sectionKey`.
+- Rewrite `publishExperience` to run create + patch in a Strapi DB transaction; replace per-row UPDATEs with one `UPDATE … FROM (VALUES …)` against `components_sections_videos_video_lnk` (or the direct FK column — verify via `\d` against the dev DB).
+- Add central `sanitizeSlug(input)` util that applies `/^[a-z0-9]+(?:-[a-z0-9]+)*$/`, clamps to 2–80 chars, enforces a reserved-word deny-list (`admin`, `api`, `watch`, `_next`, `.well-known`). Apply at: studio client, `publishExperience` controller, any future endpoint that accepts slug.
+- Audit `apps/web/src/lib/fragments/*.ts` for nested-relation pagination (Strapi v5 truncates to 10 silently); add explicit `pagination: { pageSize: 100 }` on every nested zone.
+
+**Success criteria:** A manually-crafted 10-Section experience with 1 video-relation per Section publishes cleanly; GraphQL confirms every `ComponentSectionsVideo.video` is populated; EXPLAIN ANALYZE shows one UPDATE statement.
+
+#### Phase 3: Single-shot strict-schema generator
+
+- Add capability flag in `apps/seed-studio/src/lib/ai/providers.ts`: `supportsStrictJsonSchema: boolean`.
+- Create `apps/seed-studio/src/lib/ai/generator.server.ts` exporting `generateExperience({ query, candidates, provider, model, signal })`. Top of file: `import "server-only"`.
+- Build JSON Schema dynamically from the template constant + per-section `videoId` enums from partitioned candidates.
+- Request: OpenRouter `response_format: { type: "json_schema", json_schema: { name: "experience", strict: true, schema } }`, `provider.require_parameters: true`, `AbortSignal.any([req.signal, AbortSignal.timeout(25000)])`.
+- One Zod-feedback retry on schema failure or 5xx.
+- Rewrite `/api/chat/route.ts` to:
+ 1. `extractKeywords` → combine top-3 into a single query string (simple whitespace join is fine).
+ 2. Call `GET http://localhost:1337/api/search?q=&locale=en&type=video&limit=20` with `X-Seed-Studio-Token` header (or confirm `/api/search` allows anon from localhost).
+ 3. Emit SSE `patch: ["skeleton"]` with the empty template.
+ 4. Call `generateExperience` with candidates.
+ 5. Emit SSE `patch: ["experience"]` with the filled tree.
+ 6. Heartbeat comment every 15s between the search and the LLM response.
+- Switch the `streamClaude` fallback from `-p ` to `--input-format text` + `proc.stdin.write(prompt)` + `proc.stdin.end()` (removes flag-injection surface).
+- ESLint rule: forbid `process.env.OPENROUTER` in any file under `apps/seed-studio/src/components/`.
+
+**Success criteria:** For 3 canonical themes (forgiveness, prayer, easter-new), the generator returns schema-valid output with 100% of `videoId` fields resolved to catalog IDs. p50 < 10s, p95 < 20s, $ p50 < $0.025.
+
+#### Phase 4: Moderation + progressive SSE + CLI parity check
+
+- Add a moderation pass in `publishExperience`: flatten generated text fields (`heading`, `contentParagraphs`, `bibleQuote.text`, `questions`), run OpenAI Moderation endpoint (or OpenRouter equivalent), on flag: publish as Draft (`publishedAt = null`) and surface a warning in the save dialog.
+- Single SSE event `patch` carrying `{ path, value }` tuple; client applies via a small patch util (20 lines).
+- `apps/seed-studio/scripts/parity-check.ts`: CLI that takes a slug, fetches the published experience via GraphQL, runs `parityDiff` from the shared package, prints the block-count / nesting / missing-video diff. Exits non-zero on structural mismatch.
+- Write `docs/solutions/best-practices/seed-studio-watch-parity-generator-20260422.md` with template shape, prompt patterns, and pitfalls discovered.
+
+**Success criteria:** Three green parity-check runs for the canonical themes, captured as terminal output in the PR.
+
+#### Phase 5 (optional, contingent on measurement): Fan-out fill
+
+If Phase 3 measurement shows single-shot quality is inadequate (e.g., >20% of sections have thin copy or `contentParagraphs.length < 2`), add a plan + fill fan-out as a fallback path. Guardrails:
+
+- Bounded `p-limit(4)` over Fills; 6s per-Fill timeout with boilerplate fallback.
+- Shared `AbortSignal` from the outer request.
+- Only enabled for providers with `supportsStrictJsonSchema`.
+
+Fan-out is tested but not on the critical path unless measurement says so.
+
+## System-Wide Impact
+
+### Interaction Graph
+
+```
+Studio UI (Next.js client)
+ └─► useChat hook → POST /api/chat
+ └─► apps/seed-studio route.ts
+ ├─► GET /api/search?q=…&locale=en&type=video (cms, existing)
+ │ └─► OpenRouter embed (text-embedding-3-small, $0.0000004/call)
+ │ └─► pgvector HNSW (partial index per locale) + FTS
+ │ └─► RRF fusion + 3-layer dedup (core_id prefix + title + sim >0.95)
+ ├─► POST openrouter.ai/v1/chat/completions (strict json_schema)
+ │ └─► 1 Zod-feedback retry on schema failure
+ └─► SSE stream: patch events + 15s heartbeat
+
+Publish (unchanged external contract):
+ └─► POST /api/seed-studio/publish-experience (X-Seed-Studio-Token)
+ ├─► sanitizeSlug + reserved-word deny-list
+ ├─► moderation pass on flattened text fields
+ ├─► DB TRANSACTION:
+ │ delete existing published Experience with same slug
+ │ create via Document Service
+ │ collectVideoRelations walker (proven recursive)
+ │ single batched UPDATE … FROM (VALUES …)
+ └─► on moderation flag: publishedAt = null + "draft" warning in response
+```
+
+### Error Propagation
+
+- `/api/search` returns 0 candidates → fail-fast with SSE `patch: ["error"]` ({code: "NO_CANDIDATES", message: "No matching videos"}). Studio shows inline retry.
+- OpenRouter fails (timeout, 5xx, schema mismatch after 1 retry) → SSE error; studio shows retry card.
+- Moderation flag → publish succeeds as Draft; save dialog shows "Saved as draft (content flagged for review)" with a link to Strapi admin.
+- Publish fails (Strapi validation) → detailed field-path messages (already implemented earlier in this session).
+- Slug collision → Save returns 409 with `{ error, suggestions: ["forgiveness-2", "forgiveness-3"] }`; save dialog renders suggestions inline. No separate pre-check endpoint.
+- Aborted request (user clicks "New chat") → upstream `AbortController` propagates via `AbortSignal.any`; all in-flight fetches cancel; spawned docker subprocesses receive `SIGTERM`.
+
+### State Lifecycle Risks
+
+- **Stream interruption:** `AbortController` shared with the single OpenRouter fetch + the `/api/search` fetch. No fan-out in V1 → no per-Fill leaks.
+- **Partial assembly:** never possible with single-shot. Save button only appears after the LLM response is fully parsed.
+- **Cost overrun:** per-IP daily counter (server-side) caps at $1/day/IP; global OpenRouter account cap at $10/day. Guardrails replace the per-hour rate-limit theatre.
+- **TOCTOU create+patch:** closed by wrapping both in a Strapi DB transaction in Phase 2.
+- **Per-connection pgvector GUCs:** already set in `apps/cms/config/database.ts` afterCreate. Don't introduce `SET LOCAL hnsw.ef_search` without a transaction wrapper.
+
+### API Surface Parity
+
+- **No new endpoints.** `/api/search` already exists with the right shape and rate limiting.
+- **Internal-only:** the SSE event shape changes inside `/api/chat`, consumed only by `use-chat.ts`.
+- **Unchanged:** `/api/seed-studio/publish-experience`, `/api/seed-studio/search-videos` (kept for back-compat; may remove after migration).
+
+### Integration Test Scenarios
+
+1. **Zero-candidate theme:** Query "xyzzy123"; `/api/search` returns 0; SSE `patch: ["error"]`; UI shows retry.
+2. **Slug collision:** Type "easter" as slug; publish endpoint returns 409 with suggestions; dialog shows "Slug taken. Try: easter-2, easter-3" inline; user accepts.
+3. **Stale video candidate:** LLM picks videoId=55; by moderation pass video #55 is unpublished. Save delete+create runs in a transaction; on failure, full rollback; user sees transactional error.
+4. **Nested videoId:** Template places a `sections.video` inside a Container inside a Section; after publish, GraphQL confirms the video relation is populated (not null) — proves walker + batched UPDATE work together.
+5. **Moderation flag:** Adversarial theme returns content that Moderation flags; publish succeeds as Draft; save dialog shows warning; Strapi admin shows the experience with `publishedAt = null`.
+6. **Heartbeat survival:** Simulate 20s of network idle between SSE events; Railway gateway doesn't close the connection (proven by `: ping\n\n` comment every 15s).
+7. **Abort propagation:** User clicks "New chat" while LLM call is in flight; all fetches receive `AbortError`; no stale state in the studio UI.
+
+## Acceptance Criteria
+
+### Functional Requirements
+
+- [ ] Generated experiences include `ComponentSectionsSection` wrappers with `backgroundColor` and `sectionKey`.
+- [ ] Nested dynamic zones (Section → content) render correctly in both the studio preview and `/watch/`.
+- [ ] Video sections, carousels, and MediaCollections render with functional Mux HLS playback **lazy-inited** (click-to-play for non-hero; VideoHero autoplays muted on viewport).
+- [ ] At most 2 concurrent HLS players instantiated at any time (LRU-destroy on off-viewport).
+- [ ] BibleQuotes render with `reference`, `text`, `imageUrl`, `backgroundColor`; RelatedQuestions render with Q&A pairs.
+- [ ] NavigationCarousel anchor-links scroll to the corresponding `sections.section` by `sectionKey`.
+- [ ] Single LLM call latency p50 < 10s, p95 < 20s on OpenRouter gpt-4o-mini; cost p50 < $0.025.
+- [ ] Every `videoId` in the generated experience is present in the CMS catalog (strict JSON Schema enum).
+- [ ] Every `ComponentSectionsVideo` after publish has its `video` relation populated (recursive walker + batched UPDATE proven).
+- [ ] Save returns 409 with suggestions on slug collision; dialog renders suggestions inline.
+- [ ] Content flagged by Moderation is published as Draft, never public.
+- [ ] Preview uses a single `patch` SSE event type.
+- [ ] Legacy Ollama/Codex/Exo path still works via the free-form fallback with the same sanitization.
+
+### Non-Functional Requirements
+
+- [ ] **Memory:** ≤120MB per preview/watch page at steady state with ≤3 active HLS instances.
+- [ ] **Cost:** p50 < $0.025/generation; global hard cap $10/day via OpenRouter; per-IP cap $1/day via server-side counter.
+- [ ] **Accessibility:** Every video element has `aria-label` including title; BibleQuote background images have derived alt text.
+- [ ] **Security:**
+ - Slug sanitized (`/^[a-z0-9]+(?:-[a-z0-9]+)*$/`, 2–80 chars, reserved-word deny-list) at every boundary.
+ - `streamClaude` prompt passed via stdin, not `-p` flag.
+ - `OPENROUTER_API_KEY` access gated by `import "server-only"` + ESLint rule against `components/` references.
+ - `X-Seed-Studio-Token` compared with `crypto.timingSafeEqual`.
+ - Moderation pass before public publish; flag → Draft.
+ - Zod error messages never echo user-entered query (per `docs/solutions/security-issues/zod-validation-errors-must-not-echo-user-controlled-input-20260420.md`).
+- [ ] **Observability:** Per-IP daily $ counter; LLM latency + cost logged per generation; moderation flag rate tracked.
+
+### Quality Gates
+
+- [ ] Unit tests: parityDiff helper, recursive walker against easter fixture, sanitizeSlug, Zod schemas, dynamic-enum schema builder.
+- [ ] Integration smoke: three canonical themes (forgiveness, prayer, easter-new) publish cleanly and the CLI parity check reports zero structural mismatches.
+- [ ] Typecheck + lint clean across `apps/seed-studio`, `apps/cms`, `apps/web`, `packages/experience-templates`.
+- [ ] `docs/solutions/best-practices/seed-studio-watch-parity-generator-20260422.md` committed.
+
+## Implementation Units (collapsed to 4 from 8)
+
+### Unit 1 — Shared package + schema + preview coverage
+
+**Goal:** Single source of truth for types/template; studio can render anything `/watch/easter` renders.
+
+**Files:**
+
+- Create: `packages/experience-templates/package.json` + `tsconfig.json`
+- Create: `packages/experience-templates/src/types.ts` — `SectionBlock`, `SectionWrapper`, `MediaCollection`, `NavigationCarousel`, `Card`, `CTA`, `InfoBlocks`, `PromoBanner`, `AdventCountdown`, `EasterDates`, `backgroundColor` enum
+- Create: `packages/experience-templates/src/template.ts` — `EASTER_SHAPED_TEMPLATE`, `ARCHETYPES`
+- Create: `packages/experience-templates/src/aliases.ts` — `COMPONENT_ALIASES`
+- Create: `packages/experience-templates/src/parity.ts` — `parityDiff(expected, actual): DiffReport`
+- Modify: `apps/seed-studio/package.json` — add `"@forge/experience-templates": "workspace:*"`
+- Modify: `apps/seed-studio/src/lib/ai/experience-schema.ts` — re-export from package
+- Modify: `apps/seed-studio/src/components/preview/SectionRenderer.tsx` — dispatcher for new types
+- Create: preview components: `SectionWrapperPreview.tsx`, `MediaCollectionPreview.tsx`, `NavigationCarouselPreview.tsx`, `CtaPreview.tsx`
+- Modify: `apps/seed-studio/src/lib/chat/use-chat.ts` — import aliases from package, Zod validate, backfill `sectionKey`
+
+**Tests:**
+
+- Package tests: `parityDiff` with identical trees = empty diff; with missing block = reported; with wrong archetype = reported.
+- Snapshot: `/watch/easter` GraphQL payload through `SectionRenderer` produces no "unknown \_\_component" warnings.
+- `use-chat.ts` test: output missing `sectionKey` on video block → deterministic backfill.
+
+### Unit 2 — Batched + transactional publish + sanitizeSlug + walker proof
+
+**Goal:** Bulletproof the CMS save path.
+
+**Files:**
+
+- Modify: `apps/cms/src/api/seed-studio/services/seed-studio.ts` — wrap create+patch in a transaction, replace N UPDATEs with one `UPDATE … FROM (VALUES …)`, add unit test for recursive walker.
+- Create: `apps/cms/src/api/seed-studio/services/seed-studio.test.ts` — walker tests against easter fixture.
+- Create: `apps/cms/src/lib/sanitize-slug.ts` — central slug sanitizer with deny-list.
+- Modify: `apps/cms/src/api/seed-studio/controllers/seed-studio.ts` — apply `sanitizeSlug`, return 409 with suggestions on collision.
+- Modify: `apps/seed-studio/src/lib/strapi-client.ts` — handle 409 + suggestions.
+- Modify: `apps/seed-studio/src/components/publish/PublishDialog.tsx` — render suggestions inline.
+- Modify: `apps/web/src/lib/fragments/*.ts` — audit + add `pagination: { pageSize: 100 }` on nested zones.
+
+**Tests:**
+
+- Walker: every `sections.video` in the easter fixture is caught; missing-`sectionKey` yields a warning entry.
+- sanitizeSlug: reserved words rejected; unicode stripped; length clamped.
+- Publish: batched UPDATE verified via `EXPLAIN ANALYZE` note in PR.
+
+### Unit 3 — Single-shot strict-schema generator + semantic search
+
+**Goal:** One LLM call, schema-enforced, catalog-constrained.
+
+**Files:**
+
+- Modify: `apps/seed-studio/src/lib/ai/providers.ts` — add `supportsStrictJsonSchema` capability flag.
+- Create: `apps/seed-studio/src/lib/ai/generator.server.ts` — `import "server-only"`; exports `generateExperience`.
+- Create: `apps/seed-studio/src/lib/ai/schemas.ts` — JSON Schema builder from `EASTER_SHAPED_TEMPLATE` + per-request video enums.
+- Modify: `apps/seed-studio/src/app/api/chat/route.ts` — swap `extractKeywords` → single `/api/search` call; dispatch to `generateExperience`; emit `patch` SSE events; heartbeat every 15s; switch `streamClaude` to stdin input.
+- Modify: `apps/seed-studio/src/lib/chat/use-chat.ts` — consume `patch` events (apply to `experienceSnapshot` atom).
+- Create: `apps/seed-studio/src/lib/rate-limit.ts` — per-IP daily $ counter (in-memory, reset at UTC midnight).
+- Add: ESLint rule in `apps/seed-studio/.eslintrc` forbidding `OPENROUTER` string in `components/**`.
+
+**Tests:**
+
+- Schema builder: enum of `[1,2,3]` → correct JSON Schema + Zod.
+- Mocked OpenRouter returns valid response → assembler yields typed experience.
+- Mocked OpenRouter returns malformed JSON → 1 retry with Zod error in feedback → success → typed output.
+- Mocked OpenRouter 5xx twice → `{ code: "UPSTREAM_ERROR" }`.
+- Rate limit: 20 requests at $0.025 each from same IP → 21st blocked with $-limit message.
+
+### Unit 4 — Moderation + CLI parity check + solutions doc
+
+**Goal:** Close the safety and feedback loops.
+
+**Files:**
+
+- Modify: `apps/cms/src/api/seed-studio/services/seed-studio.ts` — moderation pass before public publish; on flag, set `publishedAt = null` and return warning in response.
+- Modify: `apps/seed-studio/src/components/publish/PublishDialog.tsx` — render "Saved as Draft" warning with Strapi admin link.
+- Create: `apps/seed-studio/scripts/parity-check.ts` — CLI consuming `packages/experience-templates/parity`.
+- Create: `docs/solutions/best-practices/seed-studio-watch-parity-generator-20260422.md`.
+
+**Tests:**
+
+- Moderation mocked flag → publish returns 200 with warning; `publishedAt` confirmed null.
+- CLI parity: against known-good experience → exits 0; against experience with missing Section → exits non-zero with diff.
+
+## Alternative Approaches Considered (rereviewed during deepening)
+
+1. **Two-stage plan + fill fan-out.** Rejected as V1 default on simplicity grounds — the template is doing the rigidity work, and one-shot with strict schema is the standard industry pattern for slot-filling (Vercel v0, Replit Agent). Kept as an optional Phase 5 fallback if single-shot quality benchmarks below 80%.
+
+2. **Keep one-shot generation, tighten prompt only (no schema, no template).** Still rejected: the structural gap (`ComponentSectionsSection` wrapper missing) can't be closed by prompting alone.
+
+3. **Shared renderer between seed-studio and apps/web.** Rejected: web components are tied to `FragmentOf` and assume resolved relations. Seed-studio renders unsaved state. A minimal mirror in the shared package is simpler than contorting web's components.
+
+4. **Expose pgvector semantic search directly from seed-studio Node.** Rejected: crosses app boundaries. Use `/api/search`.
+
+5. **EventSource instead of fetch body streaming.** Rejected (per framework research): `EventSource` can't set Authorization headers or send POST bodies. `fetch + Response.body.getReader()` is the Next.js 16 standard for streamed POST responses.
+
+## Risks & Mitigations
+
+- **Risk: single-shot quality below threshold.** Baseline assumption: one strict-schema call fills a 10-Section template at ≥90% quality. **Mitigation:** measurement in Phase 3. If below threshold, Phase 5 fan-out is pre-designed and can be enabled without protocol changes.
+
+- **Risk: strict-JSON-Schema support varies across providers.** OpenRouter gpt-4o-mini works (production-ready); Anthropic Sonnet 4.5/Opus 4.1+ via `anthropic-beta: structured-outputs-2025-11-13` (beta, Sonnet 4.6 included); Gemini via `responseJsonSchema` (Nov 2025); Ollama/Codex/Exo unreliable. **Mitigation:** `supportsStrictJsonSchema` capability flag routes only strict-capable providers through the new path; legacy free-form kept for the rest with the same sanitization.
+
+- **Risk: HLS player memory on 20-video pages.** Without mitigation, ~500MB per page. **Mitigation:** lazy-init with `` placeholder + IntersectionObserver; LRU-destroy idle players; click-to-play for non-hero. Target ≤120MB, ≤3 active players.
+
+- **Risk: `patchNestedVideoRelations` regression.** **Mitigation:** Unit 2 tests against the real easter GraphQL fixture; CI guard.
+
+- **Risk: cost overrun from runaway generations.** **Mitigation:** per-IP daily $ counter (not ceremonial per-hour), OpenRouter account-level daily cap, alerts at $5/day global.
+
+- **Risk: parity illusion — studio preview looks right but `/watch/` doesn't.** **Mitigation:** CLI parity check runs shared-package `parityDiff` against the GraphQL shape; enforced in implementation feedback loop.
+
+- **Risk: `streamClaude` flag-injection.** **Mitigation:** Phase 3 switches to stdin input.
+
+- **Risk: generated offensive content on public `/watch/`.** **Mitigation:** moderation pass + Draft state. Reviewer must manually promote to Published.
+
+- **Risk: Strapi v5 nested relation truncation.** **Mitigation:** explicit `pagination: { pageSize: 100 }` on every nested zone in apps/web fragments (audited in Unit 2).
+
+## Documentation / Operational Notes
+
+- **Pre-merge checklist:**
+ 1. Verify `OPENROUTER_API_KEY` set in `apps/seed-studio/.env.local` (server-only).
+ 2. Run `pnpm --filter @forge/cms codegen` if any CMS component JSON changed.
+ 3. Run CLI parity check against forgiveness / prayer / easter-new themes; attach output to PR.
+ 4. Confirm `apps/web`'s `STRAPI_API_TOKEN` matches `apps/cms`'s `STRAPI_INTERNAL_API_TOKEN`.
+ 5. Confirm no component under `apps/seed-studio/src/components/` references `process.env.OPENROUTER` (ESLint guards this).
+- **Post-merge validation:**
+ - Railway logs: error rates for `generateExperience`, moderation flag frequency, p50/p95 latency.
+ - OpenRouter dashboard: daily spend trending.
+- **Follow-ups:**
+ - If single-shot quality <90%, enable Phase 5 fan-out.
+ - Move the shared template package into its own GitHub-published npm package if `apps/mobile` needs it too.
+ - Per-section regenerate (natural fit once shared `parityDiff` exists).
+
+## Sources & References
+
+### Internal References
+
+- `apps/seed-studio/src/lib/ai/experience-schema.ts` — current schema (re-exports in Phase 1).
+- `apps/seed-studio/src/app/api/chat/route.ts` — current single-shot generator (to rewrite in Phase 3).
+- `apps/seed-studio/src/lib/chat/use-chat.ts:20` — COMPONENT_ALIASES (moves to shared package).
+- `apps/seed-studio/src/components/preview/SectionRenderer.tsx` — preview dispatcher (to extend).
+- `apps/seed-studio/src/lib/strapi-client.ts` — Strapi/search client helpers.
+- `apps/cms/src/api/seed-studio/services/seed-studio.ts:48` — keyword `searchVideos` (legacy, superseded by `/api/search`).
+- `apps/cms/src/api/seed-studio/services/seed-studio.ts:199` — `collectVideoRelations` walker (just add tests).
+- `apps/cms/src/api/search/routes/search.ts:5` — existing `GET /api/search` endpoint.
+- `apps/cms/src/api/search/services/semantic-search.ts` — hybrid RRF fused search with partial HNSW indexes per locale.
+- `apps/cms/src/components/sections/section.json` — Section wrapper CMS schema.
+- `apps/web/src/lib/fragments/section.ts` — authoritative Section GraphQL shape.
+- `apps/web/src/components/sections/Section.tsx` — web's wrapper implementation (styling tokens to mirror).
+- `apps/web/src/app/[slug]/page.tsx` — `/watch/` page route (basePath `/watch`).
+- `apps/cms/config/database.ts` — per-connection pgvector GUCs (afterCreate).
+
+### Institutional Learnings (docs/solutions/)
+
+- `integration-issues/strapi-v5-nested-component-relation-ids-2026-03-31.md` — nested relations require numeric IDs + post-create patch.
+- `performance-issues/strapi-nested-relation-truncation-and-n-plus-one-manager-20260328.md` — Strapi v5 silently truncates nested relations to 10.
+- `best-practices/nextjs-server-action-llm-structured-output-pattern-2026-04-21.md` — closed-union error codes, slug allowlist, retry policy.
+- `best-practices/hybrid-semantic-search-api-strapi-v5-pgvector.md` — RRF-fused pgvector + FTS is the proven picker.
+- `performance-issues/pgvector-hnsw-index-bypass-with-where-filter-20260415.md` — `WHERE` on indexed table bypasses HNSW; partial indexes or subquery.
+- `database-issues/set-local-requires-transaction-for-pgvector-search.md` — `SET LOCAL hnsw.ef_search` no-ops outside a transaction.
+- `security-issues/zod-validation-errors-must-not-echo-user-controlled-input-20260420.md` — strip user input from Zod error messages.
+- `ui-bugs/tv-video-hero-blank-autoplay-20260413.md` — thumbnail array vs object pitfall; stable source ref for video player.
+
+### External References (Q1 2026)
+
+- OpenAI Structured Outputs (strict `json_schema`, 1000-enum cap): https://developers.openai.com/api/docs/guides/structured-outputs
+- Anthropic Structured Outputs (beta, Sonnet 4.5/Opus 4.1+): https://platform.claude.com/docs/en/build-with-claude/structured-outputs
+- Gemini `responseJsonSchema` (supersedes `responseSchema`): https://blog.google/innovation-and-ai/technology/developers-tools/gemini-api-structured-outputs/
+- OpenRouter Structured Outputs (`provider.require_parameters: true`): https://openrouter.ai/docs/guides/features/structured-outputs
+- Instructor (validation + retry-with-feedback): https://js.useinstructor.com/why/
+- Template-slot composition pattern (v0): https://vercel.com/blog/how-we-made-v0-an-effective-coding-agent
+- MDN AbortSignal + `AbortSignal.any` (Node 20+): https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal
+- Next.js 16 streaming guide: https://nextjs.org/docs/app/guides/streaming
+
+### Related Plans
+
+- `docs/plans/2026-04-21-001-feat-demo-search-ai-experience-generator-plan.md` — preceding demo-search generator (flat blocks, single-shot, OpenRouter gpt-4o-mini). This plan generalizes its strict-schema pattern to the full `/watch` shape.
+
+## Open Questions
+
+### Resolved During Planning / Deepening
+
+- **Pipeline shape: one-shot or two-stage?** One-shot strict JSON Schema as V1. Fan-out fall-back behind a measurement gate.
+- **Template location:** shared `packages/experience-templates/` package.
+- **Semantic search endpoint:** reuse existing `/api/search`; no new endpoint.
+- **SSE event proliferation:** single `patch` event with `{ path, value }`.
+- **Parity check UI:** CLI script under `apps/seed-studio/scripts/`, not product React component.
+- **Slug pre-check endpoint:** not needed; Save returns 409 with suggestions.
+- **Rate limit posture:** per-IP daily $ counter, not per-hour req count. OpenRouter account cap as backstop.
+- **Plan model:** gpt-4o (cheaper, sufficient for slot-fill) — Sonnet 4.6 only if measurement needs reasoning.
+- **`streamClaude` hardening:** stdin (not `-p` flag).
+- **Moderation:** required, gates Draft vs Published state.
+- **Low-candidate fallback:** if `/api/search` returns < 4 candidates, drop to 2 video-centric + 1 carousel; if < 2, surface "not enough content" error.
+- **Recursive walker rewrite:** unneeded; just test. Behaviour is already correct.
+- **Parity measurement:** structural JSON diff via shared `parityDiff` + optional screenshot in CLI.
+
+### Deferred to Implementation
+
+- **Exact prompt wording.** The schema + template lock the shape; prose iterates during Phase 3 using the CLI parity check.
+- **Whether to cache search results per theme.** Cold pgvector is already <240ms; cache only if telemetry shows repeat themes dominating.
+- **Real-time cost meter in the studio UI.** Nice-to-have; not blocking.
+- **Per-section regenerate.** Natural follow-up once `parityDiff` exists.
+- **Whether the shared package becomes an npm-published package for apps/mobile.** Depends on mobile renderer adoption timing.
diff --git a/docs/plans/2026-04-23-002-feat-admin-ai-experience-drafting-plan.md b/docs/plans/2026-04-23-002-feat-admin-ai-experience-drafting-plan.md
new file mode 100644
index 000000000..79903b198
--- /dev/null
+++ b/docs/plans/2026-04-23-002-feat-admin-ai-experience-drafting-plan.md
@@ -0,0 +1,369 @@
+---
+title: "feat: Admin AI experience drafting"
+type: feat
+status: active
+date: 2026-04-23
+---
+
+# Admin AI Experience Drafting
+
+## Overview
+
+Add a prompt-first drafting flow to `apps/admin` so an editor can open an empty
+experience canvas, describe a theme or story, and receive a generated first
+draft composed from the existing admin block system. The generated result should
+land directly in the current editor state as editable title, description, and
+blocks. Seed Studio is reference material only; the shipped runtime surface
+stays entirely inside the admin experience editor.
+
+## Problem Frame
+
+The current admin editor is structurally capable but operationally manual:
+operators start from a blank experience and add blocks one at a time. That is
+good for precise editing, but weak for ideation and first-draft speed. The
+product you described is different: type a theme, story, or angle, then let AI
+compose the initial editorial structure using blocks. The generated draft must
+still honor the system's real constraints:
+
+- it must produce admin-native blocks rather than Seed Studio or Strapi payloads
+- it must use only real catalog-backed videos/media references
+- it must remain editable with the normal canvas tools
+- it must not auto-save or auto-publish
+
+## Requirements Trace
+
+- **R1.** Add a `Generate with AI` entry point to the empty-canvas experience
+ editor UI in `apps/admin`.
+- **R2.** The editor provides one prompt field for theme/story/angle. No chat
+ thread or multi-step wizard in v1.
+- **R3.** AI returns a first draft containing `title`, `metaDescription`, and
+ a block tree that can be normalized into admin's canonical `BlocksSchema`.
+- **R4.** V1 works only on an empty canvas. Existing non-empty drafts are out of
+ scope for merge/append/replace behavior.
+- **R5.** Generated drafts stay in local editor state until the operator
+ explicitly saves or publishes through the existing actions.
+- **R6.** Video-bearing blocks may only reference server-provided catalog
+ candidates. No hallucinated external streaming URLs or freeform video ids.
+- **R7.** The provider path reuses admin's env posture: prefer OpenRouter, fall
+ back to OpenAI, no new SDK requirement.
+- **R8.** The flow handles loading, retryable failure, and normalization errors
+ inline in the editor.
+- **R9.** The provider-facing schema supports every current admin block type,
+ even if the prompt biases toward simpler compositions.
+
+## Scope Boundaries
+
+- No Seed Studio UI embedding and no runtime cross-import from `apps/seed-studio`.
+- No changes to the canonical saved block schema in `apps/admin/src/domain/blocks.ts`.
+- No auto-save, auto-publish, or background workflow dispatch on generation.
+- No AI behavior on a non-empty canvas in v1.
+- No slug/path mutation by AI in v1; route controls remain manual.
+- No semantic video ranking work that requires a new video embeddings pipeline.
+ Candidate retrieval in v1 should use the current admin video catalog data.
+- No general-purpose chat assistant in admin. This is one-shot draft generation,
+ not conversational editing.
+
+## Context & Research
+
+### Relevant Code And Patterns
+
+- `apps/admin/src/app/dashboard/experiences/[id]/page.tsx`
+ already owns the editor's server actions (`saveLocaleAction`,
+ `publishLocaleAction`, `createLocaleAction`, `restoreRevisionAction`). That is
+ the clean seam for a new ephemeral `generateDraftAction`.
+- `apps/admin/src/app/dashboard/experiences/experience-editor.tsx`
+ already renders the empty-canvas state and owns the local editor state
+ (`title`, `metaDescription`, `parsedBlocks`, selection state). This is the
+ correct client surface for the new AI entry point.
+- `apps/admin/src/domain/blocks.ts` is the saved-source-of-truth contract. It is
+ strict, nested, and not model-friendly in several places
+ (`containerSlot` markers, `sectionKey` cross-references, `videoId` references).
+- `apps/admin/src/app/dashboard/experiences/experience-editor/block-helpers.ts`
+ defines `VideoLibraryItem` and the editor's existing catalog-backed video
+ summary shape (`id`, `previewImageUrl`, `previewStreamUrl`, etc.).
+- `apps/admin/src/services/experience.service.ts` shows the mutation boundary
+ the editor already trusts. Generation should not bypass this for save/publish,
+ but it also should not call it during the generate step because v1 is
+ intentionally ephemeral.
+- `apps/admin/src/services/embeddings.service.ts` shows the repo-local provider
+ pattern already used in admin: raw `fetch`, OpenRouter-first/OpenAI-fallback,
+ env validation in `apps/admin/src/config/env.ts`, timeout handling, and no SDK
+ abstraction layer.
+- `docs/solutions/best-practices/nextjs-server-action-llm-structured-output-pattern-2026-04-21.md`
+ provides the closest existing pattern for strict structured-output generation
+ in a Next.js Server Action.
+
+### Institutional Learnings
+
+- The admin app already has a safe pattern for provider-backed server work:
+ keep keys server-side, return serializable discriminated unions, log unknown
+ failures server-side, and keep user-facing messages typed and narrow.
+- Prior Seed Studio work proved that "real catalog only" is not just a prompt
+ preference. The system needs server-owned candidate reconciliation, not trust
+ in freeform model URLs or ids.
+- The experience editor has already invested in a canonical admin block model.
+ The right move is to normalize model output into that contract, not invent a
+ second saved representation.
+
+## Key Technical Decisions
+
+1. **Use a server action from the editor route, not a GraphQL mutation.**
+ The draft is ephemeral and immediately consumed by the current editor page.
+ It does not need an API surface discoverable by other clients, and it should
+ stay colocated with the existing editor action seam.
+
+2. **Keep the provider contract separate from `BlocksSchema`.**
+ Admin's saved block schema is too implementation-shaped for a model:
+ `container` uses flat slot markers, navigation references depend on concrete
+ `sectionKey` values, and video-bearing blocks want real `videoId`s. Create a
+ model-facing draft schema that covers every block type but uses model-friendly
+ constructs, then normalize into `BlocksSchema` server-side.
+
+3. **Use server-assigned aliases for references.**
+ The model should not emit raw `videoId`s or fragile cross-block keys. The
+ prompt should expose compact candidate ids like `v01`, `v02`, and optional
+ section refs like `s01`, `s02`. The normalizer resolves those aliases into
+ real `videoId`s and generated `sectionKey`s.
+
+4. **Bias prompting toward good editorial defaults without restricting block
+ coverage.**
+ The user explicitly wants "any block." Support every current block type in
+ the schema, but guide the model toward sensible first-draft structures unless
+ the prompt clearly calls for more exotic compositions.
+
+5. **Empty canvas only in v1.**
+ This keeps the first shipping behavior safe and legible: no merge semantics,
+ no destructive replacement, no partial rewrite complexity, and no ambiguity
+ about what AI owns.
+
+6. **Generation populates local state only.**
+ The AI step updates `title`, `metaDescription`, and `parsedBlocks` in the
+ client. Save/publish remain explicit operator actions through the existing
+ form actions and revision path.
+
+7. **Keep slug/path manual.**
+ Title and description are good AI fields. Route identity is not. Leaving
+ slug/path under operator control avoids accidental route churn and collision
+ policy complexity in the first version.
+
+8. **Use admin-catalog retrieval, not a new embeddings system.**
+ Admin does not yet have a semantic video-retrieval pipeline. V1 should build
+ a lexical/topical candidate search over existing `Video` + `VideoLocale`
+ fields, then let the model choose from that bounded set. Better ranking can
+ become a follow-up feature later.
+
+## High-Level Technical Design
+
+### Request Flow
+
+1. Operator opens `/dashboard/experiences/[id]` for a locale with no blocks.
+2. Empty canvas shows `Generate with AI`.
+3. Operator enters a theme/story prompt and submits.
+4. Client calls a new server action on the editor route.
+5. Server action:
+ - revalidates session/permissions for the locale
+ - loads a bounded candidate video catalog from admin
+ - builds compact aliases (`v01`, `v02`, ...)
+ - calls the provider with a strict JSON schema
+ - parses and normalizes the model-facing draft
+ - validates the normalized output with `BlocksSchema`
+ - returns `{ ok: true, draft }` or `{ ok: false, code, message }`
+6. Client updates local editor state from the returned draft.
+7. Operator reviews and manually saves/publishes if satisfied.
+
+### Draft Contract Shape
+
+The provider-facing contract should be a model-friendly AST, not the raw saved
+block JSON. Directionally:
+
+- top-level result:
+ - `title`
+ - `metaDescription`
+ - `blocks`
+- each block uses a friendly `t` or `kind`
+- `container` uses nested slots/columns, not flat `containerSlot` markers
+- video-bearing blocks use `candidateRef: "v01"` instead of raw `videoId`
+- navigation/media cross-links use `targetRef: "s02"` instead of raw
+ `sectionKey`
+- optional presentation fields not needed for a first draft can be omitted and
+ defaulted server-side during normalization
+
+The server-owned normalizer then:
+
+- assigns real `sectionKey`s
+- resolves video aliases to `videoId`s
+- expands container slot abstractions into admin's flat marker representation
+- drops or errors on unknown candidate refs
+- fills safe defaults where the block schema expects them
+- validates with `BlocksSchema.parse(...)`
+
+## Implementation Units
+
+- [ ] **Unit 1: Model-Facing Draft Schema + Normalizer**
+
+ **Goal:** Define a provider-facing experience draft contract that can express
+ every current admin block type, then normalize it into canonical
+ `BlocksSchema`.
+
+ **Requirements:** R3, R6, R9
+
+ **Files:**
+ - Create `apps/admin/src/services/experience-ai/experience-ai.schemas.ts`
+ - Create `apps/admin/src/services/experience-ai/experience-ai-normalize.ts`
+ - Create `apps/admin/src/services/experience-ai/experience-ai-normalize.test.ts`
+ - Reference `apps/admin/src/domain/blocks.ts`
+
+ **Approach:**
+ - Introduce a model-facing discriminated-union schema that represents all
+ top-level and nested block kinds in a prompt-friendly way.
+ - Include alias fields for video and section references.
+ - Normalize the model output into admin's saved shape:
+ - real `sectionKey` generation
+ - candidate alias resolution
+ - container slot flattening
+ - omission of unsupported/empty optional fields
+ - Validate the final result with `BlocksSchema`.
+
+ **Test scenarios:**
+ - Every supported draft block kind normalizes into a `BlocksSchema`-valid
+ payload.
+ - Container slot abstractions flatten into the expected `containerSlot`
+ markers + content ordering.
+ - Navigation/media refs resolve into real `sectionKey`s.
+ - Unknown video aliases fail with a typed normalization error.
+ - Empty optional URL-like fields are omitted, not serialized as invalid empty
+ strings.
+
+- [ ] **Unit 2: Catalog Candidate Retrieval + Provider Helper**
+
+ **Goal:** Build the server-only generation service that retrieves bounded
+ video candidates, calls the provider, and returns a normalized draft result.
+
+ **Requirements:** R2, R6, R7, R8, R9
+
+ **Files:**
+ - Create `apps/admin/src/services/experience-ai/experience-ai.service.ts`
+ - Create `apps/admin/src/services/experience-ai/experience-ai.service.test.ts`
+ - Modify `apps/admin/src/config/env.ts` only if a missing AI env doc/update is
+ needed
+ - Modify `apps/admin/.env.example`
+
+ **Approach:**
+ - Reuse admin's env posture: OpenRouter first, OpenAI fallback, raw `fetch`,
+ timeout handling, typed errors.
+ - Add a candidate retrieval helper that ranks admin catalog rows from
+ `Video`/`VideoLocale` data for the given prompt and locale, returning a
+ bounded list with compact aliases.
+ - Build the provider prompt from:
+ - user prompt
+ - compact candidate catalog
+ - editor-safe system instructions
+ - strict JSON schema for the model-facing draft
+ - Parse, normalize, and return a discriminated-union result.
+
+ **Test scenarios:**
+ - Missing provider env returns a typed `NOT_CONFIGURED` result.
+ - Provider 5xx/timeout returns a typed upstream error.
+ - Provider response that violates the draft schema fails before touching the
+ editor.
+ - Unknown candidate aliases are rejected during normalization.
+ - Candidate retrieval is bounded and returns stable aliases in order.
+ - Successful generation returns title + description + normalized blocks.
+
+- [ ] **Unit 3: Editor Route Server Action**
+
+ **Goal:** Add an editor-local server action that enforces permissions and
+ returns serializable draft results to the client.
+
+ **Requirements:** R1, R5, R7, R8
+
+ **Files:**
+ - Modify `apps/admin/src/app/dashboard/experiences/[id]/page.tsx`
+ - Create `apps/admin/src/app/dashboard/experiences/generate-draft-action.ts`
+ - Create `apps/admin/src/app/dashboard/experiences/generate-draft-action.test.ts`
+
+ **Approach:**
+ - Extract the draft-generation action into a route-local module so the server
+ action body stays thin and testable.
+ - Reuse `requireSession()` and the existing experience/locale access model.
+ - Enforce that only users who could edit the locale can generate a draft for
+ it.
+ - Return only serializable success/error unions; no raw `Error` objects.
+ - Do not write to Prisma in this action.
+
+ **Test scenarios:**
+ - Unauthorized/editor-forbidden access returns a typed forbidden result.
+ - Empty-canvas generation returns a serializable draft payload.
+ - The action never calls save/publish services.
+ - Unknown errors are collapsed into a generic typed response.
+
+- [ ] **Unit 4: Empty-Canvas Editor UX**
+
+ **Goal:** Surface `Generate with AI` in the empty-canvas experience editor and
+ apply successful drafts into local state.
+
+ **Requirements:** R1, R2, R4, R5, R8
+
+ **Files:**
+ - Modify `apps/admin/src/app/dashboard/experiences/experience-editor.tsx`
+ - Create `apps/admin/src/app/dashboard/experiences/experience-editor/ai-draft-panel.tsx`
+ - Modify `apps/admin/src/app/dashboard/experiences/experience-editor.test.tsx`
+
+ **Approach:**
+ - Add a `Generate with AI` starter to the empty-canvas state only.
+ - Use a compact prompt panel/modal with one textarea.
+ - Show clear `idle -> loading -> success/error` states inline.
+ - On success:
+ - set `title`
+ - set `metaDescription`
+ - replace `parsedBlocks`
+ - select the first generated block
+ - leave save/publish untouched until the operator chooses them
+ - Hide or disable the AI entry point once the canvas is non-empty.
+
+ **Test scenarios:**
+ - Empty canvas shows `Generate with AI`; non-empty canvas does not.
+ - Submitting a prompt shows loading state and disables duplicate submit.
+ - Successful generation updates local editor state without writing through
+ save/publish actions.
+ - Failure shows inline error text and permits retry.
+
+- [ ] **Unit 5: Final Validation + Browser Pass**
+
+ **Goal:** Confirm the full flow works inside the real admin editor without
+ regressing normal manual editing.
+
+ **Requirements:** R1-R9
+
+ **Files:**
+ - No planned source changes unless validation finds a real issue.
+
+ **Approach:**
+ - Run focused unit tests first, then full admin validation.
+ - In the browser:
+ - create/open an empty experience
+ - generate a draft from a prompt
+ - confirm title/description/blocks populate
+ - confirm generated video blocks use catalog-backed selections only
+ - confirm save still requires an explicit click
+
+ **Verification:**
+ - `pnpm --filter @forge/admin test -- experience-ai experience-editor`
+ - `pnpm --filter @forge/admin lint`
+ - `pnpm --filter @forge/admin typecheck`
+ - `pnpm --filter @forge/admin test`
+
+## Risks And Follow-Ups
+
+- **Provider/schema complexity:** Supporting every block type in one provider
+ contract is the hardest part of this feature. The normalizer boundary is what
+ keeps that complexity from leaking into saved data.
+- **Candidate quality:** V1 retrieval is bounded by current video metadata and
+ lexical ranking. If outputs feel weak for broad themes, a follow-up ticket
+ should add stronger retrieval rather than loosening the "real catalog only"
+ rule.
+- **Cross-block references:** `navigationCarousel` and section-linking content
+ are exactly why alias normalization exists. This area needs explicit tests.
+- **Future phases:** append-to-end, insert-at-position, rewrite-selected-block,
+ and conversational refinement should be follow-up tickets, not hidden inside
+ v1.
diff --git a/docs/plans/2026-05-04-001-feat-admin-ai-experience-editorial-quality-plan.md b/docs/plans/2026-05-04-001-feat-admin-ai-experience-editorial-quality-plan.md
new file mode 100644
index 000000000..d4005eb29
--- /dev/null
+++ b/docs/plans/2026-05-04-001-feat-admin-ai-experience-editorial-quality-plan.md
@@ -0,0 +1,680 @@
+---
+title: "feat: Admin AI Experience editorial quality + compliance pass"
+type: feat
+status: active
+date: 2026-05-04
+origin: docs/brainstorms/2026-05-04-admin-ai-experience-editorial-quality-requirements.md
+---
+
+# Admin AI Experience — Editorial Quality + Compliance Pass
+
+## Overview
+
+`feat-107` shipped the Admin AI Experience drafting pipeline end to end:
+prompt → server action → catalog candidates → LLM → normalizer →
+`BlocksSchema`-valid draft in editor state. The pipeline is correct,
+but two gaps now block it from feeling like a v1 worth handing to
+operators:
+
+1. **Visual quality** — Generated drafts, when previewed via the
+ dashboard's Preview button (which opens the public watch page in
+ `apps/web`), read as much flatter than the curated Easter
+ (`feat-029`) and Christmas (`feat-034`) experiences. The schema
+ already supports every block kind the editor exposes, so the gap
+ is editorial composition, not capability.
+
+2. **Rule compliance** — The plan defines R1–R9 invariants the
+ generator must hold every time. Several are unit-covered, but
+ there is no end-to-end guardrail against silent regressions in
+ ephemeral state (R5), the empty-canvas-only contract (R4), the
+ provider posture (R7), or the locale-matched dub contract that
+ the 1-May fix introduced.
+
+Both gaps share a root cause: the system prompt in
+`apps/admin/src/services/experience-ai/experience-ai.service.ts:339`
+is a single thin paragraph with no structural template, no editorial
+bias, no minimum block diversity, and no in-prompt restatement of
+the invariants. Schema, normalizer, and action layers were built
+carefully; the prompt is doing almost none of the heavy lifting.
+
+This plan closes both gaps in one PR via three coupled levers:
+
+- **Lever A (Editorial system prompt)** — restructure the prompt
+ with an editorial template, locale-aware copy guidance, and a
+ small structurally-rich few-shot example modelled on the Christmas
+ seed (shape only, theme-agnostic). Add a soft floor on block
+ diversity at both Zod and provider-JSON-schema boundaries so they
+ stay aligned.
+- **Lever B (Normalizer presentation defaults)** — fill safe defaults
+ on optional fields the model omits, derived from candidate metadata
+ where appropriate, never overriding explicit model choices.
+- **Lever C (Compliance invariant tests + rule witness log)** — add
+ an end-to-end action test asserting catalog-only refs, locale-matched
+ dubs, no Prisma writes, and `BlocksSchema` parse; add an
+ empty-canvas guard test on the editor; emit a structured
+ rule-witness log per generation so Railway has a greppable trail.
+
+Origin requirements: see
+`docs/brainstorms/2026-05-04-admin-ai-experience-editorial-quality-requirements.md`.
+
+## Problem Statement / Motivation
+
+The first operator preview of an AI-drafted experience looks unfinished
+next to anything the team has hand-built. Operators quickly conclude
+that AI drafting is "for skeletons only," which undercuts the whole
+premise of `feat-107`. At the same time, the only thing keeping the
+generator inside its R1–R9 contract today is unit-level coverage on
+the normalizer, the schemas, and the candidate retrieval layer —
+nothing exercises the end-to-end action under realistic conditions.
+A regression in the action layer (e.g., an inadvertent Prisma write,
+a UI guard removed during refactoring, a reordered provider stack
+that activates Codex in production) would land silently.
+
+## Proposed Solution
+
+Three levers, one PR. The prompt change is the headline visual win;
+the normalizer change is the safety net for fields the model still
+forgets; the compliance work pins the invariants for everything the
+team will land after this.
+
+### Lever A — Editorial system prompt + aligned schema floor
+
+Replace the thin prompt in
+`apps/admin/src/services/experience-ai/experience-ai.service.ts` with
+a builder that emits:
+
+- A short editorial brief: tone, voice, and length expectations
+ appropriate to the requested experience locale.
+- A structural template:
+ - Open with a `videoHero`-shaped block.
+ - Follow with 2–4 `section`-level blocks, each preferably wrapping
+ a `navigationCarousel`, `mediaCollection`, `videoCarousel`, or
+ a `container` with mixed slot content.
+ - Close with a `quizButton` or `cta` if the prompt invites
+ reflection or response.
+- A single truncated few-shot example modelled on the Christmas seed
+ shape (videoHero + 1 section with navigationCarousel + 1 section
+ with mediaCollection), explicitly labelled "shape, not theme".
+- An explicit list of invariants the model must respect:
+ - only the provided candidate refs may appear in `candidateRef`
+ - section refs (`s01`, `s02`, …) only target `section` blocks the
+ draft itself emits
+ - copy must be in the requested locale
+ - schema-strict JSON; no markdown fences
+
+Bump the **soft floor** on block diversity at both ends so the Zod
+and provider JSON schema stay aligned (per the LLM structured-output
+learning at
+`docs/solutions/best-practices/nextjs-server-action-llm-structured-output-pattern-2026-04-21.md`):
+
+- `DraftExperienceSchema.blocks` → `.min(2)` (was `.min(1)`)
+- Provider JSON schema `blocks.minItems` → `2`
+
+The floor stays at 2 (not 3+) so well-prompted simple drafts stay
+valid; the visual lift comes from the structural template, not from
+forcing length.
+
+Resolved deferred question (origin): the few-shot is the smallest
+slice of the Christmas seed that demonstrates the layered shape
+without adding theme-specific copy. The "shape only" caveat keeps
+unrelated prompts from inheriting Christmas tone.
+
+### Lever B — Normalizer presentation defaults
+
+Extend `apps/admin/src/services/experience-ai/experience-ai-normalize.ts`
+to fill safe defaults on optional fields. Strict rule: **fill, never
+override**. If the model emitted `false` explicitly, that stands; only
+truly omitted fields receive defaults. This means the draft schemas
+stay free of Zod `.default(...)` so the normalizer can distinguish
+"explicit false" from "undefined".
+
+Concrete defaults:
+
+- `videoHero.clipStartSeconds` defaults to `0` and
+ `clipEndSeconds` defaults to `8` when both are omitted (gives the
+ hero a usable trimmed window without committing to specific
+ candidate runtime).
+- `container.slots[].spans` defaults to a balanced layout based on
+ slot count: 1 slot → no spans needed; 2 slots → `{ md: 6, md: 6 }`;
+ 3 slots → `{ md: 4, md: 4, md: 4 }`; 4 slots → `{ md: 3, … }`.
+- `section.dynamicBackgroundImage` is set to `true` only when the
+ section's first video-bearing nested block resolves to a candidate
+ whose `previewImageUrl` is non-null, AND the model did not emit
+ the field. If `previewImageUrl` is missing, leave it false.
+- `section.backgroundOpacity` defaults to a single project-wide
+ constant (e.g., `0.65`) when `dynamicBackgroundImage` ends up true
+ and the field is absent. No data-derived choice — overlays are a
+ brand decision.
+
+Add unit tests covering each default rule plus a "model emitted
+explicit false" case to confirm overrides do not happen.
+
+Resolved deferred question (origin): defaults that depend on
+candidate metadata (`dynamicBackgroundImage`, the chosen image
+asset) are derived; opacity, hero clip windows, and slot spans are
+constants in the normalizer so brand/style changes are a single-line
+edit later.
+
+### Lever C — Compliance invariant tests + rule witness log
+
+#### C.1 Action-level integration test
+
+Extend
+`apps/admin/src/app/dashboard/experiences/generate-draft-action.test.ts`
+with a happy-path test using a stub provider that returns a hand-
+written draft fixture mirroring the Lever A shape. Assertions:
+
+- Every `videoId` referenced in the normalized output appears in the
+ candidate set returned by `loadVideoCandidates` for the test's
+ prompt + locale (R6: catalog-only).
+- For every block that persists a `streamingUrl`, the URL equals one
+ of the candidate's `previewStreamUrl` values, which by construction
+ came from a `VideoDub` whose language matches the requested locale
+ (R6: locale-matched dubs — see solution doc
+ `docs/solutions/integration-issues/admin-ai-experience-preview-videodub-language-selection-20260501.md`).
+- Spies on every Prisma write entry point that the action could
+ conceivably touch — `experienceLocale.update`, `experience.update`,
+ `contentRevision.create`, `contentRevision.update`,
+ `experienceLocale.upsert` — show zero invocations after the action
+ resolves with `{ ok: true, draft }` (R5: ephemeral).
+- `BlocksSchema.safeParse(result.draft.blocks).success` is `true`
+ (R3 / R9).
+
+Resolved deferred question (origin): the stub provider is a
+hand-written fixture for legibility and runtime cost. Recorded
+provider responses are unnecessary at this layer — provider-specific
+behavior is already covered in the service-level tests.
+
+#### C.2 Server-side empty-canvas guard
+
+Today the empty-canvas guard lives only on the client
+(`experience-editor.tsx` hides the AI entry point when
+`parsedBlocks.length > 0`). A malicious or out-of-date client could
+call the action with a non-empty locale. Add a server-side check at
+the top of `runGenerateDraftAction` (in
+`apps/admin/src/app/dashboard/experiences/generate-draft-action.ts`):
+read the locale's persisted blocks, and if the saved canonical is
+non-empty AND no DRAFT revision is present, return a typed
+`{ ok: false, code: "CANVAS_NOT_EMPTY", message: "..." }`. Cover with
+a unit test in `generate-draft-action.test.ts`.
+
+Note: a DRAFT revision with non-empty content also blocks the action.
+Implementation must compare the operator's working state, not just
+the canonical row.
+
+#### C.3 UI-level empty-canvas guard test
+
+Extend
+`apps/admin/src/app/dashboard/experiences/experience-editor.test.tsx`
+with a case that renders the editor with a non-empty `parsedBlocks`
+prop and asserts the AI entry point is not present. This is a
+regression guard against UI refactors that accidentally remove the
+visual hide rule.
+
+#### C.4 Rule-witness log
+
+Mirror the existing structured log shape used by admin workflows
+(see `apps/admin/src/workflows/transcriptEmbeddingBackfill.ts:364`).
+After a successful generation in
+`generateExperienceAiDraft`, emit:
+
+```ts
+console.log(
+ JSON.stringify({
+ service: "experience-ai",
+ event: "draft_generated",
+ experienceId,
+ locale,
+ providerKind, // "openrouter" | "openai" | "codex"
+ candidateCount,
+ blockCount,
+ rulesSatisfied: {
+ catalogOnly: true,
+ localeMatchedDubs: true,
+ blocksSchemaParsed: true,
+ ephemeralAction: true,
+ },
+ durationMs,
+ }),
+)
+```
+
+Constraints: the log MUST NOT include the operator's prompt text or
+candidate metadata (titles, descriptions, URLs). The point is a
+greppable invariant trail in Railway, not a content trace.
+
+Resolved deferred question (origin): the log shape mirrors the
+admin workflow convention exactly so existing log tooling and
+parsing rules continue to work.
+
+#### C.5 Provider posture clarification (R7)
+
+The current `pickProvider()` falls through to a `codex` CLI
+invocation when neither `OPENROUTER_API_KEY` nor `OPENAI_API_KEY` is
+set. R7 specified "OpenRouter first, OpenAI fallback, no new SDK
+requirement" — Codex was not part of that posture. The pragmatic
+resolution: keep Codex as an explicit local-development fallback,
+gated by env. Add `EXPERIENCE_AI_ALLOW_CODEX_FALLBACK` (default
+`false` in production env validation) and short-circuit
+`pickProvider` to return `null` (which surfaces as `NOT_CONFIGURED`)
+when neither API key is set and the gate is off. Cover with a unit
+test in `experience-ai.service.test.ts`.
+
+This avoids a deployment-time surprise where an env misconfiguration
+on Railway silently spawns a CLI process at request time.
+
+## Technical Considerations
+
+- **Prompt size.** The few-shot example must stay small (well under
+ 1 KB serialized) so token cost per generation does not balloon.
+ Truncate aggressively: omit nested ctas and meta fields not needed
+ to communicate shape.
+- **Schema alignment.** Both Zod (`DraftExperienceSchema.blocks`)
+ and the JSON schema sent to OpenRouter / OpenAI must update
+ together. The learning at
+ `docs/solutions/best-practices/nextjs-server-action-llm-structured-output-pattern-2026-04-21.md`
+ is explicit: misaligned bounds produce intermittent
+ `SCHEMA_MISMATCH` under load. The plan keeps both at
+ `minItems: 2` / `.min(2)`.
+- **`fill, never override` requires schema honesty.** Existing draft
+ schemas use `.default(...)` on a few fields. Where Lever B needs
+ to distinguish "omitted" from "explicit false," remove those
+ `.default(...)` calls and let the normalizer be the single point
+ of default-fill. Cover the change with tests so it cannot drift.
+- **Locale-matched dub contract is load-bearing.** The service code
+ at `experience-ai.service.ts:593` already filters dubs to the
+ requested locale. Lever C.1 proves that contract holds end to end
+ when the action returns a draft. Do not weaken it.
+- **Codex fallback is deliberate dev convenience.** Removing it
+ entirely would break local development for anyone without an
+ OpenRouter or OpenAI key. The env gate keeps the feature available
+ but defaults safe for prod.
+
+## System-Wide Impact
+
+- **Interaction graph.** `runGenerateDraftAction` calls
+ `generateExperienceAiDraft` (service) → `loadVideoCandidates` →
+ `pickProvider` → provider HTTP/CLI → `parseProviderDraftContent`
+ → `DraftExperienceSchema.safeParse` → `normalizeExperienceDraft` →
+ `BlocksSchema.safeParse`. No callbacks, no middleware, no observers
+ in this chain — failure modes are all visible at the action's
+ return value. The new server-side empty-canvas guard sits at the
+ top of this chain and queries `experienceLocale.findUnique` plus a
+ DRAFT-revision lookup before the rest of the pipeline runs.
+- **Error propagation.** Existing
+ `ExperienceAiGenerationError` codes (`NOT_CONFIGURED`,
+ `UPSTREAM_ERROR`, `SCHEMA_MISMATCH`, etc.) cover provider failure.
+ Add `CANVAS_NOT_EMPTY` as a new typed code at the action layer
+ (not the service) since the guard is action-level. Map it to a
+ user-facing message in the existing `USER_MESSAGES` record.
+- **State lifecycle risks.** The action remains read-only by
+ design. Lever C.1 makes that an enforced invariant rather than a
+ convention. No new persistence is introduced by this plan.
+- **API surface parity.** The action is the only entry point for AI
+ drafting. No GraphQL mutation, no REST endpoint, no alternative
+ workflow exposes this surface, so the empty-canvas guard does not
+ need to be replicated.
+- **Integration test scenarios.**
+ 1. Empty canvas, valid candidates → success, all invariants log
+ true.
+ 2. Non-empty canvas → action returns
+ `{ ok: false, code: "CANVAS_NOT_EMPTY" }` without invoking the
+ provider.
+ 3. Provider returns a draft that references a `candidateRef` not
+ in the candidate set → normalizer rejects with typed error,
+ action returns a mapped failure code, no Prisma write.
+ 4. Provider returns a `streamingUrl` not present in any candidate
+ → C.1 catches it (synthetic test only — production path can
+ never produce this since the model never sees raw stream
+ URLs).
+ 5. Empty-canvas guard race: locale was empty when UI rendered but
+ a concurrent edit added a block before action ran → guard
+ reads fresh state and returns `CANVAS_NOT_EMPTY` rather than
+ overwriting.
+
+## Acceptance Criteria
+
+- [ ] System prompt builder includes structural template, locale
+ guidance, and a single shape-only few-shot example.
+- [ ] Both `DraftExperienceSchema.blocks.min(2)` and the provider
+ JSON schema's `minItems: 2` are set; no other bounds drift.
+- [ ] Normalizer fills hero clip seconds, container slot spans,
+ section background opacity, and section dynamic background
+ image when the model omitted them; never overrides explicit
+ model choices.
+- [ ] Action returns `{ ok: false, code: "CANVAS_NOT_EMPTY" }` for
+ non-empty locales and never invokes the provider in that case.
+- [ ] Action-level integration test asserts catalog-only refs,
+ locale-matched stream URLs, zero Prisma writes, and
+ `BlocksSchema.safeParse` success.
+- [ ] UI test asserts the AI entry point is hidden when
+ `parsedBlocks.length > 0`.
+- [ ] Successful generations emit a single
+ `service: "experience-ai", event: "draft_generated"` JSON log
+ line with no operator prompt or candidate metadata.
+- [ ] `EXPERIENCE_AI_ALLOW_CODEX_FALLBACK` env (default false) gates
+ Codex CLI fallback; tests cover both gate states.
+- [ ] All existing tests still pass.
+- [ ] `pnpm --filter @forge/admin lint` passes.
+- [ ] `pnpm --filter @forge/admin typecheck` passes.
+- [ ] `pnpm --filter @forge/admin test` passes.
+- [ ] Manual browser pass: three reference prompts ("Easter for
+ teens", "Christmas family devotional", "What is forgiveness?")
+ produce drafts that, on visual review of the published preview,
+ include a hero, ≥2 sections, ≥1 cross-block carousel, and
+ locale-matched copy.
+
+## Success Metrics
+
+- Visual: blind comparison of generated previews against curated
+ Easter / Christmas pages across the three reference prompts —
+ drafts read as comparably editorial (hero present, layered
+ sections, ≥1 cross-block carousel).
+- Compliance: action-level integration test is a CI gate; the rule
+ witness log is greppable in Railway with the expected shape.
+- Operational: zero new
+ `experience-ai.service` warnings or unhandled errors during the
+ three-prompt smoke test in browser.
+
+## Implementation Units
+
+- [x] **Unit 1: Editorial system prompt + schema floor**
+
+ **Goal:** Replace the thin prompt with a structured editorial
+ brief, structural template, locale guidance, and a shape-only
+ few-shot example. Align Zod and provider JSON schema floors at 2
+ blocks.
+
+ **Requirements:** R1, R2, R3, R4 (origin doc)
+
+ **Files:**
+ - Modify
+ `apps/admin/src/services/experience-ai/experience-ai.service.ts`
+ (`buildExperienceAiMessages`, `buildCodexPrompt`,
+ `buildDraftExperienceJsonSchema`).
+ - Create
+ `apps/admin/src/services/experience-ai/experience-ai-prompts.ts`
+ (extract template, locale guidance, few-shot constant; pure
+ string builder, no IO).
+ - Modify
+ `apps/admin/src/services/experience-ai/experience-ai.schemas.ts`
+ (`DraftExperienceSchema.blocks.min(2)`).
+ - Modify
+ `apps/admin/src/services/experience-ai/experience-ai.service.test.ts`
+ (assert prompt content includes structural directives + few-shot
+ ref; update fixtures for `min(2)`).
+ - Reference (read only): `apps/cms/src/bootstrap/seed-christmas.ts`
+ for shape inspiration.
+
+ **Approach:**
+ - Move all string-building logic into the new
+ `experience-ai-prompts.ts` module with named exports per piece
+ (`SYSTEM_BRIEF`, `STRUCTURAL_TEMPLATE`, `FEW_SHOT_EXAMPLE`,
+ `localeCopyGuidance(locale)`).
+ - The few-shot is a frozen constant — write it by hand mirroring
+ the Christmas seed's first videoHero + first section
+ (navigationCarousel) + a second mediaCollection-bearing
+ section. No theme-specific copy.
+ - JSON schema builder bumps `minItems` to 2 in lockstep.
+
+ **Test scenarios:**
+ - Generated prompt for `locale: "en"` contains the structural
+ template and the few-shot label "shape only".
+ - Generated prompt for `locale: "es"` contains Spanish-specific
+ copy guidance.
+ - `DraftExperienceSchema` rejects a 1-block draft.
+ - JSON schema includes `blocks.minItems: 2`.
+
+ **Verification:**
+ - `pnpm --filter @forge/admin test -- experience-ai.service`
+
+ **Execution note:** Test-first. Write the prompt-content
+ assertions before extracting the builder.
+
+- [x] **Unit 2: Normalizer presentation defaults**
+
+ **Goal:** Fill safe presentation defaults on optional fields the
+ model omits; never override explicit model choices.
+
+ **Requirements:** R5, R6 (origin doc)
+
+ **Files:**
+ - Modify
+ `apps/admin/src/services/experience-ai/experience-ai-normalize.ts`
+ (videoHero, container, section default-fill).
+ - Modify
+ `apps/admin/src/services/experience-ai/experience-ai-normalize.test.ts`
+ (new cases per default rule + "explicit false survives").
+ - Modify
+ `apps/admin/src/services/experience-ai/experience-ai.schemas.ts`
+ only if Zod `.default(...)` calls need to be removed to preserve
+ the omitted-vs-false distinction.
+
+ **Approach:**
+ - For each block kind that gains a default, branch on
+ `block.field === undefined` rather than `block.field ?? fallback`
+ so explicit `false` stays `false`.
+ - Hero clip windows: constants in a `HERO_DEFAULTS` record at the
+ top of the file. Slot spans: lookup table by slot count.
+ Section dynamic-bg: derive from candidate `previewImageUrl` of
+ the section's first video-bearing nested block; opacity is the
+ paired constant.
+
+ **Patterns to follow:** existing `gridSpan: slot.gridSpan ?? 6`
+ in `experience-ai-normalize.ts:400` (already in the right shape;
+ extend the same defensive pattern).
+
+ **Test scenarios:**
+ - Hero with no clip seconds → normalized has 0 / 8.
+ - Hero with `clipStartSeconds: 5` → normalized has 5, default
+ end.
+ - Section with video-bearing slot whose candidate has
+ `previewImageUrl` → `dynamicBackgroundImage: true`,
+ `backgroundOpacity: 0.65`.
+ - Section with `dynamicBackgroundImage: false` explicit → stays
+ false even when candidate image is present.
+ - Container with 3 slots, no spans → spans default to balanced
+ layout.
+ - Container with 2 slots, one slot specifies spans, other does
+ not → only the omitted slot fills.
+
+ **Verification:**
+ - `pnpm --filter @forge/admin test -- experience-ai-normalize`
+
+ **Execution note:** Test-first per case to lock down the
+ fill-never-override rule.
+
+- [x] **Unit 3: Compliance invariant tests + rule-witness log + provider gate**
+
+ **Goal:** Pin R1–R9 invariants with end-to-end tests, emit a
+ structured rule-witness log per generation, and gate the Codex
+ CLI fallback behind an explicit env flag.
+
+ **Requirements:** R7, R8, R9 (origin doc) + parent feat-107 R4,
+ R5, R6
+
+ **Files:**
+ - Modify
+ `apps/admin/src/services/experience-ai/experience-ai.service.ts`
+ (emit rule-witness log on success; gate codex fallback via
+ new env).
+ - Modify `apps/admin/src/config/env.ts`
+ (`EXPERIENCE_AI_ALLOW_CODEX_FALLBACK: z.coerce.boolean().default(false)`).
+ - Modify `apps/admin/.env.example` (document the new env).
+ - Modify
+ `apps/admin/src/app/dashboard/experiences/generate-draft-action.ts`
+ (server-side empty-canvas guard with `CANVAS_NOT_EMPTY` typed
+ code).
+ - Modify
+ `apps/admin/src/app/dashboard/experiences/generate-draft-action.test.ts`
+ (action-level integration test per C.1; non-empty-canvas guard
+ case per C.2).
+ - Modify
+ `apps/admin/src/app/dashboard/experiences/experience-editor.test.tsx`
+ (UI guard test per C.3).
+ - Modify
+ `apps/admin/src/services/experience-ai/experience-ai.service.test.ts`
+ (codex gate test).
+
+ **Approach:**
+ - The integration test stubs the provider via a `vi.spyOn`
+ against `experienceAiService.createStructuredDraft` (already
+ re-exported for testing per `experience-ai.service.ts:872`)
+ and feeds a hand-written fixture mirroring Lever A's shape.
+ - Prisma write spies cover every method that could fire during
+ the action: `experienceLocale.update`, `experienceLocale.upsert`,
+ `experience.update`, `contentRevision.create`,
+ `contentRevision.update`. Use a single shared mock factory.
+ - Empty-canvas guard reads the locale's canonical blocks AND any
+ DRAFT revision in a single Prisma call; non-empty in either
+ is a guard hit. Returns
+ `{ ok: false, code: "CANVAS_NOT_EMPTY", message: USER_MESSAGES.CANVAS_NOT_EMPTY }`
+ without invoking the AI service.
+ - Rule-witness log shape exactly mirrors
+ `apps/admin/src/workflows/transcriptEmbeddingBackfill.ts:364`
+ (see C.4 for the field list). Emitted from
+ `generateExperienceAiDraft` only on the success path.
+ - Codex gate: `pickProvider` returns `null` (→ `NOT_CONFIGURED`)
+ when neither API key is set and
+ `EXPERIENCE_AI_ALLOW_CODEX_FALLBACK !== true`.
+
+ **Test scenarios:**
+ - Happy path: action returns `{ ok: true, draft }`, every
+ `videoId` and `streamingUrl` traces to the candidate set,
+ Prisma write spies show zero calls, `BlocksSchema.safeParse`
+ succeeds.
+ - Non-empty canonical → guard returns `CANVAS_NOT_EMPTY`,
+ provider stub never invoked.
+ - DRAFT revision with non-empty content while canonical empty →
+ guard still triggers.
+ - UI test: editor with non-empty `parsedBlocks` does not render
+ the AI entry point.
+ - Codex gate off + no API key → `pickProvider` returns null,
+ service returns `NOT_CONFIGURED`.
+ - Codex gate on + no API key → `pickProvider` returns codex
+ descriptor.
+ - Rule-witness log: capture stdout in test harness, assert one
+ `event: "draft_generated"` line per success, no
+ `prompt` / `query` / candidate-metadata field present.
+
+ **Verification:**
+ - `pnpm --filter @forge/admin test -- generate-draft-action experience-editor experience-ai`
+
+ **Execution note:** Test-first for guard and log; the env gate
+ can land alongside its test in a single commit since it is a
+ pure config change.
+
+- [x] **Unit 4: Final validation + browser pass**
+
+ **Goal:** Confirm the three-prompt browser smoke test produces
+ visibly editorial drafts and that all CI gates remain green.
+
+ **Requirements:** R1, R7, R8 (origin doc) + acceptance criteria
+
+ **Files:**
+ - No source changes unless validation surfaces a defect.
+
+ **Approach:**
+ - `pnpm --filter @forge/admin lint` + `typecheck` + `test`.
+ - Local `pnpm --filter @forge/admin dev` (port 3003) with
+ `OPENROUTER_API_KEY` set; create three empty experiences;
+ generate against each prompt; click Preview; visually compare
+ to Easter and Christmas published pages.
+ - Capture before/after screenshots of one representative
+ generated preview for the PR description.
+
+ **Verification:**
+ - All three prompts produce drafts with hero + ≥2 sections + ≥1
+ cross-block carousel.
+ - Rule-witness log lines visible in the dev server output for
+ each successful generation.
+ - No new console warnings or errors.
+
+## Dependencies & Risks
+
+**Dependencies:**
+
+- Existing locale-matched dub logic in
+ `experience-ai.service.ts:593` stays intact; the integration test
+ in C.1 is the long-term guardrail.
+- Catalog candidate retrieval continues to return ≥4 candidates for
+ typical prompts; structural template asks for hero + sections,
+ which needs at least one video per section in the demo set.
+- Provider stack continues to support strict JSON schema responses
+ (OpenRouter `response_format`, OpenAI `response_format`).
+
+**Risks:**
+
+- **Few-shot leakage.** A Christmas-shaped few-shot, even with the
+ "shape only" caveat, may bleed into unrelated prompts. Mitigation:
+ keep the few-shot small, use neutral copy in the example, and
+ watch for tone drift in the three-prompt smoke test. If drift
+ appears, swap the few-shot for an even more abstract structural
+ skeleton.
+- **Prompt token cost.** Adding ~1 KB of structural guidance + a
+ few-shot raises per-call token cost on a public provider.
+ Mitigation: the lift is bounded; admin AI generation is operator-
+ triggered, not anonymous. Cost stays trivial relative to embed
+ pipelines.
+- **Schema floor regressions.** Bumping `min` from 1 to 2 risks
+ `SCHEMA_MISMATCH` for prompts the model can only satisfy with one
+ block. Mitigation: 2 is a soft floor; the structural template
+ steers the model well above it. Watch the rule-witness log in
+ the smoke test for a blockCount of 1 — none expected.
+- **Empty-canvas guard race.** Concurrent edits between UI render
+ and action submission could race. Mitigation: the server-side
+ guard reads fresh Prisma state each call, so the worst case is a
+ late `CANVAS_NOT_EMPTY` rather than an overwrite.
+- **Codex gate breaking dev workflows.** Default `false` means
+ developers without API keys lose AI generation locally.
+ Mitigation: `.env.example` documents the gate; team lead
+ communicates the change in the PR.
+- **Rule-witness log noise.** Every successful generation emits one
+ line. Mitigation: one line is the same cadence as the workflow
+ logs already in production; no further sampling needed at v1.
+
+## Sources & References
+
+### Origin
+
+- **Origin document:**
+ [`docs/brainstorms/2026-05-04-admin-ai-experience-editorial-quality-requirements.md`](../brainstorms/2026-05-04-admin-ai-experience-editorial-quality-requirements.md)
+ — key decisions carried forward: bundle three levers in one PR;
+ treat the system prompt as the primary visual lever; test
+ compliance at the action boundary; keep `BlocksSchema` unchanged.
+
+### Internal references
+
+- Parent feature plan:
+ [`docs/plans/2026-04-23-002-feat-admin-ai-experience-drafting-plan.md`](2026-04-23-002-feat-admin-ai-experience-drafting-plan.md)
+ — defines R1–R9 invariants this plan pins.
+- Roadmap ticket:
+ [`docs/roadmap/platform/feat-107-admin-ai-experience-drafting.md`](../roadmap/platform/feat-107-admin-ai-experience-drafting.md).
+- Locale-match fix:
+ [`docs/solutions/integration-issues/admin-ai-experience-preview-videodub-language-selection-20260501.md`](../solutions/integration-issues/admin-ai-experience-preview-videodub-language-selection-20260501.md).
+- Structured-output pattern:
+ [`docs/solutions/best-practices/nextjs-server-action-llm-structured-output-pattern-2026-04-21.md`](../solutions/best-practices/nextjs-server-action-llm-structured-output-pattern-2026-04-21.md)
+ — schema-alignment rule (Zod ↔ provider JSON schema bounds) is
+ load-bearing here.
+- Editorial reference (shape, not theme):
+ `apps/cms/src/bootstrap/seed-christmas.ts`,
+ `apps/cms/src/bootstrap/seed-easter.ts`.
+- Existing log convention:
+ `apps/admin/src/workflows/transcriptEmbeddingBackfill.ts:364`.
+- Current AI service:
+ `apps/admin/src/services/experience-ai/experience-ai.service.ts`.
+- Current normalizer:
+ `apps/admin/src/services/experience-ai/experience-ai-normalize.ts`.
+- Current draft schemas:
+ `apps/admin/src/services/experience-ai/experience-ai.schemas.ts`.
+- Current action:
+ `apps/admin/src/app/dashboard/experiences/generate-draft-action.ts`.
+- Editor empty-canvas UI:
+ `apps/admin/src/app/dashboard/experiences/experience-editor.tsx`.
+- Admin app conventions: `apps/admin/CLAUDE.md`.
+
+### Related work
+
+- Related roadmap features: `feat-103` (editor refinement, parent
+ dependency), `feat-101` (block editor parity), `feat-029`
+ (Easter), `feat-034` (Christmas).
diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md
index 485316213..044c3a5a0 100644
--- a/docs/roadmap/README.md
+++ b/docs/roadmap/README.md
@@ -86,6 +86,7 @@ Build trusted, scalable AI capabilities that help people discover gospel content
| [feat-051](platform/feat-051-public-report-role.md) | Public Report Role | vlad | P1 | 2026-04-13 | 14 | 2026-04-26 | not-started |
| [feat-102](platform/feat-102-dependabot-security-remediation.md) | Dependabot Security Remediation | tataihono | P1 | 2026-04-16 | 1 | 2026-04-16 | complete |
| [feat-106](platform/feat-106-manager-single-process-mock-cms-mode.md) | Manager Single-Process Mock CMS Mode | vlad | P1 | 2026-04-22 | 5 | 2026-04-27 | in-progress |
+| [feat-107](platform/feat-107-admin-ai-experience-drafting.md) | Admin AI Experience Drafting | tataihono | P1 | 2026-04-23 | 10 | 2026-05-03 | in-progress |
| [feat-088](platform/feat-088-internal-tools-branding.md) | Internal Tools Branding | vlad | P2 | 2026-03-30 | 14 | 2026-04-12 | complete |
| [feat-089](platform/feat-089-agent-agnostic-repo-instructions.md) | Agent-Agnostic Repo Instructions | josh | P2 | 2026-04-13 | 1 | 2026-04-13 | complete |
| [feat-068](platform/feat-068-partner-publishing-and-user-accounts.md) | Partner Publishing and User Accounts | tataihono | P2 | 2026-10-01 | 61 | 2026-11-30 | blocked |
diff --git a/docs/roadmap/platform/feat-103-admin-experience-editor-refinement.md b/docs/roadmap/platform/feat-103-admin-experience-editor-refinement.md
index 49430f716..8fdf05268 100644
--- a/docs/roadmap/platform/feat-103-admin-experience-editor-refinement.md
+++ b/docs/roadmap/platform/feat-103-admin-experience-editor-refinement.md
@@ -8,7 +8,8 @@ start_date: "2026-04-16"
duration: 5
depends_on:
- "feat-101"
-blocks: []
+blocks:
+ - "feat-107"
tags:
- "platform"
- "admin"
diff --git a/docs/roadmap/platform/feat-107-admin-ai-experience-drafting.md b/docs/roadmap/platform/feat-107-admin-ai-experience-drafting.md
new file mode 100644
index 000000000..1ba349bc0
--- /dev/null
+++ b/docs/roadmap/platform/feat-107-admin-ai-experience-drafting.md
@@ -0,0 +1,101 @@
+---
+id: "feat-107"
+title: "Admin AI Experience Drafting"
+owner: "tataihono"
+priority: "P1"
+status: "in-progress"
+start_date: "2026-04-23"
+duration: 10
+depends_on:
+ - "feat-103"
+blocks: []
+tags:
+ - "platform"
+ - "admin"
+ - "cms"
+ - "editor"
+ - "experience"
+ - "ai"
+---
+
+## Problem
+
+The admin experience editor can now model and edit the full block system, but
+creating a first draft is still a manual empty-canvas exercise. Editors who
+already know the theme or story they want still have to add blocks one by one,
+write the title and description themselves, and manually wire video picks into
+the canvas. The product needs a prompt-first authoring path inside `apps/admin`
+so an editor can describe the story they want and receive an editable first
+draft composed from the existing admin block model.
+
+## Entry Points — Read These First
+
+1. `apps/admin/AGENTS.md`
+2. `apps/admin/CLAUDE.md`
+3. `apps/admin/src/app/dashboard/experiences/[id]/page.tsx`
+4. `apps/admin/src/app/dashboard/experiences/experience-editor.tsx`
+5. `apps/admin/src/app/dashboard/experiences/experience-editor/block-helpers.ts`
+6. `apps/admin/src/domain/blocks.ts`
+7. `apps/admin/src/services/experience.service.ts`
+8. `apps/admin/src/services/embeddings.service.ts`
+9. `docs/solutions/best-practices/nextjs-server-action-llm-structured-output-pattern-2026-04-21.md`
+
+## Grep These
+
+- `Empty Canvas|Start with a first block|Browse All Blocks` in `apps/admin/src/app/dashboard/experiences/experience-editor.tsx`
+- `"use server"|revalidatePath|saveLocaleAction|publishLocaleAction` in `apps/admin/src/app/dashboard/experiences/[id]/page.tsx`
+- `BlockSchema|BlocksSchema|SectionContentBlockSchema|ContainerContentBlockSchema` in `apps/admin/src/domain/blocks.ts`
+- `VideoLibraryItem|loadVideoRows|previewStreamUrl|previewImageUrl` in `apps/admin/src/app/dashboard`
+- `OPENROUTER_API_KEY|OPENAI_API_KEY|OPENAI_BASE_URL` in `apps/admin/src/config/env.ts`
+
+## What To Build
+
+1. Add a `Generate with AI` flow to the empty-canvas state of the admin
+ experience editor so an operator can enter a theme, story, or angle and
+ receive an editable first draft.
+2. Keep the generation path admin-native:
+ - generate `title`
+ - generate `metaDescription`
+ - generate top-level and nested blocks that normalize into
+ `apps/admin/src/domain/blocks.ts`
+ - do **not** call Seed Studio at runtime or import from another app context
+3. Keep video and media picks constrained to the real editorial catalog only.
+ The model may choose only from server-provided candidate videos; it must not
+ invent external streaming URLs or raw video references.
+4. Make v1 empty-canvas only. If the locale already has blocks, the AI entry
+ point should be hidden or disabled. Do not merge into existing block trees in
+ this ticket.
+5. Keep generation ephemeral until the editor saves. `Generate with AI` should
+ populate local editor state, but it must not write to the database or publish
+ automatically.
+6. Let the provider-facing schema cover every block type the admin editor
+ supports, but normalize the model output through a server-owned mapper before
+ validating with `BlocksSchema`.
+7. Reuse the existing admin provider/env posture (`OPENROUTER_API_KEY` first,
+ `OPENAI_API_KEY` fallback) and existing editor permissions (`write:experiences` /
+ locale edit checks).
+
+## Constraints
+
+- Keep scope inside `apps/admin` plus roadmap/plan/docs updates.
+- Preserve the admin architecture boundary: UI -> server actions/services ->
+ Prisma. No client-side provider calls.
+- Do not change the canonical saved block schema just to make the model easier
+ to prompt. Introduce a model-facing draft schema if needed, then normalize
+ into `BlocksSchema`.
+- Do not auto-save, auto-publish, or overwrite an already non-empty canvas.
+- Do not import runtime code from `apps/seed-studio`, `apps/web`, `apps/mobile`,
+ `apps/mobile-v2`, `apps/cms`, or `apps/manager`.
+- Keep slug/path routing edits manual in v1 unless a separate, explicit slug
+ policy is added in the same ticket.
+
+## Verification
+
+- `pnpm --filter @forge/admin lint`
+- `pnpm --filter @forge/admin typecheck`
+- `pnpm --filter @forge/admin test`
+- Browser check of `/dashboard/experiences/[id]`:
+ - empty canvas shows `Generate with AI`
+ - prompt submission shows loading/error/success states
+ - successful generation fills title/description/blocks without saving
+ - generated video blocks only reference catalog-backed items
diff --git a/docs/roadmap/topic-experiences/feat-106-seed-studio-watch-parity-generator.md b/docs/roadmap/topic-experiences/feat-106-seed-studio-watch-parity-generator.md
new file mode 100644
index 000000000..89145f6fc
--- /dev/null
+++ b/docs/roadmap/topic-experiences/feat-106-seed-studio-watch-parity-generator.md
@@ -0,0 +1,86 @@
+---
+id: "feat-106"
+title: "Seed Studio Watch Parity Generator"
+owner: "tataihono"
+priority: "P1"
+status: "complete"
+start_date: "2026-04-22"
+duration: 10
+depends_on: []
+blocks: []
+tags:
+ - "cms"
+ - "web"
+ - "watch"
+ - "ai-pipeline"
+---
+
+## Problem
+
+Seed Studio can already save generated experiences, but its output still falls
+short of the production `/watch` experience shape. The generator produces a
+flatter block structure than `/watch/easter`, preview support is incomplete for
+the richer section wrappers and collection modules, and publish-time nested
+video relation handling is easy to break when `sectionKey` or slug hygiene
+falls out of sync. That makes the tool feel experimental instead of dependable
+for real editorial use.
+
+## Entry Points — Read These First
+
+1. `docs/plans/2026-04-22-001-feat-seed-studio-watch-parity-plan.md` — plan,
+ implementation units, and parity acceptance criteria
+2. `apps/seed-studio/src/app/api/chat/route.ts` — search, generation, SSE, and
+ fallback provider orchestration
+3. `packages/experience-templates/src/template.ts` — canonical easter-shaped
+ layout, archetypes, and `sectionKey` generation helpers
+4. `packages/experience-templates/src/parity.ts` — structural diff logic for
+ comparing generated experiences to the reference shape
+5. `apps/seed-studio/src/components/preview/SectionRenderer.tsx` — preview
+ coverage for section wrappers and nested content
+6. `apps/cms/src/api/seed-studio/services/seed-studio.ts` — publish flow,
+ nested video relation collection, and relation patching
+7. `apps/cms/src/api/seed-studio/controllers/seed-studio.ts` — slug validation
+ and publish error contract returned to the studio UI
+
+## Grep These
+
+- `supportsStrictJsonSchema|generateExperience|patch`
+- `collectVideoRelations|patchNestedVideoRelations|sanitizeSlug`
+- `sections.section|media-collection|navigation-carousel`
+- `EASTER_SHAPED_TEMPLATE_LAYOUT|parityDiff|buildSectionKey`
+
+## What To Build
+
+1. Move the watch-parity section contract into
+ `packages/experience-templates/` and make seed-studio consume it for shared
+ types, alias normalization, template layout, and structural parity checks.
+2. Upgrade the studio generation path to use the existing CMS search surface
+ for candidate videos and the strict JSON Schema generator path where the
+ selected provider supports it, while preserving the legacy free-form
+ fallback.
+3. Harden the CMS publish flow with central slug sanitization, deterministic
+ nested video relation collection, and collision feedback that the seed-studio
+ publish dialog can surface directly.
+4. Add package-level parity tests and focused CMS publish-path tests so the new
+ shared template package and nested relation behavior stay trustworthy.
+
+## Constraints
+
+- Keep canonical content modeling in `apps/cms`; do not invent a separate
+ experience schema inside seed-studio.
+- Preserve the existing seed-studio publish endpoint contract for current
+ callers.
+- Do not expand this scope into unrelated admin editor or login-copy work.
+- If the CMS schema changes as part of this work, regenerate downstream GraphQL
+ types in the same PR.
+
+## Verification
+
+- `pnpm --filter @forge/experience-templates lint`
+- `pnpm --filter @forge/experience-templates typecheck`
+- `pnpm --filter @forge/experience-templates test`
+- `pnpm --filter @forge/seed-studio lint`
+- `pnpm --filter @forge/seed-studio typecheck`
+- `pnpm --filter @forge/cms lint`
+- `pnpm --filter @forge/cms typecheck`
+- `pnpm --filter @forge/cms test src/api/seed-studio/services/seed-studio.test.ts src/lib/sanitize-slug.test.ts`
diff --git a/docs/solutions/integration-issues/admin-ai-experience-preview-videodub-language-selection-20260501.md b/docs/solutions/integration-issues/admin-ai-experience-preview-videodub-language-selection-20260501.md
new file mode 100644
index 000000000..df99451e3
--- /dev/null
+++ b/docs/solutions/integration-issues/admin-ai-experience-preview-videodub-language-selection-20260501.md
@@ -0,0 +1,191 @@
+---
+title: "Admin AI Experience preview: match VideoDub language before using stream URLs"
+category: integration-issues
+module: apps/admin + apps/web
+date: 2026-05-01
+problem_type: integration_issue
+component: service_object
+symptoms:
+ - "/watch//en showed English generated copy but video playback used non-English audio"
+ - "Generated blocks could persist streamingUrl from the first available dub rather than the requested locale"
+ - "Preview hydration could fall back to a non-English dub when a generated block only stored videoId"
+root_cause: logic_error
+resolution_type: code_fix
+severity: high
+tags:
+ - admin-experience-ai
+ - preview
+ - video-dubs
+ - locale
+ - graphql
+ - vector-search
+ - watch-page
+ - generated-content
+affected_components:
+ - apps/admin/src/services/experience-ai/experience-ai.service.ts
+ - apps/admin/src/services/experience-ai/experience-ai.service.test.ts
+ - apps/web/src/lib/admin-content.ts
+ - apps/web/src/lib/admin-content.test.ts
+ - apps/admin/src/graphql/types/experience.ts
+ - apps/web/src/lib/content.ts
+related_docs:
+ - docs/solutions/cms/admin-app-data-model-decisions.md
+ - docs/solutions/best-practices/prototype-defaults-vs-data-derived-enumeration-20260422.md
+ - docs/roadmap/platform/feat-107-admin-ai-experience-drafting.md
+---
+
+# Admin AI Experience preview: match VideoDub language before using stream URLs
+
+## Problem
+
+Admin AI Experience generation for `locale=en` could create a page whose text
+was English while its video playback used a different audio language. The page
+looked like an English Experience, but the generated `streamingUrl` or preview
+fallback pointed at whichever `VideoDub` happened to sort first.
+
+This is a different class of bug from bad AI copy. The text source is
+`VideoLocale`; playback audio is `VideoDub.language`. Both must match the
+requested Experience locale before the generated page is considered locale
+correct.
+
+## Symptoms
+
+- A freshly generated page such as `/watch/vector-retest-1777607482347-9/en`
+ rendered English headings, collection titles, and question text.
+- The hero block persisted a `streamingUrl` for `what-is-christianity` whose
+ `VideoDub.language.bcp47` was `hr`, while the English dub existed later in
+ the same catalog list.
+- Other referenced videos had the same shape: English `VideoLocale` rows but
+ first dubs in languages such as `ru` or `th`.
+- Preview correctly preserved generated block data, so old records with a
+ wrong stored `streamingUrl` kept playing the wrong audio.
+
+## What Didn't Work
+
+- Prompting the model to write all generated copy in English only fixed page
+ text. It did not affect which HLS URL was chosen for playback.
+- Filtering candidate titles/descriptions to `videoLocale.locale === "en"`
+ prevented cross-language copy fallback, but still allowed `previewStreamUrl`
+ to come from the first dub.
+- Preview-side overwriting of generated fields is the wrong fix. Preview must
+ render real Experience JSON, using catalog data only to hydrate missing
+ fields. Existing wrong generated records should be regenerated and
+ re-published, not silently corrected at render time.
+- Prior session history showed that Admin preview already needed an Admin
+ content source and block-shape adapter; relying on the Strapi watch path did
+ not make Admin-published Experiences visible on web. (session history)
+
+## Solution
+
+The fix is to make stream selection locale-aware in both places where a stream
+URL can enter preview output.
+
+### Generation candidate loading
+
+`apps/admin/src/services/experience-ai/experience-ai.service.ts` now fetches
+language metadata for candidate dubs:
+
+```ts
+language: {
+ select: {
+ bcp47: true,
+ iso3: true,
+ slug: true,
+ },
+},
+```
+
+Candidate assembly only assigns `previewStreamUrl` from a dub matching the
+requested locale:
+
+```ts
+const preferredDub =
+ dubsByVideo
+ .get(video.id)
+ ?.find(
+ (row) =>
+ (row.hls || row.dash || row.share) &&
+ (row.language?.bcp47 === locale ||
+ row.language?.iso3 === locale ||
+ row.language?.slug === locale),
+ ) ?? null
+```
+
+If a catalog video has English copy but no English dub, the candidate can still
+exist, but `previewStreamUrl` is `null`. That is safer than generating an
+English page with non-English audio.
+
+### Preview fallback hydration
+
+`apps/web/src/lib/admin-content.ts` now requests `language { bcp47 iso3 slug }`
+for Admin `referencedVideos.dubs`, carries the `ExperienceLocale.locale` through
+normalization, and only hydrates fallback streams from matching dubs:
+
+```ts
+function videoStream(video: AdminReferencedVideo | undefined, locale: string) {
+ return (
+ video?.dubs?.find(
+ (dub) =>
+ dub?.published === true && dub.hls && dubMatchesLocale(dub, locale),
+ )?.hls ??
+ video?.dubs?.find((dub) => dub?.hls && dubMatchesLocale(dub, locale))
+ ?.hls ??
+ null
+ )
+}
+```
+
+The adapter still preserves generated block data first:
+
+```ts
+streamingUrl: block.streamingUrl ?? videoStream(heroVideo, locale)
+```
+
+That keeps preview honest. It prevents fallback from introducing the wrong
+audio when `streamingUrl` is missing, while leaving already persisted generated
+data visible as-is.
+
+## Why This Works
+
+The data model separates viewer-facing metadata from playback audio:
+
+- `VideoLocale` is the localized title, description, and snippet for an
+ audience.
+- `VideoDub` is the audio-language-specific playable media.
+
+The bug came from treating those as interchangeable. Vector search and locale
+copy filtering could find English candidate text, but a language-agnostic dub
+lookup could still pick Croatian, Russian, Thai, or any other stream based on
+row ordering.
+
+Matching `VideoDub.language` at generation time prevents new Experiences from
+persisting bad stream URLs. Matching it again in preview fallback prevents the
+renderer from inventing a wrong-language stream when a block only contains a
+`videoId`.
+
+## Prevention
+
+- Test candidate loading with a newer non-English dub before an older English
+ dub; `previewStreamUrl` must pick the English HLS.
+- Test candidate loading with only another locale's `VideoLocale`; the video
+ should not become an English candidate.
+- Test preview hydration with a Spanish dub before an English dub; fallback
+ should use English for an English Experience.
+- Test preview hydration with only a Spanish dub; fallback should return
+ `streamingUrl: null` for an English Experience.
+- Treat "catalog-backed video" as necessary but insufficient. Any persisted or
+ hydrated playback URL must also be selected from a `VideoDub` whose language
+ matches the Experience locale.
+- Do not fix old generated pages by overriding preview output. Regenerate and
+ re-publish those Experiences so the stored block JSON is correct.
+
+## Related Issues
+
+- [Admin App Data Model Decisions](../cms/admin-app-data-model-decisions.md) —
+ defines `VideoDub` as the audio-language grain and `VideoLocale` as
+ audience-facing metadata.
+- [Prototype defaults vs data-derived enumeration](../best-practices/prototype-defaults-vs-data-derived-enumeration-20260422.md)
+ — same failure class: silent language defaults create data that looks valid
+ but lies about the underlying language.
+- [Admin AI Experience Drafting](../../roadmap/platform/feat-107-admin-ai-experience-drafting.md)
+ — feature scope for prompt-first Admin-native Experience generation.
diff --git a/docs/solutions/integration-issues/seed-studio-quiz-button-iframesrc-required.md b/docs/solutions/integration-issues/seed-studio-quiz-button-iframesrc-required.md
new file mode 100644
index 000000000..692b5276a
--- /dev/null
+++ b/docs/solutions/integration-issues/seed-studio-quiz-button-iframesrc-required.md
@@ -0,0 +1,78 @@
+---
+title: Seed Studio quiz-button save fails — iframeSrc required by Strapi schema
+category: integration-issues
+date: 2026-04-22
+tags:
+ - seed-studio
+ - strapi
+ - cms
+ - validation
+ - llm-generation
+related_paths:
+ - apps/seed-studio/src/lib/ai/generator.server.ts
+ - apps/seed-studio/src/app/api/chat/route.ts
+ - apps/seed-studio/src/lib/ai/experience-schema.ts
+ - apps/cms/src/components/sections/quiz-button.json
+---
+
+## Problem
+
+"Save to Strapi" in Seed Studio failed with a Yup validation error:
+
+```
+blocks.3.content.3.iframeSrc: blocks[3].content[3].iframeSrc must be a `string` type, but the final value was: `null`.
+```
+
+Seed Studio generated a `sections.quiz-button` component containing only `buttonText`, but the Strapi schema requires `iframeSrc` as a non-null string.
+
+## Root cause
+
+Contract mismatch between Seed Studio's deterministic section builders and the Strapi `sections.quiz-button` component schema. The builders in `generator.server.ts` emitted quiz-button blocks with just `buttonText`, and the schema (`apps/cms/src/components/sections/quiz-button.json`) declares `iframeSrc` as `required: true` with a regex constraint (`^https://[\w.-]+\.nextstep\.is/.*$`). The LLM wasn't asked for the field either, so it was never populated.
+
+## Solution
+
+Provide a default `iframeSrc` at every quiz-button emission site, extend the TS type, and teach the LLM via the example in the system prompt.
+
+**1. `apps/seed-studio/src/lib/ai/generator.server.ts`** — add a default and include it in both builders:
+
+```ts
+const DEFAULT_QUIZ_IFRAME_SRC =
+ "https://your.nextstep.is/embed/default?expand=false"
+
+// both quiz-button emission sites:
+{
+ __component: "sections.quiz-button",
+ buttonText: block.quizButtonText ?? "Take the quiz",
+ iframeSrc: DEFAULT_QUIZ_IFRAME_SRC,
+}
+```
+
+**2. `apps/seed-studio/src/lib/ai/experience-schema.ts`** — add the field to the type:
+
+```ts
+export type QuizButtonSection = {
+ __component: "sections.quiz-button"
+ buttonText: string
+ iframeSrc: string
+}
+```
+
+**3. `apps/seed-studio/src/app/api/chat/route.ts`** — update the JSON example in the system prompt so the LLM sees the correct shape:
+
+```json
+{
+ "__component": "sections.quiz-button",
+ "buttonText": "Take the Quiz",
+ "iframeSrc": "https://your.nextstep.is/embed/default?expand=false"
+}
+```
+
+## Verification
+
+1. Start CMS + Seed Studio: `pnpm --filter @forge/cms dev` and `pnpm --filter seed-studio dev`
+2. Generate an experience that includes a quiz-button section
+3. Click **Save to Strapi** — publish action returns 200, page viewable at `localhost:3000/watch/`
+
+## Prevention
+
+When adding or changing a Strapi component with required fields, update the Seed Studio surface in three places together: builders in `generator.server.ts`, TS types in `experience-schema.ts`, and the example JSON in the chat route's system prompt. A CI-side contract test that validates a sample generated payload against the Strapi component schemas would catch this class of drift before it hits runtime.
diff --git a/packages/experience-templates/package.json b/packages/experience-templates/package.json
new file mode 100644
index 000000000..cb2f2a691
--- /dev/null
+++ b/packages/experience-templates/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "@forge/experience-templates",
+ "private": true,
+ "version": "0.0.1",
+ "type": "module",
+ "exports": {
+ ".": "./src/index.ts",
+ "./types": "./src/types.ts",
+ "./template": "./src/template.ts",
+ "./aliases": "./src/aliases.ts",
+ "./parity": "./src/parity.ts"
+ },
+ "scripts": {
+ "lint": "eslint .",
+ "typecheck": "tsc --noEmit",
+ "test": "vitest run"
+ },
+ "dependencies": {
+ "zod": "^4.1.12"
+ },
+ "devDependencies": {
+ "typescript": "^5.9.3",
+ "vitest": "^3.2.4"
+ }
+}
diff --git a/packages/experience-templates/src/aliases.ts b/packages/experience-templates/src/aliases.ts
new file mode 100644
index 000000000..1e431cded
--- /dev/null
+++ b/packages/experience-templates/src/aliases.ts
@@ -0,0 +1,78 @@
+/**
+ * Map of shorthand component names to their canonical `sections.*` strings.
+ *
+ * Small, fast-moving language models like local Ollama instances frequently
+ * emit bare names ("video") instead of the fully-qualified Strapi component
+ * identifier ("sections.video"). We normalize both forms to the canonical name
+ * before validation so downstream consumers never have to guard against it.
+ *
+ * Keys are matched case-insensitively (after trimming + lowercasing) by
+ * `normalizeComponent`. When adding a new allowed component, add both its
+ * canonical form and any common short forms.
+ */
+export const COMPONENT_ALIASES: Record = {
+ // Canonical forms — keep as-is so round-trips are idempotent.
+ "sections.video": "sections.video",
+ "sections.video-hero": "sections.video-hero",
+ "sections.video-carousel": "sections.video-carousel",
+ "sections.section": "sections.section",
+ "sections.text": "sections.text",
+ "sections.container": "sections.container",
+ "sections.related-questions": "sections.related-questions",
+ "sections.bible-quotes-carousel": "sections.bible-quotes-carousel",
+ "sections.quiz-button": "sections.quiz-button",
+ "sections.media-collection": "sections.media-collection",
+ "sections.navigation-carousel": "sections.navigation-carousel",
+ "sections.cta": "sections.cta",
+ "sections.card": "sections.card",
+ "sections.info-blocks": "sections.info-blocks",
+ "sections.promo-banner": "sections.promo-banner",
+ "sections.advent-countdown": "sections.advent-countdown",
+ "sections.easter-dates": "sections.easter-dates",
+
+ // Existing short forms (migrated from apps/seed-studio/src/lib/chat/use-chat.ts)
+ video: "sections.video",
+ "video-hero": "sections.video-hero",
+ hero: "sections.video-hero",
+ "video-carousel": "sections.video-carousel",
+ carousel: "sections.video-carousel",
+ text: "sections.text",
+ paragraph: "sections.text",
+ container: "sections.container",
+ "related-questions": "sections.related-questions",
+ questions: "sections.related-questions",
+ faq: "sections.related-questions",
+ "bible-quotes": "sections.bible-quotes-carousel",
+ "bible-quotes-carousel": "sections.bible-quotes-carousel",
+ quotes: "sections.bible-quotes-carousel",
+ scripture: "sections.bible-quotes-carousel",
+ "quiz-button": "sections.quiz-button",
+ quiz: "sections.quiz-button",
+
+ // New aliases for the wrapper-shaped model.
+ section: "sections.section",
+ wrapper: "sections.section",
+ "media-collection": "sections.media-collection",
+ "navigation-carousel": "sections.navigation-carousel",
+ navigation: "sections.navigation-carousel",
+ cta: "sections.cta",
+ card: "sections.card",
+ "info-blocks": "sections.info-blocks",
+ "promo-banner": "sections.promo-banner",
+ "advent-countdown": "sections.advent-countdown",
+ "easter-dates": "sections.easter-dates",
+}
+
+/**
+ * Normalize a raw component string to its canonical `sections.*` name, or
+ * return `null` if no alias is known. Input is trimmed and lowercased; both
+ * `"Sections.Video"` and `"video"` resolve to `"sections.video"`.
+ */
+export function normalizeComponent(name: string): string | null {
+ if (typeof name !== "string") return null
+ const key = name.trim().toLowerCase()
+ if (!key) return null
+ if (COMPONENT_ALIASES[key]) return COMPONENT_ALIASES[key]
+ const bare = key.replace(/^sections?\./, "")
+ return COMPONENT_ALIASES[bare] ?? null
+}
diff --git a/packages/experience-templates/src/index.ts b/packages/experience-templates/src/index.ts
new file mode 100644
index 000000000..b5262f76e
--- /dev/null
+++ b/packages/experience-templates/src/index.ts
@@ -0,0 +1,4 @@
+export * from "./types"
+export * from "./template"
+export * from "./aliases"
+export * from "./parity"
diff --git a/packages/experience-templates/src/parity.test.ts b/packages/experience-templates/src/parity.test.ts
new file mode 100644
index 000000000..216ecfc9d
--- /dev/null
+++ b/packages/experience-templates/src/parity.test.ts
@@ -0,0 +1,85 @@
+import { describe, expect, it } from "vitest"
+
+import type { TopLevelBlock } from "./types"
+import { parityDiff } from "./parity"
+
+const HERO_BLOCK = {
+ __component: "sections.video-hero",
+ sectionKey: "forgiveness-hero",
+ streamingUrl: "https://cdn.example.com/hero.m3u8",
+ heading: "Forgiveness",
+} satisfies TopLevelBlock
+
+const CAROUSEL_WRAPPER = {
+ __component: "sections.section",
+ sectionKey: "forgiveness-series",
+ backgroundColor: "light",
+ content: [
+ {
+ __component: "sections.video-carousel",
+ sectionKey: "forgiveness-carousel",
+ title: "Keep watching",
+ items: [
+ {
+ sectionKey: "forgiveness-carousel-1",
+ video: 11,
+ streamingUrl: "https://cdn.example.com/series-1.m3u8",
+ title: "Part 1",
+ },
+ ],
+ },
+ ],
+} satisfies TopLevelBlock
+
+describe("parityDiff", () => {
+ it("returns an empty diff for identical trees", () => {
+ const report = parityDiff(
+ [HERO_BLOCK, CAROUSEL_WRAPPER],
+ [HERO_BLOCK, CAROUSEL_WRAPPER],
+ )
+
+ expect(report).toEqual({ ok: true, mismatches: [] })
+ })
+
+ it("reports missing top-level blocks", () => {
+ const report = parityDiff([HERO_BLOCK, CAROUSEL_WRAPPER], [HERO_BLOCK])
+
+ expect(report.ok).toBe(false)
+ expect(report.mismatches).toContainEqual(
+ expect.objectContaining({
+ kind: "block-count",
+ path: ["blocks"],
+ expected: 2,
+ actual: 1,
+ }),
+ )
+ })
+
+ it("reports nested archetype mismatches inside section wrappers", () => {
+ const actual = [
+ HERO_BLOCK,
+ {
+ ...CAROUSEL_WRAPPER,
+ content: [
+ {
+ __component: "sections.media-collection",
+ variant: "collection",
+ title: "Related media",
+ },
+ ],
+ } satisfies TopLevelBlock,
+ ]
+
+ const report = parityDiff([HERO_BLOCK, CAROUSEL_WRAPPER], actual)
+
+ expect(report.ok).toBe(false)
+ expect(report.mismatches).toContainEqual(
+ expect.objectContaining({
+ kind: "component-mismatch",
+ path: [1, "content", 0],
+ expected: "sections.video-carousel",
+ actual: "sections.media-collection",
+ }),
+ )
+ })
+})
diff --git a/packages/experience-templates/src/parity.ts b/packages/experience-templates/src/parity.ts
new file mode 100644
index 000000000..b22bfc852
--- /dev/null
+++ b/packages/experience-templates/src/parity.ts
@@ -0,0 +1,186 @@
+import type { SectionContent, TopLevelBlock } from "./types"
+
+/**
+ * Parity diff — compares an expected structural template against a generated
+ * tree and reports every mismatch without throwing. Pure, dependency-free.
+ *
+ * We walk top-level blocks, then recurse into `sections.section.content[]`
+ * and `sections.container.slots[].content[]` where relevant. We also surface
+ * two cross-cutting invariants required by the CMS:
+ * 1. Every `sections.video` / `sections.video-hero` must carry a sectionKey.
+ * 2. Every `sections.video` must have a non-zero `video` relation id.
+ */
+
+export type DiffMismatchKind =
+ | "block-count"
+ | "component-mismatch"
+ | "nested-count-mismatch"
+ | "missing-section-key"
+ | "missing-video-relation"
+
+export type DiffMismatch = {
+ path: (string | number)[]
+ kind: DiffMismatchKind
+ expected?: unknown
+ actual?: unknown
+ message: string
+}
+
+export type DiffReport = {
+ ok: boolean
+ mismatches: DiffMismatch[]
+}
+
+type AnyBlock = { __component?: string; [key: string]: unknown }
+
+function componentOf(block: unknown): string | undefined {
+ if (block && typeof block === "object") {
+ const c = (block as AnyBlock).__component
+ if (typeof c === "string") return c
+ }
+ return undefined
+}
+
+function pathString(path: (string | number)[]): string {
+ return path.length === 0 ? "" : path.join(".")
+}
+
+export function parityDiff(
+ expected: TopLevelBlock[],
+ actual: TopLevelBlock[],
+): DiffReport {
+ const mismatches: DiffMismatch[] = []
+
+ // 1. Top-level block count
+ if (expected.length !== actual.length) {
+ mismatches.push({
+ path: ["blocks"],
+ kind: "block-count",
+ expected: expected.length,
+ actual: actual.length,
+ message: `Expected ${expected.length} top-level blocks, got ${actual.length}.`,
+ })
+ }
+
+ const shared = Math.min(expected.length, actual.length)
+ for (let i = 0; i < shared; i++) {
+ const exp = expected[i]
+ const act = actual[i]
+ const expComp = componentOf(exp)
+ const actComp = componentOf(act)
+
+ if (expComp !== actComp) {
+ mismatches.push({
+ path: [i],
+ kind: "component-mismatch",
+ expected: expComp,
+ actual: actComp,
+ message: `Block ${i}: expected ${expComp ?? ""}, got ${actComp ?? ""}.`,
+ })
+ }
+
+ // 2. If this is a wrapper, recurse into content
+ if (expComp === "sections.section" && actComp === "sections.section") {
+ const expContent =
+ ((exp as { content?: SectionContent[] }).content as SectionContent[]) ??
+ []
+ const actContent =
+ ((act as { content?: SectionContent[] }).content as SectionContent[]) ??
+ []
+ if (expContent.length !== actContent.length) {
+ mismatches.push({
+ path: [i, "content"],
+ kind: "nested-count-mismatch",
+ expected: expContent.length,
+ actual: actContent.length,
+ message: `Block ${i}: expected ${expContent.length} content items, got ${actContent.length}.`,
+ })
+ }
+ const nestedShared = Math.min(expContent.length, actContent.length)
+ for (let j = 0; j < nestedShared; j++) {
+ const expNested = componentOf(expContent[j])
+ const actNested = componentOf(actContent[j])
+ if (expNested !== actNested) {
+ mismatches.push({
+ path: [i, "content", j],
+ kind: "component-mismatch",
+ expected: expNested,
+ actual: actNested,
+ message: `Block ${i}.content[${j}]: expected ${expNested ?? ""}, got ${actNested ?? ""}.`,
+ })
+ }
+ }
+ }
+ }
+
+ // 3. Cross-cutting invariants: walk the actual tree for missing keys/relations
+ walk(actual, [], (node, path) => {
+ const comp = componentOf(node)
+ if (comp === "sections.video" || comp === "sections.video-hero") {
+ const key = (node as { sectionKey?: unknown }).sectionKey
+ if (typeof key !== "string" || key.length === 0) {
+ mismatches.push({
+ path,
+ kind: "missing-section-key",
+ actual: key,
+ message: `${pathString(path)}: ${comp} is missing a sectionKey.`,
+ })
+ }
+ }
+ if (comp === "sections.video") {
+ const videoId = (node as { video?: unknown }).video
+ if (
+ videoId === null ||
+ videoId === undefined ||
+ videoId === 0 ||
+ (typeof videoId !== "number" && typeof videoId !== "object")
+ ) {
+ mismatches.push({
+ path,
+ kind: "missing-video-relation",
+ actual: videoId,
+ message: `${pathString(path)}: sections.video is missing a video relation.`,
+ })
+ }
+ }
+ })
+
+ return { ok: mismatches.length === 0, mismatches }
+}
+
+type Walker = (node: unknown, path: (string | number)[]) => void
+
+function walk(
+ blocks: unknown[],
+ basePath: (string | number)[],
+ visit: Walker,
+): void {
+ blocks.forEach((block, i) => {
+ const path = [...basePath, i]
+ visit(block, path)
+ const comp = componentOf(block)
+ if (comp === "sections.section") {
+ const content =
+ (block as { content?: unknown[] }).content ?? ([] as unknown[])
+ walk(content, [...path, "content"], visit)
+ } else if (comp === "sections.container") {
+ const slots = (block as { slots?: unknown[] }).slots ?? []
+ slots.forEach((slot, s) => {
+ const content =
+ (slot as { content?: unknown[] }).content ?? ([] as unknown[])
+ walk(content, [...path, "slots", s, "content"], visit)
+ })
+ } else if (comp === "sections.video-carousel") {
+ const items = (block as { items?: unknown[] }).items ?? []
+ walk(items, [...path, "items"], (itemNode, itemPath) => {
+ // Carousel items aren't full components, but they carry sectionKey +
+ // video relations, so normalize them into a synthetic sections.video
+ // shape for invariant checks.
+ visit(
+ { __component: "sections.video", ...(itemNode as object) },
+ itemPath,
+ )
+ })
+ }
+ })
+}
diff --git a/packages/experience-templates/src/template.test.ts b/packages/experience-templates/src/template.test.ts
new file mode 100644
index 000000000..64af05825
--- /dev/null
+++ b/packages/experience-templates/src/template.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, it } from "vitest"
+
+import { normalizeComponent } from "./aliases"
+import { buildSectionKey, computePlatformOrdering } from "./template"
+
+describe("buildSectionKey", () => {
+ it("normalizes user-provided text into kebab-case", () => {
+ expect(buildSectionKey("Forgiveness & Mercy", "Video 1")).toBe(
+ "forgiveness-mercy-video-1",
+ )
+ })
+})
+
+describe("computePlatformOrdering", () => {
+ it("returns declaration-order indices for both platforms", () => {
+ expect(computePlatformOrdering(4)).toEqual({
+ web: [0, 1, 2, 3],
+ mobile: [0, 1, 2, 3],
+ })
+ })
+})
+
+describe("normalizeComponent", () => {
+ it("maps wrapper aliases to the canonical section component", () => {
+ expect(normalizeComponent(" Wrapper ")).toBe("sections.section")
+ expect(normalizeComponent("navigation")).toBe(
+ "sections.navigation-carousel",
+ )
+ })
+})
diff --git a/packages/experience-templates/src/template.ts b/packages/experience-templates/src/template.ts
new file mode 100644
index 000000000..7c948062c
--- /dev/null
+++ b/packages/experience-templates/src/template.ts
@@ -0,0 +1,137 @@
+import type { BackgroundColor } from "./types"
+
+/**
+ * Named archetypes that describe the structural "shape" of a generated section.
+ * The names are stable identifiers referenced by the template layout and by the
+ * AI generator prompts. Do not rename without also updating the generator.
+ */
+export type ArchetypeName =
+ | "VIDEO_HERO"
+ | "INTRODUCTION"
+ | "VIDEO_CENTRIC"
+ | "VIDEO_CAROUSEL"
+ | "MEDIA_COLLECTION"
+
+/**
+ * Shape descriptor for each archetype — tells a validator / generator which
+ * top-level component the archetype produces and (when wrapped) which nested
+ * `__component` strings are expected in order.
+ *
+ * Shapes are derived from /watch/easter, our reference experience.
+ */
+export const ARCHETYPE_SHAPES = {
+ VIDEO_HERO: { topLevel: "sections.video-hero" },
+ INTRODUCTION: {
+ topLevel: "sections.section",
+ content: [
+ "sections.navigation-carousel",
+ "sections.container",
+ "sections.video",
+ "sections.container",
+ "sections.bible-quotes-carousel",
+ "sections.quiz-button",
+ ],
+ },
+ VIDEO_CENTRIC: {
+ topLevel: "sections.section",
+ content: ["sections.video", "sections.container", "sections.quiz-button"],
+ },
+ VIDEO_CAROUSEL: {
+ topLevel: "sections.section",
+ content: ["sections.video-carousel"],
+ },
+ MEDIA_COLLECTION: {
+ topLevel: "sections.section",
+ content: ["sections.media-collection"],
+ },
+} as const
+
+export type ArchetypeShape = (typeof ARCHETYPE_SHAPES)[ArchetypeName]
+
+/**
+ * One layout entry in the default template. `sectionKeySuffix` is combined with
+ * the experience's theme slug via `buildSectionKey()` to produce the canonical
+ * sectionKey for the generated block.
+ */
+export type TemplateLayoutEntry = {
+ archetype: ArchetypeName
+ backgroundColor?: BackgroundColor
+ sectionKeySuffix: string
+}
+
+/**
+ * Default 9-block layout modelled on /watch/easter. The AI generator should
+ * produce one block per entry, in order. Editors may re-order or swap after
+ * publish — this only defines the starting point.
+ */
+export const EASTER_SHAPED_TEMPLATE_LAYOUT: readonly TemplateLayoutEntry[] = [
+ { archetype: "VIDEO_HERO", sectionKeySuffix: "hero" },
+ {
+ archetype: "INTRODUCTION",
+ backgroundColor: "dark",
+ sectionKeySuffix: "meaning",
+ },
+ {
+ archetype: "VIDEO_CENTRIC",
+ backgroundColor: "default",
+ sectionKeySuffix: "video-1",
+ },
+ {
+ archetype: "VIDEO_CAROUSEL",
+ backgroundColor: "light",
+ sectionKeySuffix: "series",
+ },
+ {
+ archetype: "VIDEO_CENTRIC",
+ backgroundColor: "default",
+ sectionKeySuffix: "video-2",
+ },
+ {
+ archetype: "VIDEO_CENTRIC",
+ backgroundColor: "primary",
+ sectionKeySuffix: "video-3",
+ },
+ {
+ archetype: "VIDEO_CAROUSEL",
+ backgroundColor: "light",
+ sectionKeySuffix: "day-by-day",
+ },
+ {
+ archetype: "VIDEO_CENTRIC",
+ backgroundColor: "cosmic",
+ sectionKeySuffix: "video-4",
+ },
+ {
+ archetype: "VIDEO_CENTRIC",
+ backgroundColor: "default",
+ sectionKeySuffix: "invitation",
+ },
+] as const
+
+/**
+ * Trivial deterministic platform ordering for V1 — web and mobile both render
+ * blocks in declaration order. Editors can override post-publish via the CMS
+ * `platformOrdering` field.
+ */
+export function computePlatformOrdering(blockCount: number): {
+ web: number[]
+ mobile: number[]
+} {
+ const indices: number[] = []
+ for (let i = 0; i < blockCount; i++) indices.push(i)
+ return { web: [...indices], mobile: [...indices] }
+}
+
+/**
+ * Build a canonical sectionKey by joining `themeSlug` and `suffix` with `-` and
+ * normalizing the result to kebab-case (lowercase, non-alphanumeric → dashes,
+ * collapsed repeats, trimmed). Safe to call with user-provided slugs.
+ */
+export function buildSectionKey(themeSlug: string, suffix: string): string {
+ const joined = `${themeSlug}-${suffix}`
+ return joined
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/-+/g, "-")
+ .replace(/^-|-$/g, "")
+}
diff --git a/packages/experience-templates/src/types.ts b/packages/experience-templates/src/types.ts
new file mode 100644
index 000000000..72e488b1e
--- /dev/null
+++ b/packages/experience-templates/src/types.ts
@@ -0,0 +1,497 @@
+import { z } from "zod"
+
+/**
+ * Shared types for generated experiences.
+ *
+ * These mirror the Strapi v5 section components in apps/cms/src/components/sections/*.
+ * The wrapper is `sections.section` which contains nested `content[]`. Top-level
+ * blocks may be a wrapper, a hero, or a carousel/collection used full-bleed.
+ *
+ * Source of truth: apps/cms/src/components/sections/*.json
+ */
+
+// -----------------------------------------------------------------------------
+// Primitives
+// -----------------------------------------------------------------------------
+
+export type Platform = "web" | "mobile"
+
+export type PlatformOrdering = {
+ web: number[]
+ mobile: number[]
+}
+
+export type BackgroundColor =
+ | "default"
+ | "light"
+ | "dark"
+ | "primary"
+ | "cosmic"
+ | "purple"
+
+export type VideoRef = {
+ id: number
+ documentId: string
+ title: string
+ slug: string
+ streamingUrl: string
+ thumbnailUrl?: string
+}
+
+// -----------------------------------------------------------------------------
+// Video sections
+// -----------------------------------------------------------------------------
+
+export type VideoSection = {
+ __component: "sections.video"
+ /**
+ * Required in the shared model — closes a historical AI bug where the model
+ * would omit sectionKey and the CMS would silently generate one.
+ */
+ sectionKey: string
+ video: number
+ streamingUrl: string
+ title: string
+ subtitle: string
+ videoRef?: VideoRef
+}
+
+export type VideoHeroSection = {
+ __component: "sections.video-hero"
+ sectionKey: string
+ streamingUrl: string
+ heading: string
+ ctaLabel?: string
+ ctaLink?: string
+ videoRef?: VideoRef
+}
+
+export type VideoCarouselItem = {
+ sectionKey: string
+ video: number
+ streamingUrl: string
+ title: string
+ subtitle?: string
+ videoRef?: VideoRef
+}
+
+export type VideoCarouselSection = {
+ __component: "sections.video-carousel"
+ title: string
+ subtitle?: string
+ description?: string
+ sectionKey: string
+ items: VideoCarouselItem[]
+}
+
+// -----------------------------------------------------------------------------
+// Media / navigation collections
+// -----------------------------------------------------------------------------
+
+export type MediaCollectionVariant =
+ | "carousel"
+ | "grid"
+ | "collection"
+ | "hero"
+ | "player"
+
+export type MediaCollectionItemsSource = "manual" | "routeVideoChildren"
+
+export type MediaCollectionItem = {
+ video?: { id: number; documentId?: string; slug?: string }
+ imageOverride?: unknown
+ titleOverride?: string
+ subtitleOverride?: string
+ labelOverride?: string
+ collectionSize?: string
+ imageUrl?: string
+ linkToSectionKey?: string
+}
+
+export type MediaCollectionSection = {
+ __component: "sections.media-collection"
+ sectionKey?: string
+ categoryLabel?: string
+ variant: MediaCollectionVariant
+ itemsSource?: MediaCollectionItemsSource
+ title?: string
+ subtitle?: string
+ description?: string
+ ctaLink?: string
+ ctaLabel?: string
+ showItemNumbers?: boolean
+ footerText?: string
+ items?: MediaCollectionItem[]
+}
+
+export type NavigationCarouselItem = {
+ contentId: string
+ title: string
+ category?: string
+ imageUrl?: string
+ backgroundColor?: string
+}
+
+export type NavigationCarouselSection = {
+ __component: "sections.navigation-carousel"
+ sectionKey?: string
+ items?: NavigationCarouselItem[]
+}
+
+// -----------------------------------------------------------------------------
+// Text / content blocks
+// -----------------------------------------------------------------------------
+
+export type TextSection = {
+ __component: "sections.text"
+ heading?: string
+ subtitle?: string
+ contentParagraphs: string[]
+}
+
+export type RelatedQuestion = {
+ question: string
+ answer: string
+}
+
+export type RelatedQuestionsSection = {
+ __component: "sections.related-questions"
+ heading: string
+ ctaLabel?: string
+ ctaLink?: string
+ questions: RelatedQuestion[]
+}
+
+export type BibleQuote = {
+ reference: string
+ text: string
+ attribution?: string
+ imageUrl: string
+ backgroundColor: string
+ ctaLabel?: string
+ ctaLink?: string
+}
+
+export type BibleQuotesCarouselSection = {
+ __component: "sections.bible-quotes-carousel"
+ heading: string
+ sectionKey: string
+ quotes: BibleQuote[]
+}
+
+export type QuizButtonSection = {
+ __component: "sections.quiz-button"
+ buttonText: string
+ iframeSrc: string
+}
+
+export type CardSection = {
+ __component: "sections.card"
+ sectionKey?: string
+ title: string
+ description: string
+ media?: unknown
+ link?: string
+ variant?: "default" | "featured"
+}
+
+export type CTASection = {
+ __component: "sections.cta"
+ sectionKey?: string
+ heading?: string
+ body?: string
+ buttonLabel: string
+ buttonLink?: string
+ variant?: "primary" | "secondary"
+}
+
+export type InfoBlock = {
+ /** Strapi schema marks icon/title/description as required on the leaf item. */
+ icon: string
+ title: string
+ description: string
+}
+
+export type InfoBlocksSection = {
+ __component: "sections.info-blocks"
+ sectionKey?: string
+ widthPercent?: number
+ intro?: string
+ heading?: string
+ description?: string
+ blocks?: InfoBlock[]
+}
+
+export type PromoBannerSection = {
+ __component: "sections.promo-banner"
+ sectionKey?: string
+ widthPercent?: number
+ intro?: string
+ heading: string
+ description: string
+ ctaLink: string
+}
+
+// -----------------------------------------------------------------------------
+// Date-aware stubs (kept minimal for V1; schemas live in CMS)
+// -----------------------------------------------------------------------------
+
+export type AdventCountdownSection = {
+ __component: "sections.advent-countdown"
+ sectionKey?: string
+ heading?: string
+ targetDate?: string
+}
+
+export type EasterDatesSection = {
+ __component: "sections.easter-dates"
+ sectionKey?: string
+ heading?: string
+}
+
+// -----------------------------------------------------------------------------
+// Container + wrapper
+// -----------------------------------------------------------------------------
+
+/**
+ * Components allowed inside `sections.container.slots[i].content[]`.
+ * Per container-slot.json: media-collection, text, related-questions, cta,
+ * bible-quotes-carousel, card, easter-dates, advent-countdown, video.
+ */
+export type SlotContent =
+ | VideoSection
+ | MediaCollectionSection
+ | TextSection
+ | RelatedQuestionsSection
+ | BibleQuotesCarouselSection
+ | CardSection
+ | CTASection
+ | AdventCountdownSection
+ | EasterDatesSection
+
+export type ContainerSlot = {
+ gridSpan: number
+ content: SlotContent[]
+}
+
+export type ContainerSection = {
+ __component: "sections.container"
+ sectionKey?: string
+ slots: ContainerSlot[]
+}
+
+/**
+ * Components allowed inside `sections.section.content[]`.
+ * Per section.json: media-collection, text, promo-banner, info-blocks, cta,
+ * container, related-questions, bible-quotes-carousel, card, video,
+ * quiz-button, video-carousel, navigation-carousel.
+ *
+ * Note: we also permit advent-countdown / easter-dates as nested content for
+ * forward compatibility — the CMS enforces the final allow-list at write time.
+ */
+export type SectionContent =
+ | VideoSection
+ | VideoCarouselSection
+ | MediaCollectionSection
+ | NavigationCarouselSection
+ | TextSection
+ | ContainerSection
+ | RelatedQuestionsSection
+ | BibleQuotesCarouselSection
+ | QuizButtonSection
+ | CardSection
+ | CTASection
+ | InfoBlocksSection
+ | PromoBannerSection
+ | AdventCountdownSection
+ | EasterDatesSection
+
+export type SectionWrapper = {
+ __component: "sections.section"
+ sectionKey: string
+ backgroundColor?: BackgroundColor
+ blurHash?: string
+ backgroundOpacity?: number
+ dynamicBackgroundImage?: boolean
+ staticOverlay?: boolean
+ content: SectionContent[]
+}
+
+// -----------------------------------------------------------------------------
+// Top-level blocks (what lives in GeneratedExperience.blocks[])
+// -----------------------------------------------------------------------------
+
+/**
+ * Blocks that may appear at the top level of a generated experience.
+ * Nested content (text, container, etc.) always sits inside a SectionWrapper.
+ */
+export type TopLevelBlock =
+ | VideoHeroSection
+ | SectionWrapper
+ | VideoCarouselSection
+ | MediaCollectionSection
+
+/** Convenience union used by parsers that walk mixed trees. */
+export type SectionBlock = TopLevelBlock | SectionContent
+
+export type GeneratedExperience = {
+ title: string
+ slug: string
+ metaDescription?: string
+ blocks: TopLevelBlock[]
+ platformOrdering: PlatformOrdering
+}
+
+export type ChatMessage = {
+ id: string
+ role: "user" | "assistant"
+ content: string
+ experienceSnapshot?: GeneratedExperience
+ suggestions?: string[]
+}
+
+// -----------------------------------------------------------------------------
+// Zod peers — used by seed-studio for validation + for the generator's
+// strict-JSON-Schema instructions.
+//
+// These mirror the TypeScript types 1:1. We intentionally keep the nested
+// content schemas loose (`z.any()`) inside container/section content to avoid
+// an explosion of forward-reference hacks; runtime callers can re-narrow with
+// discriminated unions after initial parse if they need deeper guarantees.
+// -----------------------------------------------------------------------------
+
+const backgroundColorSchema = z.enum([
+ "default",
+ "light",
+ "dark",
+ "primary",
+ "cosmic",
+ "purple",
+])
+
+const videoRefSchema = z.object({
+ id: z.number(),
+ documentId: z.string(),
+ title: z.string(),
+ slug: z.string(),
+ streamingUrl: z.string(),
+ thumbnailUrl: z.string().optional(),
+})
+
+const videoHeroSectionSchema = z.object({
+ __component: z.literal("sections.video-hero"),
+ sectionKey: z.string().min(1),
+ streamingUrl: z.string(),
+ heading: z.string(),
+ ctaLabel: z.string().optional(),
+ ctaLink: z.string().optional(),
+ videoRef: videoRefSchema.optional(),
+})
+
+const videoCarouselItemSchema = z.object({
+ sectionKey: z.string(),
+ video: z.number(),
+ streamingUrl: z.string(),
+ title: z.string(),
+ subtitle: z.string().optional(),
+ videoRef: videoRefSchema.optional(),
+})
+
+const videoCarouselSectionSchema = z.object({
+ __component: z.literal("sections.video-carousel"),
+ title: z.string(),
+ subtitle: z.string().optional(),
+ description: z.string().optional(),
+ sectionKey: z.string().min(1),
+ items: z.array(videoCarouselItemSchema),
+})
+
+const mediaCollectionSectionSchema = z.object({
+ __component: z.literal("sections.media-collection"),
+ sectionKey: z.string().optional(),
+ categoryLabel: z.string().optional(),
+ variant: z.enum(["carousel", "grid", "collection", "hero", "player"]),
+ itemsSource: z.enum(["manual", "routeVideoChildren"]).optional(),
+ title: z.string().optional(),
+ subtitle: z.string().optional(),
+ description: z.string().optional(),
+ ctaLink: z.string().optional(),
+ ctaLabel: z.string().optional(),
+ showItemNumbers: z.boolean().optional(),
+ footerText: z.string().optional(),
+ items: z
+ .array(
+ z.object({
+ video: z
+ .object({
+ id: z.number(),
+ documentId: z.string().optional(),
+ slug: z.string().optional(),
+ })
+ .optional(),
+ imageOverride: z.unknown().optional(),
+ titleOverride: z.string().optional(),
+ subtitleOverride: z.string().optional(),
+ labelOverride: z.string().optional(),
+ collectionSize: z.string().optional(),
+ imageUrl: z.string().optional(),
+ linkToSectionKey: z.string().optional(),
+ }),
+ )
+ .optional(),
+})
+
+const sectionWrapperSchema = z.object({
+ __component: z.literal("sections.section"),
+ sectionKey: z.string().min(1),
+ backgroundColor: backgroundColorSchema.optional(),
+ blurHash: z.string().optional(),
+ backgroundOpacity: z.number().min(0).max(1).optional(),
+ dynamicBackgroundImage: z.boolean().optional(),
+ staticOverlay: z.boolean().optional(),
+ // Nested content is kept loose to avoid a cyclic schema; consumers validate
+ // deeper using the per-component schemas when they need to.
+ content: z.array(z.record(z.string(), z.unknown())),
+})
+
+/**
+ * Discriminated union for the four canonical top-level block shapes produced
+ * by the strict-schema generator. Consumers that need deeper guarantees can
+ * re-parse `SectionWrapper.content[]` against a nested schema of their choice.
+ */
+export const topLevelBlockSchema = z.discriminatedUnion("__component", [
+ videoHeroSectionSchema,
+ sectionWrapperSchema,
+ videoCarouselSectionSchema,
+ mediaCollectionSectionSchema,
+])
+
+/**
+ * Permissive catch-all for blocks produced by legacy free-form providers
+ * (Gemini, Claude CLI, Ollama, Codex) which emit flat shapes like
+ * `sections.text`, `sections.video`, `sections.bible-quotes-carousel`, etc.
+ * at the top level. We only enforce that `__component` is a string — the
+ * preview SectionRenderer dispatches by `__component` and unknown types
+ * render as null with a dev console warning.
+ */
+const anyBlockSchema = z.looseObject({
+ __component: z.string(),
+})
+
+/**
+ * Block-level schema that accepts either a canonical strict top-level block
+ * OR any loose legacy block. This keeps the strict-schema generator (which
+ * always emits canonical shapes) honest while tolerating legacy providers.
+ */
+export const blockSchema = z.union([topLevelBlockSchema, anyBlockSchema])
+
+export const generatedExperienceSchema = z.object({
+ title: z.string(),
+ slug: z.string(),
+ metaDescription: z.string().optional(),
+ blocks: z.array(blockSchema),
+ platformOrdering: z.object({
+ web: z.array(z.number()),
+ mobile: z.array(z.number()),
+ }),
+})
diff --git a/packages/experience-templates/tsconfig.json b/packages/experience-templates/tsconfig.json
new file mode 100644
index 000000000..5765685b9
--- /dev/null
+++ b/packages/experience-templates/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "strict": true,
+ "noEmit": true,
+ "skipLibCheck": true,
+ "esModuleInterop": true
+ },
+ "include": ["src/**/*.ts"],
+ "exclude": ["node_modules"]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6557c42c9..cb76851cf 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -562,6 +562,61 @@ importers:
specifier: ^5.8.3
version: 5.9.3
+ apps/seed-studio:
+ dependencies:
+ '@forge/experience-templates':
+ specifier: workspace:*
+ version: link:../../packages/experience-templates
+ clsx:
+ specifier: ^2.1.1
+ version: 2.1.1
+ hls.js:
+ specifier: ^1.6.15
+ version: 1.6.16
+ lucide-react:
+ specifier: ^0.577.0
+ version: 0.577.0(react@19.2.4)
+ next:
+ specifier: ^16.1.6
+ version: 16.2.4(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ react:
+ specifier: ^19.0.0
+ version: 19.2.4
+ react-dom:
+ specifier: ^19.0.0
+ version: 19.2.4(react@19.2.4)
+ tailwind-merge:
+ specifier: ^3.5.0
+ version: 3.5.0
+ zod:
+ specifier: ^4.1.12
+ version: 4.3.6
+ devDependencies:
+ '@tailwindcss/postcss':
+ specifier: ^4.1.18
+ version: 4.2.2
+ '@types/react':
+ specifier: ^19.0.0
+ version: 19.1.17
+ '@types/react-dom':
+ specifier: ^19.0.0
+ version: 19.2.3(@types/react@19.1.17)
+ eslint:
+ specifier: ^9.0.0
+ version: 9.39.2(jiti@2.6.1)
+ eslint-config-next:
+ specifier: ^16.1.6
+ version: 16.1.6(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
+ postcss:
+ specifier: ^8.5.6
+ version: 8.5.6
+ tailwindcss:
+ specifier: ^4.1.18
+ version: 4.2.2
+ typescript:
+ specifier: ^5
+ version: 5.9.3
+
apps/tv:
dependencies:
'@apollo/client':
@@ -753,6 +808,19 @@ importers:
specifier: 3.2.4
version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(msw@2.12.10(@types/node@25.2.3)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)
+ packages/experience-templates:
+ dependencies:
+ zod:
+ specifier: ^4.1.12
+ version: 4.3.6
+ devDependencies:
+ typescript:
+ specifier: ^5.9.3
+ version: 5.9.3
+ vitest:
+ specifier: ^3.2.4
+ version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(msw@2.12.10(@types/node@25.2.3)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)
+
packages/graphql:
dependencies:
gql.tada:
@@ -10219,6 +10287,9 @@ packages:
hls.js@1.5.20:
resolution: {integrity: sha512-uu0VXUK52JhihhnN/MVVo1lvqNNuhoxkonqgO3IpjvQiGpJBdIXMGkofjQb/j9zvV7a1SW8U9g1FslWx/1HOiQ==}
+ hls.js@1.6.16:
+ resolution: {integrity: sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==}
+
hmac-drbg@1.0.1:
resolution: {integrity: sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==}
@@ -19117,6 +19188,18 @@ snapshots:
dependencies:
tslib: 2.8.1
+ '@formatjs/intl@2.10.0(typescript@5.4.4)':
+ dependencies:
+ '@formatjs/ecma402-abstract': 1.18.2
+ '@formatjs/fast-memoize': 2.2.0
+ '@formatjs/icu-messageformat-parser': 2.7.6
+ '@formatjs/intl-displaynames': 6.6.6
+ '@formatjs/intl-listformat': 7.5.5
+ intl-messageformat: 10.5.11
+ tslib: 2.8.1
+ optionalDependencies:
+ typescript: 5.4.4
+
'@formatjs/intl@2.10.0(typescript@5.9.3)':
dependencies:
'@formatjs/ecma402-abstract': 1.18.2
@@ -24588,7 +24671,6 @@ snapshots:
'@types/react-dom@19.2.3(@types/react@19.1.17)':
dependencies:
'@types/react': 19.1.17
- optional: true
'@types/react-dom@19.2.3(@types/react@19.2.14)':
dependencies:
@@ -27571,7 +27653,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-module-utils@2.12.1(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)):
+ eslint-module-utils@2.12.1(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)):
dependencies:
debug: 3.2.7
optionalDependencies:
@@ -27602,7 +27684,7 @@ snapshots:
doctrine: 2.1.0
eslint: 9.39.2(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
- eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1))
+ eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
@@ -29237,6 +29319,8 @@ snapshots:
hls.js@1.5.20: {}
+ hls.js@1.6.16: {}
+
hmac-drbg@1.0.1:
dependencies:
hash.js: 1.1.7
@@ -33156,7 +33240,7 @@ snapshots:
dependencies:
'@formatjs/ecma402-abstract': 1.18.2
'@formatjs/icu-messageformat-parser': 2.7.6
- '@formatjs/intl': 2.10.0(typescript@5.9.3)
+ '@formatjs/intl': 2.10.0(typescript@5.4.4)
'@formatjs/intl-displaynames': 6.6.6
'@formatjs/intl-listformat': 7.5.5
'@types/hoist-non-react-statics': 3.3.7(@types/react@18.3.28)