diff --git a/docs/development/SLICE_QUEUE.md b/docs/development/SLICE_QUEUE.md index 0c246a1d..10ea868d 100644 --- a/docs/development/SLICE_QUEUE.md +++ b/docs/development/SLICE_QUEUE.md @@ -24,10 +24,11 @@ recording, Video Editor screenshot/export, comic animatic. Sidecar failure: Hunyuan3D and Rig keep the GLB when provenance write fails. -`useStore` slices (facade kept): theme, settings, developerMode, sidebar, -retake dialog. `openModelVisibility` still lives in `useStore` because it -writes settings fields. `developerModeSlice` still writes `mediaFilter` when -leaving `auditdev`. +`useStore` slices (facade kept): theme, settings (includes model-visibility +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. Story Lab: `relationships` and `world` tabs extracted. `StoryWorldTab` is an intermediate cut (too many props; `LocationEditor` / `ReferenceGallery` still @@ -38,12 +39,10 @@ namespace) + Assets catalog list chrome. ## Next medium PRs -1. **Domain provenance contract** (this track): `workspace_id` vs - `output_folder`, `GenerationProvenance` / `CommandContext`, initiator vs - provider/model, uniform sidecar-failure helper, Extra info timing keys. -2. **Typed Zustand composition**: drop `as never`, typed slice merge, stop - cross-slice writes (`developerMode` → `mediaFilter`), extract 2–3 *related* - areas together. +1. **Domain provenance contract** — landed (#86). +2. **Typed Zustand composition** (this track): `bindSlice`, no `as never` at + compose time, settings owns model-visibility focus, developerMode does not + write `mediaFilter`. 3. **Story Lab simple tabs**: extract shared `ReferenceGallery` / `LocationEditor` / controllers first, then Characters + Structure (and other purely visual tabs) in one PR, with i18n and tests. Do not pass 16 props. diff --git a/ui/src/stores/developerModeSlice.ts b/ui/src/stores/developerModeSlice.ts index 5b794b61..3e69c643 100644 --- a/ui/src/stores/developerModeSlice.ts +++ b/ui/src/stores/developerModeSlice.ts @@ -1,33 +1,15 @@ import { loadDeveloperMode, saveDeveloperMode } from '../lib/developerMode' -import type { MediaFilter } from '../types' +import type { SliceCreator } from './storeApi' export type DeveloperModeSlice = { developerMode: boolean setDeveloperMode: (enabled: boolean) => void } -// Runtime get() is the full AppState. The setter may also write mediaFilter -// when leaving auditdev; that field stays owned by the gallery store. -type DeveloperModeWriteState = DeveloperModeSlice & { - mediaFilter?: MediaFilter -} - -type SetDeveloperModeState = ( - partial: Partial | ((state: DeveloperModeWriteState) => Partial), -) => void - -type GetDeveloperModeState = () => DeveloperModeWriteState - -export function createDeveloperModeSlice(set: SetDeveloperModeState, get: GetDeveloperModeState): DeveloperModeSlice { - return { - developerMode: loadDeveloperMode(), - setDeveloperMode: enabled => { - saveDeveloperMode(enabled) - const { mediaFilter } = get() - set({ - developerMode: enabled, - mediaFilter: !enabled && mediaFilter === 'auditdev' ? 'all' : mediaFilter, - }) - }, - } -} +export const createDeveloperModeSlice: SliceCreator = set => ({ + developerMode: loadDeveloperMode(), + setDeveloperMode: enabled => { + saveDeveloperMode(enabled) + set({ developerMode: enabled }) + }, +}) diff --git a/ui/src/stores/directorSlice.ts b/ui/src/stores/directorSlice.ts index 291917b2..4363b0cb 100644 --- a/ui/src/stores/directorSlice.ts +++ b/ui/src/stores/directorSlice.ts @@ -14,6 +14,7 @@ import type { SpeakerMapping, } from '../types' import { DEFAULT_DIRECT_VIDEO_MASTER_PROMPT } from '../types' +import type { SliceCreator } from './storeApi' export type DirectorSlice = { directorStep: 'upload' | 'analyze' | 'structure' | 'style' | 'plan' | 'review' | 'generate_images' | 'plan_video' | 'review_video' @@ -97,11 +98,6 @@ export type DirectorSlice = { directorAllowClipText: boolean } -export type DirectorState = DirectorSlice & { directorSeamless: boolean } -type SetDirectorState = ( - partial: Partial | ((state: DirectorState) => Partial), -) => void - const defaultTreatment = (): MusicVideoTreatment => ({ generation_mode: 'image_guided', direct_video_master_prompt: DEFAULT_DIRECT_VIDEO_MASTER_PROMPT, @@ -122,7 +118,7 @@ const defaultTreatment = (): MusicVideoTreatment => ({ * Director's state and local reducers. Async orchestration remains in the * store for now; this slice gives it a stable, compatible state boundary. */ -export function createDirectorSlice(set: SetDirectorState): DirectorSlice { +export const createDirectorSlice: SliceCreator = set => { return { directorStep: 'upload', directorAudioFile: null, diff --git a/ui/src/stores/retakeDialogSlice.ts b/ui/src/stores/retakeDialogSlice.ts index 2dcfefe2..bc9b84d8 100644 --- a/ui/src/stores/retakeDialogSlice.ts +++ b/ui/src/stores/retakeDialogSlice.ts @@ -1,3 +1,5 @@ +import type { SliceCreator } from './storeApi' + export type RetakeDialogSlice = { retakeDialogOpen: boolean retakeSourceFile: string | null @@ -5,15 +7,9 @@ export type RetakeDialogSlice = { closeRetakeDialog: () => void } -type SetRetakeDialogState = ( - partial: Partial | ((state: RetakeDialogSlice) => Partial), -) => void - -export function createRetakeDialogSlice(set: SetRetakeDialogState): RetakeDialogSlice { - return { - retakeDialogOpen: false, - retakeSourceFile: null, - openRetakeDialog: filename => set({ retakeDialogOpen: true, retakeSourceFile: filename }), - closeRetakeDialog: () => set({ retakeDialogOpen: false, retakeSourceFile: null }), - } -} +export const createRetakeDialogSlice: SliceCreator = set => ({ + retakeDialogOpen: false, + retakeSourceFile: null, + openRetakeDialog: filename => set({ retakeDialogOpen: true, retakeSourceFile: filename }), + closeRetakeDialog: () => set({ retakeDialogOpen: false, retakeSourceFile: null }), +}) diff --git a/ui/src/stores/settingsSlice.ts b/ui/src/stores/settingsSlice.ts index c5cb9bae..d0492d65 100644 --- a/ui/src/stores/settingsSlice.ts +++ b/ui/src/stores/settingsSlice.ts @@ -1,4 +1,5 @@ -import type { SettingsTab } from '../types' +import type { GenerationMode, SettingsTab } from '../types' +import type { SliceCreator } from './storeApi' export type SettingsSlice = { settingsOpen: boolean @@ -6,20 +7,22 @@ export type SettingsSlice = { setSettingsOpen: (open: boolean) => void settingsTab: SettingsTab setSettingsTab: (tab: SettingsTab) => void + modelVisibilityFocus: GenerationMode | null + openModelVisibility: (mode: GenerationMode) => void + clearModelVisibilityFocus: () => void } -type SetSettingsState = ( - partial: Partial | ((state: SettingsSlice) => Partial), -) => void - -type GetSettingsState = () => SettingsSlice - -export function createSettingsSlice(set: SetSettingsState, get: GetSettingsState): SettingsSlice { - return { - settingsOpen: false, - toggleSettings: () => set({ settingsOpen: !get().settingsOpen }), - setSettingsOpen: open => set({ settingsOpen: open }), +export const createSettingsSlice: SliceCreator = (set, get) => ({ + settingsOpen: false, + toggleSettings: () => set({ settingsOpen: !get().settingsOpen }), + setSettingsOpen: open => set({ settingsOpen: open }), + settingsTab: 'performance', + setSettingsTab: tab => set({ settingsTab: tab }), + modelVisibilityFocus: null, + openModelVisibility: mode => set({ + settingsOpen: true, settingsTab: 'performance', - setSettingsTab: tab => set({ settingsTab: tab }), - } -} + modelVisibilityFocus: mode, + }), + clearModelVisibilityFocus: () => set({ modelVisibilityFocus: null }), +}) diff --git a/ui/src/stores/sidebarSlice.ts b/ui/src/stores/sidebarSlice.ts index 51f6e0ce..9e505de8 100644 --- a/ui/src/stores/sidebarSlice.ts +++ b/ui/src/stores/sidebarSlice.ts @@ -1,19 +1,13 @@ +import type { SliceCreator } from './storeApi' + export type SidebarSlice = { sidebarOpen: boolean toggleSidebar: () => void setSidebarOpen: (open: boolean) => void } -type SetSidebarState = ( - partial: Partial | ((state: SidebarSlice) => Partial), -) => void - -type GetSidebarState = () => SidebarSlice - -export function createSidebarSlice(set: SetSidebarState, get: GetSidebarState): SidebarSlice { - return { - sidebarOpen: false, - toggleSidebar: () => set({ sidebarOpen: !get().sidebarOpen }), - setSidebarOpen: open => set({ sidebarOpen: open }), - } -} +export const createSidebarSlice: SliceCreator = (set, get) => ({ + sidebarOpen: false, + toggleSidebar: () => set({ sidebarOpen: !get().sidebarOpen }), + setSidebarOpen: open => set({ sidebarOpen: open }), +}) diff --git a/ui/src/stores/storeApi.ts b/ui/src/stores/storeApi.ts new file mode 100644 index 00000000..acf787da --- /dev/null +++ b/ui/src/stores/storeApi.ts @@ -0,0 +1,28 @@ +/** Typed Zustand slice binding so composers do not use `as never`. */ + +export type SliceSet = ( + partial: Partial | ((state: T) => Partial), +) => void + +export type SliceGet = () => T + +export type SliceCreator = (set: SliceSet, get: SliceGet) => T + +/** + * Bind a slice to the full store. `TStore` must include the slice keys, so a + * `Partial` is a valid store update without `as never`. + */ +export function bindSlice( + set: SliceSet, + get: SliceGet, + create: SliceCreator, +): TSlice { + const sliceSet: SliceSet = partial => { + if (typeof partial === 'function') { + set(state => partial(state) as Partial) + return + } + set(partial as Partial) + } + return create(sliceSet, get) +} diff --git a/ui/src/stores/themeSlice.ts b/ui/src/stores/themeSlice.ts index a6e05336..812fd61e 100644 --- a/ui/src/stores/themeSlice.ts +++ b/ui/src/stores/themeSlice.ts @@ -1,4 +1,5 @@ import { applyThemePrefs, getStoredPrefs, type FamilyId, type ThemeMode, type ThemePrefs } from '../lib/theme' +import type { SliceCreator } from './storeApi' export type ThemeSlice = { themePrefs: ThemePrefs @@ -6,24 +7,16 @@ export type ThemeSlice = { setThemeFamily: (family: FamilyId) => void } -type SetThemeState = ( - partial: Partial | ((state: ThemeSlice) => Partial), -) => void - -type GetThemeState = () => ThemeSlice - -export function createThemeSlice(set: SetThemeState, get: GetThemeState): ThemeSlice { - return { - themePrefs: getStoredPrefs(), - setThemeMode: mode => { - const prefs = { ...get().themePrefs, mode } - applyThemePrefs(prefs) - set({ themePrefs: prefs }) - }, - setThemeFamily: family => { - const prefs = { ...get().themePrefs, family } - applyThemePrefs(prefs) - set({ themePrefs: prefs }) - }, - } -} +export const createThemeSlice: SliceCreator = (set, get) => ({ + themePrefs: getStoredPrefs(), + setThemeMode: mode => { + const prefs = { ...get().themePrefs, mode } + applyThemePrefs(prefs) + set({ themePrefs: prefs }) + }, + setThemeFamily: family => { + const prefs = { ...get().themePrefs, family } + applyThemePrefs(prefs) + set({ themePrefs: prefs }) + }, +}) diff --git a/ui/src/stores/useStore.ts b/ui/src/stores/useStore.ts index 8af502d6..0c3ff0f4 100644 --- a/ui/src/stores/useStore.ts +++ b/ui/src/stores/useStore.ts @@ -15,6 +15,7 @@ import { createDirectorSlice } from './directorSlice' import { createRetakeDialogSlice } from './retakeDialogSlice' import { createSettingsSlice } from './settingsSlice' import { createSidebarSlice } from './sidebarSlice' +import { bindSlice } from './storeApi' import { createThemeSlice } from './themeSlice' import { markJobsCancelling, prependJob, removeJob, updateJob, withJobs } from './jobReducers' import { @@ -2322,25 +2323,21 @@ async function _syncGlobalProductionVideoFormat( }) } -export const useStore = create((set, get) => ({ - ...createDirectorSlice(partial => set(partial as never)), - ...createThemeSlice( - partial => set(partial as never), - () => get(), - ), - ...createSettingsSlice( - partial => set(partial as never), - () => get(), - ), - ...createSidebarSlice( - partial => set(partial as never), - () => get(), - ), - ...createRetakeDialogSlice(partial => set(partial as never)), - ...createDeveloperModeSlice( - partial => set(partial as never), - () => get(), - ), +export const useStore = create((set, get) => { + const developerMode = bindSlice(set, get, createDeveloperModeSlice) + return { + ...bindSlice(set, get, createDirectorSlice), + ...bindSlice(set, get, createThemeSlice), + ...bindSlice(set, get, createSettingsSlice), + ...bindSlice(set, get, createSidebarSlice), + ...bindSlice(set, get, createRetakeDialogSlice), + ...developerMode, + setDeveloperMode: (enabled: boolean) => { + developerMode.setDeveloperMode(enabled) + if (!enabled && get().mediaFilter === 'auditdev') { + get().setMediaFilter('all') + } + }, // Generation mode generationMode: 'video', editSubMode: 'retake' as import('../types').EditSubMode, @@ -3737,15 +3734,6 @@ export const useStore = create((set, get) => ({ return { enabledModels: next } }) }, - // Open Settings → Performance and ask the Enabled Models section to - // expand + scroll to the given mode (fired by the ModelSelector hint). - modelVisibilityFocus: null, - openModelVisibility: (mode) => set({ - settingsOpen: true, - settingsTab: 'performance', - modelVisibilityFocus: mode, - }), - clearModelVisibilityFocus: () => set({ modelVisibilityFocus: null }), loadModels: async () => { try { // The backend catalog is the single source for Hunyuan3D models too: @@ -10953,4 +10941,5 @@ export const useStore = create((set, get) => ({ setTimeout(poll, 1000) }, -})) + } +}) diff --git a/ui/tests/architectureSlices.test.mjs b/ui/tests/architectureSlices.test.mjs index dbdc3aca..02180e15 100644 --- a/ui/tests/architectureSlices.test.mjs +++ b/ui/tests/architectureSlices.test.mjs @@ -50,7 +50,7 @@ test('Director slice keeps the public setter behavior while state is composed se const partial = typeof update === 'function' ? update(state) : update state = { ...state, ...partial } } - state = createDirectorSlice(set) + state = createDirectorSlice(set, () => state) state.setDirectorMusicVideoTreatment({ generation_mode: 'direct_video' }) assert.equal(state.directorMusicVideoTreatment.generation_mode, 'direct_video') @@ -117,6 +117,12 @@ test('settings slice toggles open and tab through the public facade', async () = assert.equal(useStore.getState().settingsTab, 'integrations') useStore.getState().toggleSettings() assert.equal(useStore.getState().settingsOpen, false) + useStore.getState().openModelVisibility('video') + assert.equal(useStore.getState().settingsOpen, true) + assert.equal(useStore.getState().settingsTab, 'performance') + assert.equal(useStore.getState().modelVisibilityFocus, 'video') + useStore.getState().clearModelVisibilityFocus() + assert.equal(useStore.getState().modelVisibilityFocus, null) }) test('sidebar slice toggles open through the public facade', async () => { @@ -147,7 +153,7 @@ test('retake dialog slice opens and closes through the public facade', async () const partial = typeof update === 'function' ? update(state) : update state = { ...state, ...partial } } - state = createRetakeDialogSlice(set) + state = createRetakeDialogSlice(set, () => state) assert.equal(state.retakeDialogOpen, false) assert.equal(state.retakeSourceFile, null) state.openRetakeDialog('clip.mp4') @@ -207,6 +213,19 @@ test('developer-mode slice persists the local flag through the public facade', a useStore.getState().setDeveloperMode(true) assert.equal(useStore.getState().developerMode, true) assert.equal(useStore.getState().mediaFilter, 'videos') + + const isolated = createDeveloperModeSlice(set, () => state) + isolated.setDeveloperMode(false) + assert.equal('mediaFilter' in isolated, false) +}) + +test('composed slices bind without as-never casts at the useStore call site', async () => { + const fs = await import('node:fs/promises') + const source = await fs.readFile(new URL('../src/stores/useStore.ts', import.meta.url), 'utf8') + const composition = source.split('export const useStore')[1].split('generationMode:')[0] + assert.match(composition, /bindSlice\(set, get, createSettingsSlice\)/) + assert.match(composition, /bindSlice\(set, get, createThemeSlice\)/) + assert.doesNotMatch(composition, /as never/) }) test('useStore keeps Director actions available through its existing public facade', async () => {