Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
6 changes: 4 additions & 2 deletions PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
20 changes: 20 additions & 0 deletions packages/freecut-editor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
6 changes: 4 additions & 2 deletions packages/freecut-editor/consumer-smoke.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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',
Expand Down
14 changes: 14 additions & 0 deletions packages/freecut-editor/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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
4 changes: 4 additions & 0 deletions packages/freecut-editor/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
15 changes: 15 additions & 0 deletions playwright.layout-refresh.config.ts
Original file line number Diff line number Diff line change
@@ -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',
},
})
2 changes: 1 addition & 1 deletion provenance/dependency-inventory.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"generatedFrom": "package.json",
"packageName": "freecut",
"packageVersion": "0.0.0",
"packageJsonSha256": "7786ffe5cde5b09b6f88fc4fd13af399365ac0d6dc32f320d6f719c6f62edc89",
"packageJsonSha256": "6047ae253aacc102cf79a9b30f7f45250974e0c312f5106fe8056cb7a37a7538",
"lockfile": {
"path": "package-lock.json",
"lockfileVersion": 3,
Expand Down
2 changes: 1 addition & 1 deletion provenance/freecut-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
],
"dependencies": {
"packageJson": "package.json",
"packageJsonSha256": "7786ffe5cde5b09b6f88fc4fd13af399365ac0d6dc32f320d6f719c6f62edc89",
"packageJsonSha256": "6047ae253aacc102cf79a9b30f7f45250974e0c312f5106fe8056cb7a37a7538",
"lockfile": "package-lock.json",
"lockfileVersion": 3,
"lockfileSha256": "b4a86741ce7891da1f63df01b6fdd4ed507e8887d5097c6a0f93fc2ea6f3420e",
Expand Down
12 changes: 12 additions & 0 deletions src/config/hotkeys-dom-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<div role="separator" tabindex="0" id="control"></div>', '#control', key),
).toEqual({ captureSawEvent: true, defaultPrevented: false })
expect(
dispatchFrom('<div data-timeline-item="true" id="timeline"></div>', '#timeline', key),
).toEqual({ captureSawEvent: true, defaultPrevented: true })
},
)

it('guards every dialog descendant, even when the target is a plain span', () => {
expect(
dispatchFrom('<div role="dialog"><span id="control">Message</span></div>', '#control', 'j'),
Expand Down
1 change: 1 addition & 0 deletions src/config/hotkeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1234,6 +1234,7 @@ const INTERACTIVE_CONTROL_SELECTOR = [
'[role="treeitem"]',
'[role="slider"]',
'[role="scrollbar"]',
'[role="separator"]',
'[role="spinbutton"]',
'[role="textbox"]',
'[role="searchbox"]',
Expand Down
164 changes: 164 additions & 0 deletions src/features/editor/components/editor-workspace-shell.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<>
<input aria-label="Transcript search" />
{transcriptActions}
</>
),
}))
vi.mock('./properties-sidebar', () => ({
PropertiesSidebar: () => <input aria-label="Property" />,
}))
vi.mock('./audio-meter-panel', () => ({ AudioMeterPanel: () => <div>Meter</div> }))
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 <span>Consent pending</span>
}

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(
<EditorWorkspaceShell options={{ onLayoutChange, transcriptActions: <PollingActions /> }}>
<input aria-label="Timeline state" />
</EditorWorkspaceShell>,
)
const search = screen.getByLabelText('Transcript search') as HTMLInputElement
fireEvent.change(search, { target: { value: 'keep my search' } })
const editor = container.querySelector<HTMLElement>('[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(
<EditorWorkspaceShell>
<div />
</EditorWorkspaceShell>,
)
fireEvent.click(screen.getByRole('button', { name: 'editor.refresh.clipSettings' }))
expect(screen.getByRole('region', { name: 'editor.refresh.settings' })).toBeTruthy()
selection.selectedItemIds = []
rerender(
<EditorWorkspaceShell>
<div />
</EditorWorkspaceShell>,
)
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 = (
<>
<textarea aria-label="Host chat" />
<EditorWorkspaceShell>
<div />
</EditorWorkspaceShell>
</>
)
const { rerender } = render(workspace)
fireEvent.click(screen.getByRole('button', { name: 'editor.refresh.clipSettings' }))
const chat = screen.getByRole('textbox', { name: 'Host chat' })
chat.focus()
selection.selectedItemIds = []
rerender(
<>
<textarea aria-label="Host chat" />
<EditorWorkspaceShell>
<div />
</EditorWorkspaceShell>
</>,
)
expect(screen.queryByRole('region', { name: 'editor.refresh.settings' })).toBeNull()
expect(document.activeElement).toBe(chat)
fireEvent.change(chat, { target: { value: 'continue the draft' } })
expect(chat).toHaveValue('continue the draft')
})

it('resizes with keyboard without resetting the reading state', () => {
render(
<EditorWorkspaceShell>
<div />
</EditorWorkspaceShell>,
)
const separator = screen.getByRole('separator')
fireEvent.keyDown(separator, { key: 'ArrowRight' })
expect(separator.getAttribute('aria-valuenow')).toBe('296')
})
})
Loading
Loading