Compare commits

...

12 Commits

Author SHA1 Message Date
Song367
02f917ba76 test: exclude local worktrees from vitest discovery
All checks were successful
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 1m4s
2026-03-21 15:03:19 +08:00
Song367
369d6f6886 fix: scale subtitle preview text 2026-03-21 14:52:27 +08:00
Song367
3324012338 docs: capture subtitle preview scaling 2026-03-21 14:51:01 +08:00
Song367
eac9f65925 feat: swap upload card panel order 2026-03-21 14:46:55 +08:00
Song367
4d664abb26 docs: capture swapped upload panel design 2026-03-21 14:45:21 +08:00
Song367
0b50b0b432 feat: embed subtitle defaults in upload card 2026-03-21 14:25:50 +08:00
Song367
bb19dafc19 docs: add inline subtitle style design 2026-03-21 14:23:40 +08:00
Song367
ea353c963c feat: move language card into top row 2026-03-21 14:06:30 +08:00
Song367
7ac7c4b216 docs: add upload three-card row design 2026-03-21 14:04:23 +08:00
Song367
fd86f7e10b feat: fit upload controls into first fold 2026-03-21 13:59:42 +08:00
Song367
5c750ae34f docs: add first-fold upload layout design 2026-03-21 13:56:16 +08:00
Song367
5234f5b5db feat: redesign upload workbench layout 2026-03-21 12:50:58 +08:00
17 changed files with 1891 additions and 227 deletions

View File

@ -0,0 +1,55 @@
# Subtitle Preview Scale Design
**Goal:** Make the subtitle sample inside the small preview screen visually proportional by scaling the rendered preview text down, while keeping the real subtitle size values unchanged for export and editing.
## Context
The current subtitle defaults card renders the preview text using the raw subtitle font size value. In a compact sample screen, that makes the text appear nearly as tall as the preview display itself, which breaks visual proportion and makes the mock preview feel unrealistic.
The user approved the "preview scale mapping" direction instead of enlarging the screen or rebuilding the preview as a strict video-canvas simulator.
## Approved Direction
- keep the stored subtitle size value unchanged
- keep the slider values unchanged
- keep exported subtitle defaults unchanged
- only scale the preview rendering inside the small sample screen
- make the preview text visually smaller relative to the sample screen
## Recommended Behavior
Use a lightweight preview-only mapping:
- compute a preview font size from the real subtitle size
- apply a clamp so small values do not become unreadable
- keep the label beside the slider showing the real value such as `24px`
- keep upload/export using the real value such as `24`
This preserves the product meaning of the controls while making the small preview believable.
## Rendering Rules
- real subtitle size remains the source of truth in state
- preview font size is a derived value for UI only
- preview outline strength should scale down with the preview text so the stroke does not overpower the sample
- subtitle bottom offset behavior remains unchanged
## Testing Strategy
Update upload-screen tests to verify:
- the preview text no longer renders at the raw `24px` size
- the slider can still change the real font size value
- the preview updates to the mapped display size
- upload confirmation still sends the unmodified real subtitle size
## Out of Scope
- changing the export subtitle engine
- changing slider ranges
- redesigning the subtitle defaults card
- introducing full video-canvas simulation
## Success Criteria
Success means the sample subtitle looks appropriately smaller than the preview screen, while the UI still communicates and submits the real subtitle size values unchanged.

View File

@ -0,0 +1,163 @@
# Subtitle Preview Scale Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Scale subtitle text inside the compact preview screen so it looks proportional, without changing the real subtitle size values used elsewhere in the flow.
**Architecture:** Keep `subtitleDefaults.fontSize` as the source of truth and derive a preview-only font size in `src/components/UploadScreen.tsx`. Lock the behavior with upload-screen tests that prove the preview is scaled down while upload/export still use the real font size.
**Tech Stack:** React 19, TypeScript, Tailwind CSS v4 utilities, Vitest, React Testing Library
---
### Task 1: Lock preview-only scaling with a failing test
**Files:**
- Modify: `src/components/UploadScreen.test.tsx`
- Reference: `src/components/UploadScreen.tsx`
**Step 1: Write the failing test**
Update the existing preview test so it expects scaled preview rendering rather than raw font size rendering:
```tsx
it('scales the subtitle preview while preserving the real subtitle size value', () => {
renderUploadScreen();
const sizeSlider = screen.getByLabelText(/subtitle initial size/i);
const positionSlider = screen.getByLabelText(/subtitle initial position/i);
const preview = screen.getByTestId('upload-subtitle-preview');
expect(sizeSlider).toHaveValue('24');
expect(positionSlider).toHaveValue('10');
expect(preview).toHaveStyle({ fontSize: '12px', bottom: '10%' });
fireEvent.change(sizeSlider, { target: { value: '32' } });
fireEvent.change(positionSlider, { target: { value: '18' } });
expect(preview).toHaveStyle({ fontSize: '16px', bottom: '18%' });
});
```
**Step 2: Run test to verify it fails**
Run:
```bash
node ./node_modules/vitest/vitest.mjs run src/components/UploadScreen.test.tsx
```
Expected: FAIL because the preview currently renders the raw font size values.
**Step 3: Write minimal implementation**
Add a preview-only derived font size and use it for the preview text.
**Step 4: Run test to verify it passes**
Run:
```bash
node ./node_modules/vitest/vitest.mjs run src/components/UploadScreen.test.tsx
```
Expected: PASS for the new preview-scaling expectation.
**Step 5: Commit**
```bash
git add src/components/UploadScreen.tsx src/components/UploadScreen.test.tsx
git commit -m "test: lock subtitle preview scaling"
```
### Task 2: Keep export behavior unchanged
**Files:**
- Modify: `src/components/UploadScreen.tsx`
- Reference: `src/components/UploadScreen.test.tsx`
**Step 1: Write the failing test**
Use the existing upload-confirmation test as the regression check for preserving the real font size value.
**Step 2: Run test to verify it fails**
Run:
```bash
node ./node_modules/vitest/vitest.mjs run src/components/UploadScreen.test.tsx
```
Expected: FAIL only if the implementation accidentally changes uploaded subtitle defaults instead of preview-only rendering.
**Step 3: Write minimal implementation**
Ensure only the preview rendering uses the derived font size. Keep:
- slider state values unchanged
- upload callback values unchanged
- bottom offset behavior unchanged
**Step 4: Run test to verify it passes**
Run:
```bash
node ./node_modules/vitest/vitest.mjs run src/components/UploadScreen.test.tsx
```
Expected: PASS for both the preview mapping test and the upload-confirmation regression test.
**Step 5: Commit**
```bash
git add src/components/UploadScreen.tsx src/components/UploadScreen.test.tsx
git commit -m "fix: scale subtitle preview text"
```
### Task 3: Full verification
**Files:**
- Modify: `src/components/UploadScreen.tsx`
- Modify: `src/components/UploadScreen.test.tsx`
**Step 1: Write the failing test**
If any final regression appears, add the smallest targeted test first.
**Step 2: Run test to verify it fails**
Run:
```bash
node ./node_modules/vitest/vitest.mjs run src/components/UploadScreen.test.tsx src/App.test.tsx
```
Expected: FAIL only if a real regression remains.
**Step 3: Write minimal implementation**
Apply only the smallest visual or logic adjustment needed to restore parity.
**Step 4: Run test to verify it passes**
Run:
```bash
npm.cmd test
npm.cmd run lint
npm.cmd run build
```
Expected:
- all tests PASS
- lint PASS
- build PASS
**Step 5: Commit**
```bash
git add src/components/UploadScreen.tsx src/components/UploadScreen.test.tsx docs/plans/2026-03-21-subtitle-preview-scale-design.md docs/plans/2026-03-21-subtitle-preview-scale.md
git commit -m "test: verify subtitle preview scaling"
```

View File

