From 37cee0f71d3b53b344dbbc62b1daa908b326b571 Mon Sep 17 00:00:00 2001
From: IAnMove <216241348+IAnMove@users.noreply.github.com>
Date: Wed, 2 Sep 2026 21:00:19 +0200
Subject: [PATCH] refactor: extract Story Lab assembly tab and library chrome
i18n
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Move assembly and leftover library chrome out of StoryLabPanel.
Handlers stay in the panel; tab labels, project types and chrome
copy live in storyLab EN/ES catalogs. New files stay at complexity ≤ 25.
Does not touch useStore.ts or _launch_runtime.py.
---
docs/development/SLICE_QUEUE.md | 26 +-
tests/test_series_lab_ui.py | 12 +-
tests/test_story_lab_trailer_ui.py | 21 +-
ui/scripts/check-i18n-catalogs.mjs | 9 +
ui/src/features/stories/StoryAssemblyTab.tsx | 48 ++++
.../stories/StoryLabLibraryChrome.tsx | 240 ++++++++++++++++
.../features/stories/StoryLabNavigation.tsx | 4 +-
ui/src/features/stories/StoryLabPanel.tsx | 265 +++++-------------
ui/src/features/stories/storyLabTabs.ts | 96 +++++++
ui/src/i18n/locales/en/storyLab.json | 94 +++++++
ui/src/i18n/locales/es/storyLab.json | 94 +++++++
ui/tests/i18nFoundation.test.tsx | 4 +
ui/tests/storyLabResponsive.test.tsx | 22 +-
13 files changed, 718 insertions(+), 217 deletions(-)
create mode 100644 ui/src/features/stories/StoryAssemblyTab.tsx
create mode 100644 ui/src/features/stories/StoryLabLibraryChrome.tsx
create mode 100644 ui/src/features/stories/storyLabTabs.ts
diff --git a/docs/development/SLICE_QUEUE.md b/docs/development/SLICE_QUEUE.md
index a7c0ab85..b2d6daf1 100644
--- a/docs/development/SLICE_QUEUE.md
+++ b/docs/development/SLICE_QUEUE.md
@@ -15,7 +15,7 @@ Canonical sources in git:
Working notes under `comunicaciones/` are session handoff only. They are
gitignored and are not canonical.
-## Landed on main (as of #98)
+## Landed on main (as of #100)
Asset-manifest v1 writers: Studio generate (simulated, WGP, H3, SFX), Tools
upscale/revoice, Recast/Repaint/Outpaint, MiniMax image, Series assembly, 3D,
@@ -36,7 +36,8 @@ publish so Wizard→Studio→asset can share one durable identity.
focus), developerMode, sidebar, retake dialog. Slices bind through
`bindSlice` without `as never`. `developerModeSlice` no longer writes
`mediaFilter`; the facade still leaves `auditdev` when developer mode turns
-off.
+off. A parallel gallery/workspace slice PR may be in flight; it has not
+landed on main.
Story Lab UI extracts:
@@ -46,7 +47,8 @@ Story Lab UI extracts:
- #91 Music, Trailer, Productions and Compact workspace, with `storyLab` EN+ES.
- #97 split those extracted tabs into smaller panels and added the code-health
PR table (`scripts/code_health.py --check --markdown`).
-- #98 Overview + generation-agent panel, EN+ES. Assets remains in
+- #98 Overview + generation-agent panel, EN+ES.
+- #100 Assets tab extracted with EN/ES. Assets is no longer remaining in
`StoryLabPanel`.
i18n: foundation + Extra info inspector + Extra info video dialog (`extraInfo`
@@ -58,8 +60,8 @@ Recipe audio duration: generated audio is sized for its consumers (#93).
Character Kits / Face Rig / cutout dialogue HOWUSEIT: #94 on `main`. #26 was
the older Cursor docs pass and is closed as superseded.
-`--markdown` without `--check` prints **Ratchet not evaluated.** CI uses
-`--check --markdown`.
+`--markdown` without `--check` prints **Ratchet not evaluated.** (#99). CI
+uses `--check --markdown`.
## Next medium PRs
@@ -68,16 +70,18 @@ the older Cursor docs pass and is closed as superseded.
3. **Story Lab simple tabs** — landed (#88).
4. **Story Lab Music + Productions** — landed (#91, split further in #97).
5. **Story Lab Overview** — landed (#98).
-6. **Story Lab Assets tab** — remaining chrome in `StoryLabPanel` (importer,
- style conversion, visual library), EN+ES, no `useStore.ts` /
- `_launch_runtime.py`.
-7. **`useStore` slice** — one moderate cohesive extract with the public facade
+6. **Story Lab Assets tab** — landed (#100).
+7. **Story Lab assembly + library chrome** — remaining: extract Assembly tab
+ and leftover library chrome (header, tab labels, project types, prepare
+ buttons, nav notes) with EN/ES. No `useStore.ts` / `_launch_runtime.py`.
+8. **`useStore` slice** — one moderate cohesive extract with the public facade
kept and `architectureSlices.test.mjs` extended. Do not move all of
`startGeneration` in one PR. At most one open PR may touch `useStore.ts`.
-8. **Backend by domain**: one complete router + services per PR (Assets, Music,
+ A gallery/workspace slice may already be in flight; do not claim it landed.
+9. **Backend by domain**: one complete router + services per PR (Assets, Music,
Series, Comics, …). Preserve route-table ordinals. Do not split
`_launch_runtime.py` by line count.
-9. **Provenance applied by flow**: Studio+Wizard landed (#95). Remaining:
+10. **Provenance applied by flow**: Studio+Wizard landed (#95). Remaining:
Story Lab+videoclip, Series+Comics. 3D+Director already has folder vs
Workspace provenance (#89). Do not start Director Paso 5 until a human
decides release order; that work must begin with 5.0 PipelineRuntime.
diff --git a/tests/test_series_lab_ui.py b/tests/test_series_lab_ui.py
index de84de71..83aba5bc 100644
--- a/tests/test_series_lab_ui.py
+++ b/tests/test_series_lab_ui.py
@@ -77,11 +77,15 @@ def test_review_is_thumbnail_first_and_exposes_ordered_editable_attempt_history(
def test_story_productions_have_an_in_place_ordered_clip_timeline():
- story = (ROOT / "ui" / "src" / "features" / "stories" / "StoryLabPanel.tsx").read_text(encoding="utf-8")
+ tabs = (ROOT / "ui" / "src" / "features" / "stories" / "storyLabTabs.ts").read_text(encoding="utf-8")
+ assembly = (ROOT / "ui" / "src" / "features" / "stories" / "StoryAssemblyTab.tsx").read_text(encoding="utf-8")
timeline = (ROOT / "ui" / "src" / "features" / "stories" / "StoryProductionTimeline.tsx").read_text(encoding="utf-8")
- assert "StoryProductionTimeline" in story
- assert "{ id: 'assembly', label: 'Montaje', icon: Play }" in story
- assert "initiallyOpen={index === 0}" in story
+ catalog_es = (ROOT / "ui" / "src" / "i18n" / "locales" / "es" / "storyLab.json").read_text(encoding="utf-8")
+ assert "id: 'assembly'" in tabs
+ assert "icon: Play" in tabs
+ assert "StoryProductionTimeline" in assembly
+ assert "initiallyOpen={index === 0}" in assembly
+ assert '"assembly": "Montaje"' in catalog_es
assert "View ordered clips" in timeline and "Play all" in timeline
assert "Edit/regenerate clips" in timeline and "Join clips" in timeline
assert "fetchSavedPipeline" in timeline
diff --git a/tests/test_story_lab_trailer_ui.py b/tests/test_story_lab_trailer_ui.py
index 1abaea7d..f4e6491c 100644
--- a/tests/test_story_lab_trailer_ui.py
+++ b/tests/test_story_lab_trailer_ui.py
@@ -6,6 +6,7 @@
ROOT = Path(__file__).resolve().parents[1]
STORIES = ROOT / "ui" / "src" / "features" / "stories"
PANEL = STORIES / "StoryLabPanel.tsx"
+TABS = STORIES / "storyLabTabs.ts"
TRAILER = STORIES / "StoryTrailerTab.tsx"
COMPACT = STORIES / "CompactVideoWorkspace.tsx"
VIDEO_FORMAT = STORIES / "storyLabVideoFormat.ts"
@@ -22,7 +23,7 @@
def story_lab_trailer_ui() -> str:
extracted = sorted(STORIES.glob("StoryTrailer*.tsx")) + sorted(STORIES.glob("Compact*.tsx"))
return "\n".join(path.read_text(encoding="utf-8") for path in (
- PANEL, TRAILER, COMPACT, VIDEO_FORMAT, VIDEO_CONTROLS, CATALOG_EN, CATALOG_ES, *extracted,
+ PANEL, TABS, TRAILER, COMPACT, VIDEO_FORMAT, VIDEO_CONTROLS, CATALOG_EN, CATALOG_ES, *extracted,
))
@@ -38,13 +39,19 @@ def test_trailer_is_a_standalone_story_project_type_without_music():
types = TYPES.read_text(encoding="utf-8")
model = MODEL.read_text(encoding="utf-8")
panel = PANEL.read_text(encoding="utf-8")
+ tabs = TABS.read_text(encoding="utf-8")
+ catalog_en = CATALOG_EN.read_text(encoding="utf-8")
+ catalog_es = CATALOG_ES.read_text(encoding="utf-8")
backend = ROOT.joinpath("app", "_launch_runtime.py").read_text(encoding="utf-8")
assert "'full_story' | 'music_video' | 'trailer' | 'quick_video'" in types
assert "projectType === 'trailer' ? 60" in model
- assert "{ id: 'trailer', label: 'Tráiler cinematográfico'" in panel
- assert "{ id: 'trailer', label: 'Crear tráiler'" in panel
- assert "No escribirá ni exigirá una canción" in CATALOG_ES.read_text(encoding="utf-8")
+ assert "id: 'trailer'" in tabs
+ assert '"label": "Cinematic trailer"' in catalog_en
+ assert '"label": "Tráiler cinematográfico"' in catalog_es
+ assert '"createTrailer": "Create trailer"' in catalog_en
+ assert '"createTrailer": "Crear tráiler"' in catalog_es
+ assert "No escribirá ni exigirá una canción" in catalog_es
assert "musicVideoGenerationMode: 'image_guided' as const" in panel
assert "project.projectType === 'trailer' ? 'trailer' : 'productions'" in panel
assert 'if project_type in {"trailer", "quick_video"}' in backend
@@ -66,10 +73,12 @@ def test_trailer_adapter_enforces_a_story_arc_without_revealing_the_ending():
def test_story_lab_exposes_editable_trailer_controls_and_timed_preview():
source = story_lab_trailer_ui()
- panel = PANEL.read_text(encoding="utf-8")
+ tabs = TABS.read_text(encoding="utf-8")
trailer = source
- assert "{ id: 'trailer', label: 'Tráiler'" in panel
+ assert "id: 'trailer'" in tabs
+ assert '"trailer": "Trailer"' in CATALOG_EN.read_text(encoding="utf-8")
+ assert '"trailer": "Tráiler"' in CATALOG_ES.read_text(encoding="utf-8")
assert "Creador de tráileres cinematográficos" in source
assert "TRAILER_ARC.map" in trailer
assert "setTrailerDuration" in trailer
diff --git a/ui/scripts/check-i18n-catalogs.mjs b/ui/scripts/check-i18n-catalogs.mjs
index 27083152..20f43b9b 100644
--- a/ui/scripts/check-i18n-catalogs.mjs
+++ b/ui/scripts/check-i18n-catalogs.mjs
@@ -45,6 +45,10 @@ const PILOT_FILES = [
'src/features/workspaceCollections/WorkspaceCollectionsPanel.tsx',
'src/features/workspaces/WorkspacesPanel.tsx',
'src/features/stories/storyLabChrome.tsx',
+ 'src/features/stories/StoryLabNavigation.tsx',
+ 'src/features/stories/storyLabTabs.ts',
+ 'src/features/stories/StoryLabLibraryChrome.tsx',
+ 'src/features/stories/StoryAssemblyTab.tsx',
'src/features/stories/StoryOverviewTab.tsx',
'src/features/stories/StoryAssetsTab.tsx',
'src/features/stories/StoryAssetsImporter.tsx',
@@ -107,6 +111,11 @@ const FORBIDDEN = [
'Cinematic trailer creator',
'Generate / refresh Lyria prompt',
'Import custom MP3',
+ 'Montaje de producciones',
+ 'Smart assets',
+ 'Guided · approve stages',
+ 'Preparar historia completa · solo texto',
+ 'Desliza para más secciones',
]
export function forbiddenLiterals() {
diff --git a/ui/src/features/stories/StoryAssemblyTab.tsx b/ui/src/features/stories/StoryAssemblyTab.tsx
new file mode 100644
index 00000000..88136bf4
--- /dev/null
+++ b/ui/src/features/stories/StoryAssemblyTab.tsx
@@ -0,0 +1,48 @@
+import { useUiTranslation } from '../../i18n'
+import { button, panel } from './storyLabChrome'
+import { StoryProductionTimeline } from './StoryProductionTimeline'
+import type { StoryProject } from './types'
+
+export function StoryAssemblyTab({
+ project, reopenProduction, restoreProductionSource,
+}: {
+ project: StoryProject
+ reopenProduction: (id: string) => void
+ restoreProductionSource: (id: string) => void
+}) {
+ const { t } = useUiTranslation('storyLab')
+ const productions = [...project.productions].reverse()
+ return (
+
+
+
{t('assembly.title')}
+
{t('assembly.description')}
+
+ {productions.length ? productions.map((item, index) => (
+
+
+
+
+ {item.kind === 'music_video' ? t('assembly.musicVideo') : item.kind} · {item.targetName || item.title}
+
+
+ {t('assembly.sourceMeta', { version: item.sourceVersion, when: new Date(item.createdAt).toLocaleString() })}
+
+ {item.sourceSnapshot?.sectionVersions
+ && JSON.stringify(item.sourceSnapshot.sectionVersions) !== JSON.stringify(project.sectionVersions) && (
+ {t('assembly.sourceChanged')}
+ )}
+
+
+
+ {item.sourceSnapshot && (
+
+ )}
+
+
+
+
+ )) :
{t('assembly.empty')}
}
+
+ )
+}
diff --git a/ui/src/features/stories/StoryLabLibraryChrome.tsx b/ui/src/features/stories/StoryLabLibraryChrome.tsx
new file mode 100644
index 00000000..9e7f0a7b
--- /dev/null
+++ b/ui/src/features/stories/StoryLabLibraryChrome.tsx
@@ -0,0 +1,240 @@
+import { useRef } from 'react'
+import {
+ BookOpen, Download, ImagePlus, Loader2, Plus, Sparkles, Trash2, Upload,
+} from 'lucide-react'
+import { useUiTranslation } from '../../i18n'
+import { StoryLibraryConflictNotice } from './StoryLibraryConflictNotice'
+import { button, input, requiredPreparationButton } from './storyLabChrome'
+import { storyProjectTypes } from './storyLabTabs'
+import type { StoryLibraryConflict } from './library'
+import type { StoryGenerationScope, StoryProject, StoryProjectType } from './types'
+
+const PREPARE_TEXT = {
+ full_story: 'library.prepareTextFullStory',
+ music_video: 'library.prepareTextMusicVideo',
+ trailer: 'library.prepareTextTrailer',
+ quick_video: 'library.prepareTextQuickVideo',
+} as const
+
+const PREPARE_IMAGES = {
+ full_story: 'library.prepareImagesFullStory',
+ music_video: 'library.prepareImagesMusicVideo',
+ trailer: 'library.prepareImagesTrailer',
+ quick_video: 'library.prepareImagesQuickVideo',
+} as const
+
+function StoryLabSaveStatus({
+ loading, saveError, dirty, hydrated,
+}: {
+ loading: boolean
+ saveError: string | null
+ dirty: boolean
+ hydrated: boolean
+}) {
+ const { t } = useUiTranslation('storyLab')
+ if (loading) return {t('library.saveLoading')}
+ if (saveError) return {t('library.saveLocalFallback')}
+ if (dirty) return {t('library.saveSaving')}
+ if (hydrated) return {t('library.saveSaved')}
+ return {t('library.saveCached')}
+}
+
+function StoryLabPrepareButtons({
+ projectType, needsPrep, busy, referenceBatchBusy, jobProgress, onPrepareText, onPrepareImages,
+}: {
+ projectType: StoryProjectType
+ needsPrep: boolean
+ busy: StoryGenerationScope | null
+ referenceBatchBusy: boolean
+ jobProgress: string
+ onPrepareText: () => void
+ onPrepareImages: () => void
+}) {
+ const { t } = useUiTranslation('storyLab')
+ const className = `${button} ${needsPrep ? requiredPreparationButton : ''}`
+ const preparing = Boolean(busy || referenceBatchBusy)
+ return (
+ <>
+
+
+ >
+ )
+}
+
+function StoryLabNewProjectMenu({
+ types, disabled, onNewProject,
+}: {
+ types: Array<{ id: StoryProjectType; label: string; description: string }>
+ disabled: boolean
+ onNewProject: (type: StoryProjectType) => void
+}) {
+ const { t } = useUiTranslation('storyLab')
+ return (
+
+ {t('library.new')}
+
+ {types.map(item => (
+
+ ))}
+
+
+ )
+}
+
+export function StoryLabLibraryChrome({
+ project, projects, activeWorkspace, progress, foundationTotal,
+ storyLoading, storySaveError, dirty, storyHydrated, storyLibraryConflicts,
+ resolveStoryLibraryConflict, busy, imageBusy, projectOperationBusy, referenceBatchBusy,
+ jobProgress, showCancel, showResume, recoveryJobId, smartAssetBusy,
+ onOpenProject, onProjectTypeChange, onWorkflowModeChange, onPrepareText, onPrepareImages,
+ onCancel, onResume, onExportStorypack, onImport, onSmartAssets, onNewProject, onDuplicate, onDelete,
+}: {
+ project: StoryProject
+ projects: Record
+ activeWorkspace: string
+ progress: number
+ foundationTotal: number
+ storyLoading: boolean
+ storySaveError: string | null
+ dirty: boolean
+ storyHydrated: boolean
+ storyLibraryConflicts: StoryLibraryConflict[]
+ resolveStoryLibraryConflict: (id: string, resolution: 'local' | 'remote') => void
+ busy: StoryGenerationScope | null
+ imageBusy: string
+ projectOperationBusy: boolean
+ referenceBatchBusy: boolean
+ jobProgress: string
+ showCancel: boolean
+ showResume: boolean
+ recoveryJobId: string
+ smartAssetBusy: boolean
+ onOpenProject: (id: string) => void
+ onProjectTypeChange: (type: StoryProjectType) => void
+ onWorkflowModeChange: (mode: StoryProject['workflowMode']) => void
+ onPrepareText: () => void
+ onPrepareImages: () => void
+ onCancel: () => void
+ onResume: () => void
+ onExportStorypack: () => void
+ onImport: (file?: File) => void
+ onSmartAssets: () => void
+ onNewProject: (type: StoryProjectType) => void
+ onDuplicate: () => void
+ onDelete: () => void
+}) {
+ const { t } = useUiTranslation('storyLab')
+ const importRef = useRef(null)
+ const types = storyProjectTypes(t)
+ const description = types.find(item => item.id === project.projectType)?.description
+ const libraryLocked = Boolean(busy || imageBusy || projectOperationBusy)
+ const progressUnit = project.projectType === 'full_story' ? t('library.foundations') : t('library.requirements')
+ return (
+
+
+
+
+ {t('library.title')}
+
+ {t('library.revisionProgress', {
+ revision: project.revision, progress, total: foundationTotal, unit: progressUnit,
+ })}
+
+
+
+
{description}
+
+
+
+ {t('library.legendRequired')}
+
+
+ {t('library.legendComplete')}
+
+
+
+
+
+
+
+ {showCancel && (
+
+ )}
+ {showResume && (
+
+ )}
+
+
+
+
+
+
+
{
+ onImport(event.target.files?.[0])
+ event.target.value = ''
+ }} />
+
+ )
+}
diff --git a/ui/src/features/stories/StoryLabNavigation.tsx b/ui/src/features/stories/StoryLabNavigation.tsx
index c69e7a8a..6ed0684a 100644
--- a/ui/src/features/stories/StoryLabNavigation.tsx
+++ b/ui/src/features/stories/StoryLabNavigation.tsx
@@ -1,5 +1,6 @@
import type { LucideIcon } from 'lucide-react'
import type { ReactNode } from 'react'
+import { useUiTranslation } from '../../i18n'
export interface StoryLabNavigationTab {
id: T
@@ -15,6 +16,7 @@ interface StoryLabNavigationProps {
}
export function StoryLabNavigation({ tabs, activeTab, onChange, notes }: StoryLabNavigationProps) {
+ const { t } = useUiTranslation('storyLab')
return (