feat(dub): polish workspace controls
This commit is contained in:
@@ -8,6 +8,14 @@ the frozen-backend fallback mirror it for their toolchains.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
**Highlights**
|
||||
|
||||
- A faster, cleaner Dub workspace for multilingual production
|
||||
|
||||
### Changed
|
||||
|
||||
- The Dub workspace now has a compact production command bar, responsive flag-based language cards, media previews in Dub History, and a narrower Projects rail.
|
||||
|
||||
## [5.0.0] — 2026-08-11
|
||||
|
||||
**Highlights**
|
||||
|
||||
+13
-9
@@ -1462,6 +1462,19 @@ function App() {
|
||||
<div
|
||||
className={`studio-with-history ${dubStep === 'idle' ? '' : 'studio-with-history--editing'}`}
|
||||
>
|
||||
{dubStep === 'idle' && (
|
||||
<div className="studio-projects">
|
||||
<WorkspaceProjects
|
||||
projects={studioProjects}
|
||||
activeProjectId={activeProjectId}
|
||||
canSave={dubStep !== 'idle' || !!dubVideoFile}
|
||||
saveProject={saveProject}
|
||||
loadProject={loadProject}
|
||||
deleteProject={deleteProject}
|
||||
renameProject={renameProject}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="studio-with-history__main">
|
||||
<ErrorBoundary name="dub">
|
||||
<Suspense fallback={<LazyFallback />}>
|
||||
@@ -1529,15 +1542,6 @@ function App() {
|
||||
editor (dubStep !== 'idle'). */}
|
||||
{dubStep === 'idle' && (
|
||||
<div className="studio-right">
|
||||
<WorkspaceProjects
|
||||
projects={studioProjects}
|
||||
activeProjectId={activeProjectId}
|
||||
canSave={dubStep !== 'idle' || !!dubVideoFile}
|
||||
saveProject={saveProject}
|
||||
loadProject={loadProject}
|
||||
deleteProject={deleteProject}
|
||||
renameProject={renameProject}
|
||||
/>
|
||||
<WorkspaceHistory
|
||||
variant="dub"
|
||||
dubHistory={dubHistory}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import React from 'react';
|
||||
import { Globe } from 'lucide-react';
|
||||
import AF from 'country-flag-icons/react/3x2/AF';
|
||||
import AL from 'country-flag-icons/react/3x2/AL';
|
||||
import AM from 'country-flag-icons/react/3x2/AM';
|
||||
import AZ from 'country-flag-icons/react/3x2/AZ';
|
||||
import BA from 'country-flag-icons/react/3x2/BA';
|
||||
import BD from 'country-flag-icons/react/3x2/BD';
|
||||
import BG from 'country-flag-icons/react/3x2/BG';
|
||||
import BY from 'country-flag-icons/react/3x2/BY';
|
||||
import CN from 'country-flag-icons/react/3x2/CN';
|
||||
import CZ from 'country-flag-icons/react/3x2/CZ';
|
||||
import DE from 'country-flag-icons/react/3x2/DE';
|
||||
import DK from 'country-flag-icons/react/3x2/DK';
|
||||
import EE from 'country-flag-icons/react/3x2/EE';
|
||||
import ES from 'country-flag-icons/react/3x2/ES';
|
||||
import ES_CT from 'country-flag-icons/react/3x2/ES-CT';
|
||||
import ET from 'country-flag-icons/react/3x2/ET';
|
||||
import FI from 'country-flag-icons/react/3x2/FI';
|
||||
import FR from 'country-flag-icons/react/3x2/FR';
|
||||
import GB from 'country-flag-icons/react/3x2/GB';
|
||||
import GB_SCT from 'country-flag-icons/react/3x2/GB-SCT';
|
||||
import GB_WLS from 'country-flag-icons/react/3x2/GB-WLS';
|
||||
import GE from 'country-flag-icons/react/3x2/GE';
|
||||
import GR from 'country-flag-icons/react/3x2/GR';
|
||||
import HR from 'country-flag-icons/react/3x2/HR';
|
||||
import HT from 'country-flag-icons/react/3x2/HT';
|
||||
import HU from 'country-flag-icons/react/3x2/HU';
|
||||
import ID from 'country-flag-icons/react/3x2/ID';
|
||||
import IL from 'country-flag-icons/react/3x2/IL';
|
||||
import IN from 'country-flag-icons/react/3x2/IN';
|
||||
import IQ from 'country-flag-icons/react/3x2/IQ';
|
||||
import IR from 'country-flag-icons/react/3x2/IR';
|
||||
import IS from 'country-flag-icons/react/3x2/IS';
|
||||
import IT from 'country-flag-icons/react/3x2/IT';
|
||||
import JP from 'country-flag-icons/react/3x2/JP';
|
||||
import KG from 'country-flag-icons/react/3x2/KG';
|
||||
import KH from 'country-flag-icons/react/3x2/KH';
|
||||
import KR from 'country-flag-icons/react/3x2/KR';
|
||||
import KZ from 'country-flag-icons/react/3x2/KZ';
|
||||
import LA from 'country-flag-icons/react/3x2/LA';
|
||||
import LK from 'country-flag-icons/react/3x2/LK';
|
||||
import LT from 'country-flag-icons/react/3x2/LT';
|
||||
import LV from 'country-flag-icons/react/3x2/LV';
|
||||
import MK from 'country-flag-icons/react/3x2/MK';
|
||||
import MM from 'country-flag-icons/react/3x2/MM';
|
||||
import MN from 'country-flag-icons/react/3x2/MN';
|
||||
import MT from 'country-flag-icons/react/3x2/MT';
|
||||
import MY from 'country-flag-icons/react/3x2/MY';
|
||||
import NG from 'country-flag-icons/react/3x2/NG';
|
||||
import NL from 'country-flag-icons/react/3x2/NL';
|
||||
import NO from 'country-flag-icons/react/3x2/NO';
|
||||
import NP from 'country-flag-icons/react/3x2/NP';
|
||||
import NZ from 'country-flag-icons/react/3x2/NZ';
|
||||
import PK from 'country-flag-icons/react/3x2/PK';
|
||||
import PL from 'country-flag-icons/react/3x2/PL';
|
||||
import PT from 'country-flag-icons/react/3x2/PT';
|
||||
import RO from 'country-flag-icons/react/3x2/RO';
|
||||
import RS from 'country-flag-icons/react/3x2/RS';
|
||||
import RU from 'country-flag-icons/react/3x2/RU';
|
||||
import SA from 'country-flag-icons/react/3x2/SA';
|
||||
import SE from 'country-flag-icons/react/3x2/SE';
|
||||
import SI from 'country-flag-icons/react/3x2/SI';
|
||||
import SK from 'country-flag-icons/react/3x2/SK';
|
||||
import SO from 'country-flag-icons/react/3x2/SO';
|
||||
import TH from 'country-flag-icons/react/3x2/TH';
|
||||
import TJ from 'country-flag-icons/react/3x2/TJ';
|
||||
import TR from 'country-flag-icons/react/3x2/TR';
|
||||
import TW from 'country-flag-icons/react/3x2/TW';
|
||||
import TZ from 'country-flag-icons/react/3x2/TZ';
|
||||
import UA from 'country-flag-icons/react/3x2/UA';
|
||||
import US from 'country-flag-icons/react/3x2/US';
|
||||
import UZ from 'country-flag-icons/react/3x2/UZ';
|
||||
import VA from 'country-flag-icons/react/3x2/VA';
|
||||
import VN from 'country-flag-icons/react/3x2/VN';
|
||||
import WS from 'country-flag-icons/react/3x2/WS';
|
||||
import ZA from 'country-flag-icons/react/3x2/ZA';
|
||||
import ZW from 'country-flag-icons/react/3x2/ZW';
|
||||
|
||||
// Languages do not map one-to-one to countries. These are stable,
|
||||
// representative flags for quick visual scanning; regional language variants
|
||||
// keep their explicit flag (Simplified Chinese → CN, Traditional → TW).
|
||||
// oxlint-disable-next-line react/only-export-components -- exported for the coverage regression test
|
||||
export const LANGUAGE_FLAGS = {
|
||||
af: ZA,
|
||||
sq: AL,
|
||||
am: ET,
|
||||
ar: SA,
|
||||
hy: AM,
|
||||
az: AZ,
|
||||
eu: ES,
|
||||
be: BY,
|
||||
bn: BD,
|
||||
bs: BA,
|
||||
bg: BG,
|
||||
my: MM,
|
||||
ca: ES_CT,
|
||||
'cmn-Hans': CN,
|
||||
'cmn-Hant': TW,
|
||||
hr: HR,
|
||||
cs: CZ,
|
||||
da: DK,
|
||||
nl: NL,
|
||||
en: GB,
|
||||
et: EE,
|
||||
fi: FI,
|
||||
fr: FR,
|
||||
gl: ES,
|
||||
ka: GE,
|
||||
de: DE,
|
||||
el: GR,
|
||||
gu: IN,
|
||||
ht: HT,
|
||||
ha: NG,
|
||||
haw: US,
|
||||
he: IL,
|
||||
hi: IN,
|
||||
hu: HU,
|
||||
is: IS,
|
||||
id: ID,
|
||||
it: IT,
|
||||
ja: JP,
|
||||
jw: ID,
|
||||
kn: IN,
|
||||
kk: KZ,
|
||||
km: KH,
|
||||
ko: KR,
|
||||
ku: IQ,
|
||||
ky: KG,
|
||||
lo: LA,
|
||||
la: VA,
|
||||
lv: LV,
|
||||
lt: LT,
|
||||
mk: MK,
|
||||
ms: MY,
|
||||
ml: IN,
|
||||
mt: MT,
|
||||
mi: NZ,
|
||||
mr: IN,
|
||||
mn: MN,
|
||||
ne: NP,
|
||||
no: NO,
|
||||
ps: AF,
|
||||
fa: IR,
|
||||
pl: PL,
|
||||
pt: PT,
|
||||
pa: IN,
|
||||
ro: RO,
|
||||
ru: RU,
|
||||
sm: WS,
|
||||
gd: GB_SCT,
|
||||
sr: RS,
|
||||
sn: ZW,
|
||||
sd: PK,
|
||||
si: LK,
|
||||
sk: SK,
|
||||
sl: SI,
|
||||
so: SO,
|
||||
es: ES,
|
||||
su: ID,
|
||||
sw: TZ,
|
||||
sv: SE,
|
||||
tg: TJ,
|
||||
ta: IN,
|
||||
te: IN,
|
||||
th: TH,
|
||||
tr: TR,
|
||||
uk: UA,
|
||||
ur: PK,
|
||||
uz: UZ,
|
||||
vi: VN,
|
||||
cy: GB_WLS,
|
||||
xh: ZA,
|
||||
yi: IL,
|
||||
yo: NG,
|
||||
zu: ZA,
|
||||
};
|
||||
|
||||
export default function LanguageFlag({ code, className = '' }) {
|
||||
const Flag = LANGUAGE_FLAGS[code];
|
||||
if (!Flag) {
|
||||
return (
|
||||
<Globe
|
||||
size={16}
|
||||
aria-hidden="true"
|
||||
className={`shrink-0 text-[color:var(--chrome-fg-dim)] ${className}`}
|
||||
data-language-flag={code}
|
||||
data-testid={`language-flag-${code}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Flag
|
||||
aria-hidden="true"
|
||||
className={`h-[12px] w-[18px] shrink-0 rounded-[2px] shadow-[0_0_0_1px_rgba(255,255,255,0.14)] ${className}`}
|
||||
data-language-flag={code}
|
||||
data-testid={`language-flag-${code}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useState, useMemo, useRef, useEffect, useLayoutEffect, useId } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X, Search, Globe, Plus } from 'lucide-react';
|
||||
import { X, Search, Plus } from 'lucide-react';
|
||||
import { POPULAR_LANGS } from '../utils/constants';
|
||||
import { LANG_CODES } from '../utils/languages';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LanguageFlag from './LanguageFlag';
|
||||
|
||||
/**
|
||||
* MultiLangPicker — chip-based multi-language selector for batch dubbing.
|
||||
@@ -125,31 +126,42 @@ export default function MultiLangPicker({
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropRef}>
|
||||
<div className="flex flex-wrap gap-[4px] items-center min-h-[28px]">
|
||||
{selected.map((s) => (
|
||||
<span
|
||||
key={s.code}
|
||||
className="inline-flex items-center gap-[4px] px-[8px] py-[2px] bg-[var(--chrome-hover-bg)] border border-solid border-transparent rounded-full [font-family:var(--font-mono)] text-[0.68rem] font-medium text-[color:var(--chrome-fg)] uppercase"
|
||||
<div className="flex items-start gap-[5px] min-h-[28px]">
|
||||
{selected.length > 0 && (
|
||||
<div
|
||||
className="grid min-w-0 flex-1 grid-cols-[repeat(auto-fit,minmax(112px,1fr))] gap-[4px]"
|
||||
data-testid="multi-lang-selected-grid"
|
||||
>
|
||||
<Globe size={9} aria-hidden="true" />
|
||||
<span>{s.code}</span>
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
className="bg-transparent border-0 text-[color:var(--chrome-fg-muted)] cursor-pointer p-0 flex items-center rounded-full [transition:color_0.15s] hover:text-danger focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--chrome-accent)]"
|
||||
onClick={() => removeLang(s.code)}
|
||||
aria-label={t('common.remove', { term: s.lang })}
|
||||
{selected.map((s) => (
|
||||
<span
|
||||
key={s.code}
|
||||
className="flex min-w-0 items-center gap-[5px] px-[6px] py-[3px] bg-[var(--chrome-hover-bg)] border border-solid border-transparent rounded-[var(--chrome-radius-pill)] [font-family:var(--font-sans)] text-[0.68rem] font-medium text-[color:var(--chrome-fg)]"
|
||||
title={s.lang}
|
||||
>
|
||||
<X size={8} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
<LanguageFlag code={s.code} />
|
||||
<span className="min-w-0 flex-1 truncate">{s.lang}</span>
|
||||
<span className="[font-family:var(--font-mono)] text-[0.58rem] uppercase text-[color:var(--chrome-fg-dim)]">
|
||||
{s.code}
|
||||
</span>
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
className="bg-transparent border-0 text-[color:var(--chrome-fg-muted)] cursor-pointer p-0 flex shrink-0 items-center rounded-full [transition:color_0.15s] hover:text-danger focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--chrome-accent)]"
|
||||
onClick={() => removeLang(s.code)}
|
||||
aria-label={t('common.remove', { term: s.lang })}
|
||||
>
|
||||
<X size={8} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!disabled && (
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className="flex items-center justify-center w-[24px] h-[24px] rounded-full border border-dashed border-transparent bg-transparent text-[color:var(--chrome-fg-muted)] cursor-pointer [transition:background-color_0.15s,color_0.15s,border-color_0.15s] hover:bg-[var(--chrome-hover-bg)] hover:text-[color:var(--chrome-fg)] hover:border-solid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--chrome-accent)]"
|
||||
className="flex shrink-0 items-center justify-center w-[24px] h-[24px] mt-[2px] rounded-full border border-dashed border-transparent bg-transparent text-[color:var(--chrome-fg-muted)] cursor-pointer [transition:background-color_0.15s,color_0.15s,border-color_0.15s] hover:bg-[var(--chrome-hover-bg)] hover:text-[color:var(--chrome-fg)] hover:border-solid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--chrome-accent)]"
|
||||
onClick={() => setDropOpen((open) => !open)}
|
||||
title={t('dub.add_language')}
|
||||
aria-label={t('dub.add_language')}
|
||||
@@ -207,37 +219,46 @@ export default function MultiLangPicker({
|
||||
<div className="[font-family:var(--font-mono)] text-[0.62rem] font-semibold uppercase [letter-spacing:0.04em] text-[color:var(--chrome-fg-dim)] pt-[6px] px-[10px] pb-[2px]">
|
||||
{t('dub.popular')}
|
||||
</div>
|
||||
{popularFiltered.map((item) => (
|
||||
<button
|
||||
key={item.code}
|
||||
type="button"
|
||||
className="flex items-center gap-[8px] w-full px-[10px] py-[5px] bg-transparent border-0 text-[color:var(--chrome-fg)] [font-family:var(--font-sans)] text-[0.76rem] cursor-pointer text-left [transition:background_0.1s] hover:bg-[var(--chrome-hover-bg)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--chrome-accent)]"
|
||||
onClick={() => addLang(item.lang, item.code)}
|
||||
>
|
||||
<span className="[font-family:var(--font-mono)] text-[0.68rem] text-[color:var(--chrome-accent)] min-w-[28px] font-semibold">
|
||||
{item.code}
|
||||
</span>
|
||||
<span className="min-w-0 truncate">{item.lang}</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(140px,1fr))] gap-[2px] px-[4px]">
|
||||
{popularFiltered.map((item) => (
|
||||
<button
|
||||
key={item.code}
|
||||
type="button"
|
||||
className="flex min-w-0 items-center gap-[7px] rounded-[4px] px-[7px] py-[5px] bg-transparent border-0 text-[color:var(--chrome-fg)] [font-family:var(--font-sans)] text-[0.76rem] cursor-pointer text-left [transition:background_0.1s] hover:bg-[var(--chrome-hover-bg)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--chrome-accent)]"
|
||||
onClick={() => addLang(item.lang, item.code)}
|
||||
>
|
||||
<LanguageFlag code={item.code} />
|
||||
<span className="[font-family:var(--font-mono)] text-[0.64rem] text-[color:var(--chrome-accent)] min-w-[24px] font-semibold uppercase">
|
||||
{item.code}
|
||||
</span>
|
||||
<span className="min-w-0 truncate">{item.lang}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="[font-family:var(--font-mono)] text-[0.62rem] font-semibold uppercase [letter-spacing:0.04em] text-[color:var(--chrome-fg-dim)] pt-[6px] px-[10px] pb-[2px]">
|
||||
{t('dub.all_languages')}
|
||||
</div>
|
||||
{filteredLangs.slice(0, 50).map((lc) => (
|
||||
<button
|
||||
key={lc.code}
|
||||
type="button"
|
||||
className="flex items-center gap-[8px] w-full px-[10px] py-[5px] bg-transparent border-0 text-[color:var(--chrome-fg)] [font-family:var(--font-sans)] text-[0.76rem] cursor-pointer text-left [transition:background_0.1s] hover:bg-[var(--chrome-hover-bg)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--chrome-accent)]"
|
||||
onClick={() => addLang(lc.label, lc.code)}
|
||||
>
|
||||
<span className="[font-family:var(--font-mono)] text-[0.68rem] text-[color:var(--chrome-accent)] min-w-[28px] font-semibold">
|
||||
{lc.code}
|
||||
</span>
|
||||
<span className="min-w-0 truncate">{lc.label}</span>
|
||||
</button>
|
||||
))}
|
||||
<div
|
||||
className="grid grid-cols-[repeat(auto-fit,minmax(140px,1fr))] gap-[2px] px-[4px]"
|
||||
data-testid="multi-lang-all-grid"
|
||||
>
|
||||
{filteredLangs.slice(0, 50).map((lc) => (
|
||||
<button
|
||||
key={lc.code}
|
||||
type="button"
|
||||
className="flex min-w-0 items-center gap-[7px] rounded-[4px] px-[7px] py-[5px] bg-transparent border-0 text-[color:var(--chrome-fg)] [font-family:var(--font-sans)] text-[0.76rem] cursor-pointer text-left [transition:background_0.1s] hover:bg-[var(--chrome-hover-bg)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--chrome-accent)]"
|
||||
onClick={() => addLang(lc.label, lc.code)}
|
||||
>
|
||||
<LanguageFlag code={lc.code} />
|
||||
<span className="[font-family:var(--font-mono)] text-[0.64rem] text-[color:var(--chrome-accent)] min-w-[24px] font-semibold uppercase">
|
||||
{lc.code}
|
||||
</span>
|
||||
<span className="min-w-0 truncate">{lc.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{filteredLangs.length > 50 && (
|
||||
<div className="px-[10px] py-[8px] text-[0.7rem] text-[color:var(--chrome-fg-dim)] text-center">
|
||||
{t('dub.more_to_narrow', { count: filteredLangs.length - 50 })}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { render, fireEvent, screen } from '@testing-library/react';
|
||||
import { render, fireEvent, screen, within } from '@testing-library/react';
|
||||
import '../i18n';
|
||||
import MultiLangPicker from './MultiLangPicker';
|
||||
import { LANGUAGE_FLAGS } from './LanguageFlag';
|
||||
import { LANG_CODES } from '../utils/languages';
|
||||
|
||||
const rect = (overrides = {}) => ({
|
||||
x: 40,
|
||||
@@ -64,3 +66,34 @@ describe('MultiLangPicker viewport-safe menu', () => {
|
||||
expect(trigger).toHaveFocus();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MultiLangPicker responsive language cards', () => {
|
||||
it('maps every supported language to a representative flag', () => {
|
||||
expect(Object.keys(LANGUAGE_FLAGS).sort()).toEqual(LANG_CODES.map(({ code }) => code).sort());
|
||||
});
|
||||
|
||||
it('shows selected languages with flags in a responsive grid', () => {
|
||||
render(
|
||||
<MultiLangPicker
|
||||
selected={[
|
||||
{ lang: 'Spanish', code: 'es' },
|
||||
{ lang: 'Japanese', code: 'ja' },
|
||||
]}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const grid = screen.getByTestId('multi-lang-selected-grid');
|
||||
expect(grid).toHaveClass('grid');
|
||||
expect(within(grid).getByTestId('language-flag-es')).toBeInTheDocument();
|
||||
expect(within(grid).getByTestId('language-flag-ja')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows flags in the searchable result grid', () => {
|
||||
render(<MultiLangPicker selected={[]} onChange={vi.fn()} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add language' }));
|
||||
const results = screen.getByTestId('multi-lang-all-grid');
|
||||
expect(results).toHaveClass('grid');
|
||||
expect(within(results).getByTestId('language-flag-af')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Fingerprint,
|
||||
Wand2,
|
||||
Film,
|
||||
AudioWaveform,
|
||||
Save,
|
||||
Lock,
|
||||
Download as DownloadIcon,
|
||||
@@ -47,6 +48,45 @@ const displayTitle = (text) => {
|
||||
return stripped || text || '';
|
||||
};
|
||||
|
||||
const dubInputType = (item) => {
|
||||
try {
|
||||
const job = typeof item.job_data === 'string' ? JSON.parse(item.job_data) : item.job_data;
|
||||
return job?.input_type === 'audio' ? 'audio' : 'video';
|
||||
} catch {
|
||||
return 'video';
|
||||
}
|
||||
};
|
||||
|
||||
function DubMediaPreview({ item, inputType }) {
|
||||
const frameClass =
|
||||
'relative flex h-[54px] w-[88px] flex-[0_0_88px] items-center justify-center overflow-hidden rounded-[var(--chrome-radius-pill)] bg-[var(--chrome-hover-bg)] text-[color:var(--chrome-fg-dim)]';
|
||||
|
||||
if (inputType === 'audio') {
|
||||
return (
|
||||
<div className={frameClass} aria-hidden="true">
|
||||
<AudioWaveform size={28} strokeWidth={1.5} data-testid={`dub-audio-waveform-${item.id}`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={frameClass} aria-hidden="true">
|
||||
<Film size={20} strokeWidth={1.5} />
|
||||
<img
|
||||
src={`${API}/dub/thumb/${encodeURIComponent(item.id)}`}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
data-testid={`dub-thumbnail-${item.id}`}
|
||||
onError={(event) => {
|
||||
event.currentTarget.hidden = true;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Defer mounting <WaveformPlayer> (and its audio fetch + waveform decode)
|
||||
* until the row scrolls into view — the server returns up to 50 history rows,
|
||||
@@ -130,49 +170,58 @@ export default function WorkspaceHistory({
|
||||
{t('history.empty_dub', { defaultValue: 'Your dubs will appear here.' })}
|
||||
</div>
|
||||
) : (
|
||||
dubHistory.map((item) => (
|
||||
<div
|
||||
key={`dub-${item.id}`}
|
||||
className="history-item history-item--dub"
|
||||
onClick={() => restoreDubHistory(item)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 min-w-0">
|
||||
<span className="history-kind history-kind--audio">
|
||||
<Film size={9} /> {t('sidebar.dub_label')}
|
||||
</span>
|
||||
<span className="history-meta">
|
||||
{item.segments_count} segs · {Math.round(item.duration || 0)}s
|
||||
</span>
|
||||
dubHistory.map((item) => {
|
||||
const inputType = dubInputType(item);
|
||||
const MediaIcon = inputType === 'audio' ? AudioWaveform : Film;
|
||||
return (
|
||||
<div
|
||||
key={`dub-${item.id}`}
|
||||
className="history-item history-item--dub"
|
||||
onClick={() => restoreDubHistory(item)}
|
||||
>
|
||||
<div className="flex min-w-0 gap-[8px]">
|
||||
<DubMediaPreview item={item} inputType={inputType} />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-[2px]">
|
||||
<div className="flex items-center justify-between gap-2 min-w-0">
|
||||
<span className="history-kind history-kind--audio">
|
||||
<MediaIcon size={9} /> {t('sidebar.dub_label')}
|
||||
</span>
|
||||
<span className="history-meta">
|
||||
{item.segments_count} segs · {Math.round(item.duration || 0)}s
|
||||
</span>
|
||||
</div>
|
||||
<div className="history-title">{item.filename}</div>
|
||||
<div className="history-subtitle">
|
||||
{[item.language, item.language_code]
|
||||
.filter((v) => v && v !== 'und' && v !== 'Auto')
|
||||
.join(' · ') || 'Auto'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="history-actions">
|
||||
<button
|
||||
className="history-action-btn accent"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
restoreDubHistory(item);
|
||||
}}
|
||||
>
|
||||
<FolderOpen size={10} /> {t('sidebar.open')}
|
||||
</button>
|
||||
<button
|
||||
className="history-action-btn danger history-action-icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteHistory(item.id, 'dub');
|
||||
}}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="history-title">{item.filename}</div>
|
||||
<div className="history-subtitle">
|
||||
{[item.language, item.language_code]
|
||||
.filter((v) => v && v !== 'und' && v !== 'Auto')
|
||||
.join(' · ') || 'Auto'}
|
||||
</div>
|
||||
<div className="history-actions">
|
||||
<button
|
||||
className="history-action-btn accent"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
restoreDubHistory(item);
|
||||
}}
|
||||
>
|
||||
<FolderOpen size={10} /> {t('sidebar.open')}
|
||||
</button>
|
||||
<button
|
||||
className="history-action-btn danger history-action-icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteHistory(item.id, 'dub');
|
||||
}}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -90,3 +90,72 @@ describe('WorkspaceHistory takes actions', () => {
|
||||
expect(screen.queryByTestId('take-play-aa1')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('WorkspaceHistory dub media previews', () => {
|
||||
const renderDubRail = (dubHistory) =>
|
||||
render(
|
||||
<WorkspaceHistory
|
||||
variant="dub"
|
||||
dubHistory={dubHistory}
|
||||
restoreDubHistory={noop}
|
||||
deleteHistory={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
it('shows extracted thumbnails for YouTube and uploaded video dubs', () => {
|
||||
renderDubRail([
|
||||
{
|
||||
id: 'youtube-job',
|
||||
filename: 'YouTube clip',
|
||||
segments_count: 3,
|
||||
duration: 12,
|
||||
job_data: JSON.stringify({ input_type: 'video' }),
|
||||
},
|
||||
{
|
||||
id: 'uploaded-video',
|
||||
filename: 'movie.mp4',
|
||||
segments_count: 2,
|
||||
duration: 8,
|
||||
job_data: JSON.stringify({ video_path: '/dubs/uploaded-video/original.mp4' }),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(screen.getByTestId('dub-thumbnail-youtube-job')).toHaveAttribute(
|
||||
'src',
|
||||
expect.stringContaining('/dub/thumb/youtube-job'),
|
||||
);
|
||||
expect(screen.getByTestId('dub-thumbnail-uploaded-video')).toHaveAttribute(
|
||||
'src',
|
||||
expect.stringContaining('/dub/thumb/uploaded-video'),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows a waveform tile instead of a thumbnail for audio-only dubs', () => {
|
||||
renderDubRail([
|
||||
{
|
||||
id: 'audio-job',
|
||||
filename: 'interview.wav',
|
||||
segments_count: 4,
|
||||
duration: 30,
|
||||
job_data: JSON.stringify({ input_type: 'audio' }),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(screen.getByTestId('dub-audio-waveform-audio-job')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('dub-thumbnail-audio-job')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps malformed legacy job data renderable as a video row', () => {
|
||||
renderDubRail([
|
||||
{
|
||||
id: 'legacy-job',
|
||||
filename: 'legacy.mp4',
|
||||
segments_count: 1,
|
||||
duration: 5,
|
||||
job_data: '{not-json',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(screen.getByTestId('dub-thumbnail-legacy-job')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,32 +36,61 @@ export default function DubHeader({
|
||||
pipelineSteps,
|
||||
onPipelineStep,
|
||||
}) {
|
||||
const generateLabel =
|
||||
multiLangMode && multiLangs.length > 1
|
||||
? t('dub.generate_dub_multi', {
|
||||
count: multiLangs.length,
|
||||
defaultValue: 'Generate {{count}} dubs',
|
||||
})
|
||||
: t('dub.generate_dub');
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-[2px] min-w-0 px-[10px] py-[4px] shrink-0 bg-[var(--color-bg-elev-1)] rounded-md mb-[2px]">
|
||||
{/* Row 1: project title (left) + actions (right). Row 2: the pipeline
|
||||
spine (Upload → … → Export) sits directly under the title with a
|
||||
tight 2px gap — title-first, owner-requested order. */}
|
||||
<div className="flex flex-wrap justify-between items-center gap-x-[var(--space-2)] gap-y-[4px] min-w-0">
|
||||
<div className="label-row dub-head__title !gap-[6px]">
|
||||
<FileText className="label-icon" size={11} />
|
||||
<span className="font-medium text-[0.78rem] min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-fg normal-case">
|
||||
<div
|
||||
className="dub-command-bar"
|
||||
data-testid="dub-command-bar"
|
||||
role="toolbar"
|
||||
aria-label={t('dub.video_dubbing_studio')}
|
||||
>
|
||||
<div className="dub-command-bar__identity">
|
||||
<span className="dub-command-bar__file" aria-hidden="true">
|
||||
<FileText size={13} />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="dub-command-bar__title" title={dubFilename}>
|
||||
{dubFilename}
|
||||
</span>
|
||||
<span className="text-fg-muted font-normal whitespace-nowrap text-[0.68rem] normal-case shrink-0">
|
||||
· {formatTime(dubDuration)} · {dubSegments.length} {t('dub.segs')}
|
||||
</span>
|
||||
{activeProjectName && activeProjectName !== dubFilename && (
|
||||
<span className="text-[#b8bb26] ml-[var(--space-2)] whitespace-nowrap text-[0.68rem] normal-case overflow-hidden text-ellipsis min-w-0">
|
||||
— {activeProjectName}
|
||||
</div>
|
||||
<div className="dub-command-bar__meta">
|
||||
<span>{formatTime(dubDuration)}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>
|
||||
{dubSegments.length} {t('dub.segs')}
|
||||
</span>
|
||||
)}
|
||||
{activeProjectName && activeProjectName !== dubFilename && (
|
||||
<>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span className="dub-command-bar__project" title={activeProjectName}>
|
||||
{activeProjectName}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-[6px] items-center shrink-0">
|
||||
{/* Icon-only secondary actions (tooltips carry the labels);
|
||||
Generate Dub keeps its label as the primary verb. */}
|
||||
</div>
|
||||
|
||||
<DubPipelineStepper
|
||||
dubStep={dubStep}
|
||||
inline
|
||||
variant="command"
|
||||
selectableSteps={pipelineSteps}
|
||||
onStepSelect={onPipelineStep}
|
||||
/>
|
||||
|
||||
<div className="dub-command-bar__actions">
|
||||
<div className="dub-command-bar__utilities">
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
className="!size-[26px] !p-0"
|
||||
onClick={saveProject}
|
||||
title={t('dub.save')}
|
||||
aria-label={t('dub.save')}
|
||||
@@ -71,99 +100,85 @@ export default function DubHeader({
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
className="!size-[26px] !p-0"
|
||||
onClick={resetDub}
|
||||
title={t('dub.reset')}
|
||||
aria-label={t('dub.reset')}
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
</Button>
|
||||
{/* Primary actions live on the header bar (compact) — moved up from the footer. */}
|
||||
<div className="flex gap-[6px] items-center pl-[var(--space-2)] ml-[2px]">
|
||||
{dubStep === 'stopping' ? (
|
||||
<FooterBtn
|
||||
sm
|
||||
tone="stopping"
|
||||
disabled
|
||||
icon={<Loader className="spinner" size={9} />}
|
||||
label={t('dub.stopping')}
|
||||
/>
|
||||
) : dubStep === 'generating' ? (
|
||||
<FooterBtn
|
||||
sm
|
||||
tone="danger"
|
||||
onClick={handleDubStop}
|
||||
icon={<Square size={9} />}
|
||||
label={t('dub.stop_progress', {
|
||||
current: dubProgress.current,
|
||||
total: dubProgress.total,
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<FooterBtn
|
||||
sm
|
||||
tone={dubSegments.length && !isTranslating ? 'pink' : 'idle'}
|
||||
onClick={onGenerateClick}
|
||||
// The multi-language batch translates between generates while
|
||||
// dubStep briefly sits back at 'editing' — keep the CTA inert
|
||||
// during that phase so a re-click can't start a second batch.
|
||||
disabled={!dubSegments.length || isTranslating}
|
||||
icon={<Play size={11} />}
|
||||
label={
|
||||
multiLangMode && multiLangs.length > 1
|
||||
? t('dub.generate_dub_multi', {
|
||||
count: multiLangs.length,
|
||||
defaultValue: 'Generate {{count}} dubs',
|
||||
})
|
||||
: t('dub.generate_dub')
|
||||
}
|
||||
/>
|
||||
{dubStep === 'done' && incrementalPlan && incrementalPlan.stale?.length > 0 && (
|
||||
<FooterBtn
|
||||
sm
|
||||
tone="pink"
|
||||
onClick={() =>
|
||||
handleDubGenerate({ regenOnly: incrementalPlan.stale, preview: true })
|
||||
}
|
||||
icon={<Play size={11} />}
|
||||
label={t('dub.regen_changed', { count: incrementalPlan.stale.length })}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{dubStep === 'done' && (
|
||||
<FooterBtn
|
||||
sm
|
||||
tone="idle"
|
||||
disabled={qcRunning || !dubSegments.length}
|
||||
onClick={handleDubQc}
|
||||
icon={
|
||||
qcRunning ? <Loader className="spinner" size={11} /> : <ShieldCheck size={11} />
|
||||
}
|
||||
title={t('dub.qc_btn', { defaultValue: 'Verify dub timing (second-pass check)' })}
|
||||
aria-label={t('dub.qc_btn', {
|
||||
defaultValue: 'Verify dub timing (second-pass check)',
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{dubStep === 'stopping' ? (
|
||||
<FooterBtn
|
||||
sm
|
||||
tone="stopping"
|
||||
disabled
|
||||
className="dub-command-bar__primary"
|
||||
icon={<Loader className="spinner" size={10} />}
|
||||
label={t('dub.stopping')}
|
||||
/>
|
||||
) : dubStep === 'generating' ? (
|
||||
<FooterBtn
|
||||
sm
|
||||
tone="danger"
|
||||
className="dub-command-bar__primary"
|
||||
onClick={handleDubStop}
|
||||
icon={<Square size={9} />}
|
||||
label={t('dub.stop_progress', {
|
||||
current: dubProgress.current,
|
||||
total: dubProgress.total,
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<FooterBtn
|
||||
sm
|
||||
tone={dubStep === 'done' ? 'green' : 'idle'}
|
||||
disabled={dubStep !== 'done' && !dubSegments.length}
|
||||
onClick={() => setExportOpen(true)}
|
||||
icon={<Download size={12} />}
|
||||
title={t('dub.export_btn')}
|
||||
aria-label={t('dub.export_btn')}
|
||||
tone={dubSegments.length && !isTranslating ? 'pink' : 'idle'}
|
||||
className="dub-command-bar__primary"
|
||||
onClick={onGenerateClick}
|
||||
disabled={!dubSegments.length || isTranslating}
|
||||
icon={<Play size={11} />}
|
||||
label={generateLabel}
|
||||
aria-label={generateLabel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{dubStep === 'done' && incrementalPlan && incrementalPlan.stale?.length > 0 && (
|
||||
<FooterBtn
|
||||
sm
|
||||
tone="pink"
|
||||
onClick={() =>
|
||||
handleDubGenerate({ regenOnly: incrementalPlan.stale, preview: true })
|
||||
}
|
||||
icon={<Play size={11} />}
|
||||
label={t('dub.regen_changed', { count: incrementalPlan.stale.length })}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{dubStep === 'done' && (
|
||||
<FooterBtn
|
||||
sm
|
||||
tone="idle"
|
||||
disabled={qcRunning || !dubSegments.length}
|
||||
onClick={handleDubQc}
|
||||
icon={qcRunning ? <Loader className="spinner" size={11} /> : <ShieldCheck size={11} />}
|
||||
title={t('dub.qc_btn', { defaultValue: 'Verify dub timing (second-pass check)' })}
|
||||
aria-label={t('dub.qc_btn', {
|
||||
defaultValue: 'Verify dub timing (second-pass check)',
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
<FooterBtn
|
||||
sm
|
||||
tone={dubStep === 'done' ? 'green' : 'idle'}
|
||||
disabled={dubStep !== 'done' && !dubSegments.length}
|
||||
onClick={() => setExportOpen(true)}
|
||||
icon={<Download size={12} />}
|
||||
title={t('dub.export_btn')}
|
||||
aria-label={t('dub.export_btn')}
|
||||
/>
|
||||
</div>
|
||||
<DubPipelineStepper
|
||||
dubStep={dubStep}
|
||||
inline
|
||||
selectableSteps={pipelineSteps}
|
||||
onStepSelect={onPipelineStep}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import i18n from '../../i18n';
|
||||
import DubHeader from './DubHeader';
|
||||
|
||||
describe('DubHeader command bar', () => {
|
||||
it('keeps project identity, compact pipeline, and batch action in one production bar', () => {
|
||||
render(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<DubHeader
|
||||
t={i18n.t.bind(i18n)}
|
||||
dubFilename="trying the WORLD'S SMALLEST curling iron."
|
||||
dubDuration={63.6}
|
||||
dubSegments={Array.from({ length: 14 }, (_, id) => ({ id: String(id) }))}
|
||||
activeProjectName=""
|
||||
saveProject={vi.fn()}
|
||||
resetDub={vi.fn()}
|
||||
dubStep="editing"
|
||||
handleDubStop={vi.fn()}
|
||||
dubProgress={{ current: 0, total: 14 }}
|
||||
onGenerateClick={vi.fn()}
|
||||
isTranslating={false}
|
||||
multiLangMode
|
||||
multiLangs={Array.from({ length: 16 }, (_, id) => ({ code: `l${id}` }))}
|
||||
incrementalPlan={null}
|
||||
handleDubGenerate={vi.fn()}
|
||||
qcRunning={false}
|
||||
handleDubQc={vi.fn()}
|
||||
setExportOpen={vi.fn()}
|
||||
pipelineSteps={[]}
|
||||
onPipelineStep={vi.fn()}
|
||||
/>
|
||||
</I18nextProvider>,
|
||||
);
|
||||
|
||||
const bar = screen.getByTestId('dub-command-bar');
|
||||
expect(within(bar).getByText("trying the WORLD'S SMALLEST curling iron.")).toBeInTheDocument();
|
||||
expect(within(bar).getByText('1:03.6')).toBeInTheDocument();
|
||||
expect(within(bar).getByText(/14 segs/i)).toBeInTheDocument();
|
||||
expect(within(bar).getByRole('list', { name: 'Dubbing pipeline' })).toHaveClass(
|
||||
'dub-stepper--command',
|
||||
);
|
||||
expect(within(bar).getByRole('button', { name: /Generate 16 dubs/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -32,7 +32,13 @@ const DUB_PHASE_BY_STEP = {
|
||||
done: 5,
|
||||
};
|
||||
|
||||
function DubPipelineStepper({ dubStep, inline = false, selectableSteps = [], onStepSelect }) {
|
||||
function DubPipelineStepper({
|
||||
dubStep,
|
||||
inline = false,
|
||||
variant,
|
||||
selectableSteps = [],
|
||||
onStepSelect,
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const current = DUB_PHASE_BY_STEP[dubStep] ?? 0;
|
||||
const busy =
|
||||
@@ -43,7 +49,13 @@ function DubPipelineStepper({ dubStep, inline = false, selectableSteps = [], onS
|
||||
dubStep === 'stopping';
|
||||
return (
|
||||
<div
|
||||
className={inline ? 'dub-stepper dub-stepper--inline' : 'dub-stepper'}
|
||||
className={[
|
||||
'dub-stepper',
|
||||
inline ? 'dub-stepper--inline' : '',
|
||||
variant === 'command' ? 'dub-stepper--command' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
role="list"
|
||||
aria-label={t('dub.pipeline', { defaultValue: 'Dubbing pipeline' })}
|
||||
>
|
||||
|
||||
@@ -3827,6 +3827,86 @@ body:has(.capture-pill) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* One compact hierarchy for project identity, pipeline position, and the
|
||||
primary batch action. */
|
||||
.dub-command-bar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(190px, 1fr) auto minmax(max-content, 1fr);
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
min-height: 44px;
|
||||
flex: 0 0 auto;
|
||||
margin-bottom: 2px;
|
||||
padding: 5px 7px;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--color-bg-elev-1) 94%, var(--chrome-bg));
|
||||
box-shadow: inset 0 -1px 0 color-mix(in srgb, var(--chrome-fg) 6%, transparent);
|
||||
}
|
||||
.dub-command-bar__identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
}
|
||||
.dub-command-bar__file {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 7px;
|
||||
color: var(--chrome-accent);
|
||||
background: var(--chrome-accent-bg);
|
||||
}
|
||||
.dub-command-bar__title {
|
||||
overflow: hidden;
|
||||
color: var(--chrome-fg);
|
||||
font-size: 0.73rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.15;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dub-command-bar__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
margin-top: 2px;
|
||||
color: var(--chrome-fg-dim);
|
||||
font-family: var(--chrome-font-mono);
|
||||
font-size: 0.58rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
}
|
||||
.dub-command-bar__project {
|
||||
overflow: hidden;
|
||||
color: var(--chrome-severity-ok);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dub-command-bar__actions,
|
||||
.dub-command-bar__utilities {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.dub-command-bar__actions { justify-content: flex-end; min-width: 0; }
|
||||
.dub-command-bar__utilities {
|
||||
margin-right: 2px;
|
||||
padding-right: 6px;
|
||||
border-right: 1px solid var(--chrome-border);
|
||||
}
|
||||
.dub-command-bar__primary {
|
||||
flex: 0 0 auto;
|
||||
min-height: 28px;
|
||||
padding-inline: 11px !important;
|
||||
font-size: 0.66rem !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Column flex that fills height — overrides .studio-panel overflow ── */
|
||||
.dub-panel-col {
|
||||
margin-bottom: 0;
|
||||
@@ -3940,6 +4020,61 @@ button.dub-stepper__action:focus-visible {
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
/* The active stage owns the emphasis; other stages remain quiet and dense. */
|
||||
.dub-stepper--command {
|
||||
gap: 1px;
|
||||
padding: 3px;
|
||||
border-radius: 7px;
|
||||
background: color-mix(in srgb, var(--chrome-bg) 58%, transparent);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--chrome-fg) 5%, transparent);
|
||||
}
|
||||
.dub-stepper--command .dub-stepper__step { gap: 0; font-size: 0.6rem; }
|
||||
.dub-stepper--command .dub-stepper__step:not(:first-child)::before { display: none; }
|
||||
.dub-stepper--command .dub-stepper__action {
|
||||
gap: 4px;
|
||||
min-height: 24px;
|
||||
padding: 3px 6px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.dub-stepper--command .dub-stepper__icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
.dub-stepper--command .dub-stepper__icon svg { width: 11px; height: 11px; }
|
||||
.dub-stepper--command .dub-stepper__step.is-done .dub-stepper__icon {
|
||||
color: var(--chrome-severity-ok);
|
||||
background: transparent;
|
||||
}
|
||||
.dub-stepper--command .dub-stepper__step.is-active .dub-stepper__action {
|
||||
color: var(--chrome-fg);
|
||||
background: var(--chrome-accent-bg);
|
||||
box-shadow: inset 0 0 0 1px var(--chrome-accent-border);
|
||||
}
|
||||
.dub-stepper--command .dub-stepper__step.is-active .dub-stepper__icon {
|
||||
border: 0;
|
||||
color: var(--chrome-accent);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.shell-narrow .dub-command-bar,
|
||||
.shell-mini .dub-command-bar {
|
||||
grid-template-columns: minmax(0, 1fr) max-content;
|
||||
gap: 5px 8px;
|
||||
}
|
||||
.shell-narrow .dub-stepper--command,
|
||||
.shell-mini .dub-stepper--command {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 2;
|
||||
justify-content: stretch;
|
||||
}
|
||||
.shell-narrow .dub-stepper--command .dub-stepper__step,
|
||||
.shell-mini .dub-stepper--command .dub-stepper__step { flex: 1 1 auto; justify-content: center; }
|
||||
.shell-mini .dub-stepper--command .dub-stepper__step:not(.is-active) .dub-stepper__label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Idle / drop-zone ─────────────────────────────────────────────────── */
|
||||
.dub-idle-drop {
|
||||
flex: 1;
|
||||
@@ -4276,6 +4411,16 @@ button.dub-stepper__action:focus-visible {
|
||||
background: var(--chrome-bg);
|
||||
}
|
||||
|
||||
/* Dub projects stay compact while media history keeps the wider preview rail. */
|
||||
.studio-projects {
|
||||
flex: 0 0 240px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
border-right: 1px solid var(--chrome-border);
|
||||
background: var(--chrome-bg);
|
||||
}
|
||||
|
||||
/* Panel chrome (.wh / .wh__*) moved to Tailwind utilities on WorkspaceHistory.jsx. */
|
||||
|
||||
/* On a narrow shell the right column drops below the definition column. Same
|
||||
@@ -4289,6 +4434,15 @@ button.dub-stepper__action:focus-visible {
|
||||
max-height: 60vh;
|
||||
}
|
||||
|
||||
.shell-narrow .studio-projects,
|
||||
.shell-mini .studio-projects {
|
||||
flex: 0 0 auto;
|
||||
order: 2;
|
||||
border-right: none;
|
||||
border-top: 1px solid var(--chrome-border);
|
||||
max-height: 60vh;
|
||||
}
|
||||
|
||||
/* ═══ from src/components/WorkspaceVoices.css ═══ */
|
||||
/* WorkspaceVoices — right-side "Saved voices" panel. Row internals reuse the
|
||||
shared .history-* classes (bundled app-wide); this file styles the panel
|
||||
|
||||
@@ -30,6 +30,7 @@ const raw = indexRaw.slice(startIdx, endIdx);
|
||||
// Strip /* … */ comments so the guard checks real declarations, not the
|
||||
// warning comment that quotes the forbidden `@media (max-width)` pattern.
|
||||
const css = raw.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
const app = readFileSync(resolve(process.cwd(), 'src/App.jsx'), 'utf8');
|
||||
|
||||
describe('workspace narrow-shell reflow (#476 CTA-clipping guard)', () => {
|
||||
it('does NOT use a raw viewport @media (max-width) query', () => {
|
||||
@@ -44,4 +45,10 @@ describe('workspace narrow-shell reflow (#476 CTA-clipping guard)', () => {
|
||||
it('pins the action bar (Synthesize CTA) sticky so it stays on-screen', () => {
|
||||
expect(css).toMatch(/\.studio-action-bar\s*\{[^}]*position:\s*sticky/s);
|
||||
});
|
||||
|
||||
it('gives Dub Projects its own narrower rail than Dub History', () => {
|
||||
expect(app).toMatch(/className="studio-projects">\s*<WorkspaceProjects/);
|
||||
expect(css).toMatch(/\.studio-projects\s*\{[^}]*flex:\s*0 0 240px/s);
|
||||
expect(css).toMatch(/\.studio-right\s*\{[^}]*flex:\s*0 0 340px/s);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user