@ -0,0 +1,189 @@
# Upload First-Fold Layout Design
**Goal:** Make the upload page fit the primary workflow into the first visible desktop viewport so users can immediately see upload, mode selection, subtitle defaults, and dubbing choices without needing to scroll to discover more options.
## Context
The current upload workbench fixed the horizontal clipping problem, but the vertical footprint is still too tall. The page header, large upload surface, generous card spacing, subtitle preview height, and long TTS list combine to push the language card below the fold on common desktop windows.
That creates a discovery problem: users may not realize the page continues below, so important settings can be missed entirely even though the layout is technically responsive.
## User-Approved Direction
The approved direction is:
- keep everything on a single page
- prioritize first-fold visibility on common desktop screens
- avoid making users scroll to discover key settings
- compress the current layout rather than changing the product flow
## Approaches Considered
### Option A: Compact first-fold workbench
Reduce vertical whitespace, shorten the upload card, tighten the right-side cards, and replace the tall language list with a compact always-visible button grid.
**Pros**
- Preserves the current workflow and layout logic
- Directly addresses the "hidden options below the fold" problem
- Keeps all major choices visible at once
**Cons**
- Requires denser spacing and more careful visual balance
### Option B: Merge the settings cards
Combine multiple settings sections into one large card to reduce stack height.
**Pros**
- Fewer visual blocks
- Lower total vertical gap
**Cons**
- Weakens hierarchy
- Makes scanning and editing harder
### Option C: Move one settings section into the upload card
Embed mode selection or language selection into the left upload card.
**Pros**
- Reduces right-column height quickly
**Cons**
- Creates mixed responsibilities inside the upload card
- Makes the composition feel less structured
### Recommendation
Use **Option A**. It preserves a clear workbench layout while making all key actions visible in the first fold.
## Architecture
### Header Compression
The upload-page header should become a slimmer top strip:
- smaller vertical padding
- smaller title scale
- reduced bottom margin
- language switcher remains visible but no longer consumes hero-card space
The header still provides context, but it should stop competing with the main workspace for height.
### Upload Card Compression
The left upload card should remain the primary visual anchor, but it no longer needs a tall poster-like dropzone.
Adjustments:
- reduce top summary spacing
- shorten the dropzone height substantially
- tighten the button and support-copy spacing
- keep mode summary compact
The upload action should still read as primary, just not oversized.
### Right Column Compression
Keep the three semantic cards:
1. mode and workflow
2. subtitle defaults
3. language and dubbing
But each card should be tightened:
- smaller paddings
- smaller inter-card gaps
- smaller preview panel
- less decorative empty space
### TTS Language Visibility
The biggest vertical cost comes from the current alphabet-tab + scrolling list treatment. Replace it with an always-visible compact grid of language buttons.
For the current supported languages, a compact grid is sufficient and preferable because:
- all options are visible immediately
- there is no hidden overflow area
- the user does not need to infer that more settings exist below
This is a visibility optimization, not a data-model change.
## Layout Rules
### Desktop-First Height Budget
Target the upload page body to sit comfortably within the first fold on common desktop heights. The implementation may use:
- tighter shell spacing
- a more compact upload-card min height
- reduced card gaps
- right-column sections that size to content without scroll regions
### No Discovery Scroll for Core Options
Core settings that must be visible without scrolling:
- upload entry point
- mode selection
- subtitle default preview and sliders
- subtitle language context
- all supported TTS language options
- primary "Generate Translated Video" button
### Fallback Behavior
If the viewport is unusually short, the page may still scroll slightly. But normal desktop usage should not require scrolling to discover additional settings.
## Interaction Details
### Mode Selection
Mode options should become denser selection blocks. Keep both options visible at once, but reduce their height and spacing.
### Subtitle Defaults
Keep:
- reset action
- preview
- position slider
- size slider
Reduce the preview height enough to preserve usefulness while reclaiming space.
### TTS Languages
Present the current supported languages as a compact grid, using the existing localized labels and current selection styling. Remove the alphabet tabs and long per-letter sections because they optimize for a larger catalog than this page currently has.
## Testing Strategy
Add or update tests to cover:
- the compact upload layout still renders all major cards
- all TTS language buttons remain visible without the old tabbed list structure
- existing subtitle default interactions still work
- upload behavior and trim flow are unchanged
Manual verification should focus on a common desktop viewport to confirm that the bottom card no longer drops below the fold.
## Out of Scope
This revision does not:
- change the upload workflow
- add or remove supported languages
- redesign the editor screen
- change backend contracts
## Rollout Notes
Success means a user can land on the upload page and immediately understand every major choice without needing to notice or guess that the page continues below the fold.

View File

@ -0,0 +1,263 @@
# Upload First-Fold Layout Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Compress the upload workbench so the complete upload workflow remains visible in the first desktop viewport without forcing users to scroll to discover mode, subtitle, and dubbing options.
**Architecture:** Keep the existing upload screen state and action flow intact, but reduce the upload page's vertical footprint. Most of the work stays inside `src/App.tsx` and `src/components/UploadScreen.tsx`, with supporting text updates in `src/i18n.tsx`, one small surface helper in `src/index.css`, and test updates in the existing app/upload test files. The tall TTS list will be replaced with a compact grid so all supported language choices are immediately visible.
**Tech Stack:** React 19, TypeScript, Tailwind CSS v4 utilities, Vitest, React Testing Library
---
### Task 1: Lock the compact first-fold language layout with failing tests
**Files:**
- Modify: `src/components/UploadScreen.test.tsx`
- Reference: `src/components/UploadScreen.tsx`
**Step 1: Write the failing test**
Add a test that describes the compact language section and rejects the old tabbed list structure:
```tsx
it('shows all supported tts languages in a compact always-visible grid', () => {
renderUploadScreen();
expect(screen.getByTestId('tts-language-grid')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Chinese' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Cantonese' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'English' })).toBeInTheDocument();
expect(screen.queryByText('ABC')).not.toBeInTheDocument();
expect(screen.queryByText('GHI')).not.toBeInTheDocument();
expect(screen.queryByText('DEF')).not.toBeInTheDocument();
});
```
**Step 2: Run test to verify it fails**
Run:
```bash
node ./node_modules/vitest/vitest.mjs run src/components/UploadScreen.test.tsx
```
Expected: FAIL because the upload page still renders the alphabet tabs and has no compact grid marker.
**Step 3: Write minimal implementation**
Update `src/components/UploadScreen.tsx` just enough to:
- remove the old alphabet tabs
- expose a compact language grid container with `data-testid="tts-language-grid"`
- keep the existing language buttons and selection behavior
**Step 4: Run test to verify it passes**
Run:
```bash
node ./node_modules/vitest/vitest.mjs run src/components/UploadScreen.test.tsx
```
Expected: PASS for the new compact-language assertion.
**Step 5: Commit**
```bash
git add src/components/UploadScreen.tsx src/components/UploadScreen.test.tsx
git commit -m "test: lock compact tts language layout"
```
### Task 2: Compress the upload page shell and card heights
**Files:**
- Modify: `src/App.tsx`
- Modify: `src/components/UploadScreen.tsx`
- Modify: `src/i18n.tsx`
- Modify: `src/index.css`
- Test: `src/App.test.tsx`
**Step 1: Write the failing test**
Add an app-level test that captures the compact first-fold shell:
```tsx
it('renders the upload shell with compact first-fold copy', () => {
render(<App />);
fireEvent.click(screen.getByLabelText('switch-ui-language-en'));
expect(screen.getByRole('heading', { name: 'Upload & prepare' })).toBeInTheDocument();
expect(screen.getByText('Everything you need to start translation is visible right away.')).toBeInTheDocument();
});
```
**Step 2: Run test to verify it fails**
Run:
```bash
node ./node_modules/vitest/vitest.mjs run src/App.test.tsx
```
Expected: FAIL because the compact shell copy does not exist yet.
**Step 3: Write minimal implementation**
Update the upload shell and localized copy to reduce the first-fold height budget:
- shrink upload-header padding and margins
- reduce title and description footprint
- tighten workbench spacing
- keep the language switcher visible
Localized copy example:
```tsx
workbenchDescription: 'Everything you need to start translation is visible right away.'
```
**Step 4: Run test to verify it passes**
Run:
```bash
node ./node_modules/vitest/vitest.mjs run src/App.test.tsx
```
Expected: PASS for the compact-header assertion.
**Step 5: Commit**
```bash
git add src/App.tsx src/App.test.tsx src/i18n.tsx src/index.css
git commit -m "feat: compress upload shell for first-fold visibility"
```
### Task 3: Tighten the upload card and settings cards to fit the first fold
**Files:**
- Modify: `src/components/UploadScreen.tsx`
- Modify: `src/components/UploadScreen.test.tsx`
- Modify: `src/index.css`
- Reference: `src/types.ts`
**Step 1: Write the failing test**
Add targeted structure checks for the compact card composition:
```tsx
it('keeps the compact upload controls and primary action visible together', () => {
renderUploadScreen();
expect(screen.getByTestId('upload-dropzone-card')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Upload Video' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Generate Translated Video' })).toBeInTheDocument();
});
```
**Step 2: Run test to verify it fails**
Run:
```bash
node ./node_modules/vitest/vitest.mjs run src/components/UploadScreen.test.tsx
```
Expected: FAIL until the compact layout and new language grid are fully in place.
**Step 3: Write minimal implementation**
Refactor `src/components/UploadScreen.tsx` to reclaim vertical space:
- reduce upload-card header spacing
- shorten the dropzone min height
- reduce top and bottom padding on all cards
- tighten card gaps
- shrink subtitle preview height
- compress mode buttons
- render the TTS language options in a multi-column grid instead of a scrolling letter list
- keep the generate button at the bottom of the visible settings stack
Representative shape:
```tsx
<aside className="grid gap-4">
<section className="...compact-mode-card..." />
<section className="...compact-subtitle-card..." />
<section className="...compact-language-card...">
<div data-testid="tts-language-grid" className="grid grid-cols-2 gap-2">
...
</div>
</section>
</aside>
```
**Step 4: Run test to verify it passes**
Run:
```bash
node ./node_modules/vitest/vitest.mjs run src/components/UploadScreen.test.tsx
```
Expected: PASS for the compact-card and compact-language assertions while preserving existing upload behavior tests.
**Step 5: Commit**
```bash
git add src/components/UploadScreen.tsx src/components/UploadScreen.test.tsx src/index.css src/i18n.tsx
git commit -m "feat: fit upload workbench into first fold"
```
### Task 4: Full verification
**Files:**
- Modify: `src/App.tsx`
- Modify: `src/App.test.tsx`
- Modify: `src/components/UploadScreen.tsx`
- Modify: `src/components/UploadScreen.test.tsx`
- Modify: `src/i18n.tsx`
- Modify: `src/index.css`
**Step 1: Write the failing test**
If any final localized copy or structure assertions remain missing after Tasks 1-3, add the smallest regression test needed before the last polish.
**Step 2: Run test to verify it fails**
Run:
```bash
node ./node_modules/vitest/vitest.mjs run src/App.test.tsx src/components/UploadScreen.test.tsx
```
Expected: FAIL only if the final regression gap is real.
**Step 3: Write minimal implementation**
Apply only the last polish needed to satisfy the failing regression test, without broadening scope.
**Step 4: Run test to verify it passes**
Run:
```bash
npm.cmd test
npm.cmd run lint
npm.cmd run build
```
Expected:
- all tests PASS
- typecheck PASS
- build PASS
**Step 5: Commit**
```bash
git add src/App.tsx src/App.test.tsx src/components/UploadScreen.tsx src/components/UploadScreen.test.tsx src/i18n.tsx src/index.css
git commit -m "test: verify first-fold upload layout"
```

