diff --git a/DESIGN.md b/DESIGN.md index 5bb4cf593..8c5d5e512 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -332,3 +332,17 @@ density and more color than the rest of the app because the color is data. readable text to ~2.5–3.5:1. De-emphasize with size/weight, not sub-AA alpha. (Opacity is fine on genuinely decorative markers or disabled controls, which AA exempts.) - **Don't** add a light theme; contrast work happens within the dark ramp. + +## Editing workspace refresh + +CodePress keeps its independent AI conversation far left. FreeCut owns a separate +Library (Transcript, Media, More) and Editor (preview above timeline). Columns +resize and collapse independently, keep their mounted state, and expose restore +controls outside hidden regions. Narrow workspaces retain readable minima and +explicit visibility controls rather than combining Chat and Transcript into tabs. + +Settings and audio meters are on demand, with no permanent properties column. +Common actions use a single header group. Transcript reading uses 14px text; +quiet graphite surfaces, generous preview spacing, and restrained orange focus +keep the footage central. Timeline interaction and host edit reliability have +separate acceptance gates under quantfive/codepress#7144. diff --git a/PRODUCT.md b/PRODUCT.md index b0eb06fcf..9328dbcb6 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -6,8 +6,10 @@ product ## Users -Experienced video editors. They come from Premiere Pro and DaVinci Resolve and -expect those workflows: keyboard-driven, frame-accurate, dense panels they read +Creators making a clear cut with AI assistance, alongside experienced video +editors coming from Premiere Pro and DaVinci Resolve. Common actions must be +discoverable through readable labels and progressive disclosure. Preserve expert +workflows: keyboard-driven, frame-accurate, dense panels they read at a glance. Their context is a focused editing session, often hours long, eyes on the preview and timeline, hands on shortcuts. They want professional power without an install, a subscription, or cloud uploads. The headline draw is that diff --git a/package.json b/package.json index 9600ad50c..f10223d5b 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "routes": "tsr generate", "test": "vp test", "test:run": "vp test run", + "test:layout-refresh": "playwright test --config playwright.layout-refresh.config.ts", "test:responsive": "playwright test --config playwright.mobile.config.ts", "test:editor-hardening": "vp test run src/features/timeline/components/timeline-content.test.tsx src/features/timeline/components/timeline-item/use-timeline-item-pointer-handlers.test.tsx src/features/timeline/hooks/shortcuts/use-clipboard-shortcuts.test.tsx src/features/timeline/hooks/shortcuts/use-playback-shortcuts.test.tsx src/features/timeline/stores/export-snapshot.test.ts src/features/export/components/export-dialog.test.tsx src/features/export/hooks/client-render-source.test.ts src/features/export/hooks/use-client-render.test.tsx src/features/preview/workers/consume-video-samples.test.ts src/features/preview/utils/media-resolver.test.ts src/features/preview/hooks/use-preview-media-resolution.test.tsx src/features/preview/components/source-composition.generation.test.tsx src/features/preview/components/video-preview.sync.test.tsx src/infrastructure/browser/blob-url-manager.test.ts", "test:preview-sync": "vp test run src/features/preview/components/video-preview.sync.test.tsx", diff --git a/packages/freecut-editor/README.md b/packages/freecut-editor/README.md index 4e485e835..4d5726c0b 100644 --- a/packages/freecut-editor/README.md +++ b/packages/freecut-editor/README.md @@ -173,3 +173,23 @@ lockfile: ```bash npm install @quantfive/freecut-editor-surface@0.3.13 ``` + +## Three-column shell (pending next package release) + +`FREECUT_EDITOR_SHELL_VERSION = 1` identifies the optional `shell` prop. Hosts +resolve the marker from the same module as the component. `headerActions` places +the host history/export group in the project toolbar; `navigationActions` keeps +the host Chat visibility control outside hidden columns; `transcriptActions` +mounts generation/consent controls in Library without unmounting polling across +tab switches. `onLayoutChange` reports the visible Library/Editor minimum width +so the host can bound its own Chat separator. Existing callers need no props. + +The shell dispatches `freecut:cancel-timeline-gesture` on its own DOM root with +`bubbles: true` before pausing playback and hiding Editor. The timeline consumer +must accept only events whose target contains its own clip element and cancel +through its existing preview cleanup path; see the companion timeline PR for +that listener. Hiding a column never remounts its contents or commits a preview. + +Tracking: https://github.com/quantfive/codepress/issues/7144. Package publication +and CodePress's pinned vendor-patch/static-asset reconciliation are separate +integration steps. This source PR does not publish or bump a package version. diff --git a/packages/freecut-editor/consumer-smoke.test.tsx b/packages/freecut-editor/consumer-smoke.test.tsx index f8da9cca0..f6b902add 100644 --- a/packages/freecut-editor/consumer-smoke.test.tsx +++ b/packages/freecut-editor/consumer-smoke.test.tsx @@ -3,7 +3,7 @@ import '@testing-library/jest-dom' import '@quantfive/freecut-editor-surface/style.css' -import { render, screen, waitFor, within } from '@testing-library/react' +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { beforeAll, describe, expect, it, vi } from 'vite-plus/test' import { FreeCutEditorSurface, @@ -127,7 +127,9 @@ describe('published FreeCut browser entry', () => { { timeout: 10_000 }, ) - expect(screen.getByTestId('properties-clip-panel-host')).toBeInTheDocument() + expect(screen.queryByTestId('properties-clip-panel-host')).not.toBeInTheDocument() + fireEvent.click(within(view.container).getByRole('button', { name: 'Canvas settings' })) + expect(await screen.findByTestId('properties-clip-panel-host')).toBeInTheDocument() expect(await screen.findByTestId('caption-editor')).toBeInTheDocument() expect(HOTKEYS).toMatchObject({ SHUTTLE_REVERSE: 'j', diff --git a/packages/freecut-editor/src/index.d.ts b/packages/freecut-editor/src/index.d.ts index 4e4d145fc..4c2cd2551 100644 --- a/packages/freecut-editor/src/index.d.ts +++ b/packages/freecut-editor/src/index.d.ts @@ -526,7 +526,19 @@ export interface EditorHostProviderProps { children: ReactNode } +export interface EditorShellOptions { + onLayoutChange?: (layout: { + minimumWidth: number + libraryVisible: boolean + editorVisible: boolean + }) => void + headerActions?: import('react').ReactNode + navigationActions?: import('react').ReactNode + transcriptActions?: import('react').ReactNode +} + export interface FreeCutEditorSurfaceProps { + shell?: EditorShellOptions host: EditorHost } @@ -557,3 +569,5 @@ export declare function isHostCapabilityEnabled( capability: EditorCapability, ): boolean export declare function createLocalEditorHost(options: LocalEditorHostOptions): EditorHost + +export declare const FREECUT_EDITOR_SHELL_VERSION = 1 diff --git a/packages/freecut-editor/src/index.ts b/packages/freecut-editor/src/index.ts index d94cf24f7..b29a843fd 100644 --- a/packages/freecut-editor/src/index.ts +++ b/packages/freecut-editor/src/index.ts @@ -57,3 +57,7 @@ export type { MediaLocator, ResolvedMediaLocator, } from '@/features/editor/host/contract' + +export type { EditorShellOptions } from '@/features/editor/components/editor-workspace-shell' + +export const FREECUT_EDITOR_SHELL_VERSION = 1 diff --git a/playwright.layout-refresh.config.ts b/playwright.layout-refresh.config.ts new file mode 100644 index 000000000..5a388dfa1 --- /dev/null +++ b/playwright.layout-refresh.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'playwright/test' +export default defineConfig({ + testDir: './tests/browser', + testMatch: 'layout-refresh.spec.ts', + workers: 1, + reporter: 'line', + use: { + baseURL: 'http://127.0.0.1:4186', + channel: 'chrome', + headless: true, + viewport: { width: 1440, height: 900 }, + trace: 'retain-on-failure', + video: 'retain-on-failure', + }, +}) diff --git a/provenance/dependency-inventory.json b/provenance/dependency-inventory.json index c08f6d559..ada5bc796 100644 --- a/provenance/dependency-inventory.json +++ b/provenance/dependency-inventory.json @@ -3,7 +3,7 @@ "generatedFrom": "package.json", "packageName": "freecut", "packageVersion": "0.0.0", - "packageJsonSha256": "7786ffe5cde5b09b6f88fc4fd13af399365ac0d6dc32f320d6f719c6f62edc89", + "packageJsonSha256": "6047ae253aacc102cf79a9b30f7f45250974e0c312f5106fe8056cb7a37a7538", "lockfile": { "path": "package-lock.json", "lockfileVersion": 3, diff --git a/provenance/freecut-baseline.json b/provenance/freecut-baseline.json index 3ee69326d..2171ac0ac 100644 --- a/provenance/freecut-baseline.json +++ b/provenance/freecut-baseline.json @@ -34,7 +34,7 @@ ], "dependencies": { "packageJson": "package.json", - "packageJsonSha256": "7786ffe5cde5b09b6f88fc4fd13af399365ac0d6dc32f320d6f719c6f62edc89", + "packageJsonSha256": "6047ae253aacc102cf79a9b30f7f45250974e0c312f5106fe8056cb7a37a7538", "lockfile": "package-lock.json", "lockfileVersion": 3, "lockfileSha256": "b4a86741ce7891da1f63df01b6fdd4ed507e8887d5097c6a0f93fc2ea6f3420e", diff --git a/src/config/hotkeys-dom-guard.test.ts b/src/config/hotkeys-dom-guard.test.ts index cfdd7a238..2781495c6 100644 --- a/src/config/hotkeys-dom-guard.test.ts +++ b/src/config/hotkeys-dom-guard.test.ts @@ -87,6 +87,18 @@ describe('global shortcut DOM guards', () => { }) }) + it.each(['ArrowRight', 'ArrowLeft', ' ', 'Backspace', 'Delete'])( + 'leaves %s to a focused resize separator', + (key) => { + expect( + dispatchFrom('', '#control', key), + ).toEqual({ captureSawEvent: true, defaultPrevented: false }) + expect( + dispatchFrom('
', '#timeline', key), + ).toEqual({ captureSawEvent: true, defaultPrevented: true }) + }, + ) + it('guards every dialog descendant, even when the target is a plain span', () => { expect( dispatchFrom('
Message
', '#control', 'j'), diff --git a/src/config/hotkeys.ts b/src/config/hotkeys.ts index d9c520624..77d2f3313 100644 --- a/src/config/hotkeys.ts +++ b/src/config/hotkeys.ts @@ -1234,6 +1234,7 @@ const INTERACTIVE_CONTROL_SELECTOR = [ '[role="treeitem"]', '[role="slider"]', '[role="scrollbar"]', + '[role="separator"]', '[role="spinbutton"]', '[role="textbox"]', '[role="searchbox"]', diff --git a/src/features/editor/components/editor-workspace-shell.test.tsx b/src/features/editor/components/editor-workspace-shell.test.tsx new file mode 100644 index 000000000..9b49f5708 --- /dev/null +++ b/src/features/editor/components/editor-workspace-shell.test.tsx @@ -0,0 +1,164 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vite-plus/test' +import { useEffect } from 'react' +const { pause, sourcePause, setPendingPlay, cancel, actionMounts, actionUnmounts, selection } = vi.hoisted(() => ({ + pause: vi.fn(), + sourcePause: vi.fn(), + setPendingPlay: vi.fn(), + cancel: vi.fn(), + actionMounts: vi.fn(), + actionUnmounts: vi.fn(), + selection: { selectedItemIds: ['clip-1'] }, +})) +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })) +vi.mock('@/shared/state/playback', () => ({ usePlaybackStore: { getState: () => ({ pause }) } })) +vi.mock('@/shared/state/source-player', () => ({ + useSourcePlayerStore: { + getState: () => ({ playerMethods: { pause: sourcePause }, setPendingPlay }), + }, +})) +vi.mock('@/shared/state/selection', () => ({ + useSelectionStore: (select: (s: typeof selection) => unknown) => select(selection), +})) +vi.mock('./media-sidebar', () => ({ + MediaSidebar: ({ transcriptActions }: { transcriptActions: React.ReactNode }) => ( + <> + + {transcriptActions} + + ), +})) +vi.mock('./properties-sidebar', () => ({ + PropertiesSidebar: () => , +})) +vi.mock('./audio-meter-panel', () => ({ AudioMeterPanel: () =>
Meter
})) +import { EditorWorkspaceShell } from './editor-workspace-shell' +class ResizeObserverMock { + observe() {} + disconnect() {} +} +vi.stubGlobal('ResizeObserver', ResizeObserverMock) +afterEach(() => { + cleanup() + vi.clearAllMocks() + selection.selectedItemIds = ['clip-1'] +}) +function PollingActions() { + useEffect(() => { + actionMounts() + return actionUnmounts + }, []) + return Consent pending +} + +describe('editor workspace columns', () => { + it('retains search, pending work and timeline while each column hides; cancels before hiding', () => { + const onLayoutChange = vi.fn() + const { container } = render( + }}> + + , + ) + const search = screen.getByLabelText('Transcript search') as HTMLInputElement + fireEvent.change(search, { target: { value: 'keep my search' } }) + const editor = container.querySelector('[data-editor-column="editor"]')! + const listener = (event: Event) => { + expect(editor.style.display).toBe('flex') + expect(event.target).toBe(container.firstElementChild) + cancel() + } + window.addEventListener('freecut:cancel-timeline-gesture', listener) + fireEvent.click(screen.getByRole('button', { name: 'editor.refresh.hideLibrary' })) + expect(onLayoutChange).toHaveBeenLastCalledWith({ + minimumWidth: 480, + libraryVisible: false, + editorVisible: true, + }) + sourcePause.mockImplementationOnce(() => { + expect(editor.style.display).toBe('flex') + expect(cancel).toHaveBeenCalledOnce() + expect(pause).toHaveBeenCalledOnce() + expect(setPendingPlay).toHaveBeenCalledWith(false) + }) + fireEvent.click(screen.getByRole('button', { name: 'editor.refresh.hideEditor' })) + expect(cancel).toHaveBeenCalledOnce() + expect(pause).toHaveBeenCalledOnce() + expect(sourcePause).toHaveBeenCalledOnce() + expect(editor.style.display).toBe('none') + expect(actionMounts).toHaveBeenCalledOnce() + expect(actionUnmounts).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: 'editor.refresh.showLibrary' })) + expect(onLayoutChange).toHaveBeenLastCalledWith({ + minimumWidth: 260, + libraryVisible: true, + editorVisible: false, + }) + fireEvent.click(screen.getByRole('button', { name: 'editor.refresh.showEditor' })) + expect(search.value).toBe('keep my search') + expect(screen.getByLabelText('Timeline state')).toBeTruthy() + expect(onLayoutChange).toHaveBeenLastCalledWith({ + minimumWidth: 748, + libraryVisible: true, + editorVisible: true, + }) + window.removeEventListener('freecut:cancel-timeline-gesture', listener) + }) + it('closes stale clip settings and restores trigger focus', () => { + const { rerender } = render( + +
+ , + ) + fireEvent.click(screen.getByRole('button', { name: 'editor.refresh.clipSettings' })) + expect(screen.getByRole('region', { name: 'editor.refresh.settings' })).toBeTruthy() + selection.selectedItemIds = [] + rerender( + +
+ , + ) + expect(screen.queryByRole('region', { name: 'editor.refresh.settings' })).toBeNull() + expect(document.activeElement).toBe( + screen.getByRole('button', { name: 'editor.refresh.canvasSettings' }), + ) + }) + it('closes stale settings without stealing focus from the host chat', () => { + const workspace = ( + <> +