| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import React, { useCallback, useEffect, useMemo, useState } from "react"; |
| import { |
| ArrowLeft, |
| Check, |
| ChevronDown, |
| ChevronUp, |
| Clock, |
| Compass, |
| Heart, |
| Pencil, |
| Sparkles, |
| Target, |
| } from "lucide-react"; |
| import { createInteractiveApi } from "./api"; |
| import { ExpertModeToggle } from "./ExpertLogPanel"; |
| import type { |
| ExperienceMode, |
| HealthInfo, |
| PlanAutoForm, |
| PlanAutoResult, |
| } from "./types"; |
| import { InteractiveApiError } from "./types"; |
| import { |
| ErrorBanner, |
| PrimaryButton, |
| useAsyncResource, |
| useToast, |
| } from "./ui"; |
| import { |
| startGeneration, |
| useWizardProgress, |
| dismissOverlay, |
| } from "./wizardProgressStore"; |
|
|
| const MODE_LABELS: Record<ExperienceMode, string> = { |
| sfw_general: "General (safe for work)", |
| sfw_education: "Education", |
| language_learning: "Language learning", |
| enterprise_training: "Enterprise training", |
| social_romantic: "Social / romantic", |
| mature_gated: "Mature (gated)", |
| }; |
|
|
| const LEVEL_LABELS: Record<PlanAutoForm["audience_level"], string> = { |
| beginner: "Beginner", |
| intermediate: "Intermediate", |
| advanced: "Advanced", |
| }; |
|
|
| const LANG_LABELS: Record<string, string> = { |
| en: "English", |
| es: "Spanish", |
| fr: "French", |
| de: "German", |
| it: "Italian", |
| pt: "Portuguese", |
| ja: "Japanese", |
| zh: "Chinese", |
| }; |
|
|
| const GENERATE_STEPS = [ |
| { label: "Saving your project" }, |
| { label: "Drafting the scene graph" }, |
| { label: "Writing dialogue + choices" }, |
| { label: "Rendering scenes" }, |
| { label: "Opening the editor" }, |
| ]; |
|
|
| export interface InteractionSelection { |
| interaction_type: "standard_project" | "persona_live_play"; |
| persona_project_id?: string; |
| persona_label?: string; |
| persona_avatar_url?: string; |
| persona_portrait_url?: string; |
| persona_image_fit?: "cover" | "contain"; |
| persona_image_position?: string; |
| render_media_type?: "video" | "image"; |
| } |
|
|
| export interface WizardAutoPreviewProps { |
| backendUrl: string; |
| apiKey?: string; |
| initial: PlanAutoResult; |
| originalIdea: string; |
| interaction: InteractionSelection; |
| onCreated: (experienceId: string) => void; |
| onStartOver: () => void; |
| } |
|
|
| export function WizardAutoPreview({ |
| backendUrl, |
| apiKey, |
| initial, |
| originalIdea, |
| interaction, |
| onCreated, |
| onStartOver, |
| }: WizardAutoPreviewProps) { |
| const api = useMemo( |
| () => createInteractiveApi(backendUrl, apiKey), |
| [backendUrl, apiKey], |
| ); |
| const toast = useToast(); |
|
|
| const [form, setForm] = useState<PlanAutoForm>({ |
| ...initial.form, |
| render_media_type: |
| initial.form.render_media_type || |
| interaction.render_media_type || |
| (interaction.interaction_type === "persona_live_play" ? "image" : "video"), |
| }); |
|
|
| const [advancedOpen, setAdvancedOpen] = useState(false); |
| |
| |
| |
| const [customizeOpen, setCustomizeOpen] = useState(false); |
| const [error, setError] = useState<string | null>(null); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const [resolvedAvatar, setResolvedAvatar] = useState<string>(""); |
| const [resolvedArchetype, setResolvedArchetype] = useState<string>(""); |
| |
| |
| const [personaAllowExplicit, setPersonaAllowExplicit] = useState<boolean>(false); |
| const [libraryAlreadyBuilt, setLibraryAlreadyBuilt] = useState<number>(0); |
|
|
| useEffect(() => { |
| const pid = interaction.persona_project_id; |
| if (!pid) return; |
| const ctrl = new AbortController(); |
| const base = backendUrl.replace(/\/+$/, ""); |
| fetch(`${base}/projects/${encodeURIComponent(pid)}`, { |
| signal: ctrl.signal, |
| credentials: "include", |
| }) |
| .then((r) => (r.ok ? r.json() : null)) |
| .then((body) => { |
| if (!body || !body.ok || !body.project) return; |
| const project = body.project as { |
| persona_appearance?: { |
| selected_filename?: unknown; |
| asset_library?: Record<string, unknown>; |
| }; |
| persona_agent?: { |
| persona_class?: unknown; |
| response_style?: { tone?: unknown }; |
| safety?: { allow_explicit?: unknown }; |
| }; |
| }; |
| const filename = String( |
| project.persona_appearance?.selected_filename || "", |
| ).trim(); |
| if (filename && !interaction.persona_avatar_url) { |
| setResolvedAvatar(`${base}/files/${filename}`); |
| } |
| |
| setPersonaAllowExplicit( |
| Boolean(project.persona_agent?.safety?.allow_explicit), |
| ); |
| |
| |
| |
| |
| |
| const lib = project.persona_appearance?.asset_library; |
| if (lib && typeof lib === "object") { |
| setLibraryAlreadyBuilt(Object.keys(lib).length); |
| } |
| const archetype = |
| String(project.persona_agent?.persona_class || "").trim() || |
| String(project.persona_agent?.response_style?.tone || "").trim(); |
| if (archetype) setResolvedArchetype(archetype); |
| }) |
| .catch(() => { }); |
| return () => ctrl.abort(); |
| }, [backendUrl, interaction.persona_project_id, interaction.persona_avatar_url]); |
|
|
| |
| |
| |
| |
| |
| const progress = useWizardProgress(); |
| const submitting = progress.active; |
| const genStep = progress.genStep; |
| const renderTotal = progress.renderTotal; |
| const renderDone = progress.renderDone; |
| const renderSkipped = progress.renderSkipped; |
| const currentSceneTitle = progress.currentSceneTitle; |
|
|
| const personaLive = interaction.interaction_type === "persona_live_play"; |
|
|
| const health = useAsyncResource<HealthInfo>( |
| (signal) => api.health(signal), |
| [api], |
| ); |
| const renderEnabled = health.data?.playback?.render_enabled !== false; |
|
|
| const patch = useCallback( |
| <K extends keyof PlanAutoForm>(key: K, value: PlanAutoForm[K]) => { |
| setForm((prev) => ({ ...prev, [key]: value })); |
| }, |
| [], |
| ); |
|
|
| const buildCreatePayload = useCallback(() => { |
| const renderMediaType = |
| form.render_media_type || |
| interaction.render_media_type || |
| (personaLive ? "image" : "video"); |
|
|
| return { |
| title: (form.title || "").trim(), |
| description: (form.prompt || "").trim(), |
| experience_mode: form.experience_mode, |
| policy_profile_id: form.policy_profile_id, |
| project_type: personaLive ? "persona_live" : "standard", |
| audience_profile: { |
| role: form.audience_role, |
| level: form.audience_level, |
| language: form.audience_language, |
| locale_hint: (form.audience_locale_hint || "").trim(), |
| interaction_type: interaction.interaction_type, |
| persona_project_id: cleanOptional(interaction.persona_project_id), |
| persona_label: cleanOptional(interaction.persona_label), |
| persona_avatar_url: cleanOptional(interaction.persona_avatar_url), |
| persona_portrait_url: cleanOptional(interaction.persona_portrait_url), |
| persona_image_fit: interaction.persona_image_fit || (personaLive ? "contain" : undefined), |
| persona_image_position: cleanOptional( |
| interaction.persona_image_position || (personaLive ? "center" : undefined), |
| ), |
| render_media_type: renderMediaType, |
| }, |
| }; |
| }, [form, interaction, personaLive]); |
|
|
| const onSubmit = useCallback(async () => { |
| setError(null); |
|
|
| |
| |
| |
| |
| |
| let createdId = ""; |
| const result = await startGeneration({ |
| api, |
| payload: buildCreatePayload(), |
| totalSteps: GENERATE_STEPS.length, |
| onCreated: (id) => { createdId = id; }, |
| }); |
|
|
| if (!result) { |
| |
| |
| |
| |
| const errMsg = |
| useWizardProgressStateError() || "Couldn't create the project."; |
| setError(errMsg); |
| return; |
| } |
|
|
| toast.toast({ |
| variant: result.source === "llm" ? "success" : "info", |
| title: |
| result.source === "existing" |
| ? "Project already has scenes" |
| : "Project ready", |
| message: |
| result.source === "existing" |
| ? "Opening the editor." |
| : `${result.node_count} scenes · ${result.edge_count} transitions${ |
| result.action_count > 0 ? ` · ${result.action_count} choices` : "" |
| }`, |
| }); |
|
|
| await new Promise((r) => window.setTimeout(r, 220)); |
| if (createdId) { |
| |
| |
| dismissOverlay(); |
| onCreated(createdId); |
| } |
| }, [api, buildCreatePayload, onCreated, toast]); |
|
|
| |
| |
| |
| function useWizardProgressStateError(): string | null { |
| return progress.error; |
| } |
|
|
| return ( |
| <div className="relative flex flex-col h-full w-full"> |
| <div className="flex-1 min-h-0 overflow-y-auto"> |
| <div className="max-w-2xl mx-auto px-6 py-8"> |
| <header className="flex items-center justify-between gap-4 mb-6"> |
| <button |
| type="button" |
| onClick={onStartOver} |
| disabled={submitting} |
| className="inline-flex items-center gap-1.5 text-xs font-medium text-[#aaa] hover:text-[#f1f1f1] transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3ea6ff] rounded px-1 py-0.5 disabled:opacity-50" |
| > |
| <ArrowLeft className="w-3.5 h-3.5" aria-hidden /> |
| Start over |
| </button> |
| <SourceBadge source={initial.source} /> |
| </header> |
| |
| <h1 className="text-xl font-semibold text-[#f1f1f1] flex items-center gap-2"> |
| {personaLive && ( |
| <Sparkles className="w-5 h-5 text-[#c4b5fd]" aria-hidden /> |
| )} |
| {personaLive ? "Your experience is ready" : "Review your project"} |
| </h1> |
| <p className="text-sm text-[#aaa] mt-1.5 leading-relaxed"> |
| {personaLive ? ( |
| <> |
| AI shaped{" "} |
| <span className="text-[#cfd8dc]"> |
| "{originalIdea.slice(0, 80)} |
| {originalIdea.length > 80 ? "…" : ""}" |
| </span>{" "} |
| into a persona-centered setup. Approve to launch, or customize first. |
| </> |
| ) : ( |
| <> |
| The AI turned{" "} |
| <span className="text-[#cfd8dc]"> |
| "{originalIdea.slice(0, 80)} |
| {originalIdea.length > 80 ? "…" : ""}" |
| </span> |
| {" "}into the draft below. Edit anything you'd like before creating. |
| </> |
| )} |
| </p> |
| |
| {error && ( |
| <div className="mt-5"> |
| <ErrorBanner |
| title="Couldn't create the project" |
| message={error} |
| onRetry={onSubmit} |
| /> |
| </div> |
| )} |
| |
| <div className="mt-6 flex flex-col gap-5"> |
| {personaLive ? ( |
| <ApproveHero |
| form={form} |
| initial={initial} |
| avatarUrl={interaction.persona_avatar_url || resolvedAvatar} |
| personaLabel={interaction.persona_label || "Selected persona"} |
| archetype={resolvedArchetype || "Persona companion"} |
| disabled={submitting} |
| onTitleChange={(v) => patch("title", v)} |
| /> |
| ) : ( |
| <StandardApproveHero |
| form={form} |
| initial={initial} |
| disabled={submitting} |
| onTitleChange={(v) => patch("title", v)} |
| /> |
| )} |
| |
| {!customizeOpen && ( |
| <div className="flex justify-center"> |
| <button |
| type="button" |
| onClick={() => setCustomizeOpen(true)} |
| disabled={submitting} |
| className="text-xs font-medium text-[#9f7fd1] hover:text-[#c4b5fd] transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3ea6ff] rounded px-2 py-1 disabled:opacity-50" |
| > |
| Customize (optional) |
| </button> |
| </div> |
| )} |
| |
| {customizeOpen && personaLive && ( |
| <CustomizePanel |
| form={form} |
| initial={initial} |
| patch={patch} |
| disabled={submitting} |
| advancedOpen={advancedOpen} |
| onToggleAdvanced={() => setAdvancedOpen((v) => !v)} |
| renderEnabled={renderEnabled} |
| personaAllowExplicit={personaAllowExplicit} |
| libraryAlreadyBuilt={libraryAlreadyBuilt} |
| onClose={() => setCustomizeOpen(false)} |
| /> |
| )} |
| |
| {customizeOpen && !personaLive && ( |
| <StandardCustomizePanel |
| form={form} |
| initial={initial} |
| patch={patch} |
| disabled={submitting} |
| advancedOpen={advancedOpen} |
| onToggleAdvanced={() => setAdvancedOpen((v) => !v)} |
| renderEnabled={renderEnabled} |
| onClose={() => setCustomizeOpen(false)} |
| /> |
| )} |
| </div> |
| {/* Planner preview moved into the Customize panel for both |
| flows — it's noise on the default Approve view. */} |
| </div> |
| </div> |
|
|
| { |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| } |
| { |
| |
| } |
|
|
| <footer className="shrink-0 border-t border-[#2a2a2a] bg-[#0f0f0f] py-3"> |
| <div className="max-w-2xl mx-auto px-6 flex items-center justify-end gap-3"> |
| <PrimaryButton |
| onClick={onSubmit} |
| disabled={submitting || !form.title.trim()} |
| loading={submitting} |
| size="lg" |
| icon={!submitting ? <Check className="w-4 h-4" aria-hidden /> : undefined} |
| > |
| Create project |
| </PrimaryButton> |
| </div> |
| </footer> |
|
|
| { |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| } |
| </div> |
| ); |
| } |
|
|
| const INPUT_CLS = [ |
| "w-full bg-[#121212] border border-[#3f3f3f] rounded-md", |
| "px-3 py-2 text-sm outline-none", |
| "focus:border-[#3ea6ff] focus:ring-1 focus:ring-[#3ea6ff]/50", |
| "disabled:opacity-60 disabled:cursor-not-allowed", |
| ].join(" "); |
|
|
| function Field({ |
| label, |
| hint, |
| children, |
| }: { |
| label: string; |
| hint?: string; |
| children: React.ReactNode; |
| }) { |
| return ( |
| <div className="flex flex-col gap-1.5"> |
| <label className="text-xs font-medium text-[#cfd8dc]">{label}</label> |
| {hint && <p className="text-[11px] text-[#777] -mt-0.5">{hint}</p>} |
| {children} |
| </div> |
| ); |
| } |
|
|
| function NumberPill({ |
| label, |
| value, |
| min, |
| max, |
| onChange, |
| disabled, |
| }: { |
| label: string; |
| value: number; |
| min: number; |
| max: number; |
| onChange: (n: number) => void; |
| disabled?: boolean; |
| }) { |
| const dec = () => onChange(Math.max(min, value - 1)); |
| const inc = () => onChange(Math.min(max, value + 1)); |
|
|
| return ( |
| <div className="bg-[#121212] border border-[#3f3f3f] rounded-md px-3 py-2"> |
| <div className="text-[10px] text-[#777] uppercase tracking-wide">{label}</div> |
| <div className="mt-1 flex items-center justify-between gap-2"> |
| <button |
| type="button" |
| onClick={dec} |
| disabled={disabled || value <= min} |
| aria-label={`Decrease ${label}`} |
| className="w-6 h-6 rounded-md bg-white/5 hover:bg-white/10 disabled:opacity-40 disabled:cursor-not-allowed text-[#cfd8dc] text-sm font-medium" |
| > |
| − |
| </button> |
| <span className="text-lg font-semibold text-[#f1f1f1] tabular-nums">{value}</span> |
| <button |
| type="button" |
| onClick={inc} |
| disabled={disabled || value >= max} |
| aria-label={`Increase ${label}`} |
| className="w-6 h-6 rounded-md bg-white/5 hover:bg-white/10 disabled:opacity-40 disabled:cursor-not-allowed text-[#cfd8dc] text-sm font-medium" |
| > |
| + |
| </button> |
| </div> |
| </div> |
| ); |
| } |
|
|
| function AdvancedBlock({ |
| open, |
| onToggle, |
| form, |
| patch, |
| disabled, |
| personaLive, |
| }: { |
| open: boolean; |
| onToggle: () => void; |
| form: PlanAutoForm; |
| patch: <K extends keyof PlanAutoForm>(k: K, v: PlanAutoForm[K]) => void; |
| disabled: boolean; |
| personaLive: boolean; |
| }) { |
| return ( |
| <section className="rounded-lg border border-[#2a2a2a] bg-[#121212]/60"> |
| <button |
| type="button" |
| onClick={onToggle} |
| aria-expanded={open} |
| className="w-full flex items-center justify-between px-4 py-3 text-sm font-medium text-[#f1f1f1] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3ea6ff] rounded-lg" |
| > |
| <span className="inline-flex items-center gap-2"> |
| <Sparkles className="w-4 h-4 text-[#c4b5fd]" aria-hidden /> |
| Advanced |
| </span> |
| {open ? ( |
| <ChevronUp className="w-4 h-4 text-[#aaa]" aria-hidden /> |
| ) : ( |
| <ChevronDown className="w-4 h-4 text-[#aaa]" aria-hidden /> |
| )} |
| </button> |
| |
| {open && ( |
| <div className="px-4 pb-4 grid grid-cols-2 gap-3 text-sm"> |
| <Field label="Viewer role"> |
| <input |
| type="text" |
| value={form.audience_role} |
| onChange={(e) => patch("audience_role", e.target.value)} |
| disabled={disabled} |
| className={INPUT_CLS} |
| /> |
| </Field> |
| |
| <Field label="Language"> |
| <select |
| value={form.audience_language} |
| onChange={(e) => patch("audience_language", e.target.value)} |
| disabled={disabled} |
| className={INPUT_CLS} |
| > |
| {Object.entries(LANG_LABELS).map(([v, l]) => ( |
| <option key={v} value={v}> |
| {l} ({v}) |
| </option> |
| ))} |
| </select> |
| </Field> |
| |
| <Field label="Locale hint" hint="Optional region cue."> |
| <input |
| type="text" |
| value={form.audience_locale_hint} |
| onChange={(e) => patch("audience_locale_hint", e.target.value)} |
| placeholder="(optional, e.g. us-west)" |
| maxLength={32} |
| disabled={disabled} |
| className={INPUT_CLS} |
| /> |
| </Field> |
| |
| <Field label="Policy profile"> |
| <input |
| type="text" |
| value={form.policy_profile_id} |
| onChange={(e) => patch("policy_profile_id", e.target.value)} |
| disabled={disabled} |
| className={INPUT_CLS} |
| /> |
| </Field> |
| |
| <Field label="Render media"> |
| <select |
| value={form.render_media_type || (personaLive ? "image" : "video")} |
| onChange={(e) => patch("render_media_type", e.target.value as "image" | "video")} |
| disabled={disabled} |
| className={INPUT_CLS} |
| > |
| <option value="image">Image</option> |
| <option value="video">Video</option> |
| </select> |
| </Field> |
| </div> |
| )} |
| </section> |
| ); |
| } |
|
|
| |
| |
| |
| |
|
|
| function SourceBadge({ source }: { source: "llm" | "heuristic" }) { |
| if (source === "llm") { |
| return ( |
| <span className="inline-flex items-center gap-1.5 rounded-full bg-[#8b5cf6]/15 border border-[#8b5cf6]/30 text-[#c4b5fd] text-[11px] font-medium px-2.5 py-1"> |
| <Sparkles className="w-3 h-3" aria-hidden /> |
| AI-generated draft |
| </span> |
| ); |
| } |
|
|
| return ( |
| <span className="inline-flex items-center rounded-full bg-white/5 border border-white/10 text-[#aaa] text-[11px] font-medium px-2.5 py-1"> |
| Smart defaults |
| </span> |
| ); |
| } |
|
|
| function cleanOptional(value?: string) { |
| const v = String(value || "").trim(); |
| return v ? v : undefined; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const PERSONA_LIBRARY_TIER2_SFW_COUNT = 17; |
| const PERSONA_LIBRARY_TIER2_NSFW_COUNT = 23; |
| |
| |
| |
| const PERSONA_LIVE_SCENE_COUNT = 7; |
|
|
|
|
| function RenderPlanPanel({ |
| personaLive, |
| renderEnabled, |
| sceneCount, |
| libraryPlanned, |
| libraryAlreadyBuilt, |
| }: { |
| personaLive: boolean; |
| renderEnabled: boolean; |
| sceneCount: number; |
| libraryPlanned: number; |
| libraryAlreadyBuilt: number; |
| }) { |
| |
| |
| const libraryToBuild = personaLive |
| ? Math.max(0, libraryPlanned - libraryAlreadyBuilt) |
| : 0; |
| |
| |
| |
| |
| const sceneRendersThisRun = personaLive |
| ? 0 |
| : (renderEnabled ? sceneCount : 0); |
| const totalThisRun = sceneRendersThisRun + libraryToBuild; |
|
|
| return ( |
| <div className="shrink-0 border-t border-[#2a2a2a] bg-[#121212]/60"> |
| <div className="max-w-2xl mx-auto px-6 py-3"> |
| <div className="text-[11px] uppercase tracking-wider text-[#9f7fd1] mb-2"> |
| Render plan |
| </div> |
| <div className="grid grid-cols-1 sm:grid-cols-3 gap-2 text-xs"> |
| <PlanStat |
| label="Scene graph" |
| value={sceneCount} |
| unit={sceneCount === 1 ? "scene" : "scenes"} |
| hint={ |
| personaLive |
| ? "Authored spine — the live runtime doesn't traverse this." |
| : (renderEnabled |
| ? "All rendered as images / video clips." |
| : "Scene render is OFF — no GPU work this pass.") |
| } |
| /> |
| {personaLive && ( |
| <PlanStat |
| label="Persona library" |
| value={libraryPlanned} |
| unit={libraryPlanned === 1 ? "image" : "images"} |
| hint={ |
| libraryAlreadyBuilt > 0 |
| ? `${libraryAlreadyBuilt} already cached → ${libraryToBuild} new this run.` |
| : "Pre-rendered once per persona; reused on every session." |
| } |
| /> |
| )} |
| <PlanStat |
| label="Total this run" |
| value={totalThisRun} |
| unit={totalThisRun === 1 ? "render" : "renders"} |
| emphasised |
| hint={ |
| totalThisRun === 0 |
| ? "No renders queued — wizard finishes in seconds." |
| : "Each render typically takes 3–8 s on a capable GPU." |
| } |
| /> |
| </div> |
| </div> |
| </div> |
| ); |
| } |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const SEED_TO_STAGE: Record<string, string> = { |
| greeting: "Curiosity", |
| flirt: "Flirt", |
| compliment: "Connection", |
| tease: "Playfulness", |
| ask_personal: "Intimacy", |
| followup: "Reflection", |
| learn: "Discovery", |
| question: "Discovery", |
| challenge: "Tension", |
| }; |
|
|
| function titleCase(s: string): string { |
| return s |
| .replace(/_/g, " ") |
| .replace(/\b\w/g, (c) => c.toUpperCase()); |
| } |
|
|
| function journeyFromSeeds(seeds: string[]): string[] { |
| if (!seeds || seeds.length === 0) return []; |
| const seen = new Set<string>(); |
| const out: string[] = []; |
| for (const raw of seeds) { |
| const key = String(raw || "").trim().toLowerCase(); |
| if (!key) continue; |
| const stage = SEED_TO_STAGE[key] || titleCase(key); |
| if (!seen.has(stage)) { |
| seen.add(stage); |
| out.push(stage); |
| } |
| } |
| return out; |
| } |
|
|
| type IconType = React.ComponentType<React.SVGProps<SVGSVGElement>>; |
|
|
| function SummaryRow({ |
| icon: Icon, |
| label, |
| value, |
| }: { |
| icon: IconType; |
| label: string; |
| value: string; |
| }) { |
| return ( |
| <div className="flex items-start gap-2.5 text-sm"> |
| <Icon className="w-4 h-4 text-[#9f7fd1] mt-0.5 flex-shrink-0" aria-hidden /> |
| <div className="min-w-0 flex-1 leading-relaxed"> |
| <span className="text-[#9f7fd1] font-medium">{label}:</span>{" "} |
| <span className="text-[#cfd8dc]">{value}</span> |
| </div> |
| </div> |
| ); |
| } |
|
|
| function PersonaHeroCard({ |
| avatarUrl, |
| personaLabel, |
| archetype, |
| }: { |
| avatarUrl: string; |
| personaLabel: string; |
| archetype: string; |
| }) { |
| return ( |
| <div |
| className={[ |
| "rounded-lg border border-[#3a2a58]", |
| "bg-gradient-to-br from-[#130f1f] to-[#1a0f24]", |
| "p-4 flex flex-col sm:flex-row gap-4", |
| "shadow-[0_0_24px_-12px_rgba(139,92,246,0.4)]", |
| ].join(" ")} |
| > |
| {avatarUrl ? ( |
| <img |
| src={avatarUrl} |
| alt={personaLabel} |
| className={[ |
| "w-32 h-32 rounded-md object-cover object-top", |
| "border-2 border-[#51347f]", |
| "shadow-md flex-shrink-0", |
| "self-center sm:self-start bg-black", |
| ].join(" ")} |
| /> |
| ) : ( |
| <div |
| className={[ |
| "w-32 h-32 rounded-md bg-[#24173a]", |
| "border-2 border-[#51347f]", |
| "flex-shrink-0 self-center sm:self-start", |
| "flex items-center justify-center", |
| "text-[#7c3aed] text-2xl font-semibold", |
| ].join(" ")} |
| aria-label="No portrait available" |
| > |
| {(personaLabel || "?").trim().charAt(0).toUpperCase()} |
| </div> |
| )} |
| <div className="min-w-0 flex flex-col justify-center gap-1"> |
| <div className="text-[11px] uppercase tracking-wider text-[#9f7fd1]"> |
| Persona |
| </div> |
| <div className="text-base font-medium text-[#f1f1f1] truncate"> |
| {personaLabel} |
| </div> |
| <div className="text-xs text-[#b59ed9] truncate">{archetype}</div> |
| <div className="text-[11px] text-[#7c3aed] mt-2"> |
| Portrait is frozen into this experience for consistent playback. |
| </div> |
| </div> |
| </div> |
| ); |
| } |
|
|
| function ApproveHero({ |
| form, |
| initial, |
| avatarUrl, |
| personaLabel, |
| archetype, |
| disabled, |
| onTitleChange, |
| }: { |
| form: PlanAutoForm; |
| initial: PlanAutoResult; |
| avatarUrl: string; |
| personaLabel: string; |
| archetype: string; |
| disabled: boolean; |
| onTitleChange: (next: string) => void; |
| }) { |
| const journey = journeyFromSeeds(initial.seed_intents); |
| const modeLabel = MODE_LABELS[form.experience_mode] || form.experience_mode; |
| const goal = (initial.objective || "").trim() |
| || "Build rapport through a branching conversation"; |
| const vibe = (form.prompt || "").trim() |
| || "Open, curious, easygoing"; |
|
|
| return ( |
| <div className="flex flex-col gap-5"> |
| {/* Title — looks like a heading, edits inline. Kept editable |
| (rather than click-to-edit) so the field is discoverable |
| without a hover hint. */} |
| <div className="flex items-center gap-2"> |
| <Pencil |
| className="w-4 h-4 text-[#777] flex-shrink-0" |
| aria-hidden |
| /> |
| <input |
| type="text" |
| value={form.title} |
| onChange={(e) => onTitleChange(e.target.value)} |
| maxLength={80} |
| disabled={disabled} |
| placeholder="Name your experience" |
| aria-label="Experience title" |
| className={[ |
| "flex-1 bg-transparent border-b border-transparent", |
| "hover:border-[#3f3f3f] focus:border-[#3ea6ff] focus:outline-none", |
| "text-xl font-semibold text-[#f1f1f1] py-1", |
| "disabled:opacity-60 disabled:cursor-not-allowed", |
| "placeholder:text-[#555]", |
| ].join(" ")} |
| /> |
| </div> |
| |
| <PersonaHeroCard |
| avatarUrl={avatarUrl} |
| personaLabel={personaLabel} |
| archetype={archetype} |
| /> |
| |
| <div className="grid grid-cols-1 sm:grid-cols-2 gap-x-5 gap-y-2.5"> |
| <SummaryRow icon={Target} label="Goal" value={goal} /> |
| <SummaryRow icon={Heart} label="Vibe" value={vibe} /> |
| <SummaryRow icon={Sparkles} label="Mode" value={modeLabel} /> |
| <SummaryRow icon={Clock} label="Length" value="~5–7 min experience" /> |
| </div> |
| |
| {journey.length > 0 && ( |
| <div className="rounded-lg border border-[#2a2a2a] bg-[#121212]/60 px-4 py-3"> |
| <div className="flex items-center gap-2 text-[11px] uppercase tracking-wider text-[#9f7fd1] mb-2"> |
| <Compass className="w-3.5 h-3.5" aria-hidden /> |
| Emotional journey |
| </div> |
| <div className="flex flex-wrap items-center gap-2 text-sm text-[#cfd8dc]"> |
| {journey.map((stage, i) => ( |
| <React.Fragment key={`${stage}-${i}`}> |
| {i > 0 && ( |
| <span className="text-[#555]" aria-hidden> |
| → |
| </span> |
| )} |
| <span>{stage}</span> |
| </React.Fragment> |
| ))} |
| </div> |
| </div> |
| )} |
| </div> |
| ); |
| } |
|
|
| function CustomizePanel({ |
| form, |
| initial, |
| patch, |
| disabled, |
| advancedOpen, |
| onToggleAdvanced, |
| renderEnabled, |
| personaAllowExplicit, |
| libraryAlreadyBuilt, |
| onClose, |
| }: { |
| form: PlanAutoForm; |
| initial: PlanAutoResult; |
| patch: <K extends keyof PlanAutoForm>(k: K, v: PlanAutoForm[K]) => void; |
| disabled: boolean; |
| advancedOpen: boolean; |
| onToggleAdvanced: () => void; |
| renderEnabled: boolean; |
| personaAllowExplicit: boolean; |
| libraryAlreadyBuilt: number; |
| onClose: () => void; |
| }) { |
| const libraryPlanned = personaAllowExplicit |
| ? PERSONA_LIBRARY_TIER2_NSFW_COUNT |
| : PERSONA_LIBRARY_TIER2_SFW_COUNT; |
|
|
| return ( |
| <section |
| className="rounded-lg border border-[#2a2a2a] bg-[#121212]/60 px-4 py-4 flex flex-col gap-4" |
| aria-label="Customize your experience" |
| > |
| <div className="flex items-center justify-between gap-3"> |
| <div className="flex items-center gap-2 text-sm font-medium text-[#f1f1f1]"> |
| <Sparkles className="w-4 h-4 text-[#c4b5fd]" aria-hidden /> |
| Customize your experience |
| </div> |
| <button |
| type="button" |
| onClick={onClose} |
| disabled={disabled} |
| className="text-[11px] text-[#aaa] hover:text-[#f1f1f1] transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3ea6ff] rounded px-1 py-0.5 disabled:opacity-50" |
| > |
| Hide |
| </button> |
| </div> |
| |
| <Field label="Mode"> |
| <select |
| value={form.experience_mode} |
| onChange={(e) => { |
| const next = e.target.value as ExperienceMode; |
| patch("experience_mode", next); |
| patch("policy_profile_id", next); |
| }} |
| disabled={disabled} |
| className={INPUT_CLS} |
| > |
| {(Object.keys(MODE_LABELS) as ExperienceMode[]).map((m) => ( |
| <option key={m} value={m}> |
| {MODE_LABELS[m]} |
| </option> |
| ))} |
| </select> |
| </Field> |
| |
| <Field |
| label="Session vibe" |
| hint="Short brief the planner uses to flavor the conversation." |
| > |
| <textarea |
| rows={3} |
| value={form.prompt} |
| onChange={(e) => patch("prompt", e.target.value)} |
| disabled={disabled} |
| className={`${INPUT_CLS} resize-y leading-relaxed`} |
| /> |
| </Field> |
| |
| {initial.seed_intents.length > 0 && ( |
| <Field |
| label="Story elements" |
| hint="Seeds the planner uses for branches. Edit raw seeds in Advanced." |
| > |
| <div className="flex flex-wrap gap-1.5"> |
| {initial.seed_intents.map((s) => ( |
| <span |
| key={s} |
| className="inline-flex items-center gap-1 rounded-full bg-[#1f1f1f] border border-[#3f3f3f] text-[#cfd8dc] text-[11px] font-medium px-2 py-0.5" |
| > |
| <Check className="w-3 h-3 text-[#9f7fd1]" aria-hidden /> |
| {titleCase(s)} |
| </span> |
| ))} |
| </div> |
| </Field> |
| )} |
| |
| <Field label="Generation preview"> |
| <RenderPlanPanel |
| personaLive={true} |
| renderEnabled={renderEnabled} |
| sceneCount={PERSONA_LIVE_SCENE_COUNT} |
| libraryPlanned={libraryPlanned} |
| libraryAlreadyBuilt={libraryAlreadyBuilt} |
| /> |
| </Field> |
| |
| {(initial.objective || initial.topic || initial.success_metric) && ( |
| <div className="rounded-md border border-[#2a2a2a] bg-[#0f0f0f]/60 px-3 py-2.5 text-[11px] leading-relaxed"> |
| <div className="text-[#cfd8dc] font-medium mb-1">Planner preview</div> |
| <dl className="grid grid-cols-[auto,1fr] gap-x-3 gap-y-0.5 text-[#aaa]"> |
| {initial.objective && ( |
| <> |
| <dt>Objective</dt> |
| <dd className="text-[#cfd8dc]">{initial.objective}</dd> |
| </> |
| )} |
| {initial.topic && ( |
| <> |
| <dt>Topic</dt> |
| <dd className="text-[#cfd8dc]">{initial.topic}</dd> |
| </> |
| )} |
| {initial.success_metric && ( |
| <> |
| <dt>Success</dt> |
| <dd className="text-[#cfd8dc]">{initial.success_metric}</dd> |
| </> |
| )} |
| </dl> |
| </div> |
| )} |
|
|
| <AdvancedBlock |
| open={advancedOpen} |
| onToggle={onToggleAdvanced} |
| form={form} |
| patch={patch} |
| disabled={disabled} |
| personaLive={true} |
| /> |
|
|
| <div className="border-t border-[#2a2a2a] pt-3 -mb-1"> |
| <ExpertModeToggle /> |
| </div> |
| </section> |
| ); |
| } |
|
|
| function PlanStat({ |
| label, |
| value, |
| unit, |
| hint, |
| emphasised, |
| }: { |
| label: string; |
| value: number; |
| unit: string; |
| hint?: string; |
| emphasised?: boolean; |
| }) { |
| return ( |
| <div |
| className={[ |
| "rounded-md border px-3 py-2", |
| emphasised |
| ? "border-[#8b5cf6]/50 bg-[#8b5cf6]/10" |
| : "border-[#2a2a2a] bg-[#0f0f0f]/60", |
| ].join(" ")} |
| > |
| <div className="text-[10px] uppercase tracking-wider text-[#777]"> |
| {label} |
| </div> |
| <div className="flex items-baseline gap-1.5 mt-0.5"> |
| <span |
| className={[ |
| "font-semibold", |
| emphasised ? "text-[#c4b5fd] text-lg" : "text-[#f1f1f1] text-base", |
| ].join(" ")} |
| > |
| {value} |
| </span> |
| <span className="text-[11px] text-[#aaa]">{unit}</span> |
| </div> |
| {hint && ( |
| <div className="text-[10px] text-[#777] mt-1 leading-snug"> |
| {hint} |
| </div> |
| )} |
| </div> |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| type ChoicesPreset = "simple" | "standard" | "complex"; |
| type LengthPreset = "short" | "medium" | "long"; |
| type DepthPreset = "light" | "balanced" | "deep"; |
|
|
| const CHOICES_VALUES: Record<ChoicesPreset, number> = { |
| simple: 2, |
| standard: 3, |
| complex: 5, |
| }; |
| const LENGTH_VALUES: Record<LengthPreset, number> = { |
| short: 1, |
| medium: 2, |
| long: 4, |
| }; |
| const DEPTH_VALUES: Record<DepthPreset, number> = { |
| light: 1, |
| balanced: 2, |
| deep: 4, |
| }; |
|
|
| const CHOICES_LABELS: Record<ChoicesPreset, string> = { |
| simple: "Simple (2 choices)", |
| standard: "Standard (3–4 choices)", |
| complex: "Complex (5+ choices)", |
| }; |
| const LENGTH_LABELS: Record<LengthPreset, string> = { |
| short: "Short (4–5 scenes)", |
| medium: "Medium (6–8 scenes)", |
| long: "Long (10+ scenes)", |
| }; |
| const DEPTH_LABELS: Record<DepthPreset, string> = { |
| light: "Light branching", |
| balanced: "Balanced", |
| deep: "Deep branching", |
| }; |
|
|
| function inferChoicesPreset(branch: number): ChoicesPreset { |
| if (branch <= 2) return "simple"; |
| if (branch <= 4) return "standard"; |
| return "complex"; |
| } |
| function inferLengthPreset(scenes_per_branch: number): LengthPreset { |
| if (scenes_per_branch <= 1) return "short"; |
| if (scenes_per_branch <= 3) return "medium"; |
| return "long"; |
| } |
| function inferDepthPreset(depth: number): DepthPreset { |
| if (depth <= 1) return "light"; |
| if (depth <= 2) return "balanced"; |
| return "deep"; |
| } |
|
|
| function estimateSceneTotal(form: PlanAutoForm): number { |
| return Math.max(1, form.branch_count * form.depth * form.scenes_per_branch); |
| } |
|
|
| function estimatePlaytime(scenes: number): string { |
| |
| |
| const lo = Math.max(2, Math.round(scenes * 0.75)); |
| const hi = Math.max(lo + 1, Math.round(scenes * 1.1)); |
| return `~${lo}–${hi} min interactive experience`; |
| } |
|
|
| function StandardApproveHero({ |
| form, |
| initial, |
| disabled, |
| onTitleChange, |
| }: { |
| form: PlanAutoForm; |
| initial: PlanAutoResult; |
| disabled: boolean; |
| onTitleChange: (next: string) => void; |
| }) { |
| const totalScenes = estimateSceneTotal(form); |
| const modeLabel = MODE_LABELS[form.experience_mode] || form.experience_mode; |
| const levelLabel = LEVEL_LABELS[form.audience_level] || form.audience_level; |
| const goal = (initial.objective || "").trim() |
| || "Build rapport through choices"; |
| const vibe = (form.prompt || "").trim() |
| || "An interactive scene-by-scene story"; |
| const journey = journeyFromSeeds(initial.seed_intents); |
|
|
| return ( |
| <div className="flex flex-col gap-5"> |
| <div className="flex items-center gap-2"> |
| <Pencil |
| className="w-4 h-4 text-[#777] flex-shrink-0" |
| aria-hidden |
| /> |
| <input |
| type="text" |
| value={form.title} |
| onChange={(e) => onTitleChange(e.target.value)} |
| maxLength={80} |
| disabled={disabled} |
| placeholder="Name your project" |
| aria-label="Project title" |
| className={[ |
| "flex-1 bg-transparent border-b border-transparent", |
| "hover:border-[#3f3f3f] focus:border-[#3ea6ff] focus:outline-none", |
| "text-xl font-semibold text-[#f1f1f1] py-1", |
| "disabled:opacity-60 disabled:cursor-not-allowed", |
| "placeholder:text-[#555]", |
| ].join(" ")} |
| /> |
| </div> |
| |
| <div className="grid grid-cols-1 sm:grid-cols-2 gap-x-5 gap-y-2.5"> |
| <SummaryRow |
| icon={Sparkles} |
| label="Mode" |
| value={`${modeLabel} · ${levelLabel}`} |
| /> |
| <SummaryRow |
| icon={Clock} |
| label="Length" |
| value={estimatePlaytime(totalScenes)} |
| /> |
| <SummaryRow icon={Target} label="Goal" value={goal} /> |
| <SummaryRow icon={Heart} label="What players experience" value={vibe} /> |
| </div> |
| |
| <div className="rounded-lg border border-[#2a2a2a] bg-[#121212]/60 px-4 py-3"> |
| <div className="flex items-center gap-2 text-[11px] uppercase tracking-wider text-[#9f7fd1] mb-2"> |
| <Compass className="w-3.5 h-3.5" aria-hidden /> |
| Structure |
| </div> |
| <div className="text-sm text-[#cfd8dc]"> |
| ~{totalScenes} scene{totalScenes === 1 ? "" : "s"} ·{" "} |
| {form.branch_count} choice{form.branch_count === 1 ? "" : "s"} per step ·{" "} |
| {form.depth}-step path{form.depth === 1 ? "" : "s"} |
| </div> |
| {journey.length > 0 && ( |
| <div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-[#cfd8dc]"> |
| <span className="text-[#9f7fd1]">Players will:</span> |
| {journey.map((stage, i) => ( |
| <React.Fragment key={`${stage}-${i}`}> |
| {i > 0 && ( |
| <span className="text-[#555]" aria-hidden> |
| → |
| </span> |
| )} |
| <span>{stage}</span> |
| </React.Fragment> |
| ))} |
| </div> |
| )} |
| </div> |
| </div> |
| ); |
| } |
|
|
| function PresetRadio<T extends string>({ |
| legend, |
| value, |
| options, |
| labels, |
| onChange, |
| disabled, |
| }: { |
| legend: string; |
| value: T; |
| options: T[]; |
| labels: Record<T, string>; |
| onChange: (next: T) => void; |
| disabled: boolean; |
| }) { |
| return ( |
| <fieldset className="flex flex-col gap-1.5"> |
| <legend className="text-xs font-medium text-[#cfd8dc]">{legend}</legend> |
| <div className="flex flex-col gap-1"> |
| {options.map((opt) => { |
| const selected = value === opt; |
| return ( |
| <label |
| key={opt} |
| className={[ |
| "flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-sm cursor-pointer", |
| "transition-colors", |
| selected |
| ? "border-[#8b5cf6]/60 bg-[#8b5cf6]/10 text-[#f1f1f1]" |
| : "border-[#2a2a2a] bg-[#0f0f0f]/60 text-[#cfd8dc] hover:border-[#3f3f3f]", |
| disabled ? "opacity-60 cursor-not-allowed" : "", |
| ].join(" ")} |
| > |
| <input |
| type="radio" |
| name={legend} |
| value={opt} |
| checked={selected} |
| onChange={() => onChange(opt)} |
| disabled={disabled} |
| className="accent-[#8b5cf6]" |
| /> |
| <span>{labels[opt]}</span> |
| </label> |
| ); |
| })} |
| </div> |
| </fieldset> |
| ); |
| } |
|
|
| function StoryFlowPreview({ |
| form, |
| initial, |
| }: { |
| form: PlanAutoForm; |
| initial: PlanAutoResult; |
| }) { |
| |
| |
| |
| |
| |
| const sceneNames = [ |
| "Opening scene", |
| "Reaction", |
| "Connection builds", |
| "Turning point", |
| "Resolution", |
| ]; |
| const choices = initial.seed_intents.length > 0 |
| ? initial.seed_intents.slice(0, form.branch_count) |
| : ["Be playful", "Be sincere", "Stay reserved"].slice(0, form.branch_count); |
| const stepCount = Math.max(1, Math.min(form.depth, sceneNames.length - 1)); |
|
|
| return ( |
| <div className="rounded-md border border-[#2a2a2a] bg-[#0f0f0f]/60 px-3 py-3"> |
| <div className="text-[11px] uppercase tracking-wider text-[#9f7fd1] mb-2"> |
| Story flow preview |
| </div> |
| <ol className="flex flex-col gap-1 text-xs text-[#cfd8dc] leading-relaxed"> |
| <li className="text-[#777] uppercase tracking-wider text-[10px]"> |
| Start |
| </li> |
| {Array.from({ length: stepCount }).map((_, idx) => ( |
| <React.Fragment key={idx}> |
| <li className="text-[#555]" aria-hidden>↓</li> |
| <li> |
| <span className="text-[#9f7fd1]">Scene {idx + 1}</span> —{" "} |
| {sceneNames[idx] || `Beat ${idx + 1}`} |
| </li> |
| <li className="text-[#555]" aria-hidden>↓</li> |
| <li> |
| <span className="text-[#9f7fd1]">Choose:</span> |
| <ul className="mt-0.5 ml-3 flex flex-col gap-0.5"> |
| {choices.map((c) => ( |
| <li key={`${idx}-${c}`} className="text-[#cfd8dc]"> |
| • {titleCase(c)} |
| </li> |
| ))} |
| </ul> |
| </li> |
| </React.Fragment> |
| ))} |
| <li className="text-[#555]" aria-hidden>↓</li> |
| <li className="text-[#777] uppercase tracking-wider text-[10px]"> |
| Ending |
| </li> |
| </ol> |
| </div> |
| ); |
| } |
|
|
| function StandardCustomizePanel({ |
| form, |
| initial, |
| patch, |
| disabled, |
| advancedOpen, |
| onToggleAdvanced, |
| renderEnabled, |
| onClose, |
| }: { |
| form: PlanAutoForm; |
| initial: PlanAutoResult; |
| patch: <K extends keyof PlanAutoForm>(k: K, v: PlanAutoForm[K]) => void; |
| disabled: boolean; |
| advancedOpen: boolean; |
| onToggleAdvanced: () => void; |
| renderEnabled: boolean; |
| onClose: () => void; |
| }) { |
| const choicesPreset = inferChoicesPreset(form.branch_count); |
| const lengthPreset = inferLengthPreset(form.scenes_per_branch); |
| const depthPreset = inferDepthPreset(form.depth); |
|
|
| return ( |
| <section |
| className="rounded-lg border border-[#2a2a2a] bg-[#121212]/60 px-4 py-4 flex flex-col gap-4" |
| aria-label="Customize your project" |
| > |
| <div className="flex items-center justify-between gap-3"> |
| <div className="flex items-center gap-2 text-sm font-medium text-[#f1f1f1]"> |
| <Sparkles className="w-4 h-4 text-[#c4b5fd]" aria-hidden /> |
| Customize your project |
| </div> |
| <button |
| type="button" |
| onClick={onClose} |
| disabled={disabled} |
| className="text-[11px] text-[#aaa] hover:text-[#f1f1f1] transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3ea6ff] rounded px-1 py-0.5 disabled:opacity-50" |
| > |
| Hide |
| </button> |
| </div> |
| |
| <div className="flex flex-col gap-3"> |
| <div className="text-[11px] uppercase tracking-wider text-[#9f7fd1]"> |
| Title & concept |
| </div> |
| <Field label="Title"> |
| <input |
| type="text" |
| value={form.title} |
| onChange={(e) => patch("title", e.target.value)} |
| maxLength={80} |
| disabled={disabled} |
| className={INPUT_CLS} |
| /> |
| </Field> |
| <Field |
| label="Concept" |
| hint="Short brief the planner uses as the story seed." |
| > |
| <textarea |
| rows={3} |
| value={form.prompt} |
| onChange={(e) => patch("prompt", e.target.value)} |
| disabled={disabled} |
| className={`${INPUT_CLS} resize-y leading-relaxed`} |
| /> |
| </Field> |
| </div> |
| |
| <div className="flex flex-col gap-3"> |
| <div className="text-[11px] uppercase tracking-wider text-[#9f7fd1]"> |
| Audience & mode |
| </div> |
| <div className="grid grid-cols-2 gap-3"> |
| <Field label="Mode"> |
| <select |
| value={form.experience_mode} |
| onChange={(e) => { |
| const next = e.target.value as ExperienceMode; |
| patch("experience_mode", next); |
| patch("policy_profile_id", next); |
| }} |
| disabled={disabled} |
| className={INPUT_CLS} |
| > |
| {(Object.keys(MODE_LABELS) as ExperienceMode[]).map((m) => ( |
| <option key={m} value={m}> |
| {MODE_LABELS[m]} |
| </option> |
| ))} |
| </select> |
| </Field> |
| <Field label="Audience level"> |
| <select |
| value={form.audience_level} |
| onChange={(e) => |
| patch( |
| "audience_level", |
| e.target.value as PlanAutoForm["audience_level"], |
| ) |
| } |
| disabled={disabled} |
| className={INPUT_CLS} |
| > |
| {(Object.keys(LEVEL_LABELS) as PlanAutoForm["audience_level"][]).map((lv) => ( |
| <option key={lv} value={lv}> |
| {LEVEL_LABELS[lv]} |
| </option> |
| ))} |
| </select> |
| </Field> |
| </div> |
| </div> |
| |
| <div className="flex flex-col gap-3"> |
| <div className="text-[11px] uppercase tracking-wider text-[#9f7fd1]"> |
| Experience structure |
| </div> |
| <div className="grid grid-cols-1 sm:grid-cols-3 gap-3"> |
| <PresetRadio<ChoicesPreset> |
| legend="Choices per step" |
| value={choicesPreset} |
| options={["simple", "standard", "complex"]} |
| labels={CHOICES_LABELS} |
| onChange={(next) => patch("branch_count", CHOICES_VALUES[next])} |
| disabled={disabled} |
| /> |
| <PresetRadio<LengthPreset> |
| legend="Story length" |
| value={lengthPreset} |
| options={["short", "medium", "long"]} |
| labels={LENGTH_LABELS} |
| onChange={(next) => patch("scenes_per_branch", LENGTH_VALUES[next])} |
| disabled={disabled} |
| /> |
| <PresetRadio<DepthPreset> |
| legend="Path depth" |
| value={depthPreset} |
| options={["light", "balanced", "deep"]} |
| labels={DEPTH_LABELS} |
| onChange={(next) => patch("depth", DEPTH_VALUES[next])} |
| disabled={disabled} |
| /> |
| </div> |
| <p className="text-[11px] text-[#777]"> |
| About{" "} |
| <span className="text-[#cfd8dc]">{estimateSceneTotal(form)}</span>{" "} |
| scenes total (the graph merges overlapping branches). |
| </p> |
| </div> |
| |
| <StoryFlowPreview form={form} initial={initial} /> |
| |
| {initial.seed_intents.length > 0 && ( |
| <Field |
| label="Player interactions" |
| hint="Action types the planner can use. Edit raw seeds in Advanced." |
| > |
| <div className="flex flex-wrap gap-1.5"> |
| {initial.seed_intents.map((s) => ( |
| <span |
| key={s} |
| className="inline-flex items-center gap-1 rounded-full bg-[#1f1f1f] border border-[#3f3f3f] text-[#cfd8dc] text-[11px] font-medium px-2 py-0.5" |
| > |
| <Check className="w-3 h-3 text-[#9f7fd1]" aria-hidden /> |
| {titleCase(s)} |
| </span> |
| ))} |
| </div> |
| </Field> |
| )} |
| |
| <Field label="Render plan"> |
| <RenderPlanPanel |
| personaLive={false} |
| renderEnabled={renderEnabled} |
| sceneCount={estimateSceneTotal(form)} |
| libraryPlanned={0} |
| libraryAlreadyBuilt={0} |
| /> |
| </Field> |
| |
| {(initial.objective || initial.topic || initial.success_metric) && ( |
| <div className="rounded-md border border-[#2a2a2a] bg-[#0f0f0f]/60 px-3 py-2.5 text-[11px] leading-relaxed"> |
| <div className="text-[#cfd8dc] font-medium mb-1">Planner preview</div> |
| <dl className="grid grid-cols-[auto,1fr] gap-x-3 gap-y-0.5 text-[#aaa]"> |
| {initial.objective && ( |
| <> |
| <dt>Objective</dt> |
| <dd className="text-[#cfd8dc]">{initial.objective}</dd> |
| </> |
| )} |
| {initial.topic && ( |
| <> |
| <dt>Topic</dt> |
| <dd className="text-[#cfd8dc]">{initial.topic}</dd> |
| </> |
| )} |
| {initial.success_metric && ( |
| <> |
| <dt>Success</dt> |
| <dd className="text-[#cfd8dc]">{initial.success_metric}</dd> |
| </> |
| )} |
| </dl> |
| </div> |
| )} |
| |
| <AdvancedBlock |
| open={advancedOpen} |
| onToggle={onToggleAdvanced} |
| form={form} |
| patch={patch} |
| disabled={disabled} |
| personaLive={false} |
| /> |
| |
| <div className="border-t border-[#2a2a2a] pt-3 -mb-1"> |
| <ExpertModeToggle /> |
| </div> |
| </section> |
| ); |
| } |
|
|