View File

@ -0,0 +1,64 @@
# Upload Inline Panel Swap Design
**Goal:** Swap the vertical order of the embedded subtitle defaults module and the upload dropzone inside the main upload card so subtitle styling appears first and the upload area sits below it.
## Context
The current upload workbench already moved subtitle defaults into the main upload card, but the visual order still starts with the upload dropzone and places subtitle styling underneath it. That makes the page feel almost unchanged from the previous version and does not match the requested "position swap."
The user confirmed the desired interpretation: keep the same overall card structure, but swap the internal top and bottom order so the subtitle defaults module appears above and the upload surface appears below.
## Approved Direction
- keep the first row as three cards:
- upload and prepare
- mode and workflow
- language and dubbing
- keep subtitle defaults embedded inside the upload card
- change the upload card so subtitle defaults render first
- move the upload dropzone section below the subtitle defaults module
- preserve the current approximate proportions as much as possible
## Recommended Layout
Use a two-part upload card with reversed emphasis:
- upper section:
- upload heading
- description
- subtitle defaults module
- lower section:
- upload dropzone area
- support text and mode chip beneath or beside it as space allows
This makes the position change obvious without undoing the broader three-card layout.
## Interaction Rules
The swap is structural only. Existing behavior stays the same:
- subtitle preview remains live
- reset button still restores defaults
- sliders still update preview
- upload selection and trim flow remain unchanged
- mode and language cards remain separate top-row cards
## Testing Strategy
Update upload-screen tests to verify:
- the subtitle defaults module is still inside the upload card
- the subtitle defaults module appears before the upload surface in DOM order
- the upload input remains inside the upload card after the swap
- existing preview and upload-confirm flows still work
## Out of Scope
- language list changes
- trimming flow changes
- backend behavior
- page-level information architecture changes beyond this internal swap
## Success Criteria
Success means the upload card visibly reads as "configure subtitle style first, then upload video," while the page still fits the current three-card first-row workbench.

View File

@ -0,0 +1,171 @@
# Upload Inline Panel Swap Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Reorder the embedded sections inside the upload card so subtitle defaults render above the upload dropzone while preserving current upload, mode, language, and trim behavior.
**Architecture:** Keep the existing three-card top row and embedded subtitle-defaults approach. Add a focused regression test that locks the internal order of the upload card, then minimally reorder the JSX in `src/components/UploadScreen.tsx` and keep existing interaction tests green.
**Tech Stack:** React 19, TypeScript, Tailwind CSS v4 utilities, Vitest, React Testing Library
---
### Task 1: Lock the new internal order with a failing test
**Files:**
- Modify: `src/components/UploadScreen.test.tsx`
- Reference: `src/components/UploadScreen.tsx`
**Step 1: Write the failing test**
Add a structure test that checks the subtitle defaults panel appears before the upload surface inside the upload card:
```tsx
it('renders subtitle defaults before the upload surface inside the upload card', () => {
renderUploadScreen();
const uploadCard = screen.getByTestId('upload-dropzone-card');
const subtitlePanel = screen.getByTestId('upload-subtitle-defaults-panel');
const uploadSurface = screen.getByTestId('upload-dropzone-surface');
expect(uploadCard).toContainElement(subtitlePanel);
expect(uploadCard).toContainElement(uploadSurface);
expect(
subtitlePanel.compareDocumentPosition(uploadSurface) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
```
**Step 2: Run test to verify it fails**
Run:
```bash
node ./node_modules/vitest/vitest.mjs run src/components/UploadScreen.test.tsx
```
Expected: FAIL because the upload surface currently renders before the subtitle defaults panel and does not yet expose the new marker.
**Step 3: Write minimal implementation**
Add the missing test id for the upload surface and reorder the upload-card sections.
**Step 4: Run test to verify it passes**
Run:
```bash
node ./node_modules/vitest/vitest.mjs run src/components/UploadScreen.test.tsx
```
Expected: PASS for the new order assertion.
**Step 5: Commit**
```bash
git add src/components/UploadScreen.tsx src/components/UploadScreen.test.tsx
git commit -m "test: lock upload card panel order"
```
### Task 2: Reorder the upload card sections
**Files:**
- Modify: `src/components/UploadScreen.tsx`
- Modify: `src/components/UploadScreen.test.tsx`
**Step 1: Write the failing test**
If needed, add one more focused regression test to protect the upload input location:
```tsx
it('keeps the upload interaction inside the lower upload section after the swap', () => {
renderUploadScreen();
expect(screen.getByTestId('upload-dropzone-surface')).toContainElement(
screen.getByLabelText(/upload video file/i),
);
});
```
**Step 2: Run test to verify it fails**
Run:
```bash
node ./node_modules/vitest/vitest.mjs run src/components/UploadScreen.test.tsx
```
Expected: FAIL until the new structure is in place.
**Step 3: Write minimal implementation**
Refactor `src/components/UploadScreen.tsx` to:
- move the embedded subtitle defaults block above the upload dropzone block
- keep the heading and description at the top of the card
- keep support text and the mode chip associated with the upload section
- preserve all existing handlers and controls
**Step 4: Run test to verify it passes**
Run:
```bash
node ./node_modules/vitest/vitest.mjs run src/components/UploadScreen.test.tsx
```
Expected: PASS for the structure tests and existing interaction tests.
**Step 5: Commit**
```bash
git add src/components/UploadScreen.tsx src/components/UploadScreen.test.tsx
git commit -m "feat: swap upload card panel order"
```
### Task 3: Full verification
**Files:**
- Modify: `src/components/UploadScreen.tsx`
- Modify: `src/components/UploadScreen.test.tsx`
**Step 1: Write the failing test**
If a regression appears during final polish, add the smallest possible test first.
**Step 2: Run test to verify it fails**
Run:
```bash
node ./node_modules/vitest/vitest.mjs run src/components/UploadScreen.test.tsx src/App.test.tsx
```
Expected: FAIL only if a real regression remains.
**Step 3: Write minimal implementation**
Apply only the final layout tweaks needed to restore parity.
**Step 4: Run test to verify it passes**
Run:
```bash
npm.cmd test
npm.cmd run lint
npm.cmd run build
```
Expected:
- all tests PASS
- lint PASS
- build PASS
**Step 5: Commit**
```bash
git add src/components/UploadScreen.tsx src/components/UploadScreen.test.tsx docs/plans/2026-03-21-upload-inline-panel-swap-design.md docs/plans/2026-03-21-upload-inline-panel-swap.md
git commit -m "test: verify swapped upload card layout"
```

View File

@ -0,0 +1,61 @@
# Upload Inline Subtitle Style Design
**Goal:** Move the subtitle defaults UI into the lower-left area of the main "Upload & prepare" card so the top section remains a three-card layout while keeping subtitle controls visible in the same primary workspace.
## Context
The current three-card top row improves discoverability for upload, mode, and language selection, but the subtitle defaults controls still live as a separate card beneath them. That creates a visual break and makes the upload workbench feel split into a top decision row and a detached lower settings card.
The user wants the subtitle defaults controls absorbed back into the main upload card, specifically in the lower-left portion of that card.
## Approved Direction
- keep the top row as three cards:
- upload and prepare
- mode and workflow
- language and dubbing
- remove the standalone subtitle defaults card
- place subtitle defaults inside the upload card's lower-left region
## Recommended Layout
Use a two-part upload card:
- upper section:
- upload heading
- description
- drag/drop upload area
- lower section:
- left: embedded subtitle defaults module
- right: support text and mode chip
This keeps the upload card self-contained while preserving the user's requested left-bottom placement.
## Interaction Rules
The embedded subtitle defaults module keeps all existing behavior:
- preview sample remains live
- reset button still restores defaults
- position and size sliders still update preview
- upload contract and trim flow remain unchanged
## Testing Strategy
Update upload-screen tests to verify:
- no standalone subtitle defaults card is required
- the subtitle defaults module is rendered inside the upload card
- existing subtitle preview behavior still works
- language and mode cards remain first-row peers
## Out of Scope
- backend changes
- language list changes
- editor redesign
- upload flow changes
## Success Criteria
Success means the upload page reads as one compact top workbench, with subtitle style controls clearly attached to the upload card instead of floating below it as a separate panel.

View File

@ -0,0 +1,166 @@
# Upload Inline Subtitle Style Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Embed the subtitle defaults controls into the lower-left area of the upload card while preserving the three-card top-row layout for upload, mode, and language.
**Architecture:** Keep the existing upload page behavior and TTS/mode card layout, but remove the standalone subtitle defaults card. Move the preview and sliders into a compact embedded module inside `src/components/UploadScreen.tsx`, and update upload-screen tests to lock in the new structure.
**Tech Stack:** React 19, TypeScript, Tailwind CSS v4 utilities, Vitest, React Testing Library
---
### Task 1: Lock the embedded subtitle module with a failing test
**Files:**
- Modify: `src/components/UploadScreen.test.tsx`
- Reference: `src/components/UploadScreen.tsx`
**Step 1: Write the failing test**
Add a structure test for the new placement:
```tsx
it('renders subtitle defaults inside the upload card footer area', () => {
renderUploadScreen();
expect(screen.getByTestId('upload-dropzone-card')).toContainElement(
screen.getByTestId('upload-subtitle-defaults-panel'),
);
expect(screen.queryByTestId('subtitle-defaults-card')).not.toBeInTheDocument();
});
```
**Step 2: Run test to verify it fails**
Run:
```bash
node ./node_modules/vitest/vitest.mjs run src/components/UploadScreen.test.tsx
```
Expected: FAIL because the subtitle defaults controls still render as a standalone card.
**Step 3: Write minimal implementation**
Add the new `data-testid` for the embedded subtitle module and remove the old standalone-card marker.
**Step 4: Run test to verify it passes**
Run:
```bash
node ./node_modules/vitest/vitest.mjs run src/components/UploadScreen.test.tsx
```
Expected: PASS for the new inline-module assertion.
**Step 5: Commit**
```bash
git add src/components/UploadScreen.tsx src/components/UploadScreen.test.tsx
git commit -m "test: lock inline subtitle defaults layout"
```
### Task 2: Move subtitle defaults into the upload card
**Files:**
- Modify: `src/components/UploadScreen.tsx`
- Modify: `src/components/UploadScreen.test.tsx`
**Step 1: Write the failing test**
Add a second focused regression test:
```tsx
it('keeps subtitle preview controls active after moving them into the upload card', () => {
renderUploadScreen();
expect(screen.getByTestId('upload-subtitle-defaults-panel')).toContainElement(
screen.getByTestId('upload-subtitle-preview'),
);
});
```
**Step 2: Run test to verify it fails**
Run:
```bash
node ./node_modules/vitest/vitest.mjs run src/components/UploadScreen.test.tsx
```
Expected: FAIL until the subtitle module is actually moved.
**Step 3: Write minimal implementation**
Refactor `src/components/UploadScreen.tsx` to:
- delete the standalone subtitle defaults card
- add a compact subtitle defaults module inside the upload card bottom-left region
- keep support text and mode chip in the upload card bottom-right region
- preserve preview and slider behavior exactly
**Step 4: Run test to verify it passes**
Run:
```bash
node ./node_modules/vitest/vitest.mjs run src/components/UploadScreen.test.tsx
```
Expected: PASS for the new structure tests and existing subtitle interaction tests.
**Step 5: Commit**
```bash
git add src/components/UploadScreen.tsx src/components/UploadScreen.test.tsx
git commit -m "feat: embed subtitle defaults in upload card"
```
### Task 3: Full verification
**Files:**
- Modify: `src/components/UploadScreen.tsx`
- Modify: `src/components/UploadScreen.test.tsx`
**Step 1: Write the failing test**
If any final regression appears after the move, add the smallest targeted test first.
**Step 2: Run test to verify it fails**
Run:
```bash
node ./node_modules/vitest/vitest.mjs run src/components/UploadScreen.test.tsx src/App.test.tsx
```
Expected: FAIL only if a real regression remains.
**Step 3: Write minimal implementation**
Apply only the final layout polish needed for the regression.
**Step 4: Run test to verify it passes**
Run:
```bash
npm.cmd test
npm.cmd run lint
npm.cmd run build
```
Expected:
- all tests PASS
- typecheck PASS
- build PASS
**Step 5: Commit**
```bash
git add src/components/UploadScreen.tsx src/components/UploadScreen.test.tsx src/App.tsx src/App.test.tsx
git commit -m "test: verify inline subtitle defaults layout"
```

View File

@ -0,0 +1,126 @@
# Upload Three-Card Row Design
**Goal:** Move the "Language & Dubbing" card into the same first-row layout as "Upload source video" and "Mode & Workflow" so the page presents three immediately visible decision areas across the top.
## Context
The compact first-fold redesign reduced vertical overflow, but the page still communicates a two-column mental model:
- a dominant upload card on the left
- a vertical stack of settings cards on the right
Even after compression, the language card still feels like a secondary block that lives below the more obvious controls. The user wants that card elevated into the first visible row so the primary choices are laid out horizontally and read in one scan.
## User-Approved Direction
Use a first-row, three-card layout:
1. Upload source video
2. Mode & Workflow
3. Language & Dubbing
Move "Subtitle Defaults" to the second row beneath those three cards.
## Approaches Considered
### Option A: Three-card top row with subtitle defaults below
Make the page body a two-row grid:
- row 1: upload / mode / language
- row 2: subtitle defaults
**Pros**
- Matches the requested visual model exactly
- Makes the most important choices visible in one scan
- Preserves semantic card boundaries
**Cons**
- Requires a larger grid refactor than simple spacing tweaks
### Option B: Keep two columns and visually pull language upward
Keep the current grid but reorder the right-column cards so language appears above subtitle defaults.
**Pros**
- Smaller code change
**Cons**
- Still reads as one left card plus a stacked sidebar
- Does not satisfy the "three cards in one row" requirement
### Option C: Merge mode and language into one card
Reduce the top row to two cards by combining controls.
**Pros**
- Simple height management
**Cons**
- Weakens scannability
- Mixes unrelated concerns
### Recommendation
Use **Option A**.
## Architecture
### Top-Level Grid
The upload workbench should become a two-row desktop grid with named regions:
- `upload`
- `mode`
- `language`
- `subtitle`
On large desktop widths:
- upload card occupies the wider left column
- mode and language cards sit as two narrower sibling cards to its right
- subtitle defaults spans the width beneath the row or sits below upload and settings depending on the final proportion
The important part is that upload, mode, and language share the same first visual row.
### Card Ownership
Keep card responsibilities clear:
- Upload card: file selection, supporting copy, current mode chip
- Mode card: editing vs simple mode
- Language card: subtitle language context, TTS language choices, primary generate button
- Subtitle card: preview, sliders, reset action
### Responsive Behavior
Desktop-first behavior should prioritize the requested three-card row. At narrower widths, the layout may stack back down, but on the target desktop viewport the first row must visibly contain all three cards.
## Testing Strategy
Add or update tests to cover:
- the language card remains present and visible as a first-class card
- the first-row structure exposes distinct upload, mode, and language regions
- the language grid remains intact after the grid refactor
- subtitle defaults continue to behave the same after moving to a lower row
## Out of Scope
This change does not alter:
- upload logic
- trim flow
- subtitle defaults behavior
- supported language set
- backend request contracts
## Rollout Notes
Success means the user can open the page and immediately see three top-level cards in the first row: upload, mode, and language.

View File

@ -0,0 +1,177 @@
# Upload Three-Card Row Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Refactor the upload page so "Upload source video", "Mode & Workflow", and "Language & Dubbing" appear as three first-row cards on desktop, with "Subtitle Defaults" moved to the next row.
**Architecture:** Keep the existing upload, trim, subtitle-default, and TTS state behavior intact while changing only the desktop grid composition. The main work is in `src/components/UploadScreen.tsx`, supported by focused test updates in `src/components/UploadScreen.test.tsx`. Existing compact header behavior in `src/App.tsx` can remain unchanged unless minor spacing adjustments are needed after the grid refactor.
**Tech Stack:** React 19, TypeScript, Tailwind CSS v4 utilities, Vitest, React Testing Library
---
### Task 1: Lock the three-card first row with a failing test
**Files:**
- Modify: `src/components/UploadScreen.test.tsx`
- Reference: `src/components/UploadScreen.tsx`
**Step 1: Write the failing test**
Add a test that describes the new first-row structure:
```tsx
it('renders upload, mode, and language cards as first-row regions', () => {
renderUploadScreen();
expect(screen.getByTestId('upload-dropzone-card')).toBeInTheDocument();
expect(screen.getByTestId('mode-card')).toBeInTheDocument();
expect(screen.getByTestId('language-card')).toBeInTheDocument();
expect(screen.getByTestId('subtitle-defaults-card')).toBeInTheDocument();
});
```
**Step 2: Run test to verify it fails**
Run:
```bash
node ./node_modules/vitest/vitest.mjs run src/components/UploadScreen.test.tsx
```
Expected: FAIL because the mode/language/subtitle cards do not expose the new structural markers yet.
**Step 3: Write minimal implementation**
Add the minimum `data-testid` hooks needed in `src/components/UploadScreen.tsx` for the four card regions.
**Step 4: Run test to verify it passes**
Run:
```bash
node ./node_modules/vitest/vitest.mjs run src/components/UploadScreen.test.tsx
```
Expected: PASS for the new first-row region assertion.
**Step 5: Commit**
```bash
git add src/components/UploadScreen.tsx src/components/UploadScreen.test.tsx
git commit -m "test: lock upload three-card first row"
```
### Task 2: Refactor the upload workbench grid
**Files:**
- Modify: `src/components/UploadScreen.tsx`
- Modify: `src/components/UploadScreen.test.tsx`
- Reference: `src/index.css`
**Step 1: Write the failing test**
Add one more test that preserves the compact language grid after the layout move:
```tsx
it('keeps the language card controls intact after moving into the first row', () => {
renderUploadScreen();
expect(screen.getByTestId('language-card')).toContainElement(screen.getByTestId('tts-language-grid'));
expect(screen.getByRole('button', { name: 'Generate Translated Video' })).toBeInTheDocument();
});
```
**Step 2: Run test to verify it fails**
Run:
```bash
node ./node_modules/vitest/vitest.mjs run src/components/UploadScreen.test.tsx
```
Expected: FAIL until the language card is promoted into its own first-row region.
**Step 3: Write minimal implementation**
Reshape `src/components/UploadScreen.tsx` into a desktop-first two-row grid:
- first row:
- upload card
- mode card
- language card
- second row:
- subtitle defaults card
Keep the language card self-contained with:
- subtitle language context
- TTS language grid
- generate button
Keep the subtitle card in the second row with preview and sliders unchanged functionally.
**Step 4: Run test to verify it passes**
Run:
```bash
node ./node_modules/vitest/vitest.mjs run src/components/UploadScreen.test.tsx
```
Expected: PASS for the new structure and existing interaction tests.
**Step 5: Commit**
```bash
git add src/components/UploadScreen.tsx src/components/UploadScreen.test.tsx
git commit -m "feat: move language card into first row"
```
### Task 3: Full verification
**Files:**
- Modify: `src/components/UploadScreen.tsx`
- Modify: `src/components/UploadScreen.test.tsx`
- Optionally modify: `src/App.tsx`
**Step 1: Write the failing test**
If any final regression gap appears after Task 2, add the smallest targeted test for it before final polish.
**Step 2: Run test to verify it fails**
Run:
```bash
node ./node_modules/vitest/vitest.mjs run src/components/UploadScreen.test.tsx src/App.test.tsx
```
Expected: FAIL only if a real regression remains.
**Step 3: Write minimal implementation**
Apply only the final layout polish needed to satisfy the failing regression.
**Step 4: Run test to verify it passes**
Run:
```bash
npm.cmd test
npm.cmd run lint
npm.cmd run build
```
Expected:
- all tests PASS
- typecheck PASS
- build PASS
**Step 5: Commit**
```bash
git add src/components/UploadScreen.tsx src/components/UploadScreen.test.tsx src/App.tsx src/App.test.tsx
git commit -m "test: verify upload three-card row layout"
```

View File

@ -15,12 +15,37 @@ describe('App bilingual UI', () => {
expect(screen.getByLabelText('switch-ui-language-zh')).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByText('字幕语言')).toBeInTheDocument();
expect(screen.getByText('上传视频')).toBeInTheDocument();
expect(screen.getAllByText('上传视频').length).toBeGreaterThan(0);
fireEvent.click(screen.getByLabelText('switch-ui-language-en'));
expect(screen.getByLabelText('switch-ui-language-en')).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByText('Subtitle Language')).toBeInTheDocument();
expect(screen.getByText('Upload Video')).toBeInTheDocument();
expect(screen.getAllByText('Upload Video').length).toBeGreaterThan(0);
});
it('shows the upload workbench header and keeps the language switcher visible', () => {
render(<App />);
expect(screen.getByRole('banner')).toBeInTheDocument();
expect(screen.getByLabelText('switch-ui-language-zh')).toBeInTheDocument();
expect(screen.getByLabelText('switch-ui-language-en')).toBeInTheDocument();
fireEvent.click(screen.getByLabelText('switch-ui-language-en'));
expect(screen.getByRole('heading', { name: 'Upload & prepare' })).toBeInTheDocument();
expect(screen.getByText('Everything you need to start translation is visible right away.')).toBeInTheDocument();
expect(screen.getByText('Video Translate')).toBeInTheDocument();
});
it('renders the upload shell with compact first-fold copy', () => {
render(<App />);
fireEvent.click(screen.getByLabelText('switch-ui-language-en'));
expect(screen.getByRole('heading', { name: 'Upload & prepare' })).toBeInTheDocument();
expect(
screen.getByText('Everything you need to start translation is visible right away.'),
).toBeInTheDocument();
});
});

View File

@ -38,51 +38,82 @@ function AppContent() {
setCurrentView('editor');
};
const languageSwitcher = (
<div
aria-label="app-language-switcher"
className="inline-flex rounded-full border border-slate-200/80 bg-white/80 p-1 shadow-sm shadow-slate-200/60 backdrop-blur"
>
<button
type="button"
aria-label="switch-ui-language-zh"
aria-pressed={locale === 'zh'}
onClick={() => setLocale('zh')}
className={`px-3 py-1.5 text-sm rounded-full transition-colors ${
locale === 'zh' ? 'bg-slate-900 text-white' : 'text-slate-600 hover:bg-slate-100'
}`}
>
{m.app.chinese}
</button>
<button
type="button"
aria-label="switch-ui-language-en"
aria-pressed={locale === 'en'}
onClick={() => setLocale('en')}
className={`px-3 py-1.5 text-sm rounded-full transition-colors ${
locale === 'en' ? 'bg-slate-900 text-white' : 'text-slate-600 hover:bg-slate-100'
}`}
>
{m.app.english}
</button>
</div>
);
return (
<div className="min-h-screen bg-gray-50 text-gray-800 font-sans">
<div className="mx-auto max-w-6xl px-8 pt-4">
<div className="flex justify-end">
<div
aria-label="app-language-switcher"
className="inline-flex rounded-full border border-gray-200 bg-white p-1 shadow-sm"
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(14,165,233,0.10),_transparent_24%),radial-gradient(circle_at_top_right,_rgba(16,185,129,0.10),_transparent_22%),linear-gradient(180deg,_#f8fafc_0%,_#eef2ff_52%,_#f8fafc_100%)] text-slate-900 font-sans">
<div className="mx-auto max-w-7xl px-4 py-4 sm:px-6 sm:py-5 lg:px-8">
{currentView === 'upload' ? (
<header
role="banner"
className="mb-4 flex flex-col gap-3 rounded-[24px] border border-white/70 bg-white/75 px-4 py-4 shadow-[0_18px_60px_-32px_rgba(15,23,42,0.35)] backdrop-blur sm:px-5 lg:flex-row lg:items-center lg:justify-between"
>
<button
type="button"
aria-label="switch-ui-language-zh"
aria-pressed={locale === 'zh'}
onClick={() => setLocale('zh')}
className={`px-3 py-1.5 text-sm rounded-full transition-colors ${
locale === 'zh' ? 'bg-gray-900 text-white' : 'text-gray-600 hover:bg-gray-100'
}`}
>
{m.app.chinese}
</button>
<button
type="button"
aria-label="switch-ui-language-en"
aria-pressed={locale === 'en'}
onClick={() => setLocale('en')}
className={`px-3 py-1.5 text-sm rounded-full transition-colors ${
locale === 'en' ? 'bg-gray-900 text-white' : 'text-gray-600 hover:bg-gray-100'
}`}
>
{m.app.english}
</button>
</div>
</div>
<div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center gap-2 text-xs sm:text-sm">
<span className="rounded-full border border-sky-200 bg-sky-50 px-2.5 py-1 font-medium text-sky-700">
{m.upload.workbenchEyebrow}
</span>
<span className="text-slate-500">{m.app.productName}</span>
</div>
<div className="space-y-1">
<h1 className="text-2xl font-semibold tracking-tight text-slate-950 sm:text-3xl">
{m.upload.workbenchTitle}
</h1>
<p className="max-w-2xl text-sm leading-5 text-slate-600">
{m.upload.workbenchDescription}
</p>
</div>
</div>
<div className="flex justify-start lg:justify-end">{languageSwitcher}</div>
</header>
) : (
<div className="mb-3 flex justify-end">{languageSwitcher}</div>
)}
</div>
<div className="mx-auto max-w-7xl px-4 pb-5 sm:px-6 lg:px-8">
{currentView === 'upload' ? (
<UploadScreen onUpload={handleVideoUpload} />
) : (
<EditorScreen
videoFile={videoFile}
targetLanguage={targetLanguage}
ttsLanguage={ttsLanguage}
trimRange={trimRange}
initialSubtitleDefaults={subtitleDefaults}
onBack={() => setCurrentView('upload')}
/>
)}
</div>
{currentView === 'upload' ? (
<UploadScreen onUpload={handleVideoUpload} />
) : (
<EditorScreen
videoFile={videoFile}
targetLanguage={targetLanguage}
ttsLanguage={ttsLanguage}
trimRange={trimRange}
initialSubtitleDefaults={subtitleDefaults}
onBack={() => setCurrentView('upload')}
/>
)}
</div>
);
}

View File

@ -39,7 +39,7 @@ describe('UploadScreen', () => {
cleanup();
});
it('starts from the editor default subtitle size and position and updates the preview', () => {
it('scales the subtitle preview while preserving the real subtitle size value', () => {
renderUploadScreen();
const sizeSlider = screen.getByLabelText(/subtitle initial size/i);
@ -48,12 +48,86 @@ describe('UploadScreen', () => {
expect(sizeSlider).toHaveValue('24');
expect(positionSlider).toHaveValue('10');
expect(preview).toHaveStyle({ fontSize: '24px', bottom: '10%' });
expect(preview).toHaveStyle({ fontSize: '12px', bottom: '10%' });
fireEvent.change(sizeSlider, { target: { value: '32' } });
fireEvent.change(positionSlider, { target: { value: '18' } });
expect(preview).toHaveStyle({ fontSize: '32px', bottom: '18%' });
expect(preview).toHaveStyle({ fontSize: '16px', bottom: '18%' });
});
it('renders upload, mode, subtitle defaults, and language cards inside the responsive workbench', () => {
renderUploadScreen();
expect(screen.getByTestId('upload-workbench')).toBeInTheDocument();
expect(screen.getByTestId('upload-dropzone-card')).toBeInTheDocument();
expect(screen.getByTestId('upload-settings-column')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Mode & Workflow' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Subtitle Defaults' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Language & Dubbing' })).toBeInTheDocument();
});
it('renders upload, mode, and language cards as first-row regions', () => {
renderUploadScreen();
expect(screen.getByTestId('upload-dropzone-card')).toBeInTheDocument();
expect(screen.getByTestId('mode-card')).toBeInTheDocument();
expect(screen.getByTestId('language-card')).toBeInTheDocument();
});
it('renders subtitle defaults inside the upload card footer area', () => {
renderUploadScreen();
expect(screen.getByTestId('upload-dropzone-card')).toContainElement(
screen.getByTestId('upload-subtitle-defaults-panel'),
);
expect(screen.queryByTestId('subtitle-defaults-card')).not.toBeInTheDocument();
});
it('renders subtitle defaults before the upload surface inside the upload card', () => {
renderUploadScreen();
const uploadCard = screen.getByTestId('upload-dropzone-card');
const subtitlePanel = screen.getByTestId('upload-subtitle-defaults-panel');
const uploadSurface = screen.getByTestId('upload-dropzone-surface');
expect(uploadCard).toContainElement(subtitlePanel);
expect(uploadCard).toContainElement(uploadSurface);
expect(
subtitlePanel.compareDocumentPosition(uploadSurface) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
it('shows all supported tts languages in a compact always-visible grid', () => {
renderUploadScreen();
expect(screen.getByTestId('tts-language-grid')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Chinese' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Cantonese' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'English' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'French' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Indonesian' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'German' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Filipino' })).toBeInTheDocument();
expect(screen.queryByText('ABC')).not.toBeInTheDocument();
expect(screen.queryByText('GHI')).not.toBeInTheDocument();
expect(screen.queryByText('DEF')).not.toBeInTheDocument();
});
it('keeps the language card controls intact after moving into the first row', () => {
renderUploadScreen();
expect(screen.getByTestId('language-card')).toContainElement(screen.getByTestId('tts-language-grid'));
expect(screen.getByRole('button', { name: 'Generate Translated Video' })).toBeInTheDocument();
});
it('keeps subtitle preview controls active after moving them into the upload card', () => {
renderUploadScreen();
expect(screen.getByTestId('upload-subtitle-defaults-panel')).toContainElement(
screen.getByTestId('upload-subtitle-preview'),
);
});
it('shows a fixed English subtitle language and the supported TTS languages', () => {
@ -125,4 +199,12 @@ describe('UploadScreen', () => {
expect(screen.queryByRole('button', { name: /close trim/i })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /generate translated video/i })).toBeDisabled();
});
it('keeps the upload interaction wired through the redesigned dropzone card', () => {
renderUploadScreen();
expect(screen.getByTestId('upload-dropzone-card')).toContainElement(
screen.getByLabelText(/upload video file/i),
);
});
});

View File

@ -33,6 +33,11 @@ export default function UploadScreen({
const [tempFile, setTempFile] = useState<File | null>(null);
const [subtitleDefaults, setSubtitleDefaults] = useState<SubtitleDefaults>(DEFAULT_SUBTITLE_DEFAULTS);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const activeModeLabel = mode === 'editing' ? m.upload.editingMode : m.upload.simpleMode;
const previewFontSize = Math.max(10, Math.round(subtitleDefaults.fontSize * 0.5));
const previewTextShadowSize = previewFontSize >= 18 ? 2 : 1;
const settingsCardClass =
'app-surface rounded-[24px] border border-white/70 px-4 py-4 shadow-[0_18px_60px_-34px_rgba(15,23,42,0.3)] sm:px-5';
const clearPendingUpload = () => {
setTempFile(null);
@ -55,207 +60,265 @@ export default function UploadScreen({
};
return (
<div className="max-w-6xl mx-auto p-8 flex gap-8 h-screen items-center">
{/* Left: Upload Area */}
<div className="flex-1 bg-white rounded-lg shadow-sm border border-gray-200 p-8 flex flex-col items-center justify-center min-h-[400px]">
<div className="w-full h-full border-2 border-dashed border-gray-300 rounded-lg flex flex-col items-center justify-center bg-gray-50 relative">
<input
ref={fileInputRef}
type="file"
aria-label="Upload video file"
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
accept="video/mp4,video/quicktime,video/webm"
onChange={handleFileChange}
/>
<Upload className="w-16 h-16 text-gray-400 mb-4" />
<p className="text-gray-600 mb-6">{m.upload.clickToUpload}</p>
<button className="bg-[#52c41a] hover:bg-[#46a616] text-white px-8 py-3 rounded-md font-medium flex items-center gap-2 transition-colors w-full max-w-md justify-center pointer-events-none">
<Upload className="w-5 h-5" />
{m.upload.uploadVideo}
</button>
</div>
<p className="text-sm text-gray-500 mt-4 w-full text-left">
{m.upload.supportedFormats}
</p>
</div>
{/* Right: Settings */}
<div className="w-[400px] flex flex-col gap-6">
{/* Mode Selection */}
<div className="flex gap-4">
<div
className={`flex-1 p-4 rounded-lg border-2 cursor-pointer transition-colors ${
mode === 'editing' ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-blue-200'
}`}
onClick={() => setMode('editing')}
>
<div className="flex items-center gap-2 mb-1">
{mode === 'editing' ? (
<CheckCircle2 className="w-5 h-5 text-blue-500" />
) : (
<Circle className="w-5 h-5 text-gray-300" />
)}
<span className="font-semibold text-gray-800">{m.upload.editingMode}</span>
</div>
<p className="text-xs text-gray-500 ml-7">{m.upload.editingModeDesc}</p>
</div>
<div
className={`flex-1 p-4 rounded-lg border-2 cursor-pointer transition-colors ${
mode === 'simple' ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-blue-200'
}`}
onClick={() => setMode('simple')}
>
<div className="flex items-center gap-2 mb-1">
{mode === 'simple' ? (
<CheckCircle2 className="w-5 h-5 text-blue-500" />
) : (
<Circle className="w-5 h-5 text-gray-300" />
)}
<span className="font-semibold text-gray-800">{m.upload.simpleMode}</span>
</div>
<p className="text-xs text-gray-500 ml-7">{m.upload.simpleModeDesc}</p>
<div
data-testid="upload-workbench"
className="grid items-start gap-4 lg:gap-5 xl:grid-cols-[minmax(0,1.55fr)_minmax(240px,0.72fr)_minmax(300px,0.95fr)]"
>
<section
data-testid="upload-dropzone-card"
className="app-surface min-w-0 rounded-[28px] border border-white/70 px-4 py-4 shadow-[0_24px_80px_-40px_rgba(15,23,42,0.35)] sm:px-5 sm:py-5 lg:px-6 lg:py-5"
>
<div className="mb-4 flex flex-col gap-3">
<div className="min-w-0 space-y-2">
<h2 className="text-xl font-semibold tracking-tight text-slate-950 sm:text-2xl">
{m.upload.uploadPanelTitle}
</h2>
<p className="max-w-2xl text-sm leading-5 text-slate-600">
{m.upload.uploadPanelDescription}
</p>
</div>
</div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="flex items-start justify-between gap-4 mb-4">
<div>
<h3 className="font-semibold text-gray-800 mb-1">{m.upload.subtitleDefaults}</h3>
<p className="text-xs text-gray-500">
{m.upload.subtitleDefaultsDesc}
<div className="mt-4 grid gap-4 xl:grid-cols-[minmax(0,1fr)_220px] xl:items-end">
<section
data-testid="upload-subtitle-defaults-panel"
className="rounded-[22px] border border-slate-200 bg-white/82 p-3 shadow-sm shadow-slate-200/70"
>
<div className="mb-3 flex items-start justify-between gap-4">
<div>
<h3 className="text-sm font-semibold text-slate-950">{m.upload.subtitleDefaults}</h3>
<p className="mt-1 text-xs leading-5 text-slate-600">{m.upload.subtitleDefaultsDesc}</p>
</div>
<button
type="button"
className="text-xs font-medium text-sky-700 transition-colors hover:text-sky-800"
onClick={() => setSubtitleDefaults(DEFAULT_SUBTITLE_DEFAULTS)}
>
{m.upload.reset}
</button>
</div>
<div className="mb-3 rounded-[18px] border border-slate-200 bg-slate-950/95 p-2.5">
<div className="relative h-24 overflow-hidden rounded-[14px] bg-[radial-gradient(circle_at_top,_rgba(148,163,184,0.35),_transparent_40%),linear-gradient(180deg,_#1e293b_0%,_#0f172a_100%)]">
<div className="absolute inset-0 bg-[linear-gradient(90deg,transparent_0%,rgba(255,255,255,0.04)_50%,transparent_100%)]" />
<p
data-testid="upload-subtitle-preview"
className="absolute left-1/2 max-w-[88%] -translate-x-1/2 whitespace-pre-wrap px-3 py-1 text-center font-semibold text-white"
style={{
bottom: `${subtitleDefaults.bottomOffsetPercent}%`,
fontSize: `${previewFontSize}px`,
textShadow: `${previewTextShadowSize}px 0 #000, -${previewTextShadowSize}px 0 #000, 0 ${previewTextShadowSize}px #000, 0 -${previewTextShadowSize}px #000`,
}}
>
{m.upload.subtitlePreview}
</p>
</div>
</div>
<div className="space-y-3">
<div>
<div className="mb-2 flex items-center justify-between">
<label htmlFor="subtitle-position" className="text-sm font-medium text-slate-700">
{m.upload.subtitleInitialPosition}
</label>
<span className="text-xs text-slate-500">
{format(m.upload.fromBottom, { value: subtitleDefaults.bottomOffsetPercent })}
</span>
</div>
<input
id="subtitle-position"
aria-label="Subtitle initial position"
type="range"
min="4"
max="30"
step="1"
value={subtitleDefaults.bottomOffsetPercent}
onChange={(e) =>
setSubtitleDefaults((previous) => ({
...previous,
bottomOffsetPercent: Number(e.target.value),
}))
}
className="w-full accent-sky-500"
/>
</div>
<div>
<div className="mb-2 flex items-center justify-between">
<label htmlFor="subtitle-size" className="text-sm font-medium text-slate-700">
{m.upload.subtitleInitialSize}
</label>
<span className="text-xs text-slate-500">
{format(m.upload.pxValue, { value: subtitleDefaults.fontSize })}
</span>
</div>
<input
id="subtitle-size"
aria-label="Subtitle initial size"
type="range"
min="16"
max="40"
step="1"
value={subtitleDefaults.fontSize}
onChange={(e) =>
setSubtitleDefaults((previous) => ({
...previous,
fontSize: Number(e.target.value),
}))
}
className="w-full accent-sky-500"
/>
</div>
</div>
</section>
<div className="flex flex-col gap-2 text-sm text-slate-500 xl:items-end xl:text-right">
<p>{m.upload.supportedFormats}</p>
<div className="inline-flex items-center rounded-full border border-slate-200 bg-white/80 px-3 py-1.5 font-medium text-slate-700">
{activeModeLabel}
</div>
</div>
</div>
<div
data-testid="upload-dropzone-surface"
className="relative mt-4 overflow-hidden rounded-[24px] border border-slate-200/80 bg-[linear-gradient(180deg,rgba(248,250,252,0.95)_0%,rgba(226,232,240,0.88)_100%)] p-2.5 sm:p-3"
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,_rgba(59,130,246,0.12),_transparent_38%)]" />
<div className="relative flex min-h-[230px] flex-col items-center justify-center rounded-[20px] border-2 border-dashed border-slate-300 bg-white/70 px-5 py-8 text-center sm:min-h-[250px] lg:min-h-[280px]">
<input
ref={fileInputRef}
type="file"
aria-label="Upload video file"
className="absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0"
accept="video/mp4,video/quicktime,video/webm"
onChange={handleFileChange}
/>
<div className="pointer-events-none relative z-0 flex max-w-xl flex-col items-center">
<div className="mb-4 rounded-full bg-slate-900 p-4 text-white shadow-lg shadow-slate-300/70">
<Upload className="h-8 w-8 sm:h-10 sm:w-10" />
</div>
<h3 className="text-lg font-semibold text-slate-900 sm:text-xl">{m.upload.uploadVideo}</h3>
<p className="mt-2 max-w-md text-sm leading-5 text-slate-600">
{m.upload.clickToUpload}
</p>
<button className="mt-5 flex w-full max-w-xs items-center justify-center gap-2 rounded-2xl bg-[#16a34a] px-5 py-3 text-sm font-semibold text-white shadow-lg shadow-emerald-200 transition-colors pointer-events-none">
<Upload className="h-5 w-5" />
{m.upload.uploadVideo}
</button>
</div>
</div>
</div>
</section>
<div
data-testid="upload-settings-column"
className="min-w-0 grid gap-4 xl:contents"
>
<section
data-testid="mode-card"
className={`${settingsCardClass} xl:col-start-2 xl:row-start-1`}
>
<div className="mb-3">
<h2 className="text-base font-semibold text-slate-950">{m.upload.modeWorkflowTitle}</h2>
<p className="mt-1 text-sm leading-5 text-slate-600">{m.upload.modeWorkflowDescription}</p>
</div>
<div className="grid gap-2">
<button
type="button"
className="text-sm text-blue-600 hover:text-blue-700 font-medium"
onClick={() => setSubtitleDefaults(DEFAULT_SUBTITLE_DEFAULTS)}
aria-pressed={mode === 'editing'}
className={`rounded-2xl border px-3.5 py-3 text-left transition-all ${
mode === 'editing'
? 'border-sky-400 bg-sky-50 shadow-sm shadow-sky-100'
: 'border-slate-200 bg-white/80 hover:border-sky-200 hover:bg-slate-50'
}`}
onClick={() => setMode('editing')}
>
{m.upload.reset}
<div className="flex items-center gap-3">
{mode === 'editing' ? (
<CheckCircle2 className="h-5 w-5 text-sky-600" />
) : (
<Circle className="h-5 w-5 text-slate-300" />
)}
<span className="font-semibold text-slate-900">{m.upload.editingMode}</span>
</div>
<p className="mt-2 text-sm leading-5 text-slate-600">{m.upload.editingModeDesc}</p>
</button>
<button
type="button"
aria-pressed={mode === 'simple'}
className={`rounded-2xl border px-3.5 py-3 text-left transition-all ${
mode === 'simple'
? 'border-sky-400 bg-sky-50 shadow-sm shadow-sky-100'
: 'border-slate-200 bg-white/80 hover:border-sky-200 hover:bg-slate-50'
}`}
onClick={() => setMode('simple')}
>
<div className="flex items-center gap-3">
{mode === 'simple' ? (
<CheckCircle2 className="h-5 w-5 text-sky-600" />
) : (
<Circle className="h-5 w-5 text-slate-300" />
)}
<span className="font-semibold text-slate-900">{m.upload.simpleMode}</span>
</div>
<p className="mt-2 text-sm leading-5 text-slate-600">{m.upload.simpleModeDesc}</p>
</button>
</div>
</section>
<div className="rounded-xl border border-gray-200 bg-slate-950 p-4 mb-4">
<div className="relative h-48 rounded-lg overflow-hidden bg-[radial-gradient(circle_at_top,_rgba(148,163,184,0.35),_transparent_40%),linear-gradient(180deg,_#1e293b_0%,_#0f172a_100%)]">
<div className="absolute inset-0 bg-[linear-gradient(90deg,transparent_0%,rgba(255,255,255,0.04)_50%,transparent_100%)]" />
<p
data-testid="upload-subtitle-preview"
className="absolute left-1/2 -translate-x-1/2 text-white font-semibold text-center whitespace-pre-wrap max-w-[88%] px-3 py-1"
style={{
bottom: `${subtitleDefaults.bottomOffsetPercent}%`,
fontSize: `${subtitleDefaults.fontSize}px`,
textShadow: '2px 0 #000, -2px 0 #000, 0 2px #000, 0 -2px #000',
}}
>
{m.upload.subtitlePreview}
</p>
</div>
<section
data-testid="language-card"
className={`${settingsCardClass} flex flex-col xl:col-start-3 xl:row-start-1`}
>
<div className="mb-3">
<h2 className="text-base font-semibold text-slate-950">{m.upload.languageDubbingTitle}</h2>
<p className="mt-1 text-sm leading-5 text-slate-600">{m.upload.languageDubbingDescription}</p>
</div>
<div className="space-y-4">
<div>
<div className="flex items-center justify-between mb-2">
<label htmlFor="subtitle-position" className="text-sm font-medium text-gray-700">
{m.upload.subtitleInitialPosition}
</label>
<span className="text-xs text-gray-500">{format(m.upload.fromBottom, { value: subtitleDefaults.bottomOffsetPercent })}</span>
</div>
<input
id="subtitle-position"
aria-label="Subtitle initial position"
type="range"
min="4"
max="30"
step="1"
value={subtitleDefaults.bottomOffsetPercent}
onChange={(e) =>
setSubtitleDefaults((previous) => ({
...previous,
bottomOffsetPercent: Number(e.target.value),
}))
}
className="w-full accent-blue-500"
/>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<label htmlFor="subtitle-size" className="text-sm font-medium text-gray-700">
{m.upload.subtitleInitialSize}
</label>
<span className="text-xs text-gray-500">{format(m.upload.pxValue, { value: subtitleDefaults.fontSize })}</span>
</div>
<input
id="subtitle-size"
aria-label="Subtitle initial size"
type="range"
min="16"
max="40"
step="1"
value={subtitleDefaults.fontSize}
onChange={(e) =>
setSubtitleDefaults((previous) => ({
...previous,
fontSize: Number(e.target.value),
}))
}
className="w-full accent-blue-500"
/>
</div>
</div>
</div>
{/* Language Selection */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 flex-1 flex flex-col">
<div className="mb-5">
<h3 className="font-semibold text-gray-800 mb-1">{m.upload.subtitleLanguage}</h3>
<p className="text-xs text-gray-500 mb-3">{m.upload.subtitleLanguageHint}</p>
<div className="inline-flex items-center rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-sm font-medium text-gray-700">
<div className="mb-3 rounded-2xl border border-slate-200 bg-white/80 p-3">
<h3 className="font-semibold text-slate-900">{m.upload.subtitleLanguage}</h3>
<p className="mt-1 text-xs text-slate-500">{m.upload.subtitleLanguageHint}</p>
<div className="mt-3 inline-flex items-center rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm font-medium text-slate-700">
{m.upload.languages.en}
</div>
</div>
<h3 className="font-semibold text-gray-800 mb-1">{m.upload.ttsLanguage}</h3>
<p className="text-xs text-gray-500 mb-4">{m.upload.ttsLanguageHint}</p>
{/* Alphabet Tabs */}
<div className="flex gap-4 border-b border-gray-100 pb-2 mb-4 text-sm text-gray-500 overflow-x-auto">
<button className="font-medium text-blue-600 border-b-2 border-blue-600 pb-2 -mb-[9px]">{m.upload.popular}</button>
<button className="hover:text-gray-800">{m.upload.groupABC}</button>
<button className="hover:text-gray-800">{m.upload.groupGHI}</button>
<button className="hover:text-gray-800">{m.upload.groupDEF}</button>
<div className="mb-3">
<h3 className="font-semibold text-slate-900">{m.upload.ttsLanguage}</h3>
<p className="mt-1 text-xs text-slate-500">{m.upload.ttsLanguageHint}</p>
</div>
{/* Language List */}
<div className="flex-1 overflow-y-auto pr-2 custom-scrollbar">
{['C', 'E', 'F', 'G', 'I'].map((letter) => (
<div key={letter} className="flex border-b border-gray-100 py-3 last:border-0">
<div className="w-8 text-green-600 font-medium">{letter}</div>
<div className="flex flex-wrap gap-x-6 gap-y-2 flex-1">
{LANGUAGES.filter((l) => l.group === letter).map((lang) => (
<button
key={lang.code}
className={`text-sm hover:text-blue-600 transition-colors ${
selectedTtsLanguage === lang.code
? 'bg-green-600 text-white px-2 py-0.5 rounded'
: lang.code === 'zh' || lang.code === 'yue'
? 'text-orange-500'
: 'text-gray-700'
}`}
onClick={() => setSelectedTtsLanguage(lang.code)}
>
{m.upload.languages[lang.code as keyof typeof m.upload.languages]}
</button>
))}
</div>
</div>
<div
data-testid="tts-language-grid"
className="grid grid-cols-2 gap-2"
>
{LANGUAGES.map((lang) => (
<button
key={lang.code}
type="button"
className={`rounded-2xl border px-3 py-2 text-left text-sm font-medium transition-colors ${
selectedTtsLanguage === lang.code
? 'border-emerald-500 bg-emerald-50 text-emerald-700'
: lang.code === 'zh' || lang.code === 'yue'
? 'border-orange-100 bg-orange-50 text-orange-600 hover:border-orange-200'
: 'border-slate-200 bg-white/80 text-slate-700 hover:border-sky-200 hover:text-sky-700'
}`}
onClick={() => setSelectedTtsLanguage(lang.code)}
>
{m.upload.languages[lang.code as keyof typeof m.upload.languages]}
</button>
))}
</div>
<button
className={`w-full py-3 rounded-md font-medium mt-4 transition-colors ${
tempFile
? 'bg-[#52c41a] hover:bg-[#46a616] text-white'
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
className={`mt-4 w-full rounded-2xl px-4 py-3 text-sm font-semibold transition-colors ${
tempFile
? 'bg-[#16a34a] text-white shadow-lg shadow-emerald-200 hover:bg-[#15803d]'
: 'cursor-not-allowed bg-slate-200 text-slate-400'
}`}
onClick={() => {
if (tempFile) setShowTrimModal(true);
@ -264,7 +327,8 @@ export default function UploadScreen({
>
{m.upload.generateTranslatedVideo}
</button>
</div>
</section>
</div>
{showTrimModal && tempFile && (

View File

@ -10,6 +10,15 @@ const messages = {
productName: 'Translate 1.0',
},
upload: {
workbenchEyebrow: 'Video Translate',
workbenchTitle: '\u4e0a\u4f20\u5e76\u51c6\u5907',
workbenchDescription: '\u9996\u5c4f\u5373\u53ef\u770b\u5230\u5f00\u59cb\u7ffb\u8bd1\u6240\u9700\u7684\u5168\u90e8\u5173\u952e\u9009\u9879\u3002',
uploadPanelTitle: '\u4e0a\u4f20\u6e90\u89c6\u9891',
uploadPanelDescription: '\u62d6\u62fd\u6216\u70b9\u51fb\u9009\u62e9\u89c6\u9891\u6587\u4ef6\uff0c\u6211\u4eec\u4f1a\u5728\u4e0b\u4e00\u6b65\u8fdb\u5165\u88c1\u526a\u548c\u7ffb\u8bd1\u6d41\u7a0b\u3002',
modeWorkflowTitle: '\u6a21\u5f0f\u4e0e\u6d41\u7a0b',
modeWorkflowDescription: '\u9009\u62e9\u4f60\u5e0c\u671b\u7684\u5de5\u4f5c\u65b9\u5f0f\uff0c\u4fdd\u6301\u5f53\u524d\u7ffb\u8bd1\u6d41\u7a0b\u4e0d\u53d8\u3002',
languageDubbingTitle: '\u8bed\u8a00\u4e0e\u914d\u97f3',
languageDubbingDescription: '\u786e\u8ba4\u5b57\u5e55\u8bed\u8a00\u4e0e TTS \u76ee\u6807\u8bed\u8a00\uff0c\u518d\u8fdb\u5165\u751f\u6210\u6d41\u7a0b\u3002',
uploadVideoFile: '\u4e0a\u4f20\u89c6\u9891\u6587\u4ef6',
clickToUpload: '\u70b9\u51fb\u4e0a\u4f20\u6216\u5c06\u89c6\u9891\u62d6\u62fd\u5230\u6b64\u5904',
uploadVideo: '\u4e0a\u4f20\u89c6\u9891',
@ -134,6 +143,18 @@ const messages = {
productName: 'Translate 1.0',
},
upload: {
workbenchEyebrow: 'Video Translate',
workbenchTitle: 'Upload & prepare',
workbenchDescription: 'Everything you need to start translation is visible right away.',
uploadPanelTitle: 'Upload source video',
uploadPanelDescription:
'Drop a video here or browse from your device. We will move into trim and translation setup next.',
modeWorkflowTitle: 'Mode & Workflow',
modeWorkflowDescription:
'Choose the workflow that best fits your editing needs while keeping the translation flow intact.',
languageDubbingTitle: 'Language & Dubbing',
languageDubbingDescription:
'Confirm the subtitle language and the dubbing target before starting generation.',
uploadVideoFile: 'Upload video file',
clickToUpload: 'Click to upload or drag files here',
uploadVideo: 'Upload Video',

View File

@ -1,5 +1,10 @@
@import "tailwindcss";
.app-surface {
background: rgba(255, 255, 255, 0.82);
backdrop-filter: blur(18px);
}
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;

View File

@ -1,8 +1,9 @@
import { defineConfig } from 'vitest/config';
import { configDefaults, defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
setupFiles: ['./src/test/setup.ts'],
exclude: [...configDefaults.exclude, '**/.worktrees/**'],
},
});