feat(ui): chrome theme + retina typography + launchpad charisma + export drawer + setup wizard + projects + logs footer

## Typography system
- Add Fontsource variable fonts: Inter, IBM Plex Mono, Source Serif 4
- Unify via --font-sans / --font-mono / --font-serif / --font-display
  tokens in ui/tokens.css
- Enable OpenType features globally: cv11 (single-story a), ss01, ss03,
  zero, tabular-nums slashed-zero; font-optical-sizing: auto;
  font-synthesis: none
- Alias legacy --chrome-font-mono to var(--font-mono); purge Google Fonts
  @import and hardcoded "ui-monospace, Menlo" refs across 43+ rules

## Chrome design tokens
- Migrate every panel/button/input to --chrome-* tokens with
  color-mix(in srgb, … N%, transparent) tone tints
- Flat-chromed .glass-panel, .studio-panel, .main-content, .launchpad,
  sidebar, segments, clone/design textarea, settings subsurfaces,
  export drawer, footer, modals
- Kill radial gradients, paper-grain noise, 30s mesh animation
- Rewrite Badge.css, Button.css, Tabs.css, Table.css to chrome tokens

## Launchpad charisma
- Aurora backdrop: three drifting blurred blobs (pink 22s, green 28s,
  amber 32s) behind everything at z=0
- Hero halo + animated sweep line under H1
- Wave bars with per-bar --bar-delay / --bar-dur for breathing stagger
- Action cards: single --card-hue drives bg/border/glow/spotlight
- Cursor-tracked spotlight via onMouseMove → --mx/--my custom props
  → radial-gradient paint (no JS re-renders)
- Eternal breath ring via .lp-glow-layer::after, staggered across
  three cards via :nth-child animation-delays
- All animations gated by @media (prefers-reduced-motion: reduce)

## Export drawer (new)
- ExportModal.jsx/css: 4-tab bottom drawer (Video / Audio / Subtitles
  / Package) rendered via createPortal
- Presets (YouTube, Archive, Web, Podcast, Study), per-track checklist
  with All/None/Dubs-only, MP3 bitrate 128/192/256/320
- Non-blocking: pointer-events: none on outer, auto on sheet; ESC + click
  outside to close
- DubTab: FooterBtn → React.forwardRef; export trigger opens drawer

## Dubbing ETA
- Show elapsed time during generation; genElapsed state + fmtDur helper
  renders .dub-gen-overlay__stats block

## Setup wizard (new)
- SetupWizard.jsx/css: first-run flow for engine probing, model downloads
- Projects.jsx/css: dedicated projects browser page
- LogsFooter.jsx/css: slide-up logs panel
- api/setup.ts: client for setup router endpoints
- App.jsx, NavRail, Header, store wired to new pages

## Version
- frontend/package.json 0.2.0; refresh bun.lock

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
debpalash
2026-04-22 17:49:44 +05:30
co-authored by Claude Opus 4.7
parent a44167f22f
commit e1966bdcd9
38 changed files with 5098 additions and 1220 deletions
+15
View File
@@ -15,7 +15,12 @@
"name": "omnivoice-studio",
"version": "0.0.0",
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@fontsource-variable/source-serif-4": "^5.2.9",
"@fontsource/ibm-plex-mono": "^5.2.7",
"@tauri-apps/plugin-dialog": "^2.7.0",
"@tauri-apps/plugin-process": "^2.3.0",
"@tauri-apps/plugin-updater": "^2.9.0",
"@tauri-apps/plugin-window-state": "^2.4.1",
"lucide-react": "^1.8.0",
"react": "^19.2.5",
@@ -96,6 +101,12 @@
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="],
"@fontsource-variable/inter": ["@fontsource-variable/inter@5.2.8", "", {}, "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ=="],
"@fontsource-variable/source-serif-4": ["@fontsource-variable/source-serif-4@5.2.9", "", {}, "sha512-PPcxjLFk/fS0WHg79pDM2YNvz61kC+oYZ5cWZZyCS0DHpJncmuYOuiZAsvj4tDxlWPBEvxxcRLQQNmSaRbPkqw=="],
"@fontsource/ibm-plex-mono": ["@fontsource/ibm-plex-mono@5.2.7", "", {}, "sha512-MKAb8qV+CaiMQn2B0dIi1OV3565NYzp3WN5b4oT6LTkk+F0jR6j0ZN+5BKJiIhffDC3rtBULsYZE65+0018z9w=="],
"@hapi/address": ["@hapi/address@5.1.1", "", { "dependencies": { "@hapi/hoek": "^11.0.2" } }, "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA=="],
"@hapi/formula": ["@hapi/formula@3.0.2", "", {}, "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw=="],
@@ -192,6 +203,10 @@
"@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.7.0", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-4nS/hfGMGCXiAS3LtVjH9AgsSAPJeG/7R+q8agTFqytjnMa4Zq95Bq8WzVDkckpanX+yyRHXnRtrKXkANKDHvw=="],
"@tauri-apps/plugin-process": ["@tauri-apps/plugin-process@2.3.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA=="],
"@tauri-apps/plugin-updater": ["@tauri-apps/plugin-updater@2.10.1", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA=="],
"@tauri-apps/plugin-window-state": ["@tauri-apps/plugin-window-state@2.4.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-OuvdrzyY8Q5Dbzpj+GcrnV1iCeoZbcFdzMjanZMMcAEUNy/6PH5pxZPXpaZLOR7whlzXiuzx0L9EKZbH7zpdRw=="],
"@turbo/darwin-64": ["@turbo/darwin-64@2.9.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-X/56SnVXIQZBLKwniGTwEQTGmtE5brSACnKMBWpY3YafuxVYefrC2acamfjgxP7BG5w3I+6jf0UrLoSzgPcSJg=="],
+6 -1
View File
@@ -1,7 +1,7 @@
{
"name": "omnivoice-studio",
"private": true,
"version": "0.0.0",
"version": "0.2.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -12,7 +12,12 @@
"preview": "vite preview"
},
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@fontsource-variable/source-serif-4": "^5.2.9",
"@fontsource/ibm-plex-mono": "^5.2.7",
"@tauri-apps/plugin-dialog": "^2.7.0",
"@tauri-apps/plugin-process": "^2.3.0",
"@tauri-apps/plugin-updater": "^2.9.0",
"@tauri-apps/plugin-window-state": "^2.4.1",
"lucide-react": "^1.8.0",
"react": "^19.2.5",
+193 -8
View File
@@ -15,7 +15,10 @@ const Settings = lazy(() => import('./pages/Settings'));
const VoiceProfile = lazy(() => import('./pages/VoiceProfile'));
const BatchQueue = lazy(() => import('./pages/BatchQueue'));
const ToolsPage = lazy(() => import('./pages/ToolsPage'));
const SetupWizard = lazy(() => import('./pages/SetupWizard'));
const KeyboardCheatsheet = lazy(() => import('./components/KeyboardCheatsheet'));
const LogsFooter = lazy(() => import('./components/LogsFooter'));
const ProjectsPage = lazy(() => import('./pages/Projects'));
import Header from './components/Header';
import NavRail from './components/NavRail';
import ErrorBoundary from './components/ErrorBoundary';
@@ -188,7 +191,7 @@ function App() {
const openVoiceProfile = useAppStore(s => s.openVoiceProfile);
const closeVoiceProfile = useAppStore(s => s.closeVoiceProfile);
const hideSidebar = mode === 'launchpad' || mode === 'settings' || mode === 'voice'
|| mode === 'queue' || mode === 'tools';
|| mode === 'queue' || mode === 'tools' || mode === 'projects';
const availableSidebarTabs = mode === 'dub'
? ['projects', 'history', 'downloads']
: (mode === 'clone' || mode === 'design')
@@ -332,6 +335,7 @@ function App() {
const glossaryTerms = useAppStore(s => s.glossaryTerms);
const setGlossaryTerms = useAppStore(s => s.setGlossaryTerms);
const dualSubs = useAppStore(s => s.dualSubs);
const burnSubs = useAppStore(s => s.burnSubs);
const setDualSubs = useAppStore(s => s.setDualSubs);
const [translateProvider, setTranslateProvider] = useState('argos');
@@ -538,10 +542,78 @@ function App() {
// ── LOAD DATA FROM SERVER ──
const [sysStats, setSysStats] = useState(null);
// First-run gate — `/setup/status` reports whether required HF models are
// on disk. If not, we render <SetupWizard> in place of the main studio so
// the user actually SEES the download instead of a silent 5 GB hang.
//
// Packaged .app note: the frozen backend sidecar takes several seconds to
// import torch/torchaudio/whisper/etc. before it can serve /setup/status.
// A single fetch on mount lands during that window, fails, and the wizard
// would never render. So we retry with backoff until we get a response or
// the user gives up. `setupChecked` gates main-UI render so we don't flash
// the studio in front of a user who actually needs the wizard.
const [setupNeeded, setSetupNeeded] = useState(false);
const [setupChecked, setSetupChecked] = useState(false);
useEffect(() => {
let cancelled = false;
(async () => {
const { setupStatus } = await import('./api/setup');
// ~30 attempts × ~1s ≈ 30s ceiling; enough for a cold sidecar on slow disks.
for (let attempt = 0; attempt < 30 && !cancelled; attempt++) {
try {
const s = await setupStatus();
if (cancelled) return;
setSetupNeeded(!s.models_ready);
setSetupChecked(true);
return;
} catch {
await new Promise(r => setTimeout(r, 1000));
}
}
if (!cancelled) setSetupChecked(true);
})();
return () => { cancelled = true; };
}, []);
// ── Tauri auto-updater ──
// On boot, ask GitHub Releases if a newer build is available. If yes,
// prompt the user, download the signed bundle, restart into the new
// version. Only runs in packaged .app (not `tauri dev`) — the updater
// endpoint 404s until the first signed release is published, and we
// don't want that noise in the dev console.
useEffect(() => {
if (typeof window === 'undefined') return;
if (!('__TAURI_INTERNALS__' in window)) return;
if (import.meta.env.DEV) return;
let cancelled = false;
(async () => {
try {
const [{ check }, { relaunch }, { ask }] = await Promise.all([
import('@tauri-apps/plugin-updater'),
import('@tauri-apps/plugin-process'),
import('@tauri-apps/plugin-dialog'),
]);
const update = await check();
if (cancelled || !update) return;
const proceed = await ask(
`A new version (${update.version}) of OmniVoice Studio is available.\n\nWhat's new:\n${update.body || '— see release notes'}\n\nDownload and install now?`,
{ title: 'Update available', kind: 'info' },
);
if (!proceed) return;
await update.downloadAndInstall();
await relaunch();
} catch (e) {
// eslint-disable-next-line no-console
console.debug('Updater check failed (non-fatal):', e);
}
})();
return () => { cancelled = true; };
}, []);
// ── DESKTOP NATIVE INTEGRATION ──
useEffect(() => {
if (typeof window === 'undefined') return;
// 1. Prevent default right-click to hide web nature
const handleContextMenu = (e) => {
// allow on inputs/textareas for copy/paste
@@ -1265,7 +1337,7 @@ function App() {
}
};
const handleDubIngestUrl = async (url) => {
const handleDubIngestUrl = async (url, opts = {}) => {
const clean = (url || '').trim();
if (!clean) return;
setDubStep('uploading'); setDubError(''); setDubTracks([]); setDubPrepStage('download');
@@ -1275,7 +1347,11 @@ function App() {
dubClientJobIdRef.current = clientJobId;
setDubJobId(clientJobId);
try {
const data = await dubIngestUrl(clean, clientJobId, { signal: ctrl.signal });
const data = await dubIngestUrl(clean, clientJobId, {
signal: ctrl.signal,
fetchSubs: !!opts.fetchSubs,
subLangs: opts.subLangs,
});
setDubJobId(data.job_id);
setDubTaskId(data.task_id);
await _waitForPrep(data.task_id, ctrl);
@@ -1313,6 +1389,36 @@ function App() {
}
};
// Retry transcribe on an existing job — the video + demucs + scene-cut
// preprocessing is already on disk, so we skip straight back to the ASR
// stream. Used by the "Retry" button on the transcribe-failed banner.
const handleDubRetryTranscribe = async () => {
if (!dubJobId) return;
const ctrl = new AbortController();
dubAbortCtrlRef.current = ctrl;
setDubError('');
setDubSegments([]);
setDubStep('transcribing');
setTranscribeStart(Date.now());
try {
await _waitForTranscribe(dubJobId, ctrl);
setTranscribeStart(null);
setDubStep('editing');
} catch (err) {
setTranscribeStart(null);
if (err.name === 'AbortError') {
toast('Retry cancelled');
setDubStep('idle');
} else {
setDubError(err.message);
setDubStep('idle');
toast.error('Transcription failed: ' + err.message);
}
} finally {
dubAbortCtrlRef.current = null;
}
};
// Transcription elapsed timer
useEffect(() => {
if (!transcribeStart) { setTranscribeElapsed(0); return; }
@@ -1533,12 +1639,11 @@ function App() {
if (!destPath) return; // User cancelled
await exportAction({ source_filename: sourceIdentifier, destination_path: destPath, mode });
const data = await res.json();
toast.success(`Exported: ${fallbackName}`);
loadExportHistory();
} catch (err) {
console.error(err);
toast.error('Failed to bridge save dialog to rust/python backend.');
toast.error(`Export failed: ${err?.message || err}`);
}
};
const revealInFolder = async (filePath) => {
@@ -1633,7 +1738,8 @@ function App() {
if (exportTracks['original'] !== false) selected.push('original');
dubTracks.forEach(t => { if (exportTracks[t] !== false) selected.push(t); });
const tracksParam = selected.join(',');
triggerDownload(`${API}/dub/download/${dubJobId}/dubbed_video.mp4?preserve_bg=${preserveBg}&default_track=${defaultTrack}&include_tracks=${encodeURIComponent(tracksParam)}`, 'dubbed_video.mp4');
const burnParam = burnSubs ? `&burn_subs=1&dual=${dualSubs ? 1 : 0}` : '';
triggerDownload(`${API}/dub/download/${dubJobId}/dubbed_video.mp4?preserve_bg=${preserveBg}&default_track=${defaultTrack}&include_tracks=${encodeURIComponent(tracksParam)}${burnParam}`, 'dubbed_video.mp4');
};
const handleDubAudioDownload = async () => {
await finalizeTtsBeforeExport();
@@ -1786,6 +1892,66 @@ function App() {
};
// First-run gate: if /setup/status says models aren't on disk yet, render
// the wizard instead of the main studio. Dismisses itself once the user
// completes the download (or clicks "Skip" if they want to limp along).
// Also blocks render until we've heard back from the backend at least once
// — the frozen sidecar's cold-start import is ~5-10 s and without this we
// flash the empty studio before the wizard has a chance to mount.
if (!setupChecked) {
return (
<div className="app-container sidebar-hidden" style={{ zoom: uiScale, display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', flexDirection: 'column', gap: 12, color: '#a89984', fontSize: 13 }}>
<div style={{ fontSize: 18, color: '#ebdbb2' }}>OmniVoice Studio</div>
<div>Starting backend</div>
</div>
);
}
if (setupNeeded) {
// Render outside the `app-container` grid so the wizard spans the full
// viewport instead of getting squeezed into whatever grid cell the
// studio layout reserves for the main content column.
return (
<div
style={{
/* Same pattern as .app-container: shrink by whatever the
LogsFooter is currently occupying so it never covers the
wizard footer buttons / content. */
minHeight: 'calc(100vh - var(--logs-footer-height, 28px))',
maxHeight: 'calc(100vh - var(--logs-footer-height, 28px))',
width: '100%',
overflow: 'auto',
zoom: uiScale,
background: 'var(--color-bg, #1d2021)',
position: 'relative',
}}
>
{/* Invisible drag strip across the top 28 px of the wizard —
matches the macOS traffic-light zone so the window can be
dragged / double-click-zoomed from anywhere along the top. */}
<div
data-tauri-drag-region
onDoubleClick={() => {
if ('__TAURI_INTERNALS__' in window) {
import('@tauri-apps/api/window').then(m =>
m.getCurrentWindow().toggleMaximize()
).catch(() => {});
}
}}
style={{
position: 'fixed', top: 0, left: 0, right: 0,
height: 28, zIndex: 10,
}}
/>
<Suspense fallback={<LazyFallback />}>
<SetupWizard onReady={() => setSetupNeeded(false)} />
</Suspense>
<Suspense fallback={null}>
<LogsFooter />
</Suspense>
</div>
);
}
return (
<div
className={[
@@ -1814,7 +1980,6 @@ function App() {
<Header
mode={mode} setMode={setMode}
uiScale={uiScale} setUiScale={setUiScale}
sysStats={sysStats} modelStatus={modelStatus}
doubleClickMaximize={doubleClickMaximize}
activeProjectName={activeProjectName}
@@ -1863,6 +2028,20 @@ function App() {
<ToolsPage onBack={() => setMode('launchpad')} />
</Suspense>
</ErrorBoundary>
) : mode === 'projects' ? (
<ErrorBoundary name="projects">
<Suspense fallback={<LazyFallback />}>
<ProjectsPage
studioProjects={studioProjects}
profiles={profiles}
history={history}
exportHistory={exportHistory}
onOpenDub={(id) => { loadProject(id); setMode('dub'); }}
onOpenProfile={(id) => { openVoiceProfile(id); }}
onRevealExport={(path) => { exportReveal({ path }).catch(() => {}); }}
/>
</Suspense>
</ErrorBoundary>
) : mode === 'launchpad' ? (
<ErrorBoundary name="launchpad">
<Suspense fallback={<LazyFallback />}>
@@ -1896,6 +2075,7 @@ function App() {
setDubLocalBlobUrl={setDubLocalBlobUrl}
// Handlers close over App.jsx scope so stay prop-threaded.
handleDubAbort={handleDubAbort} handleDubUpload={handleDubUpload} handleDubIngestUrl={handleDubIngestUrl}
handleDubRetryTranscribe={handleDubRetryTranscribe}
handleDubStop={handleDubStop} handleDubGenerate={handleDubGenerate}
handleDubDownload={handleDubDownload} handleDubAudioDownload={handleDubAudioDownload}
handleAudioExport={handleAudioExport}
@@ -2033,6 +2213,11 @@ function App() {
</Suspense>
)}
{/* ═══ BOTTOM LOGS PANEL (VSCode-style) ═══ */}
<Suspense fallback={null}>
<LogsFooter />
</Suspense>
</div>
);
}
+5 -1
View File
@@ -1,4 +1,8 @@
export const API = (import.meta as any).env?.DEV ? 'http://localhost:8000' : '';
// Backend always listens on localhost:8000 — both in dev (Vite @ 5173 talking
// to a separate uvicorn) and in the built .app (Tauri webview @ tauri://localhost
// talking to the bundled frozen backend sidecar). Relative fetches against
// tauri://localhost don't reach the sidecar, so we hardcode the absolute host.
export const API = 'http://localhost:8000';
export class ApiError extends Error {
status?: number;
+20 -2
View File
@@ -12,12 +12,30 @@ export async function dubUpload(
return apiPost('/dub/upload', fd, { signal });
}
export interface IngestUrlOptions {
signal?: AbortSignal;
/** Ask yt-dlp to also pull caption tracks (incl. YouTube auto-translations). */
fetchSubs?: boolean;
/** Limit caption fetch to specific lang codes; defaults to all available. */
subLangs?: string[];
}
export async function dubIngestUrl(
url: string,
jobId: string,
{ signal }: { signal?: AbortSignal } = {},
opts: IngestUrlOptions = {},
): Promise<unknown> {
return apiPost('/dub/ingest-url', { url, job_id: jobId }, { signal });
const { signal, fetchSubs, subLangs } = opts;
return apiPost(
'/dub/ingest-url',
{
url,
job_id: jobId,
fetch_subs: fetchSubs || undefined,
sub_langs: subLangs && subLangs.length ? subLangs : undefined,
},
{ signal },
);
}
export function transcribeStreamUrl(jobId: string): string {
+38 -1
View File
@@ -1,4 +1,4 @@
import { apiJson, apiPost } from './client';
import { apiJson, apiPost, apiDelete } from './client';
import type {
AllEnginesResponse,
EngineFamily,
@@ -6,6 +6,30 @@ import type {
SelectEngineResponse,
} from './types';
export interface TranslationEngine {
id: string;
display_name: string;
pip_package: string | null;
probe_module: string | null;
category: 'offline' | 'online' | 'llm';
needs_key: boolean;
builtin?: boolean;
notes?: string;
installed: boolean;
availability_reason: string;
}
export interface TranslationEnginesResponse {
engines: TranslationEngine[];
sandboxed: boolean;
}
export interface InstallEngineResponse {
status: 'installed' | 'already_installed' | 'installed_but_probe_failed' | 'uninstalled' | 'no_op';
engine: string;
package?: string;
log_tail?: string;
restart_required?: boolean;
}
export async function listEngines(): Promise<AllEnginesResponse> {
return apiJson<AllEnginesResponse>('/engines');
}
@@ -24,6 +48,19 @@ export async function selectEngine(family: EngineFamily, backendId: string): Pro
return apiPost<SelectEngineResponse>('/engines/select', { family, backend_id: backendId });
}
export async function listTranslationEngines(): Promise<TranslationEnginesResponse> {
return apiJson<TranslationEnginesResponse>('/engines/translation');
}
export async function installTranslationEngine(id: string): Promise<InstallEngineResponse> {
return apiPost<InstallEngineResponse>(`/engines/translation/${id}/install`, {});
}
export async function uninstallTranslationEngine(id: string): Promise<InstallEngineResponse> {
const res = await apiDelete(`/engines/translation/${id}`);
return (await res.json()) as InstallEngineResponse;
}
export interface JobsQuery {
status?: string;
projectId?: string;
+103
View File
@@ -0,0 +1,103 @@
import { API, apiJson, apiPost, apiFetch } from './client';
export interface MissingModel {
repo_id: string;
label: string;
}
export interface SetupStatus {
models_ready: boolean;
missing: MissingModel[];
hf_cache_dir: string;
disk_free_gb: number;
min_free_gb: number;
enough_disk: boolean;
}
export interface SetupProgressEvent {
filename: string;
downloaded: number;
total: number;
pct: number;
phase: 'start' | 'progress' | 'done';
}
export async function setupStatus(): Promise<SetupStatus> {
return apiJson<SetupStatus>('/setup/status');
}
export async function setupWarmup(): Promise<{ status: string }> {
return apiPost<{ status: string }>('/setup/warmup');
}
export function setupDownloadStreamUrl(): string {
return `${API}/setup/download-stream`;
}
// ── Model store ───────────────────────────────────────────────────────────
export interface KnownModel {
repo_id: string;
label: string;
role: 'TTS' | 'ASR' | 'Diarisation' | string;
size_gb: number;
required: boolean;
note?: string;
installed: boolean;
size_on_disk_bytes: number;
nb_files: number;
}
export interface ModelList {
models: KnownModel[];
total_installed_bytes: number;
hf_cache_dir: string;
}
export async function listModels(): Promise<ModelList> {
return apiJson<ModelList>('/models');
}
export async function installModel(repo_id: string): Promise<{ status: string; repo_id: string }> {
return apiPost('/models/install', { repo_id });
}
// ── Device-aware model recommendation ─────────────────────────────────────
export interface RecommendedModel {
repo_id: string;
label: string;
role: string;
size_gb: number;
required: boolean;
note: string | null;
installed: boolean;
}
export interface Recommendations {
device: {
os: string;
arch: string;
is_mac_arm: boolean;
is_mac_intel: boolean;
is_linux: boolean;
is_windows: boolean;
has_cuda: boolean;
label: string;
};
rationale: string;
models: RecommendedModel[];
download_gb_remaining: number;
total_gb: number;
all_installed: boolean;
}
export async function getRecommendations(): Promise<Recommendations> {
return apiJson<Recommendations>('/setup/recommendations');
}
export async function deleteModel(repo_id: string): Promise<{ deleted: boolean; repo_id: string; freed_bytes: number }> {
const r = await apiFetch(`/models/${encodeURIComponent(repo_id)}`, { method: 'DELETE' });
return r.json();
}
+24 -7
View File
@@ -50,31 +50,48 @@ export default function CheckpointBanner({ stage, count, onContinue, onDismiss,
return (
<div
className="checkpoint-banner"
style={{
// Accent shows through as a left-edge bar instead of a gradient wash,
// and the fill stays flat chrome so the banner rhymes with the rest
// of the studio strips.
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '8px 12px',
marginBottom: 6,
borderRadius: 6,
background: `linear-gradient(90deg, ${cfg.accent}12 0%, rgba(255,255,255,0.01) 60%)`,
border: `1px solid ${cfg.accent}33`,
borderRadius: 'var(--chrome-radius-pill)',
background: 'var(--chrome-bg)',
border: '1px solid var(--chrome-border)',
borderLeft: `2px solid ${cfg.accent}`,
}}
role="status"
>
<Icon size={16} color={cfg.accent} style={{ flexShrink: 0 }} />
<Icon size={14} color={cfg.accent} style={{ flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 1 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
<span style={{ fontSize: '0.72rem', fontWeight: 600, color: '#ebdbb2' }}>
<span style={{
fontFamily: 'var(--chrome-font-mono)',
fontSize: 'var(--chrome-label-size)',
letterSpacing: 'var(--chrome-label-track)',
textTransform: 'uppercase',
fontWeight: 600,
color: 'var(--chrome-fg)',
}}>
{cfg.title}
</span>
{typeof count === 'number' && (
<span style={{ fontSize: '0.6rem', color: '#a89984', fontVariantNumeric: 'tabular-nums' }}>
<span style={{
fontFamily: 'var(--chrome-font-mono)',
fontSize: 'var(--chrome-label-size)',
color: 'var(--chrome-fg-muted)',
fontVariantNumeric: 'tabular-nums',
}}>
{count} segment{count === 1 ? '' : 's'}
</span>
)}
</div>
<span style={{ fontSize: '0.6rem', color: '#a89984', lineHeight: 1.35 }}>
<span style={{ fontSize: '0.64rem', color: 'var(--chrome-fg-muted)', lineHeight: 1.35 }}>
{cfg.hint}
</span>
</div>
+98 -2
View File
@@ -1,5 +1,101 @@
/* CompareModal — only layout + content-specific styles remain here.
Base controls (Dialog, Panel, Button, Select, Textarea) come from ui/. */
/* CompareModal — non-modal bottom drawer. Leaves the app interactive;
no backdrop wash. ESC or click-outside dismisses. Mirrors
ExportModal's drawer chrome so the two read as siblings. */
.compare-drawer {
position: fixed;
left: 0;
right: 0;
bottom: var(--logs-footer-height, 28px);
z-index: 90;
display: flex;
justify-content: center;
pointer-events: none;
}
.compare-drawer__sheet {
pointer-events: auto;
width: min(880px, calc(100vw - 24px));
max-height: min(80vh, 640px);
display: flex;
flex-direction: column;
background: var(--chrome-bg);
border: 1px solid var(--chrome-border-strong);
border-bottom: none;
border-radius: 8px 8px 0 0;
box-shadow:
0 -8px 24px rgba(0, 0, 0, 0.45),
0 -1px 0 var(--chrome-border) inset;
animation: compare-drawer-slide-up var(--dur-base, 180ms) var(--ease-out, cubic-bezier(.2,.8,.2,1));
overflow: hidden;
}
@keyframes compare-drawer-slide-up {
from { transform: translateY(100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.compare-drawer__head {
display: flex;
align-items: center;
gap: var(--space-3);
padding: 10px var(--space-4);
border-bottom: 1px solid var(--chrome-border);
background: linear-gradient(180deg, rgba(255,255,255,0.02), transparent);
position: relative;
}
.compare-drawer__handle {
position: absolute;
top: 4px;
left: 50%;
transform: translateX(-50%);
width: 36px;
height: 3px;
border-radius: 2px;
background: var(--chrome-border-strong);
}
.compare-drawer__title {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--font-display, inherit);
font-size: var(--text-md);
font-weight: 600;
color: var(--chrome-fg);
}
.compare-drawer__close {
margin-left: auto;
width: var(--chrome-icon-btn, 22px);
height: var(--chrome-icon-btn, 22px);
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid transparent;
border-radius: var(--chrome-radius-pill);
color: var(--chrome-fg-muted);
cursor: pointer;
transition: background var(--dur-fast), color var(--dur-fast), border-color var(--dur-fast);
}
.compare-drawer__close:hover {
background: var(--chrome-hover-bg);
color: var(--chrome-fg);
border-color: var(--chrome-border-strong);
}
.compare-drawer__body {
padding: var(--space-4);
overflow-y: auto;
}
.compare-drawer__foot {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border-top: 1px solid var(--chrome-border);
background: var(--chrome-bg);
}
/* CompareModal — content-specific styles. Base controls (Panel, Button,
Select, Textarea) come from ui/. */
.ui-compare__desc {
margin: 0 0 var(--space-5);
+77 -44
View File
@@ -1,9 +1,9 @@
import React from 'react';
import { Scale, Fingerprint, Play } from 'lucide-react';
import React, { useEffect, useRef } from 'react';
import { Scale, Fingerprint, Play, X } from 'lucide-react';
import { toast } from 'react-hot-toast';
import { PRESETS } from '../utils/constants';
import { generateSpeech } from '../api/generate';
import { Dialog, Button, Panel, Field, Textarea, Select } from '../ui';
import { Button, Panel, Field, Textarea, Select } from '../ui';
import './CompareModal.css';
export default function CompareModal({
@@ -19,6 +19,24 @@ export default function CompareModal({
steps, cfg, speed, denoise, postprocess,
fileToMediaUrl, loadHistory,
}) {
const drawerRef = useRef(null);
useEffect(() => {
if (!open) return;
const onKey = (e) => { if (e.key === 'Escape') onClose?.(); };
const onPointer = (e) => {
if (drawerRef.current && !drawerRef.current.contains(e.target)) onClose?.();
};
window.addEventListener('keydown', onKey);
// Defer click-outside so the opening click doesn't immediately close.
const t = setTimeout(() => window.addEventListener('mousedown', onPointer), 0);
return () => {
window.removeEventListener('keydown', onKey);
window.removeEventListener('mousedown', onPointer);
clearTimeout(t);
};
}, [open, onClose]);
const runCompare = async () => {
setIsComparing(true);
setCompareResultA(null);
@@ -73,14 +91,61 @@ export default function CompareModal({
const canCompare = !isComparing && compareVoiceA && compareVoiceB && compareText.trim();
if (!open) return null;
return (
<Dialog
open={open}
onClose={onClose}
size="lg"
title={<><Scale size={14} /> A/B Voice Comparison</>}
footer={
<>
<div className="compare-drawer" role="dialog" aria-modal="false" aria-label="A/B Voice Comparison">
<div className="compare-drawer__sheet" ref={drawerRef}>
<header className="compare-drawer__head">
<span className="compare-drawer__handle" aria-hidden="true" />
<span className="compare-drawer__title">
<Scale size={14} /> A/B Voice Comparison
</span>
<button
type="button"
className="compare-drawer__close"
onClick={onClose}
aria-label="Close comparison"
>
<X size={12} />
</button>
</header>
<div className="compare-drawer__body">
<p className="ui-compare__desc">
Compare two voices side by side to make casting decisions. App stays interactive behind.
</p>
<Field label="Test phrase">
<Textarea
value={compareText}
onChange={e => setCompareText(e.target.value)}
rows={2}
style={{ resize: 'none' }}
/>
</Field>
<div className="ui-compare__grid">
<CompareSide
accent="var(--color-brand)"
label="Voice A"
profiles={profiles}
value={compareVoiceA}
onChange={setCompareVoiceA}
audio={compareResultA}
/>
<CompareSide
accent="var(--color-success)"
label="Voice B"
profiles={profiles}
value={compareVoiceB}
onChange={setCompareVoiceB}
audio={compareResultB}
/>
</div>
</div>
<footer className="compare-drawer__foot">
<Button variant="ghost" onClick={onClose}>Close</Button>
<Button
variant="primary"
@@ -91,41 +156,9 @@ export default function CompareModal({
>
{isComparing ? (compareProgress || 'Comparing…') : 'Compare'}
</Button>
</>
}
>
<p className="ui-compare__desc">
Compare two voices side by side to make casting decisions.
</p>
<Field label="Test phrase">
<Textarea
value={compareText}
onChange={e => setCompareText(e.target.value)}
rows={2}
style={{ resize: 'none' }}
/>
</Field>
<div className="ui-compare__grid">
<CompareSide
accent="var(--color-brand)"
label="Voice A"
profiles={profiles}
value={compareVoiceA}
onChange={setCompareVoiceA}
audio={compareResultA}
/>
<CompareSide
accent="var(--color-success)"
label="Voice B"
profiles={profiles}
value={compareVoiceB}
onChange={setCompareVoiceB}
audio={compareResultB}
/>
</footer>
</div>
</Dialog>
</div>
);
}
+16 -16
View File
@@ -26,43 +26,43 @@ export default class ErrorBoundary extends React.Component {
return (
<div style={{
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 32, fontFamily: 'Nunito, Inter, sans-serif',
padding: 32, fontFamily: 'var(--font-sans)',
}}>
<div style={{
maxWidth: 520, width: '100%',
padding: 22, textAlign: 'center',
background: 'linear-gradient(160deg, rgba(251,73,52,0.08), rgba(47,41,39,0.8))',
border: '1px solid rgba(251,73,52,0.25)',
borderRadius: '18px 22px 16px 24px / 20px 16px 22px 18px',
boxShadow: '0 16px 40px -16px rgba(0,0,0,0.5)',
background: 'var(--chrome-bg)',
border: '1px solid color-mix(in srgb, var(--chrome-severity-err) 35%, transparent)',
borderLeft: '2px solid var(--chrome-severity-err)',
borderRadius: 'var(--chrome-radius-pill)',
boxShadow: 'none',
}}>
<AlertCircle size={40} color="#fb4934" style={{ marginBottom: 10 }} />
<AlertCircle size={32} color="var(--chrome-severity-err)" style={{ marginBottom: 10 }} />
<h2 style={{
fontFamily: 'Fraunces, Georgia, serif', fontSize: '1.35rem', fontWeight: 700,
color: '#f5e6c5', margin: '0 0 6px',
fontFamily: 'var(--font-serif)', fontStyle: 'italic', fontSize: '1.6rem', fontWeight: 400,
color: 'var(--chrome-fg)', margin: '0 0 6px', letterSpacing: '-0.01em',
}}>
This tab hit a snag.
</h2>
<p style={{ color: '#a89984', fontSize: '0.82rem', margin: '0 0 12px', lineHeight: 1.5 }}>
<p style={{ color: 'var(--chrome-fg-muted)', fontSize: '0.82rem', margin: '0 0 12px', lineHeight: 1.5 }}>
Don't worry — the rest of the app still works. You can switch tabs, or try again below.
</p>
<pre style={{
textAlign: 'left', fontSize: '0.7rem', color: '#fb4934',
background: 'rgba(0,0,0,0.3)', padding: '8px 10px', borderRadius: 8,
textAlign: 'left', fontSize: '0.72rem', color: 'var(--chrome-severity-err)',
background: 'var(--chrome-hover-bg)', padding: '8px 10px', borderRadius: 'var(--chrome-radius-pill)',
border: '1px solid var(--chrome-border)',
maxHeight: 140, overflow: 'auto', margin: '0 0 14px',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
fontFamily: 'var(--font-mono)',
}}>{msg}</pre>
<button
onClick={this.reset}
className="btn-primary"
style={{
padding: '8px 18px', fontSize: '0.78rem', fontWeight: 800,
background: 'rgba(243,165,182,0.18)', color: '#fff9ef',
border: '1px solid rgba(243,165,182,0.4)',
padding: '6px 14px', fontSize: '0.78rem', fontWeight: 500,
display: 'inline-flex', alignItems: 'center', gap: 6,
}}
>
<RefreshCw size={13} /> Try again
<RefreshCw size={12} /> Try again
</button>
</div>
</div>
+336
View File
@@ -0,0 +1,336 @@
/* ExportModal — non-blocking bottom drawer. Uses --chrome-* tokens so it
reads as part of the studio chrome. Slides up from above the LogsFooter
and leaves the dub editor interactive behind it (click-outside dismisses,
no backdrop wash). */
.export-drawer {
position: fixed;
left: 0;
right: 0;
bottom: var(--logs-footer-height, 28px);
z-index: 90;
display: flex;
justify-content: center;
pointer-events: none; /* let clicks pass through to content behind */
}
.export-drawer__sheet {
pointer-events: auto;
width: min(880px, calc(100vw - 24px));
max-height: min(70vh, 560px);
display: flex;
flex-direction: column;
background: var(--chrome-bg);
border: 1px solid var(--chrome-border-strong);
border-bottom: none;
border-radius: 8px 8px 0 0;
box-shadow:
0 -8px 24px rgba(0, 0, 0, 0.45),
0 -1px 0 var(--chrome-border) inset;
animation: export-drawer-slide-up var(--dur-base, 180ms) var(--ease-out, cubic-bezier(.2,.8,.2,1));
overflow: hidden;
}
@keyframes export-drawer-slide-up {
from { transform: translateY(100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.export-drawer__head {
display: flex;
align-items: center;
gap: var(--space-3);
padding: 6px var(--space-4) 10px;
border-bottom: 1px solid var(--chrome-border);
background: linear-gradient(180deg, rgba(255,255,255,0.02), transparent);
position: relative;
}
.export-drawer__handle {
position: absolute;
top: 4px;
left: 50%;
transform: translateX(-50%);
width: 36px;
height: 3px;
border-radius: 2px;
background: var(--chrome-border-strong);
}
.export-drawer__close {
margin-left: auto;
width: var(--chrome-icon-btn, 22px);
height: var(--chrome-icon-btn, 22px);
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid transparent;
border-radius: var(--chrome-radius-pill);
color: var(--chrome-fg-muted);
cursor: pointer;
transition: background var(--dur-fast), color var(--dur-fast), border-color var(--dur-fast);
}
.export-drawer__close:hover {
background: var(--chrome-hover-bg);
color: var(--chrome-fg);
border-color: var(--chrome-border-strong);
}
.export-modal {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.export-modal--drawer {
padding: var(--space-3) var(--space-4) var(--space-4);
overflow-y: auto;
}
.export-modal__title-inner {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.export-modal__filename {
font-family: var(--chrome-font-mono);
font-size: var(--text-sm);
color: var(--chrome-fg-muted);
margin-left: var(--space-2);
max-width: 260px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.export-modal__kicker {
font-family: var(--chrome-font-mono);
font-size: var(--chrome-label-size);
letter-spacing: var(--chrome-label-track);
text-transform: uppercase;
color: var(--chrome-fg-muted);
display: inline-flex;
align-items: center;
gap: 4px;
}
/* ── Presets ───────────────────────────────────────────────── */
.export-modal__presets {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--chrome-border);
}
.export-modal__preset-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
font-size: var(--text-xs);
font-family: var(--font-sans);
background: transparent;
color: var(--chrome-fg-muted);
border: 1px solid var(--chrome-border);
border-radius: var(--chrome-radius-pill);
cursor: pointer;
transition: background var(--dur-fast), color var(--dur-fast), border-color var(--dur-fast);
}
.export-modal__preset-chip:hover {
background: var(--chrome-hover-bg);
color: var(--chrome-fg);
border-color: var(--chrome-border-strong);
}
/* ── Track row ─────────────────────────────────────────────── */
.export-modal__tracks {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.export-modal__section-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.export-modal__track-quick {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: var(--text-xs);
color: var(--chrome-fg-dim);
}
.export-modal__track-quick button {
background: transparent;
border: none;
color: var(--chrome-fg-muted);
font-size: var(--text-xs);
padding: 2px 4px;
cursor: pointer;
}
.export-modal__track-quick button:hover { color: var(--chrome-fg); }
.export-modal__track-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.export-modal__track {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border: 1px solid var(--chrome-border);
border-radius: var(--chrome-radius-pill);
font-size: var(--text-sm);
color: var(--chrome-fg-muted);
cursor: pointer;
transition: border-color var(--dur-fast), color var(--dur-fast), background var(--dur-fast);
}
.export-modal__track input { margin: 0; accent-color: var(--color-brand); }
.export-modal__track.is-on {
color: var(--chrome-fg);
border-color: var(--chrome-border-strong);
background: var(--chrome-hover-bg);
}
.export-modal__track.is-dub.is-on {
border-color: var(--chrome-accent-border);
background: var(--chrome-accent-bg);
color: var(--chrome-accent);
}
.export-modal__track-label {
font-family: var(--chrome-font-mono);
letter-spacing: 0.02em;
}
/* ── Tabs ──────────────────────────────────────────────────── */
.export-modal__tabs {
display: flex;
gap: var(--space-1);
border-bottom: 1px solid var(--chrome-border);
}
.export-modal__tab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--chrome-fg-muted);
font-size: var(--text-sm);
cursor: pointer;
transition: color var(--dur-fast), border-color var(--dur-fast);
}
.export-modal__tab:hover { color: var(--chrome-fg); }
.export-modal__tab.is-active {
color: var(--chrome-accent);
border-bottom-color: var(--chrome-accent);
}
/* ── Body / fields ─────────────────────────────────────────── */
.export-modal__body { min-height: 160px; }
.export-modal__grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-4) var(--space-5);
}
.export-modal__field { display: flex; flex-direction: column; gap: 6px; }
.export-modal__field-head { display: flex; flex-direction: column; gap: 2px; }
.export-modal__field-label {
font-family: var(--chrome-font-mono);
font-size: var(--chrome-label-size);
letter-spacing: var(--chrome-label-track);
text-transform: uppercase;
color: var(--chrome-fg-muted);
}
.export-modal__field-hint {
font-size: var(--text-xs);
color: var(--chrome-fg-dim);
line-height: 1.4;
}
.export-modal__toggle {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: var(--text-sm);
color: var(--chrome-fg);
cursor: pointer;
}
.export-modal__toggle input { accent-color: var(--color-brand); }
.export-modal__toggle--indent { margin-left: var(--space-4); color: var(--chrome-fg-muted); }
.export-modal__mt6 { margin-top: 6px; }
.export-modal__note {
grid-column: 1 / -1;
font-size: var(--text-xs);
color: var(--chrome-fg-dim);
padding: var(--space-2) var(--space-3);
background: var(--chrome-hover-bg);
border-left: 2px solid var(--chrome-border-strong);
border-radius: 2px;
}
/* ── Package tab ───────────────────────────────────────────── */
.export-modal__pkg-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: var(--space-3);
}
.export-modal__pkg-card {
display: flex;
flex-direction: column;
gap: 8px;
padding: var(--space-3);
border: 1px solid var(--chrome-border);
border-radius: var(--chrome-radius-pill);
background: var(--chrome-bg);
}
.export-modal__pkg-card.is-ghost { opacity: 0.75; }
.export-modal__pkg-head {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: var(--text-sm);
color: var(--chrome-fg);
font-weight: 500;
}
.export-modal__pkg-body {
margin: 0;
font-size: var(--text-xs);
color: var(--chrome-fg-muted);
line-height: 1.4;
flex: 1;
}
/* ── Summary footer ────────────────────────────────────────── */
.export-modal__summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding-top: var(--space-3);
border-top: 1px solid var(--chrome-border);
}
.export-modal__summary-left {
display: flex;
align-items: center;
gap: var(--space-2);
min-width: 0;
}
.export-modal__summary-name {
font-family: var(--chrome-font-mono);
font-size: var(--text-xs);
color: var(--chrome-fg);
background: var(--chrome-hover-bg);
padding: 3px 8px;
border-radius: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 340px;
}
.export-modal__summary-right {
display: inline-flex;
gap: var(--space-2);
}
+431
View File
@@ -0,0 +1,431 @@
import React, { useMemo, useState, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import {
Film, Volume2, FileText, Package, Music, Layers, Download,
Check, Globe, Zap, X,
} from 'lucide-react';
import { Button, Segmented, Badge } from '../ui';
import './ExportModal.css';
/**
* ExportModal — comprehensive export panel for the dubbing studio.
*
* Tabs: Video · Audio · Subtitles · Package. Each tab owns a small bundle of
* format/track/quality controls. The shared track list at the top lets the
* user pick which languages participate in whatever tab they land on — so
* "export all dubs as SRT" and "mux these 3 tracks into the MP4" share one
* source of truth instead of living as three separate dropdowns.
*/
const PRESETS = {
youtube: { label: 'YouTube', tab: 'video', format: 'mp4', preserveBg: true, burnSubs: false, defaultTrack: 'dub' },
archive: { label: 'Archive', tab: 'video', format: 'mp4', preserveBg: true, burnSubs: false, includeAll: true },
web: { label: 'Web', tab: 'video', format: 'mp4', preserveBg: true, burnSubs: true, dualSubs: false },
podcast: { label: 'Podcast', tab: 'audio', audioFormat: 'mp3', mp3Bitrate: '192', preserveBg: false },
studyset: { label: 'Study set',tab: 'subs', subsFormat: 'srt', subsDual: true },
};
export default function ExportModal({
open, onClose,
jobId, filename, dubTracks, dubLangCode,
preserveBg, setPreserveBg,
defaultTrack, setDefaultTrack,
exportTracks, setExportTracks,
dualSubs, setDualSubs,
burnSubs, setBurnSubs,
API,
triggerDownload,
handleDubDownload, handleDubAudioDownload, handleAudioExport,
segmentCount = 0,
}) {
const [tab, setTab] = useState('video');
// ── Tab-local state (not persisted across sessions — each open is fresh).
const [videoFormat, setVideoFormat] = useState('mp4'); // future: webm/mov
const [audioFormat, setAudioFormat] = useState('wav'); // wav | mp3
const [mp3Bitrate, setMp3Bitrate] = useState('192'); // 128/192/256/320
const [audioBatch, setAudioBatch] = useState('each'); // each | primary — per-lang or single file
const [audioPrimaryLang, setAudioPrimaryLang] = useState(dubLangCode || '');
const [subsFormat, setSubsFormat] = useState('srt'); // srt | vtt | both
const [subsDual, setSubsDual] = useState(!!dualSubs);
const [subsBatch, setSubsBatch] = useState('target'); // target | all-dubs
// Reflect the parent's dual/burn once, then own them locally so the modal
// can toy with them without committing on cancel.
useEffect(() => { setSubsDual(!!dualSubs); }, [open, dualSubs]);
// ── Drawer dismiss — ESC closes; click-outside closes. The drawer is a
// bottom sheet (non-blocking), so background interactions stay live.
const drawerRef = useRef(null);
useEffect(() => {
if (!open) return;
const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); onClose?.(); } };
const onDown = (e) => {
if (drawerRef.current && !drawerRef.current.contains(e.target)) onClose?.();
};
window.addEventListener('keydown', onKey);
window.addEventListener('mousedown', onDown);
return () => {
window.removeEventListener('keydown', onKey);
window.removeEventListener('mousedown', onDown);
};
}, [open, onClose]);
const allTracks = useMemo(() => {
const out = [{ code: 'original', label: 'Original', kind: 'original' }];
(dubTracks || []).forEach(t => out.push({ code: t, label: t.toUpperCase(), kind: 'dub' }));
return out;
}, [dubTracks]);
const dubOnlyTracks = useMemo(() => allTracks.filter(t => t.kind === 'dub'), [allTracks]);
const selectedTracks = allTracks.filter(t => exportTracks[t.code] !== false);
const selectedDubs = selectedTracks.filter(t => t.kind === 'dub');
const toggleTrack = (code) => setExportTracks(prev => ({ ...prev, [code]: prev[code] === false ? true : false }));
const setAllTracks = (on) => setExportTracks(Object.fromEntries(allTracks.map(t => [t.code, on])));
const setDubsOnly = () => setExportTracks(Object.fromEntries(allTracks.map(t => [t.code, t.kind === 'dub'])));
// ── Presets — map label → state deltas and jump to the right tab.
const applyPreset = (key) => {
const p = PRESETS[key];
if (!p) return;
setTab(p.tab);
if (p.preserveBg !== undefined) setPreserveBg(!!p.preserveBg);
if (p.burnSubs !== undefined) setBurnSubs(!!p.burnSubs);
if (p.dualSubs !== undefined) setSubsDual(!!p.dualSubs);
if (p.audioFormat) setAudioFormat(p.audioFormat);
if (p.mp3Bitrate) setMp3Bitrate(p.mp3Bitrate);
if (p.subsFormat) setSubsFormat(p.subsFormat);
if (p.subsDual !== undefined) setSubsDual(!!p.subsDual);
if (p.includeAll) setAllTracks(true);
if (p.defaultTrack === 'dub' && dubLangCode) setDefaultTrack(dubLangCode);
};
// ── Filename preview — purely cosmetic, mirrors how the server names files.
const baseName = useMemo(() => {
const raw = (filename || 'output').replace(/\.[^.]+$/, '');
return raw.replace(/[^A-Za-z0-9 _-]/g, '').trim() || 'output';
}, [filename]);
const filenamePreview = (() => {
if (tab === 'video') return `dubbed_${baseName}_…mp4`;
if (tab === 'audio') {
const ext = audioFormat;
if (audioBatch === 'each') return `dubbed_<lang>_${baseName}_…${ext} (${selectedDubs.length} files)`;
return `dubbed_${audioPrimaryLang || dubLangCode}_${baseName}_…${ext}`;
}
if (tab === 'subs') {
const langs = subsBatch === 'all-dubs' ? selectedDubs.length || 1 : 1;
const exts = subsFormat === 'both' ? 'srt+vtt' : subsFormat;
return `subtitles${subsDual ? '_dual' : ''}.${exts} (${langs} file${langs === 1 ? '' : 's'})`;
}
return 'archive.zip';
})();
// ── Validity: what's runnable right now?
const canVideo = selectedTracks.length > 0 && (dubTracks || []).length > 0;
const canAudio = audioBatch === 'each'
? selectedDubs.length > 0
: !!audioPrimaryLang && (dubTracks || []).includes(audioPrimaryLang);
const canSubs = segmentCount > 0 && (subsBatch !== 'all-dubs' || selectedDubs.length > 0);
// ── Runners — fire backend calls based on tab. Each returns quickly;
// toasts inside triggerDownload keep the user informed.
const runVideo = () => {
handleDubDownload?.();
onClose?.();
};
const runAudio = () => {
const langs = audioBatch === 'each'
? selectedDubs.map(t => t.code)
: [audioPrimaryLang || dubLangCode];
langs.forEach(lang => {
if (!lang) return;
const q = `preserve_bg=${preserveBg ? 1 : 0}&lang=${encodeURIComponent(lang)}`;
if (audioFormat === 'wav') {
const url = `${API}/dub/download-audio/${jobId}/dubbed_${lang}.wav?${q}`;
handleAudioExport?.(url, `dubbed_${lang}.wav`);
} else {
const url = `${API}/dub/download-mp3/${jobId}/dubbed_${lang}.mp3?${q}&bitrate=${mp3Bitrate}k`;
handleAudioExport?.(url, `dubbed_${lang}.mp3`);
}
});
onClose?.();
};
const runSubs = () => {
const targets = subsBatch === 'all-dubs' ? selectedDubs.map(t => t.code) : [dubLangCode];
const formats = subsFormat === 'both' ? ['srt', 'vtt'] : [subsFormat];
targets.forEach(lang => {
formats.forEach(ext => {
const name = `subtitles${subsDual ? '_dual' : ''}_${lang}.${ext}`;
const url = `${API}/dub/${ext}/${jobId}/${name}?dual=${subsDual ? 1 : 0}`;
triggerDownload?.(url, name);
});
});
onClose?.();
};
const runStems = () => {
handleAudioExport?.(`${API}/dub/export-stems/${jobId}`, 'stems.zip');
onClose?.();
};
const runClips = () => {
handleAudioExport?.(`${API}/dub/export-segments/${jobId}`, 'segments.zip');
onClose?.();
};
const runMap = {
video: { fn: runVideo, can: canVideo, label: 'Export MP4' },
audio: { fn: runAudio, can: canAudio, label: audioBatch === 'each' ? `Export ${selectedDubs.length} audio file${selectedDubs.length === 1 ? '' : 's'}` : 'Export audio' },
subs: { fn: runSubs, can: canSubs, label: 'Export subtitles' },
pkg: { fn: null, can: false, label: 'Export' },
};
const active = runMap[tab];
if (!open) return null;
return createPortal(
<div className="export-drawer" role="dialog" aria-modal="false" aria-label="Export options">
<div className="export-drawer__sheet" ref={drawerRef}>
<header className="export-drawer__head">
<span className="export-drawer__handle" aria-hidden="true" />
<span className="export-modal__title-inner">
<Download size={13} /> Export
{filename && <span className="export-modal__filename">· {filename}</span>}
</span>
<button type="button" className="export-drawer__close" onClick={onClose} aria-label="Close export drawer">
<X size={13} />
</button>
</header>
<div className="export-modal export-modal--drawer">
{/* Preset chips */}
<div className="export-modal__presets">
<span className="export-modal__kicker">PRESETS</span>
{Object.entries(PRESETS).map(([k, v]) => (
<button key={k} type="button" className="export-modal__preset-chip" onClick={() => applyPreset(k)} title={`Jump to ${v.tab} tab with ${v.label} defaults`}>
<Zap size={9} /> {v.label}
</button>
))}
</div>
{/* Track checklist — shared across tabs */}
<div className="export-modal__tracks">
<div className="export-modal__section-head">
<span className="export-modal__kicker"><Globe size={9} /> TRACKS</span>
<div className="export-modal__track-quick">
<button type="button" onClick={() => setAllTracks(true)}>All</button>
<span>·</span>
<button type="button" onClick={() => setAllTracks(false)}>None</button>
<span>·</span>
<button type="button" onClick={setDubsOnly}>Dubs only</button>
</div>
</div>
<div className="export-modal__track-row">
{allTracks.map(t => {
const on = exportTracks[t.code] !== false;
return (
<label key={t.code} className={`export-modal__track ${on ? 'is-on' : ''} ${t.kind === 'original' ? 'is-original' : 'is-dub'}`}>
<input type="checkbox" checked={on} onChange={() => toggleTrack(t.code)} />
<span className="export-modal__track-label">{t.label}</span>
{t.kind === 'dub' && t.code === dubLangCode && <Badge tone="brand" size="xs">primary</Badge>}
</label>
);
})}
</div>
</div>
{/* Tabs */}
<div className="export-modal__tabs">
<button type="button" className={`export-modal__tab ${tab === 'video' ? 'is-active' : ''}`} onClick={() => setTab('video')}>
<Film size={10} /> Video
</button>
<button type="button" className={`export-modal__tab ${tab === 'audio' ? 'is-active' : ''}`} onClick={() => setTab('audio')}>
<Volume2 size={10} /> Audio
</button>
<button type="button" className={`export-modal__tab ${tab === 'subs' ? 'is-active' : ''}`} onClick={() => setTab('subs')}>
<FileText size={10} /> Subtitles
</button>
<button type="button" className={`export-modal__tab ${tab === 'pkg' ? 'is-active' : ''}`} onClick={() => setTab('pkg')}>
<Package size={10} /> Package
</button>
</div>
{/* Tab body */}
<div className="export-modal__body">
{tab === 'video' && (
<div className="export-modal__grid">
<Field label="Container">
<Segmented size="sm" value={videoFormat} onChange={setVideoFormat} items={[
{ value: 'mp4', label: 'MP4 (H.264)' },
]} />
</Field>
<Field label="Default audio track" hint="Which audio stream plays by default when the viewer opens the file">
<select className="input-base input-base--xs" value={defaultTrack} onChange={e => setDefaultTrack(e.target.value)}>
{exportTracks['original'] !== false && <option value="original">Original</option>}
{(dubTracks || []).filter(t => exportTracks[t] !== false).map(t => (
<option key={t} value={t}>{t.toUpperCase()} (Dub)</option>
))}
</select>
</Field>
<Field label="Background audio">
<label className="export-modal__toggle">
<input type="checkbox" checked={preserveBg} onChange={e => setPreserveBg(e.target.checked)} />
Mix music/FX under every dubbed track
</label>
</Field>
<Field label="Subtitles in video">
<label className="export-modal__toggle">
<input type="checkbox" checked={burnSubs} onChange={e => setBurnSubs(e.target.checked)} />
Burn subtitles into picture (hardsub)
</label>
{burnSubs && (
<label className="export-modal__toggle export-modal__toggle--indent">
<input type="checkbox" checked={!!dualSubs} onChange={e => setDualSubs(e.target.checked)} />
Dual (translated on top of italicised original)
</label>
)}
</Field>
</div>
)}
{tab === 'audio' && (
<div className="export-modal__grid">
<Field label="Format">
<Segmented size="sm" value={audioFormat} onChange={setAudioFormat} items={[
{ value: 'wav', label: 'WAV (lossless)' },
{ value: 'mp3', label: 'MP3 (compressed)' },
]} />
</Field>
{audioFormat === 'mp3' && (
<Field label="Bitrate">
<Segmented size="sm" value={mp3Bitrate} onChange={setMp3Bitrate} items={[
{ value: '128', label: '128k' },
{ value: '192', label: '192k' },
{ value: '256', label: '256k' },
{ value: '320', label: '320k' },
]} />
</Field>
)}
<Field label="What to export">
<Segmented size="sm" value={audioBatch} onChange={setAudioBatch} items={[
{ value: 'each', label: 'Every selected dub (separate files)' },
{ value: 'primary', label: 'Single language' },
]} />
{audioBatch === 'primary' && (
<select className="input-base input-base--xs export-modal__mt6"
value={audioPrimaryLang} onChange={e => setAudioPrimaryLang(e.target.value)}>
{(dubTracks || []).map(t => <option key={t} value={t}>{t.toUpperCase()}</option>)}
</select>
)}
</Field>
<Field label="Background audio">
<label className="export-modal__toggle">
<input type="checkbox" checked={preserveBg} onChange={e => setPreserveBg(e.target.checked)} />
Mix music/FX under the dubbed voice
</label>
</Field>
</div>
)}
{tab === 'subs' && (
<div className="export-modal__grid">
<Field label="Format">
<Segmented size="sm" value={subsFormat} onChange={setSubsFormat} items={[
{ value: 'srt', label: 'SRT' },
{ value: 'vtt', label: 'VTT' },
{ value: 'both', label: 'Both' },
]} />
</Field>
<Field label="Layout">
<Segmented size="sm" value={subsDual ? 'dual' : 'single'} onChange={v => setSubsDual(v === 'dual')} items={[
{ value: 'single', label: 'Single line' },
{ value: 'dual', label: 'Dual (translated + original)' },
]} />
</Field>
<Field label="Languages">
<Segmented size="sm" value={subsBatch} onChange={setSubsBatch} items={[
{ value: 'target', label: `Current target (${dubLangCode || '—'})` },
{ value: 'all-dubs', label: `All selected dubs (${selectedDubs.length})` },
]} />
</Field>
<div className="export-modal__note">
Subtitles are generated from segment text as-is edit the segment table before exporting if you spotted typos.
</div>
</div>
)}
{tab === 'pkg' && (
<div className="export-modal__pkg-grid">
<PkgCard
icon={<Package size={14} />} title="Per-segment clips (.zip)"
body="Every generated segment as a numbered WAV inside a zip — good for review, voice-over post, or dataset building."
onClick={runClips} cta="Export clips zip"
/>
<PkgCard
icon={<Layers size={14} />} title="Stems (.zip)"
body="Isolated vocal track + background (music/FX) as separate WAVs. Useful for downstream audio editing."
onClick={runStems} cta="Export stems zip"
/>
<PkgCard
icon={<Music size={14} />} title="Audio tracks (individual files)"
body={`Jump to the Audio tab to export per-language dubs in WAV or MP3 (${(dubTracks || []).length} dub${(dubTracks || []).length === 1 ? '' : 's'} available).`}
onClick={() => setTab('audio')} cta="Open audio tab"
ghost
/>
</div>
)}
</div>
{/* Summary footer */}
<div className="export-modal__summary">
<div className="export-modal__summary-left">
<span className="export-modal__kicker">OUTPUT</span>
<code className="export-modal__summary-name" title={filenamePreview}>{filenamePreview}</code>
</div>
<div className="export-modal__summary-right">
{tab !== 'pkg' && (
<>
<Button variant="ghost" size="sm" onClick={onClose}>Cancel</Button>
<Button
variant="primary" size="sm"
onClick={active.fn} disabled={!active.can}
leading={<Download size={11} />}
title={active.can ? '' : 'Nothing selected or track unavailable'}
>
{active.label}
</Button>
</>
)}
{tab === 'pkg' && (
<Button variant="ghost" size="sm" onClick={onClose}>Close</Button>
)}
</div>
</div>
</div>
</div>
</div>,
document.body,
);
}
function Field({ label, hint, children }) {
return (
<div className="export-modal__field">
<div className="export-modal__field-head">
<span className="export-modal__field-label">{label}</span>
{hint && <span className="export-modal__field-hint">{hint}</span>}
</div>
{children}
</div>
);
}
function PkgCard({ icon, title, body, onClick, cta, ghost = false }) {
return (
<div className={`export-modal__pkg-card ${ghost ? 'is-ghost' : ''}`}>
<div className="export-modal__pkg-head">{icon}<span>{title}</span></div>
<p className="export-modal__pkg-body">{body}</p>
<Button variant={ghost ? 'subtle' : 'primary'} size="sm" onClick={onClick} leading={ghost ? null : <Check size={10} />}>
{cta}
</Button>
</div>
);
}
+6 -15
View File
@@ -1,12 +1,13 @@
import React, { useState } from 'react';
import { Globe, Fingerprint, Wand2, Film, RefreshCw, Settings2, ChevronRight, Zap } from 'lucide-react';
import { Button, Badge, Segmented } from '../ui';
import { Globe, Fingerprint, Wand2, Film, FolderOpen, RefreshCw, Settings2, ChevronRight, Zap } from 'lucide-react';
import { Button, Badge } from '../ui';
const VIEW_META = {
launchpad: { label: 'Launchpad', Icon: Globe, accent: '#f3a5b6', kicker: 'Studio' },
clone: { label: 'Voice Clone', Icon: Fingerprint, accent: '#d3869b', kicker: 'Studio' },
design: { label: 'Voice Design', Icon: Wand2, accent: '#8ec07c', kicker: 'Studio' },
dub: { label: 'Dubbing', Icon: Film, accent: '#fe8019', kicker: 'Studio' },
projects: { label: 'Projects', Icon: FolderOpen, accent: '#83a598', kicker: 'Library' },
settings: { label: 'Settings', Icon: Settings2, accent: '#fabd2f', kicker: 'Preferences' },
};
@@ -32,7 +33,7 @@ function WaveBars({ color = '#f3a5b6', active }) {
}
export default function Header({
mode, setMode, uiScale, setUiScale, sysStats, modelStatus, doubleClickMaximize,
mode, setMode, sysStats, modelStatus, doubleClickMaximize,
activeProjectName, onFlushMemory,
}) {
const [flushing, setFlushing] = useState(false);
@@ -93,20 +94,10 @@ export default function Header({
</span>
</div>
{/* Right: wave + UI scale + sys stats */}
{/* Right: wave + sys stats. UI scale (S/M/L) lives in the bottom
LogsFooter bar so all app-wide chrome sits together. */}
<div className="hq-col-right">
<WaveBars color={view.accent} active={modelStatus === 'ready' || modelStatus === 'loading'} />
<Segmented
className="hq-scale"
size="xs"
value={uiScale}
onChange={setUiScale}
items={[
{ value: 1, label: 'S', title: 'Small UI scale' },
{ value: 1.3, label: 'M', title: 'Medium UI scale' },
{ value: 1.5, label: 'L', title: 'Large UI scale' },
]}
/>
{sysStats && (
<div className="hq-stats">
<span><b className="hq-stats__key">RAM</b> {sysStats.ram.toFixed(1)}/{sysStats.total_ram.toFixed(0)}G</span>
+29 -26
View File
@@ -49,13 +49,13 @@ function Kbd({ children }) {
display: 'inline-flex', alignItems: 'center', gap: 2,
padding: '2px 8px',
minWidth: 28, height: 22,
background: 'rgba(0,0,0,0.35)',
border: '1px solid rgba(243,165,182,0.25)',
borderRadius: 6,
color: '#f5e6c5',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
fontSize: '0.68rem', fontWeight: 700,
boxShadow: '0 2px 0 rgba(0,0,0,0.25)',
background: 'var(--chrome-hover-bg)',
border: '1px solid var(--chrome-border-strong)',
borderRadius: 'var(--chrome-radius-pill)',
color: 'var(--chrome-fg)',
fontFamily: 'var(--font-mono)',
fontSize: '0.7rem', fontWeight: 500,
boxShadow: 'none',
}}>
{children}
</span>
@@ -69,9 +69,10 @@ export default function KeyboardCheatsheet({ open, onClose }) {
onClick={onClose}
style={{
position: 'fixed', inset: 0, zIndex: 9999,
background: 'radial-gradient(circle at 30% 20%, rgba(243,165,182,0.08) 0%, rgba(0,0,0,0.82) 60%)',
backdropFilter: 'blur(8px)', WebkitBackdropFilter: 'blur(8px)',
background: 'rgba(0,0,0,0.7)',
backdropFilter: 'none', WebkitBackdropFilter: 'none',
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24,
fontFamily: 'var(--font-sans)',
}}
>
<div
@@ -79,24 +80,25 @@ export default function KeyboardCheatsheet({ open, onClose }) {
style={{
width: 'min(720px, 90vw)', maxHeight: '82vh', overflow: 'auto',
padding: 22,
background: 'linear-gradient(160deg, rgba(47,41,39,0.98), rgba(38,33,31,0.98))',
border: '1px solid rgba(243,165,182,0.2)',
borderRadius: '18px 22px 16px 24px / 20px 16px 22px 18px',
boxShadow: '0 22px 50px -18px rgba(0,0,0,0.6), 0 0 0 1px rgba(243,165,182,0.08)',
background: 'var(--chrome-bg)',
border: '1px solid var(--chrome-border-strong)',
borderRadius: 'var(--chrome-radius-pill)',
boxShadow: 'none',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<Command size={18} color="#f3a5b6" />
<Command size={16} color="var(--chrome-accent)" />
<h2 style={{
margin: 0, fontFamily: 'Fraunces, Georgia, serif',
fontWeight: 700, fontSize: '1.3rem', color: '#f5e6c5',
margin: 0, fontFamily: 'var(--font-serif)', fontStyle: 'italic',
fontWeight: 400, fontSize: '1.5rem', color: 'var(--chrome-fg)',
letterSpacing: '-0.01em',
}}>
Keyboard shortcuts
</h2>
</div>
<button onClick={onClose} style={{ background: 'none', border: 'none', color: '#a89984', cursor: 'pointer' }}>
<X size={18} />
<button onClick={onClose} style={{ background: 'none', border: 'none', color: 'var(--chrome-fg-muted)', cursor: 'pointer' }}>
<X size={16} />
</button>
</div>
@@ -104,22 +106,23 @@ export default function KeyboardCheatsheet({ open, onClose }) {
{SECTIONS.map((sec) => (
<div key={sec.title}>
<div style={{
fontFamily: 'Nunito, sans-serif', fontWeight: 800, fontSize: '0.62rem',
textTransform: 'uppercase', letterSpacing: '0.14em',
color: '#d3869b', marginBottom: 10, paddingBottom: 6,
borderBottom: '1px dashed rgba(243,165,182,0.15)',
fontFamily: 'var(--font-mono)', fontWeight: 600,
fontSize: 'var(--chrome-label-size)',
textTransform: 'uppercase', letterSpacing: 'var(--chrome-label-track)',
color: 'var(--chrome-fg-muted)', marginBottom: 10, paddingBottom: 6,
borderBottom: '1px solid var(--chrome-border)',
}}>{sec.title}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{sec.items.map(([keys, desc]) => (
<div key={keys} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, fontFamily: 'Nunito, sans-serif' }}>
<span style={{ color: '#a89984', fontSize: '0.78rem' }}>{desc}</span>
<div key={keys} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, fontFamily: 'var(--font-sans)' }}>
<span style={{ color: 'var(--chrome-fg-muted)', fontSize: '0.8rem' }}>{desc}</span>
<span style={{ display: 'flex', gap: 3, flexShrink: 0 }}>
{keys.split(' / ').map((group, i, arr) => (
<React.Fragment key={group}>
<span style={{ display: 'flex', gap: 2 }}>
{group.split('+').map((k) => <Kbd key={k}>{k}</Kbd>)}
</span>
{i < arr.length - 1 && <span style={{ color: '#665c54', alignSelf: 'center', fontSize: '0.7rem' }}>or</span>}
{i < arr.length - 1 && <span style={{ color: 'var(--chrome-fg-dim)', alignSelf: 'center', fontSize: '0.7rem' }}>or</span>}
</React.Fragment>
))}
</span>
@@ -130,7 +133,7 @@ export default function KeyboardCheatsheet({ open, onClose }) {
))}
</div>
<div style={{ marginTop: 18, textAlign: 'center', color: '#6b6657', fontFamily: 'Nunito, sans-serif', fontSize: '0.7rem' }}>
<div style={{ marginTop: 18, textAlign: 'center', color: 'var(--chrome-fg-dim)', fontFamily: 'var(--font-sans)', fontSize: '0.72rem' }}>
Press <Kbd>?</Kbd> any time to open this.
</div>
</div>
+245
View File
@@ -0,0 +1,245 @@
/* VSCode-style bottom log panel. Fixed to the bottom of the viewport so
the main app can freely scroll underneath; we reserve bottom padding
via `body { padding-bottom: 28px }` in the global styles (added via
the component's mount — see App.jsx). */
.logs-footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 40;
display: flex;
flex-direction: column;
/* Chrome tokens defined at :root in index.css. The Header topbar
uses the same set, so changes here flow to both surfaces. */
background: var(--chrome-bg);
border-top: 1px solid var(--chrome-border);
font-family: var(--font-sans);
font-size: 12px;
color: var(--chrome-fg);
user-select: none;
}
.logs-footer--collapsed {
height: var(--chrome-bar-h);
}
.logs-footer--open {
/* height is set inline via React state */
min-height: 180px;
}
/* Drag handle — 4 px strip on the top edge. Cursor + subtle hover
hint so it feels grabbable. */
.logs-footer__resize {
position: absolute;
top: -2px;
left: 0;
right: 0;
height: 5px;
cursor: ns-resize;
z-index: 1;
}
.logs-footer__resize:hover {
background: rgba(243, 165, 182, 0.35);
}
/* Top bar — always visible */
.logs-footer__bar {
display: flex;
align-items: center;
justify-content: space-between;
height: 28px;
padding: 0 8px;
gap: 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
flex-shrink: 0;
}
.logs-footer__left {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
}
.logs-footer__title {
color: #a89984;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-right: 4px;
}
/* Thin vertical divider between the UI-scale toggle (leftmost) and the
Logs title + source pills. Keeps the two distinct control groups from
visually running into each other. */
.logs-footer__divider {
width: 1px;
height: 16px;
background: rgba(255, 255, 255, 0.08);
margin: 0 4px;
}
.logs-footer__scale {
margin-right: 2px;
}
.logs-footer__toggle {
background: none;
border: none;
color: #a89984;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 3px;
}
.logs-footer__toggle:hover {
color: #ebdbb2;
background: rgba(255, 255, 255, 0.04);
}
/* Source pills with badges */
.logs-footer__pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
height: 20px;
border-radius: 3px;
background: none;
border: 1px solid transparent;
color: #a89984;
font-size: 11px;
cursor: pointer;
transition: all 0.1s;
font-family: inherit;
}
.logs-footer__pill:hover {
background: rgba(255, 255, 255, 0.04);
color: #ebdbb2;
}
.logs-footer__pill--active {
background: rgba(243, 165, 182, 0.12);
border-color: rgba(243, 165, 182, 0.35);
color: #f3a5b6;
}
.logs-footer__pill--error {
color: #fb4934;
}
.logs-footer__pill--warn {
color: #fabd2f;
}
.logs-footer__pill-label {
font-weight: 500;
}
.logs-footer__badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
padding: 0 5px;
height: 14px;
border-radius: 7px;
font-size: 10px;
font-weight: 600;
background: rgba(255, 255, 255, 0.08);
color: var(--chrome-fg);
font-family: var(--chrome-font-mono);
}
.logs-footer__badge--error {
background: rgba(251, 73, 52, 0.2);
color: #fb4934;
}
.logs-footer__badge--warn {
background: rgba(250, 189, 47, 0.2);
color: #fabd2f;
}
/* Action buttons on the right */
.logs-footer__actions {
display: flex;
align-items: center;
gap: 2px;
}
.logs-footer__icon-btn {
background: none;
border: none;
color: #a89984;
cursor: pointer;
width: 22px;
height: 22px;
border-radius: 3px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.1s;
}
.logs-footer__icon-btn:hover {
color: #ebdbb2;
background: rgba(255, 255, 255, 0.06);
}
.logs-footer__icon-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.logs-footer__icon-btn--report:hover {
color: #fabd2f;
}
.logs-footer__icon-btn .spinner {
animation: logs-spin 1s linear infinite;
}
@keyframes logs-spin {
to { transform: rotate(360deg); }
}
/* Body (scrollable log area) */
.logs-footer__body {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: auto;
padding: 4px 8px;
user-select: text;
}
.logs-footer__body::-webkit-scrollbar { width: 8px; height: 8px; }
.logs-footer__body::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.08); border-radius: 4px; }
.logs-footer__body::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.15); }
.logs-footer__empty {
padding: 12px;
color: var(--chrome-fg-dim);
font-style: normal;
font-size: 11px;
text-align: center;
}
.logs-footer__line {
display: flex;
align-items: flex-start;
gap: 6px;
padding: 1px 2px;
border-radius: 2px;
}
.logs-footer__line--error {
background: rgba(251, 73, 52, 0.06);
}
.logs-footer__line--warn {
background: rgba(250, 189, 47, 0.04);
}
.logs-footer__line-icon {
padding-top: 2px;
flex-shrink: 0;
}
.logs-footer__line-text {
margin: 0;
padding: 0;
font-family: inherit;
font-size: 11.5px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
color: #ebdbb2;
flex: 1;
}
.logs-footer__line--error .logs-footer__line-text { color: #fb4934; }
.logs-footer__line--warn .logs-footer__line-text { color: #fabd2f; }
+357
View File
@@ -0,0 +1,357 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ChevronUp, ChevronDown, RefreshCw, Trash2, Copy, Bug, X,
AlertTriangle, AlertCircle, Info, FileText,
} from 'lucide-react';
import toast from 'react-hot-toast';
import {
systemLogs, systemLogsTauri, clearSystemLogs, clearTauriLogs,
} from '../api/system';
import { getFrontendLogs, clearFrontendLogs } from '../utils/consoleBuffer';
import { Segmented } from '../ui';
import { useAppStore } from '../store';
import './LogsFooter.css';
/**
* VSCode-style bottom panel for logs. Always-visible 28 px collapsed bar
* shows error/warning counts per source (Backend, Frontend, Tauri); click
* any pill or the chevron to expand into a resizable panel. State — which
* tab is active, is it collapsed, panel height — persists in localStorage
* so the panel remembers where the user left it across launches.
*/
const SOURCES = [
{ id: 'backend', label: 'Backend', icon: FileText },
{ id: 'frontend', label: 'Frontend', icon: FileText },
{ id: 'tauri', label: 'Tauri', icon: FileText },
];
const LS_HEIGHT = 'omnivoice.logs.height';
const LS_ACTIVE = 'omnivoice.logs.active';
const MIN_H = 180;
const MAX_H = 720;
// Severity heuristics. We scan each line for these keywords so the UI
// can show badge counts per source without waiting for a structured
// logger. Matches word-boundary to avoid false positives on identifiers
// like `warning_count` or `error_handler`.
const RE_ERROR = /\b(error|fatal|exception|traceback)\b/i;
const RE_WARN = /\b(warn(ing)?|deprecated)\b/i;
function classifyLine(raw) {
const text = typeof raw === 'string' ? raw : (raw?.msg ?? String(raw));
if (RE_ERROR.test(text)) return 'error';
if (RE_WARN.test(text)) return 'warn';
return 'info';
}
function countLevels(lines) {
let error = 0, warn = 0;
for (const l of lines) {
const sev = classifyLine(l);
if (sev === 'error') error++;
else if (sev === 'warn') warn++;
}
return { error, warn, total: lines.length };
}
function formatFrontendLine(entry) {
const ts = new Date(entry.t).toISOString().slice(11, 23);
return `[${ts}] ${entry.level.toUpperCase()} ${entry.msg}`;
}
function SeverityIcon({ level, size = 11 }) {
if (level === 'error') return <AlertCircle size={size} color="#fb4934" />;
if (level === 'warn') return <AlertTriangle size={size} color="#fabd2f" />;
return <Info size={size} color="#7c6f64" />;
}
function UiScaleToggle() {
// Sources the zoom factor directly from the store so no prop-drilling
// is required. Same three values the Header previously exposed; moved
// here so app-wide chrome lives in one place.
const uiScale = useAppStore(s => s.uiScale);
const setUiScale = useAppStore(s => s.setUiScale);
return (
<Segmented
className="logs-footer__scale"
size="xs"
value={uiScale}
onChange={setUiScale}
items={[
{ value: 1, label: 'S', title: 'Small UI scale' },
{ value: 1.3, label: 'M', title: 'Medium UI scale' },
{ value: 1.5, label: 'L', title: 'Large UI scale' },
]}
/>
);
}
function SourcePill({ source, counts, active, onClick }) {
const hasErrors = counts.error > 0;
const hasWarns = counts.warn > 0;
return (
<button
type="button"
className={[
'logs-footer__pill',
active ? 'logs-footer__pill--active' : '',
hasErrors ? 'logs-footer__pill--error' : hasWarns ? 'logs-footer__pill--warn' : '',
].filter(Boolean).join(' ')}
onClick={onClick}
>
<span className="logs-footer__pill-label">{source.label}</span>
{hasErrors && (
<span className="logs-footer__badge logs-footer__badge--error">{counts.error}</span>
)}
{!hasErrors && hasWarns && (
<span className="logs-footer__badge logs-footer__badge--warn">{counts.warn}</span>
)}
{!hasErrors && !hasWarns && counts.total > 0 && (
<span className="logs-footer__badge">{counts.total}</span>
)}
</button>
);
}
export default function LogsFooter() {
// Always start collapsed on every launch — per-session toggling works
// but nothing persists. Kill the legacy key on the way out so users
// who had it stored as "open" before aren't stuck on the next load.
if (typeof localStorage !== 'undefined') {
localStorage.removeItem('omnivoice.logs.collapsed');
}
const [collapsed, setCollapsed] = useState(true);
const [height, setHeight] = useState(() => {
const v = Number(localStorage.getItem(LS_HEIGHT));
return Number.isFinite(v) && v >= MIN_H && v <= MAX_H ? v : 300;
});
const [active, setActive] = useState(() => {
const v = localStorage.getItem(LS_ACTIVE);
return SOURCES.some(s => s.id === v) ? v : 'backend';
});
// Raw log state per source. Backend / Tauri come from HTTP; frontend
// comes from the in-process ring buffer in consoleBuffer.js.
const [lines, setLines] = useState({ backend: [], frontend: [], tauri: [] });
const [loading, setLoading] = useState(false);
const scrollRef = useRef(null);
useEffect(() => localStorage.setItem(LS_HEIGHT, String(height)), [height]);
useEffect(() => localStorage.setItem(LS_ACTIVE, active), [active]);
// Expose the current footer height as a CSS variable on :root so the
// studio's .app-container grid + the setup-wizard wrapper both shrink
// by exactly the right amount. Keeps sidebar + main content out from
// under the expanded panel without any JS-driven layout math.
useEffect(() => {
const h = collapsed ? 28 : height;
document.documentElement.style.setProperty('--logs-footer-height', `${h}px`);
return () => {
document.documentElement.style.setProperty('--logs-footer-height', '28px');
};
}, [collapsed, height]);
const fetchBackend = useCallback(async () => {
try {
const r = await systemLogs(300);
setLines(prev => ({ ...prev, backend: r.lines || [] }));
} catch { /* backend may be warming up — don't spam toasts */ }
}, []);
const fetchTauri = useCallback(async () => {
try {
const r = await systemLogsTauri(300);
setLines(prev => ({ ...prev, tauri: r.lines || [] }));
} catch { /* tauri log may not exist in dev */ }
}, []);
const pullFrontend = useCallback(() => {
const raw = getFrontendLogs();
setLines(prev => ({
...prev,
frontend: raw.map(formatFrontendLine),
}));
}, []);
const refreshAll = useCallback(async () => {
setLoading(true);
await Promise.all([fetchBackend(), fetchTauri()]);
pullFrontend();
setLoading(false);
}, [fetchBackend, fetchTauri, pullFrontend]);
// Poll on a slow interval (badges update without user action), faster
// when the panel is open + focused on a source.
useEffect(() => {
refreshAll();
const slow = setInterval(refreshAll, collapsed ? 8000 : 3000);
return () => clearInterval(slow);
}, [refreshAll, collapsed]);
// Auto-scroll to bottom when new lines arrive and panel is open.
useEffect(() => {
if (collapsed) return;
const el = scrollRef.current;
if (!el) return;
const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 40;
if (atBottom) el.scrollTop = el.scrollHeight;
}, [lines, active, collapsed]);
const counts = useMemo(() => ({
backend: countLevels(lines.backend),
frontend: countLevels(lines.frontend),
tauri: countLevels(lines.tauri),
}), [lines]);
const openTo = (id) => { setActive(id); setCollapsed(false); };
// ── Resize handle (drag the top edge) ───────────────────────────────
const dragRef = useRef(null);
const onDragStart = (e) => {
e.preventDefault();
const startY = e.clientY;
const startH = height;
const move = (ev) => {
const dy = startY - ev.clientY;
const next = Math.min(MAX_H, Math.max(MIN_H, startH + dy));
setHeight(next);
};
const up = () => {
window.removeEventListener('mousemove', move);
window.removeEventListener('mouseup', up);
};
window.addEventListener('mousemove', move);
window.addEventListener('mouseup', up);
};
// ── Actions ─────────────────────────────────────────────────────────
const onClear = async () => {
try {
if (active === 'backend') await clearSystemLogs();
else if (active === 'tauri') await clearTauriLogs();
else if (active === 'frontend') clearFrontendLogs();
setLines(prev => ({ ...prev, [active]: [] }));
toast.success(`${active} log cleared`);
} catch (e) {
toast.error(`Clear failed: ${e?.message || e}`);
}
};
const onCopy = async () => {
try {
const raw = (lines[active] || []).join('\n');
await navigator.clipboard.writeText(raw);
toast.success(`Copied ${active} log`);
} catch (e) {
toast.error(`Copy failed: ${e?.message || e}`);
}
};
const onReportIssue = async () => {
// Collate a short diagnostic dump — last 80 lines per source + counts
// + user agent — onto the clipboard so the user can paste into a
// GitHub issue without hand-collecting files.
const header = [
`OmniVoice Studio — diagnostic report`,
`When: ${new Date().toISOString()}`,
`UA: ${navigator.userAgent}`,
`Counts: backend err=${counts.backend.error}/warn=${counts.backend.warn}, ` +
`frontend err=${counts.frontend.error}/warn=${counts.frontend.warn}, ` +
`tauri err=${counts.tauri.error}/warn=${counts.tauri.warn}`,
'',
].join('\n');
const body = SOURCES.map(s => {
const l = lines[s.id] || [];
return `── ${s.label} (last ${Math.min(l.length, 80)} of ${l.length}) ──────────────\n` +
l.slice(-80).join('\n');
}).join('\n\n');
try {
await navigator.clipboard.writeText(header + body);
toast.success('Diagnostic report copied — paste it into a GitHub issue.');
} catch (e) {
toast.error(`Report failed: ${e?.message || e}`);
}
};
// ── Render ──────────────────────────────────────────────────────────
const current = lines[active] || [];
return (
<div className={['logs-footer', collapsed ? 'logs-footer--collapsed' : 'logs-footer--open'].join(' ')}
style={collapsed ? undefined : { height }}>
{!collapsed && (
<div
ref={dragRef}
className="logs-footer__resize"
onMouseDown={onDragStart}
title="Drag to resize"
/>
)}
<div className="logs-footer__bar">
<div className="logs-footer__left">
<UiScaleToggle />
<span className="logs-footer__divider" />
<button
type="button"
className="logs-footer__toggle"
onClick={() => setCollapsed(c => !c)}
title={collapsed ? 'Expand logs' : 'Collapse logs'}
>
{collapsed ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
</button>
<span className="logs-footer__title">Logs</span>
{SOURCES.map(s => (
<SourcePill
key={s.id}
source={s}
counts={counts[s.id]}
active={!collapsed && active === s.id}
onClick={() => (collapsed ? openTo(s.id) : setActive(s.id))}
/>
))}
</div>
{!collapsed && (
<div className="logs-footer__actions">
<button className="logs-footer__icon-btn" onClick={refreshAll} disabled={loading} title="Refresh">
<RefreshCw size={12} className={loading ? 'spinner' : ''} />
</button>
<button className="logs-footer__icon-btn" onClick={onCopy} title="Copy visible log">
<Copy size={12} />
</button>
<button className="logs-footer__icon-btn" onClick={onClear} title="Clear">
<Trash2 size={12} />
</button>
<button className="logs-footer__icon-btn logs-footer__icon-btn--report" onClick={onReportIssue} title="Report issue (copy diagnostic)">
<Bug size={12} />
</button>
<button className="logs-footer__icon-btn" onClick={() => setCollapsed(true)} title="Close">
<X size={12} />
</button>
</div>
)}
</div>
{!collapsed && (
<div ref={scrollRef} className="logs-footer__body">
{current.length === 0 && (
<div className="logs-footer__empty">
{active === 'frontend' ? 'No frontend console output yet.' : 'No lines.'}
</div>
)}
{current.map((line, i) => {
const level = classifyLine(line);
return (
<div key={i} className={`logs-footer__line logs-footer__line--${level}`}>
<span className="logs-footer__line-icon"><SeverityIcon level={level} /></span>
<pre className="logs-footer__line-text">{typeof line === 'string' ? line : JSON.stringify(line)}</pre>
</div>
);
})}
</div>
)}
</div>
);
}
+2 -1
View File
@@ -1,6 +1,6 @@
import React from 'react';
import {
Globe, Fingerprint, Wand2, Film, Settings2, ArrowLeftRight,
Globe, Fingerprint, Wand2, Film, FolderOpen, Settings2, ArrowLeftRight,
} from 'lucide-react';
const ITEMS = [
@@ -8,6 +8,7 @@ const ITEMS = [
{ id: 'clone', label: 'Clone', Icon: Fingerprint, accent: '#d3869b' },
{ id: 'design', label: 'Design', Icon: Wand2, accent: '#8ec07c' },
{ id: 'dub', label: 'Dub', Icon: Film, accent: '#fe8019' },
{ id: 'projects', label: 'Projects', Icon: FolderOpen, accent: '#83a598' },
];
const FOOTER_ITEMS = [
{ id: 'settings', label: 'Settings', Icon: Settings2, accent: '#fabd2f' },
+70 -36
View File
@@ -6,8 +6,10 @@
display: flex;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--glass-border);
background: rgba(0, 0, 0, 0.15);
/* Match chrome — same bg + hairline as top/bottom bars so the sidebar
header reads as part of the frame, not a separate panel. */
border-bottom: 1px solid var(--chrome-border);
background: var(--chrome-bg);
flex-shrink: 0;
justify-content: center;
}
@@ -15,23 +17,27 @@
.sidebar__tab {
flex: 1;
height: 26px;
height: var(--chrome-pill-h);
max-width: 60px;
cursor: pointer;
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(0, 0, 0, 0.2);
color: var(--color-fg-muted);
border-radius: var(--radius-lg);
border: 1px solid transparent;
background: transparent;
color: var(--chrome-fg-muted);
border-radius: var(--chrome-radius-pill);
transition: border-color var(--dur-base), background var(--dur-base), color var(--dur-base);
display: flex;
justify-content: center;
align-items: center;
--sidebar-tab-accent: var(--color-brand);
}
.sidebar__tab:hover {
background: var(--chrome-hover-bg);
color: var(--chrome-fg);
}
.sidebar.is-collapsed .sidebar__tab { max-width: 100%; }
.sidebar__tab.is-active {
border-color: color-mix(in srgb, var(--sidebar-tab-accent) 35%, transparent);
background: color-mix(in srgb, var(--sidebar-tab-accent) 15%, transparent);
background: color-mix(in srgb, var(--sidebar-tab-accent) 12%, transparent);
color: var(--sidebar-tab-accent);
}
.sidebar__tab:focus-visible {
@@ -57,7 +63,9 @@
width: 100%;
font-size: var(--text-sm);
padding: 4px 26px 4px 22px;
border-radius: var(--radius-pill);
/* Chrome-radius instead of 999 px pill so the input rhymes with the
footer / header pills. Colors come from the base Input primitive. */
border-radius: var(--chrome-radius-pill);
}
.sidebar__search-clear {
position: absolute;
@@ -71,8 +79,21 @@
}
/* ── Save-project button tint ───────────────────────────────── */
.sidebar__save-btn { margin-bottom: var(--space-4); color: var(--color-info); border-color: rgba(131,165,152,0.30); background: rgba(131,165,152,0.15); }
.sidebar__save-btn.is-active-project { color: #b8bb26; border-color: rgba(184,187,38,0.35); background: rgba(184,187,38,0.15); }
.sidebar__save-btn {
margin-bottom: var(--space-4);
color: var(--chrome-fg);
border: 1px solid var(--chrome-border-strong);
background: transparent;
border-radius: var(--chrome-radius-pill);
font-family: var(--font-sans);
letter-spacing: 0.02em;
}
.sidebar__save-btn:hover { background: var(--chrome-hover-bg); }
.sidebar__save-btn.is-active-project {
color: #b8bb26;
border-color: color-mix(in srgb, #b8bb26 45%, transparent);
background: color-mix(in srgb, #b8bb26 10%, transparent);
}
.sidebar__save-btn--full { justify-content: center; }
/* ── Clear-history row ──────────────────────────────────────── */
@@ -81,51 +102,64 @@
/* ── Empty-state placeholders ───────────────────────────────── */
.sidebar__empty {
color: var(--color-fg-muted);
color: var(--chrome-fg-muted);
text-align: center;
padding: 24px 12px;
font-family: var(--font-sans);
}
.sidebar__empty-icon { opacity: 0.3; margin-bottom: var(--space-4); }
.sidebar__empty-title { font-size: var(--text-md); margin: 0 0 var(--space-2); }
.sidebar__empty-sub { font-size: var(--text-xs); margin: 0; opacity: 0.6; }
.sidebar__empty-icon { opacity: 0.3; margin-bottom: var(--space-4); color: var(--chrome-fg-dim); }
.sidebar__empty-title { font-size: 0.82rem; margin: 0 0 var(--space-2); color: var(--chrome-fg); font-weight: 500; letter-spacing: 0.02em; }
.sidebar__empty-sub { font-size: 0.7rem; margin: 0; color: var(--chrome-fg-dim); line-height: 1.5; }
/* Section label — matches the status-bar's "LOGS" uppercase treatment.
Labels are displays (not buttons) but this one IS clickable (collapses
the section), so the chevron on the right signals interactivity while
the text keeps the chrome label typography. */
.sidebar__section-title {
font-size: var(--text-sm);
color: var(--text-secondary);
margin-bottom: var(--space-4);
font-family: var(--chrome-font-mono);
font-size: var(--chrome-label-size);
font-weight: 600;
letter-spacing: var(--chrome-label-track);
text-transform: uppercase;
color: var(--chrome-fg-muted);
margin-bottom: var(--space-3);
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
padding: 2px 0;
padding: 4px 0;
}
.sidebar__section-title:hover { color: var(--chrome-fg); }
/* ── Collapsed-mode hand-drawn icon tiles ───────────────────── */
/* ── Collapsed-mode icon tiles — chrome square buttons ────────── */
.sidebar__icon-tile {
width: 34px;
height: 34px;
width: 32px;
height: 32px;
flex-shrink: 0;
display: flex;
justify-content: center;
align-items: center;
border-radius: 12px 16px 10px 18px / 14px 10px 16px 12px;
/* Squircle corners dropped in favour of chrome-radius to match the
NavRail and footer icon buttons. Same transparent-until-hover
treatment since these are interactive. */
border-radius: var(--chrome-radius-pill);
cursor: pointer;
position: relative;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.05);
color: var(--color-fg-muted);
transition:
transform var(--dur-base) var(--ease-spring),
background var(--dur-fast) var(--ease-out),
border-color var(--dur-fast) var(--ease-out);
background: transparent;
border: 1px solid transparent;
color: var(--chrome-fg-muted);
transition: background var(--dur-fast) var(--ease-out),
border-color var(--dur-fast) var(--ease-out),
color var(--dur-fast) var(--ease-out);
}
.sidebar__icon-tile:hover {
transform: rotate(0deg) scale(1.08) !important;
background: var(--chrome-hover-bg);
color: var(--chrome-fg);
}
.sidebar__icon-tile.is-active {
background: linear-gradient(140deg, rgba(243, 165, 182, 0.25), rgba(250, 189, 47, 0.15));
border-color: rgba(243, 165, 182, 0.4);
color: #fff9ef;
background: var(--chrome-accent-bg);
border-color: var(--chrome-accent-border);
color: var(--chrome-accent);
}
.sidebar__icon-tile__lock {
position: absolute;
@@ -177,9 +211,9 @@
overflow: hidden;
}
.history-subtitle--seed {
font-family: ui-monospace, Menlo, monospace;
font-family: var(--font-mono);
font-size: 0.58rem;
color: #6b6657;
color: var(--chrome-fg-dim);
}
/* Inline mini-player in generate history */
+835 -512
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -1,5 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
// Fonts load before tokens so --font-* can resolve immediately (no FOUT).
// Inter ships as a single variable file; Source Serif 4 too. Plex Mono has
// no variable build so we pull the three weights we use (400/500/600).
import '@fontsource-variable/inter'
import '@fontsource/ibm-plex-mono/400.css'
import '@fontsource/ibm-plex-mono/500.css'
import '@fontsource/ibm-plex-mono/600.css'
import '@fontsource-variable/source-serif-4'
import './ui' // design-system tokens load first so index.css can override if needed
import './index.css'
import App from './App.jsx'
+1 -1
View File
@@ -125,7 +125,7 @@
.clone-slider-kicker {
margin-left: 6px;
font-size: 0.58rem;
color: #7c6f64;
color: var(--chrome-fg-dim);
font-weight: 500;
}
+295 -76
View File
@@ -26,23 +26,25 @@
display: flex;
gap: var(--space-2);
align-items: center;
padding: 4px 6px;
background: rgba(255, 255, 255, 0.02);
border-radius: var(--radius-md);
padding: 4px var(--space-3);
background: var(--chrome-bg);
border: 1px solid var(--chrome-border);
border-radius: var(--chrome-radius-pill);
margin-bottom: var(--space-2);
flex-shrink: 0;
}
.dub-preview-toggle__kicker {
font-size: var(--text-xs);
color: var(--color-fg-subtle);
font-weight: var(--weight-bold);
margin-right: var(--space-2);
letter-spacing: 0.05em;
font-family: var(--chrome-font-mono);
font-size: var(--chrome-label-size);
color: var(--chrome-fg-muted);
letter-spacing: var(--chrome-label-track);
text-transform: uppercase;
margin-right: var(--space-2);
}
.dub-preview-toggle__hint {
font-size: var(--text-2xs);
color: var(--color-fg-subtle);
font-family: var(--font-sans);
font-size: var(--chrome-label-size);
color: var(--chrome-fg-dim);
margin-left: auto;
}
@@ -157,31 +159,56 @@
margin-bottom: var(--space-2);
padding: 0 var(--space-2);
font-size: var(--text-xs);
color: var(--color-fg-muted);
color: var(--chrome-fg-muted);
font-family: var(--font-sans);
flex-wrap: wrap;
}
.dub-outputs-row label { display: flex; align-items: center; gap: var(--space-2); cursor: pointer; }
.dub-outputs-row label:hover { color: var(--chrome-fg); }
.dub-outputs-row input[type="checkbox"] { accent-color: var(--chrome-accent); }
/* ── Export track checkbox row ───────────────────────────────── */
.dub-tracks-row {
display: flex;
align-items: center;
gap: var(--space-4);
margin-bottom: var(--space-4);
padding: 4px 6px;
gap: var(--space-3);
margin-bottom: var(--space-3);
padding: 4px var(--space-3);
font-size: var(--text-xs);
color: var(--color-fg-muted);
background: rgba(0, 0, 0, 0.15);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
color: var(--chrome-fg-muted);
font-family: var(--font-sans);
background: var(--chrome-bg);
border-radius: var(--chrome-radius-pill);
border: 1px solid var(--chrome-border);
flex-wrap: wrap;
}
.dub-tracks-row__title { font-weight: var(--weight-bold); color: var(--color-fg); }
.dub-tracks-row label { display: flex; align-items: center; gap: var(--space-2); cursor: pointer; }
.dub-tracks-row label.is-on.is-success { color: var(--color-success); }
.dub-tracks-row label.is-off { color: var(--color-fg-subtle); }
.dub-tracks-row label.is-on { color: var(--color-fg); }
.dub-tracks-row label span.code { text-transform: uppercase; }
.dub-tracks-row__title {
font-family: var(--chrome-font-mono);
font-size: var(--chrome-label-size);
letter-spacing: var(--chrome-label-track);
text-transform: uppercase;
color: var(--chrome-fg-muted);
font-weight: 600;
}
.dub-tracks-row label {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border: 1px solid transparent;
border-radius: var(--chrome-radius-pill);
cursor: pointer;
transition: border-color var(--dur-fast), background var(--dur-fast), color var(--dur-fast);
}
.dub-tracks-row label input { accent-color: var(--chrome-accent); }
.dub-tracks-row label.is-on.is-success {
color: var(--chrome-severity-ok);
border-color: color-mix(in srgb, var(--chrome-severity-ok) 45%, transparent);
background: color-mix(in srgb, var(--chrome-severity-ok) 10%, transparent);
}
.dub-tracks-row label.is-off { color: var(--chrome-fg-dim); }
.dub-tracks-row label.is-on { color: var(--chrome-fg); border-color: var(--chrome-border-strong); background: var(--chrome-hover-bg); }
.dub-tracks-row label span.code { text-transform: uppercase; letter-spacing: 0.04em; }
/* ── Utility: column flex that fills remaining height ────────── */
.dub-col { flex: 1; display: flex; flex-direction: column; min-height: 0; }
@@ -289,13 +316,66 @@
.dub-ghost-footer { padding: 4px 8px; flex-shrink: 0; }
/* Settings bar (right-pane) */
/* Settings bar (right-pane) — expanded translation settings.
Two-row layout: fields on top (CSS grid so nothing truncates),
primary/secondary actions flush-right underneath. Wraps into a
container card so it reads as a distinct cluster from the bulk-select
and segment table that follow it. */
.dub-settings-bar {
display: flex;
gap: 4px;
margin-bottom: 4px;
flex-direction: column;
gap: 6px;
margin-bottom: 6px;
padding: 7px 9px;
background: var(--chrome-bg);
border: 1px solid var(--chrome-border);
border-radius: var(--chrome-radius-pill);
}
.dub-settings-bar__fields {
display: grid;
/* collapse-btn | Language | ISO | Engine | Quality | Style(flex) */
grid-template-columns: auto minmax(120px, 1.1fr) minmax(95px, 0.9fr) minmax(140px, 1.2fr) minmax(155px, 1.1fr) minmax(120px, 1.2fr);
gap: 3px 8px;
align-items: end;
}
.dub-settings-bar__actions {
display: flex;
justify-content: flex-end;
gap: 6px;
flex-wrap: wrap;
align-items: flex-end;
}
.dub-settings-field {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0; /* lets <select> shrink inside the grid cell instead of overflowing */
}
.dub-settings-field .label-row {
font-size: 0.62rem;
color: var(--color-fg-muted);
margin: 0;
}
.dub-settings-field .input-base,
.dub-settings-field .dub-cast__select,
.dub-settings-field .dub-engine-select,
.dub-settings-field .input-base--xs {
width: 100%;
font-size: 0.68rem;
padding: 4px 6px;
}
.dub-settings-field__hint {
font-size: 0.55rem;
color: var(--color-fg-subtle);
font-style: italic;
margin-left: 3px;
}
/* Quality Segmented is a button group, not an input — let it size itself. */
.dub-settings-field--quality > *:last-child { width: 100%; }
@media (max-width: 960px) {
.dub-settings-bar__fields {
grid-template-columns: auto 1fr 1fr;
}
.dub-settings-field--style { grid-column: 1 / -1; }
}
/* Collapsed-summary strip that replaces the full grid once language/engine/
@@ -304,12 +384,14 @@
display: flex;
align-items: center;
gap: var(--space-3);
padding: 4px 6px;
padding: 4px var(--space-3);
margin-bottom: 4px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.04);
border-radius: var(--radius-md);
background: var(--chrome-bg);
border: 1px solid var(--chrome-border);
border-radius: var(--chrome-radius-pill);
font-family: var(--font-sans);
font-size: 0.68rem;
color: var(--chrome-fg-muted);
}
.dub-settings-summary__trigger {
display: inline-flex;
@@ -325,9 +407,9 @@
font: inherit;
text-align: left;
}
.dub-settings-summary__trigger strong { color: var(--color-fg); font-weight: 600; }
.dub-settings-summary__trigger strong { color: var(--chrome-fg); font-weight: 600; }
.dub-settings-summary__style {
color: var(--color-fg-subtle);
color: var(--chrome-fg-dim);
font-style: italic;
margin-left: var(--space-2);
}
@@ -335,20 +417,71 @@
flex: 0 0 auto;
padding: 0 6px;
}
.dub-engine-select { font-size: 0.65rem; padding: 5px 8px; }
.dub-quality-field { flex: none; min-width: 140px; }
.dub-engine-select { font-size: 0.68rem; padding: 4px 6px; min-width: 140px; }
/* Small opt-in below the URL ingest row: toggle YouTube caption pulling. */
.dub-ingest-sub-opt {
display: flex;
align-items: center;
gap: 6px;
margin-top: 6px;
padding: 4px 6px;
font-size: 0.62rem;
color: var(--color-fg-muted);
cursor: pointer;
border-radius: 4px;
background: rgba(255, 255, 255, 0.02);
}
.dub-ingest-sub-opt:hover { color: var(--color-fg); background: rgba(255, 255, 255, 0.05); }
.dub-ingest-sub-opt input[type="checkbox"] { margin: 0; accent-color: #d3869b; }
/* Inline "install engine" chip next to the Engine label. Tight + brand-tinted
so it reads as an affordance, not a badge. Disappears after a successful
install (refreshEngines re-renders). */
.dub-engine-install-chip {
margin-left: 6px;
padding: 1px 6px;
font-size: 0.55rem;
line-height: 1.4;
background: rgba(211, 134, 155, 0.14);
border: 1px solid rgba(211, 134, 155, 0.35);
color: #d3869b;
border-radius: 999px;
cursor: pointer;
white-space: nowrap;
transition: background var(--dur-fast) var(--ease-out);
}
.dub-engine-install-chip:hover { background: rgba(211, 134, 155, 0.22); }
.dub-engine-install-chip:disabled,
.dub-engine-install-chip--disabled {
opacity: 0.55;
cursor: default;
font-style: italic;
}
/* Transcript collapsible + content */
.dub-transcript-toggle-wrap { margin-bottom: 4px; }
.dub-transcript-toggle__inner { margin-top: 0; padding: 2px 6px; font-size: 0.65rem; }
.dub-transcript-toggle__inner {
margin-top: 0;
padding: 3px var(--space-3);
font-family: var(--chrome-font-mono);
font-size: var(--chrome-label-size);
color: var(--chrome-fg-muted);
letter-spacing: var(--chrome-label-track);
text-transform: uppercase;
background: var(--chrome-bg);
border: 1px solid var(--chrome-border);
border-radius: var(--chrome-radius-pill);
}
.dub-transcript-toggle__inner:hover { color: var(--chrome-fg); }
.dub-transcript-body {
background: rgba(0, 0, 0, 0.15);
border: 1px solid rgba(255, 255, 255, 0.04);
background: var(--chrome-bg);
border: 1px solid var(--chrome-border);
border-top: none;
border-radius: 0 0 4px 4px;
padding: 6px;
font-size: 0.65rem;
color: var(--text-secondary);
border-radius: 0 0 var(--chrome-radius-pill) var(--chrome-radius-pill);
padding: var(--space-3);
font-size: var(--text-xs);
color: var(--chrome-fg-muted);
line-height: 1.5;
max-height: 80px;
overflow-y: auto;
@@ -359,29 +492,48 @@
.dub-glossary-chip {
display: inline-flex;
align-items: center;
padding: 3px 10px;
padding: 3px var(--space-3);
margin-bottom: 4px;
font-size: 0.65rem;
color: var(--color-fg-muted);
background: rgba(255, 255, 255, 0.02);
border: 1px dashed rgba(255, 255, 255, 0.08);
border-radius: 999px;
font-family: var(--chrome-font-mono);
font-size: var(--chrome-label-size);
letter-spacing: var(--chrome-label-track);
text-transform: uppercase;
color: var(--chrome-fg-muted);
background: transparent;
border: 1px solid var(--chrome-border);
border-radius: var(--chrome-radius-pill);
cursor: pointer;
transition: background var(--dur-fast) var(--ease-out), border-color var(--dur-fast) var(--ease-out);
}
.dub-glossary-chip:hover {
background: rgba(184, 187, 38, 0.06);
border-color: rgba(184, 187, 38, 0.24);
color: var(--color-fg);
background: var(--chrome-hover-bg);
border-color: var(--chrome-border-strong);
color: var(--chrome-fg);
}
/* Footer layout */
.dub-footer-panel { padding: 4px 8px; flex-shrink: 0; }
/* Footer layout — flatten the warm studio-panel backdrop so the chrome
action strip below has somewhere neutral to sit. Keep the content
padding tight. */
.dub-footer-panel {
padding: 6px var(--space-4);
flex-shrink: 0;
background: var(--chrome-bg) !important;
border: 1px solid var(--chrome-border) !important;
border-radius: 0 !important;
}
.dub-footer-panel::before { display: none !important; }
.dub-footer-btns { display: flex; gap: 4px; }
.dub-footer-btns--spaced { margin-top: 4px; }
/* Output options row value column */
.dub-outputs-title-strong { font-weight: 600; color: var(--color-fg); }
.dub-outputs-title-strong {
font-family: var(--chrome-font-mono);
font-size: var(--chrome-label-size);
letter-spacing: var(--chrome-label-track);
text-transform: uppercase;
color: var(--chrome-fg-muted);
font-weight: 600;
}
/* Bulk-select row controls */
.dub-bulk-select { font-size: 0.62rem; padding: 2px 4px; }
@@ -427,41 +579,108 @@
.dub-gen-overlay__title.is-stopping { color: var(--color-fg-muted); }
.dub-gen-overlay__bar { width: 80%; max-width: 240px; }
.dub-gen-overlay__text { font-size: 0.65rem; color: var(--color-fg-muted); }
.dub-gen-overlay__stats { display: flex; gap: var(--space-5); font-size: 0.68rem; color: var(--color-fg-muted); font-variant-numeric: tabular-nums; }
/* ── Cast / speaker-voices strip below waveform ──────────────── */
.dub-cast { margin-top: 4px; padding: 3px 6px; background: rgba(255, 255, 255, 0.02); border-radius: 4px; border: 1px solid rgba(255, 255, 255, 0.04); }
.dub-cast__row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.dub-cast__kicker { font-size: 0.62rem; color: var(--color-fg-muted); font-weight: 600; }
.dub-cast__pair { display: flex; align-items: center; gap: 3px; }
.dub-cast__label { font-size: 0.62rem; color: var(--color-fg); }
.dub-cast {
margin-top: 4px;
padding: 4px var(--space-3);
background: var(--chrome-bg);
border-radius: var(--chrome-radius-pill);
border: 1px solid var(--chrome-border);
}
.dub-cast__row { display: flex; gap: var(--space-3); align-items: center; flex-wrap: wrap; }
.dub-cast__kicker {
font-family: var(--chrome-font-mono);
font-size: var(--chrome-label-size);
color: var(--chrome-fg-muted);
letter-spacing: var(--chrome-label-track);
text-transform: uppercase;
font-weight: 600;
}
.dub-cast__pair { display: flex; align-items: center; gap: 4px; }
.dub-cast__label { font-family: var(--chrome-font-mono); font-size: 0.65rem; color: var(--chrome-fg); }
.dub-cast__select { width: 100px; padding: 1px 4px; font-size: 0.62rem; }
/* Muted placeholder cast strip shown in the idle skeleton */
.dub-cast--muted { background: rgba(255, 255, 255, 0.015); border: 1px solid rgba(255, 255, 255, 0.03); }
.dub-cast--muted { background: transparent; border-color: var(--chrome-border); }
.dub-cast--muted .dub-cast__label,
.dub-cast--muted .dub-cast__kicker { color: #504945; }
.dub-cast--muted__chip { font-size: 0.62rem; color: #504945; padding: 1px 4px; background: rgba(255, 255, 255, 0.02); border-radius: 2px; }
.dub-cast--muted .dub-cast__kicker { color: var(--chrome-fg-dim); }
.dub-cast--muted__chip {
font-family: var(--chrome-font-mono);
font-size: 0.62rem;
color: var(--chrome-fg-dim);
padding: 1px 6px;
background: transparent;
border: 1px solid var(--chrome-border);
border-radius: var(--chrome-radius-pill);
}
/* ── Output options row (footer) ─────────────────────────────── */
.dub-outputs-title { font-weight: 600; color: var(--color-fg); }
.dub-outputs-default { font-size: 0.6rem; padding: 2px 4px; width: 120px; }
/* ── Footer download buttons ─────────────────────────────────── */
/* .btn-primary provides the base layout; tones are applied here. */
/* Flat chrome surfaces — color = accent (text+border), not a gradient fill.
Matches the status-bar chrome so the footer row reads as one strip instead
of a jelly of pastel pills. Tones tint via color-mix so a single formula
handles danger/ok/brand/etc consistently. */
.dub-footer-btn {
margin-top: 0 !important;
flex: 1;
padding: 4px 8px !important;
font-size: var(--text-base) !important;
padding: 6px 10px !important;
font-family: var(--font-sans) !important;
font-size: var(--text-sm) !important;
letter-spacing: 0.02em !important;
text-transform: none !important;
background: transparent !important;
border: 1px solid var(--chrome-border-strong) !important;
color: var(--chrome-fg) !important;
border-radius: var(--chrome-radius-pill) !important;
box-shadow: none !important;
transition: background var(--dur-fast), border-color var(--dur-fast), color var(--dur-fast) !important;
}
.dub-footer-btn--sm { padding: 4px 7px !important; font-size: var(--text-xs) !important; }
.dub-footer-btn:hover:not(:disabled) { background: var(--chrome-hover-bg) !important; }
.dub-footer-btn:disabled { opacity: 0.45; cursor: not-allowed; }
.dub-footer-btn--sm { padding: 4px 8px !important; font-size: var(--text-xs) !important; }
.dub-footer-btn--idle { opacity: 0.4; }
.dub-footer-btn--stopping { background: linear-gradient(135deg, #504945, #3c3836) !important; opacity: 0.8; }
.dub-footer-btn--danger { background: linear-gradient(135deg, #fb4934, #cc241d) !important; }
.dub-footer-btn--green { background: linear-gradient(135deg, #8ec07c, #689d6a) !important; }
.dub-footer-btn--blue { background: linear-gradient(135deg, #83a598, #458588) !important; }
.dub-footer-btn--pink { background: linear-gradient(135deg, #d3869b, #b16286) !important; }
.dub-footer-btn--lime { background: linear-gradient(135deg, #b8bb26, #98971a) !important; }
.dub-footer-btn--amber { background: linear-gradient(135deg, #fabd2f, #d79921) !important; }
.dub-footer-btn--orange { background: linear-gradient(135deg, #fe8019, #d65d0e) !important; }
.dub-footer-btn--idle { color: var(--chrome-fg-muted) !important; border-color: var(--chrome-border) !important; }
.dub-footer-btn--stopping { color: var(--chrome-fg-muted) !important; border-color: var(--chrome-border) !important; }
.dub-footer-btn--danger {
color: var(--chrome-severity-err) !important;
border-color: color-mix(in srgb, var(--chrome-severity-err) 45%, transparent) !important;
background: color-mix(in srgb, var(--chrome-severity-err) 10%, transparent) !important;
}
.dub-footer-btn--danger:hover:not(:disabled) { background: color-mix(in srgb, var(--chrome-severity-err) 18%, transparent) !important; }
.dub-footer-btn--green {
color: var(--chrome-severity-ok) !important;
border-color: color-mix(in srgb, var(--chrome-severity-ok) 45%, transparent) !important;
background: color-mix(in srgb, var(--chrome-severity-ok) 10%, transparent) !important;
}
.dub-footer-btn--green:hover:not(:disabled) { background: color-mix(in srgb, var(--chrome-severity-ok) 18%, transparent) !important; }
.dub-footer-btn--pink {
color: var(--chrome-accent) !important;
border-color: var(--chrome-accent-border) !important;
background: var(--chrome-accent-bg) !important;
}
.dub-footer-btn--pink:hover:not(:disabled) { background: color-mix(in srgb, var(--chrome-accent) 20%, transparent) !important; }
.dub-footer-btn--blue {
color: #83a598 !important;
border-color: color-mix(in srgb, #83a598 45%, transparent) !important;
background: color-mix(in srgb, #83a598 10%, transparent) !important;
}
.dub-footer-btn--lime {
color: #b8bb26 !important;
border-color: color-mix(in srgb, #b8bb26 45%, transparent) !important;
background: color-mix(in srgb, #b8bb26 10%, transparent) !important;
}
.dub-footer-btn--amber {
color: var(--chrome-severity-warn) !important;
border-color: color-mix(in srgb, var(--chrome-severity-warn) 45%, transparent) !important;
background: color-mix(in srgb, var(--chrome-severity-warn) 10%, transparent) !important;
}
.dub-footer-btn--orange {
color: #fe8019 !important;
border-color: color-mix(in srgb, #fe8019 45%, transparent) !important;
background: color-mix(in srgb, #fe8019 10%, transparent) !important;
}
+280 -122
View File
@@ -1,7 +1,7 @@
import React, { Suspense, lazy, useState } from 'react';
import React, { Suspense, lazy, useState, useEffect, useCallback } from 'react';
import {
PanelLeftOpen, PanelLeftClose, Film, Save, UploadCloud, Sparkles, Loader, Square,
FileText, Play, DownloadIcon, Volume2, Music, Package, Layers, Link2,
FileText, Play, DownloadIcon, Volume2, Link2,
Languages, ChevronDown, ChevronUp, Wand2, Trash2, Check, Globe, UserSquare2, User, AlertCircle,
} from 'lucide-react';
// lucide-react exports DownloadIcon as "Download"; alias here to match App.jsx naming.
@@ -15,8 +15,11 @@ import { POPULAR_LANGS, POPULAR_ISO, PRESETS } from '../utils/constants';
import { LANG_CODES } from '../utils/languages';
import { formatTime } from '../utils/format';
import { API } from '../api/client';
import { Button, Segmented, Badge, Progress, Menu } from '../ui';
import { listTranslationEngines, installTranslationEngine } from '../api/engines';
import toast from 'react-hot-toast';
import { Button, Segmented, Badge, Progress } from '../ui';
import GlossaryPanel from '../components/GlossaryPanel';
import ExportModal from '../components/ExportModal';
import './DubTab.css';
const DubSegmentTable = lazy(() => import('../components/DubSegmentTable'));
@@ -37,7 +40,7 @@ export default function DubTab(props) {
segmentPreviewLoading,
selectedSegIds,
setDubVideoFile, setDubLocalBlobUrl,
handleDubAbort, handleDubUpload, handleDubIngestUrl, handleDubStop, handleDubGenerate,
handleDubAbort, handleDubUpload, handleDubIngestUrl, handleDubRetryTranscribe, handleDubStop, handleDubGenerate,
handleDubDownload, handleDubAudioDownload, handleAudioExport,
speakerClones = {},
handleSegmentPreview, onDirectSegment, handleTranslateAll, handleCleanupSegments,
@@ -82,10 +85,70 @@ export default function DubTab(props) {
const setTranslateQuality = useAppStore(s => s.setTranslateQuality);
const dualSubs = useAppStore(s => s.dualSubs);
const setDualSubs = useAppStore(s => s.setDualSubs);
const burnSubs = useAppStore(s => s.burnSubs);
const setBurnSubs = useAppStore(s => s.setBurnSubs);
const showIdleSkeleton = !(dubJobId && (dubStep === 'editing' || dubStep === 'generating' || dubStep === 'done'));
const [ingestUrl, setIngestUrl] = useState('');
const [previewMode, setPreviewMode] = useState('original'); // 'original' | 'dubbed'
const [exportOpen, setExportOpen] = useState(false);
// Live ETA while generating elapsed ticks each second; remaining is
// extrapolated from the current/total rate so it's only meaningful once
// at least one segment has rendered and ~2s of clock has passed.
const [genElapsed, setGenElapsed] = useState(0);
useEffect(() => {
if (dubStep !== 'generating') { setGenElapsed(0); return; }
const start = Date.now();
setGenElapsed(0);
const id = setInterval(() => setGenElapsed(Math.floor((Date.now() - start) / 1000)), 1000);
return () => clearInterval(id);
}, [dubStep]);
const genRemaining = (() => {
if (dubStep !== 'generating') return null;
if (!dubProgress.total || !dubProgress.current || genElapsed < 2) return null;
const perSeg = genElapsed / dubProgress.current;
return Math.max(0, Math.round(perSeg * (dubProgress.total - dubProgress.current)));
})();
// Translation-engine availability drives the Engine dropdown's disabled
// state and the inline Install chip. Lazy-fetched once; refreshed after
// any install/uninstall so the chip disappears on success.
const [engines, setEngines] = useState([]);
const [enginesSandboxed, setEnginesSandboxed] = useState(false);
const [engineInstalling, setEngineInstalling] = useState(null); // engine id being installed
const refreshEngines = useCallback(async () => {
try {
const res = await listTranslationEngines();
setEngines(res.engines || []);
setEnginesSandboxed(!!res.sandboxed);
} catch {
setEngines([]);
}
}, []);
useEffect(() => { refreshEngines(); }, [refreshEngines]);
const activeEngineEntry = engines.find(e => e.id === translateProvider);
const activeEngineUnavailable = activeEngineEntry && !activeEngineEntry.installed;
const handleInstallEngine = async (engineId) => {
if (!engineId || enginesSandboxed) return;
setEngineInstalling(engineId);
const progressToast = toast.loading(`Installing ${engineId}`);
try {
const res = await installTranslationEngine(engineId);
await refreshEngines();
if (res.restart_required) {
toast(`${engineId} installed. Restart the backend to load it.`, { icon: '🔄', id: progressToast, duration: 7000 });
} else if (res.status === 'already_installed') {
toast(`${engineId} was already installed`, { icon: '️', id: progressToast });
} else {
toast.success(`${engineId} installed`, { id: progressToast });
}
} catch (err) {
toast.error(`Install failed: ${String(err.message || err).slice(0, 200)}`, { id: progressToast, duration: 8000 });
} finally {
setEngineInstalling(null);
}
};
// Collapse secondary settings (Language/ISO/Style/Engine/Quality) into an
// accordion. Once the user has translated, the row's job is done; show a
@@ -119,9 +182,21 @@ export default function DubTab(props) {
return next;
});
};
// Persist the "pull YouTube captions" intent across ingests it's opt-in
// per-URL but almost always on once the user discovers it. Stored on the
// component instead of the global store to avoid polluting cross-project
// prefs with what's really a per-ingest choice.
const [fetchYtSubs, setFetchYtSubs] = useState(false);
const onIngestUrl = () => {
if (!ingestUrl.trim() || !handleDubIngestUrl) return;
handleDubIngestUrl(ingestUrl.trim());
handleDubIngestUrl(ingestUrl.trim(), {
fetchSubs: fetchYtSubs,
// Default to "all" available tracks YouTube's auto-translator makes
// every major language available on demand, so letting yt-dlp grab
// them all up-front means switching target language later doesn't
// need another round trip.
subLangs: fetchYtSubs ? undefined : undefined,
});
setIngestUrl('');
};
const hasDubbedTrack = dubStep === 'done' && dubLangCode && dubLangCode !== 'und' && (dubTracks?.length > 0 || !!dubTracks);
@@ -159,6 +234,28 @@ export default function DubTab(props) {
</div>
</div>
{/* Transcription failure banner shown in the idle state when a
job exists but transcription produced zero segments (or threw).
Surfaces the backend error detail and offers one-click retry,
which re-runs the ASR stream on the same job without re-uploading. */}
{dubError && dubJobId && dubStep === 'idle' && (
<div className="dub-footer-banner">
<Badge tone="danger">
<AlertCircle size={11} /> {dubError}
</Badge>
{handleDubRetryTranscribe && (
<Button
variant="subtle"
size="sm"
onClick={handleDubRetryTranscribe}
leading={<Sparkles size={10} />}
>
Retry transcription
</Button>
)}
</div>
)}
{/* SPLIT LAYOUT skeleton */}
<div className={`dub-split-grid ${dubVideoFile ? 'dub-split-2' : 'dub-split-1'}`}>
{/* LEFT */}
@@ -242,6 +339,19 @@ export default function DubTab(props) {
Ingest
</button>
</div>
<label
className="dub-ingest-sub-opt"
title="When the URL is a caption-bearing host (YouTube, Vimeo, TED…), also pull the original captions and any YouTube auto-translations. Seeds the editor without running Whisper; skip Translate All for languages YouTube already covers."
onClick={e => { e.stopPropagation(); }}
>
<input
type="checkbox"
checked={fetchYtSubs}
onChange={e => setFetchYtSubs(e.target.checked)}
onClick={e => e.stopPropagation()}
/>
<span>Pull YouTube captions + auto-translations</span>
</label>
</label>
)}
@@ -401,6 +511,10 @@ export default function DubTab(props) {
</div>
{dubStep === 'generating' && (
<>
<div className="dub-gen-overlay__stats">
<span> {fmtDur(genElapsed)} elapsed</span>
{genRemaining !== null && <span>~{fmtDur(genRemaining)} remaining</span>}
</div>
<div className="dub-gen-overlay__bar">
<Progress
value={dubProgress.total ? (dubProgress.current / dubProgress.total) * 100 : 0}
@@ -496,98 +610,127 @@ export default function DubTab(props) {
)}
{settingsOpen && (
<div className="dub-settings-bar">
<button
type="button"
className="dub-settings-summary__trigger dub-settings-close"
onClick={() => setSettingsOpen(false)}
title="Collapse translation settings"
>
<ChevronUp size={10} />
</button>
<div className="dub-skel-field">
<div className="label-row"><Globe className="label-icon" size={9} /> Language</div>
<select
className="input-base dub-cast__select"
value={dubLang}
onChange={(e) => {
const lang = e.target.value;
setDubLang(lang);
const match = LANG_CODES.find(lc => lc.label.toLowerCase() === lang.toLowerCase());
if (match) setDubLangCode(match.code);
}}
<div className="dub-settings-bar__fields">
<button
type="button"
className="dub-settings-summary__trigger dub-settings-close"
onClick={() => setSettingsOpen(false)}
title="Collapse translation settings"
>
<optgroup label="Popular">
{POPULAR_LANGS.map(l => <option key={`p-${l}`} value={l}>{l}</option>)}
</optgroup>
<optgroup label="All languages">
{ALL_LANGUAGES
.filter(l => !POPULAR_LANGS.includes(l))
.map(l => <option key={l} value={l}>{l}</option>)}
</optgroup>
</select>
<ChevronUp size={10} />
</button>
<div className="dub-settings-field">
<div className="label-row"><Globe className="label-icon" size={9} /> Language</div>
<select
className="input-base dub-cast__select"
value={dubLang}
onChange={(e) => {
const lang = e.target.value;
setDubLang(lang);
const match = LANG_CODES.find(lc => lc.label.toLowerCase() === lang.toLowerCase());
if (match) setDubLangCode(match.code);
}}
>
<optgroup label="Popular">
{POPULAR_LANGS.map(l => <option key={`p-${l}`} value={l}>{l}</option>)}
</optgroup>
<optgroup label="All languages">
{ALL_LANGUAGES
.filter(l => !POPULAR_LANGS.includes(l))
.map(l => <option key={l} value={l}>{l}</option>)}
</optgroup>
</select>
</div>
<div className="dub-settings-field dub-settings-field--iso">
<div className="label-row">ISO</div>
<select
className="input-base dub-cast__select"
value={dubLangCode}
onChange={(e) => setDubLangCode(e.target.value)}
>
{LANG_CODES.map(lc => (
<option key={lc.code} value={lc.code}>{lc.code} {lc.label}</option>
))}
</select>
</div>
<div className="dub-settings-field">
<div className="label-row">
Engine
{activeEngineUnavailable && !enginesSandboxed && (
<button
type="button"
className="dub-engine-install-chip"
onClick={() => handleInstallEngine(translateProvider)}
disabled={engineInstalling === translateProvider}
title={activeEngineEntry?.notes || 'Install this engine'}
>
{engineInstalling === translateProvider ? '…installing' : `+ install ${activeEngineEntry?.pip_package || ''}`}
</button>
)}
{activeEngineUnavailable && enginesSandboxed && (
<span className="dub-engine-install-chip dub-engine-install-chip--disabled" title="Installs are disabled in packaged builds">
needs dev install
</span>
)}
</div>
<select className="input-base dub-engine-select" value={translateProvider} onChange={e => setTranslateProvider(e.target.value)}>
{(engines.length ? engines : [
{ id: 'argos', display_name: 'Argos (Fast Local)', installed: true },
{ id: 'nllb', display_name: 'NLLB (Heavy Local)', installed: true },
{ id: 'google', display_name: 'Google (Online)', installed: true },
{ id: 'openai', display_name: 'OpenAI (LLM)', installed: true },
]).map(p => (
<option key={p.id} value={p.id}>
{p.installed ? p.display_name : `${p.display_name} — needs install`}
</option>
))}
</select>
</div>
<div className="dub-settings-field dub-settings-field--quality">
<div className="label-row" title="Cinematic = 3-step LLM refinement (translate → reflect → adapt). Needs an LLM configured.">Quality</div>
<Segmented
size="sm"
value={translateQuality}
onChange={setTranslateQuality}
items={[
{ value: 'fast', label: 'Fast' },
{ value: 'cinematic', label: 'Cinematic' },
]}
/>
</div>
<div className="dub-settings-field dub-settings-field--style">
<div className="label-row"><UserSquare2 className="label-icon" size={9} /> Style <span className="dub-settings-field__hint">optional</span></div>
<input className="input-base input-base--xs" placeholder="e.g. female" value={dubInstruct} onChange={e => setDubInstruct(e.target.value)} />
</div>
</div>
<div className="dub-skel-field--sm">
<div className="label-row">ISO Code</div>
<select
className="input-base dub-cast__select"
value={dubLangCode}
onChange={(e) => setDubLangCode(e.target.value)}
<div className="dub-settings-bar__actions">
<Button
variant="subtle" size="sm"
onClick={() => editSegments(dubSegments.map(s => ({ ...s, text: s.text_original || s.text, translate_error: undefined })))}
disabled={!dubSegments.some(s => s.text_original && s.text_original !== s.text)}
title="Restore all segments to the original transcribed text"
>
{LANG_CODES.map(lc => (
<option key={lc.code} value={lc.code}>{lc.code} {lc.label}</option>
))}
</select>
Restore
</Button>
<Button
variant="subtle" size="sm"
onClick={handleCleanupSegments}
disabled={!dubSegments.length || !dubJobId}
title="Merge tiny fragments and adjacent short segments"
leading={<Wand2 size={10} />}
>
Clean Up
</Button>
<Button
variant="primary" size="sm"
onClick={handleTranslateAll}
disabled={isTranslating || !dubSegments.length}
loading={isTranslating}
leading={!isTranslating && <Languages size={10} />}
>
{isTranslating ? 'Translating…' : 'Translate All'}
</Button>
</div>
<div className="dub-skel-field">
<div className="label-row"><UserSquare2 className="label-icon" size={9} /> Style</div>
<input className="input-base input-base--xs" placeholder="e.g. female" value={dubInstruct} onChange={e => setDubInstruct(e.target.value)} />
</div>
<div className="dub-skel-field">
<div className="label-row">Engine</div>
<select className="input-base dub-engine-select" value={translateProvider} onChange={e => setTranslateProvider(e.target.value)}>
{[{ id: 'argos', name: 'Argos (Fast Local)' }, { id: 'nllb', name: 'NLLB (Heavy Local)' }, { id: 'google', name: 'Google (Online)' }, { id: 'openai', name: 'OpenAI (LLM)' }].map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
<div className="dub-quality-field">
<div className="label-row" title="Cinematic = 3-step LLM refinement (translate → reflect → adapt). Needs an LLM configured.">Quality</div>
<Segmented
size="sm"
value={translateQuality}
onChange={setTranslateQuality}
items={[
{ value: 'fast', label: 'Fast' },
{ value: 'cinematic', label: 'Cinematic' },
]}
/>
</div>
<Button
variant="subtle" size="sm"
onClick={handleTranslateAll}
disabled={isTranslating || !dubSegments.length}
loading={isTranslating}
leading={!isTranslating && <Languages size={10} />}
>
{isTranslating ? 'Translating…' : 'Translate All'}
</Button>
<Button
variant="subtle" size="sm"
onClick={() => editSegments(dubSegments.map(s => ({ ...s, text: s.text_original || s.text, translate_error: undefined })))}
disabled={!dubSegments.some(s => s.text_original && s.text_original !== s.text)}
title="Restore all segments to the original transcribed text"
>
Restore
</Button>
<Button
variant="subtle" size="sm"
onClick={handleCleanupSegments}
disabled={!dubSegments.length || !dubJobId}
title="Merge tiny fragments and adjacent short segments"
leading={<Wand2 size={10} />}
>
Clean Up
</Button>
</div>
)}
@@ -729,6 +872,9 @@ export default function DubTab(props) {
<label title="Export subtitles with translated text on top and original italicised underneath.">
<input type="checkbox" checked={!!dualSubs} onChange={e => setDualSubs(e.target.checked)} /> Dual subtitles
</label>
<label title="Render subtitles directly into the MP4 video stream (hardsubs). Uses the dual-subtitle format when Dual subtitles is on.">
<input type="checkbox" checked={!!burnSubs} onChange={e => setBurnSubs(e.target.checked)} /> Burn subtitles
</label>
<label>
Default Track:
<select className="input-base dub-outputs-default" value={defaultTrack} onChange={e => setDefaultTrack(e.target.value)}>
@@ -775,41 +921,48 @@ export default function DubTab(props) {
)}
</>
)}
<Menu
placement="top-end"
<FooterBtn
tone={dubStep === 'done' ? 'green' : 'idle'}
disabled={dubStep !== 'done' && !dubSegments.length}
items={[
{ id: 'mp4', label: 'MP4 (video + dubbed audio)', icon: Download, disabled: dubStep !== 'done', onSelect: handleDubDownload },
{ id: 'wav', label: 'WAV (audio only)', icon: Volume2, disabled: dubStep !== 'done', onSelect: handleDubAudioDownload },
'separator',
{ id: 'srt', label: dualSubs ? 'SRT ✦ (dual subtitles)' : 'SRT (subtitles)', icon: FileText, disabled: !dubSegments.length,
onSelect: () => triggerDownload(`${API}/dub/srt/${dubJobId}/subtitles.srt?dual=${dualSubs ? 1 : 0}`, dualSubs ? 'subtitles_dual.srt' : 'subtitles.srt') },
{ id: 'vtt', label: dualSubs ? 'VTT ✦ (dual subtitles)' : 'VTT (subtitles)', icon: FileText, disabled: !dubSegments.length,
onSelect: () => triggerDownload(`${API}/dub/vtt/${dubJobId}/subtitles.vtt?dual=${dualSubs ? 1 : 0}`, dualSubs ? 'subtitles_dual.vtt' : 'subtitles.vtt') },
'separator',
{ id: 'mp3', label: 'MP3 (compressed audio)', icon: Music, disabled: dubStep !== 'done',
onSelect: () => handleAudioExport(`${API}/dub/download-mp3/${dubJobId}/audio.mp3?preserve_bg=${preserveBg}`, 'dubbed_audio.mp3') },
{ id: 'clips', label: 'Clips zip (per-segment WAVs)', icon: Package, disabled: dubStep !== 'done',
onSelect: () => handleAudioExport(`${API}/dub/export-segments/${dubJobId}`, 'segments.zip') },
{ id: 'stems', label: 'Stems zip (vocals + BG)', icon: Layers, disabled: dubStep !== 'done',
onSelect: () => handleAudioExport(`${API}/dub/export-stems/${dubJobId}`, 'stems.zip') },
]}
>
<FooterBtn
tone={dubStep === 'done' ? 'green' : 'idle'}
disabled={dubStep !== 'done' && !dubSegments.length}
icon={<Download size={11} />}
label="Export ▾"
/>
</Menu>
onClick={() => setExportOpen(true)}
icon={<Download size={11} />}
label="Export…"
/>
</div>
</div>
</div>
)}
<ExportModal
open={exportOpen}
onClose={() => setExportOpen(false)}
jobId={dubJobId}
filename={dubFilename}
dubTracks={dubTracks}
dubLangCode={dubLangCode}
preserveBg={preserveBg} setPreserveBg={setPreserveBg}
defaultTrack={defaultTrack} setDefaultTrack={setDefaultTrack}
exportTracks={exportTracks} setExportTracks={setExportTracks}
dualSubs={dualSubs} setDualSubs={setDualSubs}
burnSubs={burnSubs} setBurnSubs={setBurnSubs}
API={API}
triggerDownload={triggerDownload}
handleDubDownload={handleDubDownload}
handleDubAudioDownload={handleDubAudioDownload}
handleAudioExport={handleAudioExport}
segmentCount={dubSegments.length}
/>
</div>
);
}
function fmtDur(s) {
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
const sec = s % 60;
return sec ? `${m}m ${sec}s` : `${m}m`;
}
const PREP_STAGE_LABEL = {
download: 'Downloading video…',
extract: 'Extracting audio…',
@@ -889,8 +1042,13 @@ function TranscribeOverlay({ elapsed, duration, onAbort }) {
/**
* FooterBtn the gradient-per-tone download button family in the action footer.
* Uses the legacy .btn-primary as the shape/hover base, just picks a tone class.
* forwardRef so <Menu> can wire its triggerRef to the underlying button
* without this the Export menu can't compute coords and never opens.
*/
function FooterBtn({ tone = 'idle', sm = false, disabled, onClick, icon, label }) {
const FooterBtn = React.forwardRef(function FooterBtn(
{ tone = 'idle', sm = false, disabled, onClick, icon, label, ...rest },
ref,
) {
const cls = [
'btn-primary',
'dub-footer-btn',
@@ -898,8 +1056,8 @@ function FooterBtn({ tone = 'idle', sm = false, disabled, onClick, icon, label }
`dub-footer-btn--${tone}`,
].filter(Boolean).join(' ');
return (
<button className={cls} disabled={disabled} onClick={onClick}>
<button ref={ref} className={cls} disabled={disabled} onClick={onClick} {...rest}>
{icon} {label}
</button>
);
}
});
+87 -74
View File
@@ -21,17 +21,43 @@ function DubThumb({ jobId, fallback }) {
);
}
const Squiggle = () => (
<svg className="lp-underline" viewBox="0 0 240 8" preserveAspectRatio="none" aria-hidden="true">
<path
d="M2 5 Q 20 1 40 4 T 80 4 T 120 3 T 160 5 T 200 3 T 238 4"
stroke="#f3a5b6"
strokeWidth="2.5"
fill="none"
strokeLinecap="round"
/>
</svg>
);
// Squiggle was replaced by the .lp-hero__sweep span a pure-CSS animated
// accent line under the H1. Less static, no SVG dependency.
/**
* ActionCard the three big Launchpad tiles. Reads its accent from a
* single `--card-hue` var so the CSS derives background / border / glow /
* spotlight from one hex color. Cursor-tracking spotlight: pointer events
* set --mx/--my so `.lp-glow-layer` can paint a radial gradient at the
* cursor position. Eternal breath ring lives on `.lp-glow-layer::after`
* and pulses forever whether the card is hovered or not.
*/
function ActionCard({ hue, Icon, title, accent, count, onClick, children }) {
const handleMouseMove = (e) => {
const r = e.currentTarget.getBoundingClientRect();
e.currentTarget.style.setProperty('--mx', `${e.clientX - r.left}px`);
e.currentTarget.style.setProperty('--my', `${e.clientY - r.top}px`);
};
return (
<button
type="button"
className="lp-action-card lp-animate lp-glow-card"
onClick={onClick}
onMouseMove={handleMouseMove}
style={{ '--card-hue': hue }}
>
<span className="lp-glow-layer" aria-hidden="true" />
{count > 0 && <span className="card-count">{count}</span>}
<div className="card-icon">
<Icon size={18} color={hue} />
</div>
<h3>
{title} <span className="lp-action-card__emoji" aria-hidden="true">{accent}</span>
</h3>
<p className="card-desc">{children}</p>
</button>
);
}
export default function Launchpad({
profiles, studioProjects, dubHistory,
@@ -42,52 +68,54 @@ export default function Launchpad({
return (
<div className="launchpad">
{/* Ambient backdrop chrome-accent aurora that drifts forever. Lives
behind everything at z=0, contributes the "eternal glow" the user
asked for without painting any one surface. */}
<div className="lp-aurora" aria-hidden="true">
<span className="lp-aurora__blob lp-aurora__blob--pink" />
<span className="lp-aurora__blob lp-aurora__blob--green" />
<span className="lp-aurora__blob lp-aurora__blob--amber" />
</div>
{/* Hero */}
<div className="lp-hero">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 24, flexWrap: 'wrap' }}>
<div style={{ maxWidth: 640 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '2px', height: '24px' }}>
{[14, 20, 10, 24, 16, 22, 12, 18].map((h, i) => (
<div style={{ display: 'flex', alignItems: 'center', gap: '2px', height: '22px' }}>
{[10, 14, 8, 16, 12, 14, 9, 12].map((h, i) => (
<span
key={i}
className="lp-wave-bar"
style={{
height: h,
background: 'linear-gradient(to top, #f3a5b6, #fabd2f)',
animationDelay: `${i * 0.15}s`,
opacity: 0.7 + (i % 3) * 0.1,
borderRadius: 4,
// Per-bar animation offsets + distinct durations give
// a breathing, never-identical pulse instead of the
// rigid uniform bounce the old version had.
'--bar-h': `${h}px`,
'--bar-delay': `${i * 0.17}s`,
'--bar-dur': `${1.8 + (i % 3) * 0.4}s`,
}}
/>
))}
</div>
<span style={{ fontFamily: 'Nunito, sans-serif', fontSize: '0.7rem', fontWeight: 800, letterSpacing: '0.14em', textTransform: 'uppercase', color: '#7c6f64' }}>
hello there 👋
</span>
<span className="lp-kicker">hello there</span>
</div>
<h1>
<h1 className="lp-hero__title">
<span className="lp-hero__halo" aria-hidden="true" />
Make voices that <em>sound like you</em>.
<Squiggle />
<span className="lp-hero__sweep" aria-hidden="true" />
</h1>
<p>
Clone a voice, design a new one, or dub a video into any of <span className="lp-pill">646 languages</span>.
Built for creators who care how it sounds. 🎧
Built for creators who care how it sounds.
</p>
</div>
<button
className="btn-primary"
onClick={() => setIsCompareModalOpen(true)}
style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '10px 18px', fontSize: '0.78rem', width: 'auto', marginTop: 8,
borderRadius: '999px',
fontFamily: 'Nunito, sans-serif', fontWeight: 800,
flexShrink: 0, transform: 'rotate(1deg)',
}}
className="lp-ab-compare"
title="Try two voices side by side"
>
<Scale size={14} /> A/B Compare
<Scale size={12} /> A/B Compare
</button>
</div>
@@ -95,44 +123,15 @@ export default function Launchpad({
{/* Action Cards */}
<div className="lp-actions">
<div
className="lp-action-card lp-animate"
onClick={() => setMode('clone')}
style={{ '--card-accent': 'rgba(211,134,155,0.1)', '--card-border': 'rgba(211,134,155,0.25)' }}
>
{cloneProfiles.length > 0 && <span className="card-count" style={{ background: 'rgba(211,134,155,0.12)', color: '#d3869b' }}>{cloneProfiles.length}</span>}
<div className="card-icon" style={{ background: 'rgba(211,134,155,0.1)' }}>
<Fingerprint size={18} color="#d3869b" />
</div>
<h3>Voice Clone </h3>
<p className="card-desc">Drop in a short clip we'll mirror it. One sample is usually enough.</p>
</div>
<div
className="lp-action-card lp-animate"
onClick={() => setMode('design')}
style={{ '--card-accent': 'rgba(142,192,124,0.1)', '--card-border': 'rgba(142,192,124,0.25)' }}
>
{designProfiles.length > 0 && <span className="card-count" style={{ background: 'rgba(142,192,124,0.12)', color: '#8ec07c' }}>{designProfiles.length}</span>}
<div className="card-icon" style={{ background: 'rgba(142,192,124,0.1)' }}>
<Wand2 size={18} color="#8ec07c" />
</div>
<h3>Voice Design 🧪</h3>
<p className="card-desc">Build a new voice from a sentence. Gender, age, accent, mood your call.</p>
</div>
<div
className="lp-action-card lp-animate"
onClick={() => setMode('dub')}
style={{ '--card-accent': 'rgba(254,128,25,0.1)', '--card-border': 'rgba(254,128,25,0.25)' }}
>
{studioProjects.length > 0 && <span className="card-count" style={{ background: 'rgba(254,128,25,0.12)', color: '#fe8019' }}>{studioProjects.length}</span>}
<div className="card-icon" style={{ background: 'rgba(254,128,25,0.1)' }}>
<Film size={18} color="#fe8019" />
</div>
<h3>Video Dubbing 🎬</h3>
<p className="card-desc">Transcribe, translate, re-voice. Keep each speaker, line up the timing, ship it.</p>
</div>
<ActionCard hue="#d3869b" Icon={Fingerprint} title="Voice Clone" accent="✨" count={cloneProfiles.length} onClick={() => setMode('clone')}>
Drop in a short clip we'll mirror it. One sample is usually enough.
</ActionCard>
<ActionCard hue="#8ec07c" Icon={Wand2} title="Voice Design" accent="🧪" count={designProfiles.length} onClick={() => setMode('design')}>
Build a new voice from a sentence. Gender, age, accent, mood your call.
</ActionCard>
<ActionCard hue="#fe8019" Icon={Film} title="Video Dubbing" accent="🎬" count={studioProjects.length} onClick={() => setMode('dub')}>
Transcribe, translate, re-voice. Keep each speaker, line up the timing, ship it.
</ActionCard>
</div>
{/* Recent Projects */}
@@ -172,7 +171,16 @@ export default function Launchpad({
<div className="proj-name">{p.name}</div>
<div className="proj-meta" style={{ fontStyle: 'italic' }}>{p.instruct}</div>
</div>
{p.is_locked && <span style={{ fontSize: '0.5rem', padding: '1px 6px', borderRadius: 4, background: 'rgba(184,187,38,0.12)', color: '#b8bb26', fontWeight: 600 }}>LOCKED</span>}
{p.is_locked && <span style={{
fontFamily: 'var(--chrome-font-mono)',
fontSize: 'var(--chrome-label-size)',
letterSpacing: 'var(--chrome-label-track)',
padding: '1px 7px',
borderRadius: 'var(--chrome-radius-pill)',
background: 'color-mix(in srgb, #b8bb26 10%, transparent)',
border: '1px solid color-mix(in srgb, #b8bb26 40%, transparent)',
color: '#b8bb26', fontWeight: 600,
}}>LOCKED</span>}
<button className="proj-action" onClick={() => { setMode('design'); handleSelectProfile(p); }}>Open</button>
</div>
))}
@@ -222,8 +230,13 @@ export default function Launchpad({
/>
))}
</div>
<p style={{ fontFamily: 'Fraunces, Georgia, serif', fontSize: '1rem', color: '#a89984', margin: 0, fontStyle: 'italic' }}>
Nothing here yet. Pick a card above we'll wait.
<p style={{
fontFamily: 'var(--chrome-font-mono)',
fontSize: '0.8rem',
color: 'var(--chrome-fg-muted)',
margin: 0,
}}>
Nothing here yet pick a card above.
</p>
</div>
</div>
+246
View File
@@ -0,0 +1,246 @@
/* Projects page browse all project types in one place. Uses the same
chrome tokens as the top/bottom bars so the page frame reads as a
continuation of the studio shell. */
.projects {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
background: var(--bg);
color: var(--text-primary);
}
/* ── Header strip — title + search + view toggle ────────────────── */
.projects__header {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 18px;
border-bottom: 1px solid var(--chrome-border);
background: var(--chrome-bg);
flex-shrink: 0;
}
.projects__title {
font-family: var(--font-sans);
font-size: 0.92rem;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--chrome-fg);
margin: 0;
flex-shrink: 0;
}
.projects__toolbar {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
justify-content: flex-end;
}
.projects__search {
display: flex;
align-items: center;
gap: 6px;
height: var(--chrome-pill-h);
padding: 0 10px;
background: var(--chrome-hover-bg);
border: 1px solid var(--chrome-border);
border-radius: var(--chrome-radius-pill);
color: var(--chrome-fg-muted);
flex: 1;
max-width: 420px;
}
.projects__search input {
background: transparent;
border: none;
outline: none;
color: var(--chrome-fg);
font-size: 12px;
font-family: inherit;
flex: 1;
min-width: 0;
}
.projects__search input::placeholder { color: var(--chrome-fg-dim); }
.projects__view-toggle {
display: inline-flex;
gap: 1px;
border: 1px solid var(--chrome-border);
border-radius: var(--chrome-radius-pill);
overflow: hidden;
background: var(--chrome-hover-bg);
}
.projects__view-toggle button {
background: transparent;
border: none;
color: var(--chrome-fg-muted);
cursor: pointer;
width: 26px;
height: var(--chrome-pill-h);
display: inline-flex;
align-items: center;
justify-content: center;
transition: all 0.1s;
}
.projects__view-toggle button:hover { color: var(--chrome-fg); }
.projects__view-toggle button.is-active {
background: var(--chrome-accent-bg);
color: var(--chrome-accent);
}
/* ── Body: filter rail + content pane ──────────────────────────── */
.projects__body {
display: grid;
grid-template-columns: 200px minmax(0, 1fr);
flex: 1;
min-height: 0;
}
.projects__rail {
border-right: 1px solid var(--chrome-border);
background: var(--chrome-bg);
padding: 10px 8px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 2px;
}
.projects__rail-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
height: 28px;
border: 1px solid transparent;
background: transparent;
color: var(--chrome-fg-muted);
font-size: 12px;
font-family: inherit;
border-radius: var(--chrome-radius-pill);
cursor: pointer;
transition: all 0.12s;
text-align: left;
}
.projects__rail-item span:first-of-type { flex: 1; }
.projects__rail-item:hover {
background: var(--chrome-hover-bg);
color: var(--chrome-fg);
}
.projects__rail-item.is-active {
background: var(--chrome-accent-bg);
border-color: var(--chrome-accent-border);
color: var(--chrome-accent);
}
.projects__rail-count {
font-family: var(--chrome-font-mono);
font-size: 10.5px;
color: var(--chrome-fg-dim);
font-variant-numeric: tabular-nums;
}
.projects__rail-item.is-active .projects__rail-count { color: var(--chrome-accent); }
/* ── Content: card grid / list view ────────────────────────────── */
.projects__content {
padding: 14px 18px;
overflow-y: auto;
min-height: 0;
}
.projects__content--grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 10px;
align-content: start;
}
.projects__content--list {
display: flex;
flex-direction: column;
gap: 4px;
}
.projects__empty {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 48px 16px;
color: var(--chrome-fg-dim);
text-align: center;
}
.projects__empty p { max-width: 380px; margin: 0; font-size: 0.82rem; line-height: 1.5; }
/* ── Cards ─────────────────────────────────────────────────────── */
.projects__card {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 12px;
background: var(--chrome-bg);
border: 1px solid var(--chrome-border);
border-left: 3px solid var(--card-accent, var(--chrome-accent));
border-radius: var(--chrome-radius-pill);
color: var(--chrome-fg);
text-align: left;
cursor: pointer;
font-family: inherit;
transition: border-color 0.12s, background 0.12s, transform 0.12s;
}
.projects__card:hover {
border-color: var(--chrome-border-strong);
background: color-mix(in srgb, var(--card-accent, var(--chrome-accent)) 5%, var(--chrome-bg));
}
.projects__card:active { transform: translateY(1px); }
.projects__card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.projects__card-kind {
display: inline-flex;
align-items: center;
gap: 4px;
font-family: var(--chrome-font-mono);
font-size: 10px;
font-weight: 600;
letter-spacing: var(--chrome-label-track);
color: var(--card-accent, var(--chrome-accent));
text-transform: uppercase;
}
.projects__card-trailing {
color: var(--chrome-fg-dim);
font-size: 10.5px;
}
.projects__card-time {
display: inline-flex;
align-items: center;
gap: 3px;
font-family: var(--chrome-font-mono);
}
.projects__card-title {
font-size: 0.92rem;
font-weight: 600;
color: var(--chrome-fg);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.projects__card-sub {
font-size: 0.74rem;
color: var(--chrome-fg-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* List-view variant — same card rendered flatter */
.projects__content--list .projects__card {
flex-direction: row;
align-items: center;
padding: 6px 12px;
gap: 14px;
}
.projects__content--list .projects__card-head { flex-shrink: 0; width: 110px; }
.projects__content--list .projects__card-title { flex: 1; }
.projects__content--list .projects__card-sub { flex-shrink: 0; width: 120px; }
+241
View File
@@ -0,0 +1,241 @@
import React, { useMemo, useState } from 'react';
import {
Search, FolderOpen, Film, Fingerprint, Wand2, Music, Download,
LayoutGrid, List as ListIcon, Clock,
} from 'lucide-react';
import './Projects.css';
/**
* Projects browse everything (studio dubs, voice profiles, generation
* history, exports) in one place.
*
* Shape:
*
* header strip (search + view toggle)
* filter rail content grid/list
* All
* Dubs (3) [card] [card] [card]
* Profiles (12) [card] [card] ...
* History (48)
* Exports (7)
*
*
* Props reuse what App.jsx already loads no new fetchers are added so
* this page stays in sync with the Sidebar and Launchpad automatically.
*/
const FILTERS = [
{ id: 'all', label: 'All', Icon: FolderOpen },
{ id: 'dubs', label: 'Dub Projects', Icon: Film },
{ id: 'profiles', label: 'Voice Profiles', Icon: Fingerprint },
{ id: 'history', label: 'History', Icon: Music },
{ id: 'exports', label: 'Exports', Icon: Download },
];
function fmtTime(ts) {
if (!ts) return '';
const d = typeof ts === 'number' ? ts : Date.parse(ts);
if (!Number.isFinite(d)) return '';
const diff = Date.now() - d;
const s = Math.floor(diff / 1000);
if (s < 60) return `${s}s ago`;
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
return `${Math.floor(s / 86400)}d ago`;
}
function fmtDuration(sec) {
if (!sec) return '';
const n = Number(sec);
if (!Number.isFinite(n)) return '';
if (n < 60) return `${Math.floor(n)}s`;
const m = Math.floor(n / 60);
const s = Math.floor(n % 60);
return `${m}m ${s}s`;
}
function Card({ kind, accent, title, subtitle, trailing, onClick, IconC }) {
return (
<button
type="button"
className="projects__card"
onClick={onClick}
style={{ '--card-accent': accent }}
>
<div className="projects__card-head">
<span className="projects__card-kind">
{IconC && <IconC size={11} />}
{kind}
</span>
<span className="projects__card-trailing">{trailing}</span>
</div>
<div className="projects__card-title" title={title}>{title}</div>
{subtitle && <div className="projects__card-sub" title={subtitle}>{subtitle}</div>}
</button>
);
}
export default function Projects({
studioProjects = [],
profiles = [],
history = [],
exportHistory = [],
onOpenDub, // (projectId) => void loads project + switches to dub mode
onOpenProfile, // (voiceId) => void
onRevealExport, // (path) => void
}) {
const [filter, setFilter] = useState('all');
const [query, setQuery] = useState('');
const [view, setView] = useState('grid'); // grid | list
// Normalise every source into a common shape so the filter + search +
// sort pipeline is identical regardless of origin.
const items = useMemo(() => {
const list = [];
for (const p of studioProjects) {
list.push({
type: 'dubs',
id: p.id,
title: p.name || p.video_path?.split('/').pop() || p.id,
subtitle: fmtDuration(p.duration),
ts: (p.updated_at || p.created_at || 0) * 1000,
accent: '#fe8019',
Icon: Film,
onClick: () => onOpenDub?.(p.id),
});
}
for (const pr of profiles) {
const kind = pr.kind || 'clone';
list.push({
type: 'profiles',
id: pr.id,
title: pr.name || pr.id,
subtitle: kind === 'design' ? 'Designed voice' : 'Cloned voice',
ts: (pr.updated_at || pr.created_at || 0) * 1000,
accent: kind === 'design' ? '#8ec07c' : '#d3869b',
Icon: kind === 'design' ? Wand2 : Fingerprint,
onClick: () => onOpenProfile?.(pr.id),
});
}
for (const h of history) {
list.push({
type: 'history',
id: h.filename || h.id || String(Math.random()),
title: (h.text || h.prompt || h.filename || 'Generated audio').slice(0, 80),
subtitle: h.language || h.voice || '',
ts: h.timestamp || h.created_at || 0,
accent: '#f3a5b6',
Icon: Music,
onClick: undefined,
});
}
for (const e of exportHistory) {
list.push({
type: 'exports',
id: e.path || e.id,
title: e.path?.split('/').pop() || e.filename || 'Export',
subtitle: e.mode || '',
ts: (e.created_at || 0) * 1000,
accent: '#fabd2f',
Icon: Download,
onClick: () => e.path && onRevealExport?.(e.path),
});
}
list.sort((a, b) => (b.ts || 0) - (a.ts || 0));
return list;
}, [studioProjects, profiles, history, exportHistory, onOpenDub, onOpenProfile, onRevealExport]);
const counts = useMemo(() => {
const c = { all: items.length };
for (const it of items) c[it.type] = (c[it.type] || 0) + 1;
return c;
}, [items]);
const visible = useMemo(() => {
const q = query.trim().toLowerCase();
return items.filter(it => {
if (filter !== 'all' && it.type !== filter) return false;
if (!q) return true;
return (it.title + ' ' + (it.subtitle || '')).toLowerCase().includes(q);
});
}, [items, filter, query]);
return (
<div className="projects">
<div className="projects__header">
<h1 className="projects__title">Projects</h1>
<div className="projects__toolbar">
<div className="projects__search">
<Search size={12} />
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search projects, profiles, history, exports…"
spellCheck={false}
/>
</div>
<div className="projects__view-toggle">
<button
className={view === 'grid' ? 'is-active' : ''}
onClick={() => setView('grid')}
title="Card grid"
type="button"
>
<LayoutGrid size={12} />
</button>
<button
className={view === 'list' ? 'is-active' : ''}
onClick={() => setView('list')}
title="List"
type="button"
>
<ListIcon size={12} />
</button>
</div>
</div>
</div>
<div className="projects__body">
<aside className="projects__rail">
{FILTERS.map(f => {
const FI = f.Icon;
const n = counts[f.id] ?? 0;
return (
<button
key={f.id}
type="button"
className={`projects__rail-item ${filter === f.id ? 'is-active' : ''}`}
onClick={() => setFilter(f.id)}
>
<FI size={12} />
<span>{f.label}</span>
<span className="projects__rail-count">{n}</span>
</button>
);
})}
</aside>
<section className={`projects__content projects__content--${view}`}>
{visible.length === 0 && (
<div className="projects__empty">
<FolderOpen size={28} />
<p>{query ? `No matches for “${query}` : 'Nothing here yet. Start a dub, design a voice, or generate audio to see it appear.'}</p>
</div>
)}
{visible.map(it => (
<Card
key={`${it.type}:${it.id}`}
kind={it.type.toUpperCase()}
accent={it.accent}
title={it.title}
subtitle={it.subtitle}
trailing={<span className="projects__card-time"><Clock size={10} />{fmtTime(it.ts)}</span>}
onClick={it.onClick}
IconC={it.Icon}
/>
))}
</section>
</div>
</div>
);
}
+156 -20
View File
@@ -1,9 +1,24 @@
/* Settings page — page-specific styles layered over the design system. */
/* Settings page page-specific styles layered over the design system.
Chrome tokens only: every surface here should rhyme with the status bar. */
.settings-row__mono { font-family: var(--font-mono); }
.settings-muted { color: var(--color-fg-subtle); font-size: var(--text-md); }
.settings-prose { margin: 0 0 var(--space-5); color: var(--color-fg-muted); line-height: 1.6; font-size: var(--text-lg); }
.settings-prose strong { color: #f5e6c5; }
/* Tighter page chrome — reclaim whitespace around title and section padding. */
.settings-page { padding: 18px 28px 32px; }
.settings-page h1 { font-size: 1.15rem; margin: 0 0 2px; }
.settings-page .settings-subtitle{ font-size: 0.72rem; margin-bottom: 14px; }
.settings-tabs-ui { margin-bottom: var(--space-3); }
.settings-section--compact { padding: 10px 12px; margin-bottom: 10px; }
.settings-row__mono { font-family: var(--chrome-font-mono); color: var(--chrome-fg-muted); }
.settings-muted { color: var(--chrome-fg-dim); font-size: var(--text-md); font-family: var(--font-sans); }
.settings-prose {
margin: 0 0 var(--space-5);
color: var(--chrome-fg-muted);
line-height: 1.6;
font-size: 0.82rem;
font-family: var(--font-sans);
}
.settings-prose strong { color: var(--chrome-fg); font-weight: 600; }
.settings-section__head-row { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); }
.settings-section__head-left { display: flex; align-items: center; gap: var(--space-4); }
@@ -14,14 +29,14 @@
align-items: center;
gap: var(--space-4);
font-size: var(--text-base);
color: var(--color-fg-subtle);
color: var(--chrome-fg-dim);
margin: var(--space-4) 0;
font-family: var(--font-mono);
font-family: var(--chrome-font-mono);
}
.settings-log__empty {
color: var(--color-fg-subtle);
font-style: italic;
color: var(--chrome-fg-dim);
font-family: var(--font-sans);
}
.settings-link-row {
@@ -38,8 +53,14 @@
/* ── Engines tab ─────────────────────────────────────────────── */
.engines-family { margin-bottom: var(--space-5); }
.engines-family__active { margin-left: var(--space-4); font-weight: var(--weight-regular); color: var(--color-fg-subtle); font-size: var(--text-xs); }
.engines-family__active code { color: var(--color-fg); }
.engines-family__active {
margin-left: var(--space-4);
font-weight: 400;
color: var(--chrome-fg-dim);
font-size: var(--text-xs);
font-family: var(--chrome-font-mono);
}
.engines-family__active code { color: var(--chrome-fg); }
.engines-list {
list-style: none;
@@ -54,14 +75,129 @@
align-items: center;
gap: var(--space-3);
padding: var(--space-2) var(--space-3);
background: rgba(255, 255, 255, 0.02);
border-left: 2px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: var(--text-md);
background: transparent;
border: 1px solid var(--chrome-border);
border-left: 2px solid var(--chrome-border-strong);
border-radius: var(--chrome-radius-pill);
font-family: var(--font-sans);
font-size: 0.78rem;
color: var(--chrome-fg-muted);
flex-wrap: wrap;
}
.engines-list li.is-ok { border-left-color: var(--color-success); }
.engines-list li.is-off { border-left-color: var(--color-warn); opacity: 0.8; }
.engines-list__name { flex: 1; min-width: 240px; color: var(--color-fg); }
.engines-list__name code { color: var(--color-fg); font-family: var(--font-mono); font-size: var(--text-sm); }
.engines-list__reason { flex-basis: 100%; font-size: var(--text-xs); color: var(--color-fg-subtle); font-style: italic; padding-left: var(--space-4); }
.engines-list li.is-ok {
border-left-color: var(--chrome-severity-ok);
background: color-mix(in srgb, var(--chrome-severity-ok) 5%, transparent);
}
.engines-list li.is-off {
border-left-color: var(--chrome-severity-warn);
opacity: 0.85;
}
.engines-list__name { flex: 1; min-width: 240px; color: var(--chrome-fg); }
.engines-list__name code { color: var(--chrome-fg); font-family: var(--chrome-font-mono); font-size: var(--text-sm); }
.engines-list__reason { flex-basis: 100%; font-size: var(--text-xs); color: var(--chrome-fg-dim); font-family: var(--font-sans); padding-left: var(--space-4); }
/* ── Models tab ──────────────────────────────────────────────── */
.reco-banner {
display: flex;
align-items: center;
gap: var(--space-3);
padding: 6px var(--space-4);
margin-bottom: var(--space-4);
border: 1px solid color-mix(in srgb, #8ec07c 30%, transparent);
border-left: 2px solid #8ec07c;
border-radius: var(--chrome-radius-pill);
background: color-mix(in srgb, #8ec07c 5%, transparent);
font-family: var(--font-sans);
font-size: var(--text-sm);
color: var(--chrome-fg-muted);
}
.reco-banner__text { flex: 1; }
.reco-banner__text strong { color: var(--chrome-fg); font-weight: 600; }
.reco-banner__size { color: var(--chrome-fg-dim); font-size: var(--text-xs); }
.models-row__tag {
margin-left: var(--space-2);
padding: 1px 6px;
font-family: var(--chrome-font-mono);
font-size: var(--chrome-label-size);
letter-spacing: var(--chrome-label-track);
text-transform: uppercase;
color: #d3869b;
border: 1px solid color-mix(in srgb, #d3869b 40%, transparent);
border-radius: var(--chrome-radius-pill);
background: color-mix(in srgb, #d3869b 8%, transparent);
}
/* ── Models tab: toolbar + role tabs + table ─────────────────── */
.models-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: 4px 2px 10px;
font-family: var(--chrome-font-mono);
font-size: var(--text-xs);
color: var(--chrome-fg-muted);
flex-wrap: wrap;
}
.models-toolbar__stats {
display: inline-flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
}
.models-toolbar__stats strong { color: var(--chrome-fg); font-weight: 600; }
.models-toolbar__sep { color: var(--chrome-fg-dim); }
.models-toolbar__cache code { color: var(--chrome-fg); font-family: var(--chrome-font-mono); font-size: var(--text-xs); }
.models-roletabs { margin: 8px 0 10px; }
.reco-banner--action {
align-items: center;
padding: 8px var(--space-4);
border-left-color: #f3a5b6;
border-color: color-mix(in srgb, #f3a5b6 30%, transparent);
background: color-mix(in srgb, #f3a5b6 5%, transparent);
}
.reco-banner__body { flex: 1; display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.reco-banner__title { color: var(--chrome-fg); font-weight: 600; font-size: var(--text-sm); }
.reco-banner__rationale { color: var(--chrome-fg-muted); font-size: var(--text-xs); line-height: 1.4; }
/* Table — dense rows, fixed-width action column. */
.models-table { border-radius: 8px; }
.models-table .ui-table-header { padding: 4px var(--space-3); }
.models-table__body { display: flex; flex-direction: column; max-height: 540px; overflow-y: auto; }
.models-row {
display: flex;
align-items: center;
gap: var(--space-2);
padding: 8px var(--space-3);
border-bottom: 1px solid var(--chrome-border);
font-family: var(--font-sans);
font-size: var(--text-sm);
color: var(--chrome-fg);
border-left: 2px solid transparent;
}
.models-row:last-child { border-bottom: none; }
.models-row.is-ok { border-left-color: var(--chrome-severity-ok); }
.models-row.is-off { border-left-color: var(--chrome-border-strong); opacity: 0.92; }
.models-row:hover { background: var(--chrome-hover-bg); }
.models-row__cell { min-width: 0; }
.models-row__name { display: flex; flex-direction: column; gap: 2px; }
.models-row__title { display: inline-flex; align-items: center; gap: var(--space-2); color: var(--chrome-fg); font-weight: 500; }
.models-row__repo { color: var(--chrome-fg-muted); font-size: var(--text-xs); font-family: var(--chrome-font-mono); }
.models-row__repo code{ color: var(--chrome-fg-muted); }
.models-row__note { color: var(--chrome-fg-dim); }
.models-row__progress { color: var(--chrome-accent); font-size: var(--text-xs); font-family: var(--chrome-font-mono); margin-top: 2px; }
.models-row__size { font-family: var(--chrome-font-mono); font-size: var(--text-xs); color: var(--chrome-fg-muted); font-variant-numeric: tabular-nums; }
.models-row__actions {
justify-content: flex-end;
display: inline-flex;
gap: 4px;
opacity: 0.55;
transition: opacity var(--dur-fast, 120ms) ease;
}
.models-row:hover .models-row__actions { opacity: 1; }
+342 -94
View File
@@ -1,13 +1,14 @@
import React, { useEffect, useState, useCallback } from 'react';
import {
Cpu, FileText, Info, ShieldCheck, RefreshCw, Trash2, ExternalLink,
CheckCircle, AlertCircle, Plug, Mic, MessageSquare,
CheckCircle, AlertCircle, Plug, Mic, MessageSquare, Download,
} from 'lucide-react';
import { toast } from 'react-hot-toast';
import { systemInfo, systemLogs, systemLogsTauri, clearSystemLogs, clearTauriLogs, modelStatus as fetchModelStatus } from '../api/system';
import { listEngines, selectEngine } from '../api/engines';
import { listModels, installModel, deleteModel, setupDownloadStreamUrl, getRecommendations } from '../api/setup';
import { getFrontendLogs, clearFrontendLogs } from '../utils/consoleBuffer';
import { Tabs, Segmented, Button, Badge, Panel } from '../ui';
import { Tabs, Segmented, Button, Badge, Panel, Table } from '../ui';
import { useAppStore } from '../store';
import './Settings.css';
@@ -42,10 +43,281 @@ function Row({ label, value, mono }) {
);
}
function EnginesTab() {
function fmtBytes(n) {
if (!n || n <= 0) return '—';
if (n >= 1024 ** 3) return `${(n / 1024 ** 3).toFixed(2)} GB`;
if (n >= 1024 ** 2) return `${(n / 1024 ** 2).toFixed(1)} MB`;
return `${Math.round(n / 1024)} KB`;
}
/**
* Model store list every known HF model, show install state, let the
* user install / reinstall / delete individual models. Per-model download
* progress is pulled from the shared /setup/download-stream SSE.
*/
export function ModelStoreTab({ info, modelBadge }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(new Set()); // repo_ids currently working
const [progress, setProgress] = useState({}); // filename event
const [reco, setReco] = useState(null); // /setup/recommendations payload
const [installingReco, setInstallingReco] = useState(false);
const [activeRole, setActiveRole] = useState(null);
const esRef = React.useRef(null);
const reload = useCallback(async () => {
setLoading(true);
try { setData(await listModels()); }
catch (e) { toast.error(`Failed to list models: ${e.message}`); }
finally { setLoading(false); }
}, []);
const reloadReco = useCallback(async () => {
try { setReco(await getRecommendations()); }
catch (e) { /* non-fatal — Recommendation card just hides */ void e; }
}, []);
useEffect(() => { reload(); reloadReco(); }, [reload, reloadReco]);
// Open the progress stream once when the tab mounts; close on unmount.
useEffect(() => {
const es = new EventSource(setupDownloadStreamUrl());
esRef.current = es;
es.onmessage = (evt) => {
try {
const ev = JSON.parse(evt.data);
if (ev?.filename) setProgress(prev => ({ ...prev, [ev.filename]: ev }));
} catch { /* keepalive / ignore */ }
};
return () => es.close();
}, []);
// When progress events stop firing, auto-refresh the list so "installed"
// flips without a manual click.
useEffect(() => {
const filesInFlight = Object.values(progress).filter(e => e.phase !== 'done');
if (filesInFlight.length === 0 && Object.keys(progress).length > 0) {
const t = setTimeout(reload, 1500);
return () => clearTimeout(t);
}
}, [progress, reload]);
const withBusy = async (repoId, fn, successMsg) => {
setBusy(prev => new Set(prev).add(repoId));
try {
await fn();
if (successMsg) toast.success(successMsg);
await reload();
} catch (e) {
toast.error(e.message || String(e));
} finally {
setBusy(prev => { const s = new Set(prev); s.delete(repoId); return s; });
}
};
const onInstall = (repoId) => withBusy(repoId, () => installModel(repoId), 'Install started — progress in the row');
const onDelete = (repoId) => {
if (!confirm(`Delete ${repoId}? You can reinstall it later.`)) return;
return withBusy(repoId, () => deleteModel(repoId), `Deleted ${repoId}`);
};
const onReinstall = async (repoId) => {
if (!confirm(`Reinstall ${repoId}? This will delete the current copy and download again.`)) return;
await withBusy(repoId, async () => {
await deleteModel(repoId);
await installModel(repoId);
}, 'Reinstalling');
};
const onInstallRecommended = async () => {
if (!reco) return;
const missing = reco.models.filter(m => !m.installed);
if (missing.length === 0) {
toast.success('Recommended models are already installed.');
return;
}
setInstallingReco(true);
try {
// Parallel install backend /models/install spawns each download on
// its own asyncio task so ordering doesn't matter.
await Promise.all(missing.map(m => installModel(m.repo_id)));
toast.success(`Started downloading ${missing.length} model${missing.length > 1 ? 's' : ''}`);
await Promise.all([reload(), reloadReco()]);
} catch (e) {
toast.error(`Install failed: ${e.message || e}`);
} finally {
setInstallingReco(false);
}
};
if (loading && !data) {
return (
<section className="settings-section">
<h2><Cpu size={16} color="#f3a5b6" /> Models</h2>
<div className="settings-muted">Loading</div>
</section>
);
}
if (!data) return null;
const groups = (data.models || []).reduce((acc, m) => {
const k = (m.role || 'other').toLowerCase();
(acc[k] = acc[k] || []).push(m);
return acc;
}, {});
const ROLE_ORDER = ['tts', 'asr', 'diarisation', 'diarization', 'llm'];
const ROLE_LABEL = { tts: 'TTS', asr: 'ASR', diarisation: 'Diarisation', diarization: 'Diarisation', llm: 'LLM', other: 'Other' };
const roles = Object.keys(groups).sort((a, b) => {
const ai = ROLE_ORDER.indexOf(a), bi = ROLE_ORDER.indexOf(b);
return (ai < 0 ? 99 : ai) - (bi < 0 ? 99 : bi);
});
const currentRole = activeRole && groups[activeRole] ? activeRole : roles[0];
const rows = currentRole ? groups[currentRole] : [];
const COLUMNS = [
{ key: 'name', label: 'Model', flex: 3 },
{ key: 'size', label: 'Size', width: 80, align: 'right' },
{ key: 'status', label: 'Status', width: 110, align: 'center' },
{ key: 'actions', label: '', width: 108, align: 'right' },
];
return (
<section className="settings-section settings-section--compact">
<div className="models-toolbar">
<div className="models-toolbar__stats">
<span><strong>{fmtBytes(data.total_installed_bytes)}</strong> on disk</span>
<span className="models-toolbar__sep">·</span>
<span className="models-toolbar__cache">cache: <code>{data.hf_cache_dir}</code></span>
{info && <span className="models-toolbar__sep">·</span>}
{info && <span>model: {modelBadge}</span>}
</div>
<Button variant="subtle" size="sm" onClick={reload} leading={<RefreshCw size={11} />}>
Refresh
</Button>
</div>
{reco && reco.all_installed && (
<div className="reco-banner">
<CheckCircle size={12} color="#8ec07c" />
<span className="reco-banner__text">
Recommended bundle installed for <strong>{reco.device.label}</strong>
</span>
<span className="reco-banner__size">{reco.total_gb} GB</span>
</div>
)}
{reco && !reco.all_installed && (
<div className="reco-banner reco-banner--action">
<div className="reco-banner__body">
<div className="reco-banner__title">Recommended for {reco.device.label}</div>
<div className="reco-banner__rationale">{reco.rationale}</div>
</div>
<Button
variant="primary"
size="sm"
onClick={onInstallRecommended}
disabled={installingReco}
leading={installingReco ? <RefreshCw size={12} className="spinner" /> : null}
>
{installingReco ? 'Starting…' : `Install ~${reco.download_gb_remaining} GB`}
</Button>
</div>
)}
{roles.length > 1 && (
<Segmented
size="sm"
value={currentRole}
onChange={setActiveRole}
className="models-roletabs"
items={roles.map(r => {
const installed = groups[r].filter(m => m.installed).length;
return {
value: r,
label: `${ROLE_LABEL[r] || r.toUpperCase()} ${installed}/${groups[r].length}`,
};
})}
/>
)}
<Table className="models-table">
<Table.Header columns={COLUMNS} />
<div className="models-table__body">
{rows.map((m) => {
const rowBusy = busy.has(m.repo_id);
const files = Object.values(progress).filter(p => p.filename && p.filename.includes(m.repo_id.split('/').pop()));
const activeFile = files.find(f => f.phase !== 'done');
return (
<div key={m.repo_id} className={`models-row ${m.installed ? 'is-ok' : 'is-off'}`}>
<div className="models-row__cell models-row__name" style={{ flex: 3 }}>
<span className="models-row__title">
{m.label}
{m.required && <span className="models-row__tag">required</span>}
</span>
<span className="models-row__repo">
<code>{m.repo_id}</code>
{m.note && <span className="models-row__note"> · {m.note}</span>}
</span>
{activeFile && (
<span className="models-row__progress">
{activeFile.filename.split('/').pop()} · {Math.round(activeFile.pct * 100)}%
</span>
)}
</div>
<div className="models-row__cell models-row__size" style={{ width: 80, textAlign: 'right' }}>
{m.installed ? fmtBytes(m.size_on_disk_bytes) : `${m.size_gb} GB`}
</div>
<div className="models-row__cell" style={{ width: 110, display: 'flex', justifyContent: 'center' }}>
{rowBusy
? <Badge tone="warn" size="xs"><RefreshCw size={10} className="spinner" /> working</Badge>
: m.installed
? <Badge tone="success" size="xs">installed</Badge>
: <Badge tone="neutral" size="xs">not installed</Badge>}
</div>
<div className="models-row__cell models-row__actions" style={{ width: 108 }}>
{!m.installed && !rowBusy && (
<Button
variant="subtle" size="sm"
onClick={() => onInstall(m.repo_id)}
leading={<Download size={11} />}
>
Install
</Button>
)}
{m.installed && !rowBusy && (
<>
<Button
variant="icon" iconSize="sm"
onClick={() => onReinstall(m.repo_id)}
title="Reinstall"
aria-label="Reinstall"
>
<RefreshCw size={11} />
</Button>
<Button
variant="icon" iconSize="sm"
onClick={() => onDelete(m.repo_id)}
title="Delete"
aria-label="Delete"
>
<Trash2 size={11} />
</Button>
</>
)}
</div>
</div>
);
})}
</div>
</Table>
</section>
);
}
export function EnginesTab() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [switching, setSwitching] = useState(null);
const [activeFam, setActiveFam] = useState('tts');
const reviewMode = useAppStore(s => s.reviewMode);
const setReviewMode = useAppStore(s => s.setReviewMode);
@@ -76,96 +348,94 @@ function EnginesTab() {
}
if (!data) return null;
const fams = ['tts', 'asr', 'llm'].filter(f => data[f]);
const currentFam = fams.includes(activeFam) ? activeFam : fams[0];
const family = currentFam ? data[currentFam] : null;
const famTint = currentFam ? FAMILY_META[currentFam].tint : 'neutral';
const COLUMNS = [
{ key: 'name', label: 'Backend', flex: 3 },
{ key: 'status', label: 'Status', width: 120, align: 'center' },
{ key: 'action', label: '', width: 90, align: 'right' },
];
return (
<section className="settings-section">
<h2 className="settings-section__head-row">
<span className="settings-section__head-left">
<Plug size={16} color="#d3869b" /> Engines
</span>
<span className="settings-section__head-actions">
<Button variant="subtle" size="sm" onClick={reload} leading={<RefreshCw size={11} />}>
Refresh
</Button>
</span>
</h2>
<p className="settings-prose" style={{ marginTop: 0 }}>
Click <strong>Use</strong> to switch which backend handles each stage. Your pick persists across restarts.
Env vars (<code>OMNIVOICE_TTS_BACKEND</code>, ) still override the UI so power-users can pin a backend.
Unavailable engines show why usually a missing optional dep or a hardware mismatch.
</p>
<Panel variant="flat" padding="md" className="engines-family" title="Pipeline review mode">
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<section className="settings-section settings-section--compact">
<div className="models-toolbar">
<div className="models-toolbar__stats">
<Segmented
size="sm"
size="xs"
value={reviewMode}
onChange={setReviewMode}
items={[
{ value: 'on', label: 'Review between stages' },
{ value: 'on', label: 'Review' },
{ value: 'off', label: 'Rapid-fire' },
]}
/>
<span style={{ fontSize: '0.62rem', color: '#a89984', flex: 1 }}>
{reviewMode === 'on'
? 'Banners nudge you to check transcripts and translations before advancing.'
: 'Banners hidden — go straight from Prepare to Translate to Generate.'}
<span className="models-toolbar__sep">·</span>
<span>
{reviewMode === 'on' ? 'Stage banners on' : 'Stage banners off'}
</span>
</div>
</Panel>
<Button variant="subtle" size="sm" onClick={reload} leading={<RefreshCw size={11} />}>
Refresh
</Button>
</div>
{['tts', 'asr', 'llm'].map(fam => {
const { label, icon: FamIcon, tint } = FAMILY_META[fam];
const family = data[fam];
if (!family) return null;
return (
<Panel
key={fam}
variant="flat"
padding="md"
className="engines-family"
title={
<>
<FamIcon size={13} /> {label}
<span className="engines-family__active">
active: <code>{family.active}</code>
</span>
</>
}
>
<ul className="engines-list">
{family.backends.map(b => {
const isActive = family.active === b.id;
const isSwitching = switching === `${fam}:${b.id}`;
return (
<li key={b.id} className={b.available ? 'is-ok' : 'is-off'}>
<span className="engines-list__name">
<code>{b.id}</code> {b.display_name}
{fams.length > 1 && (
<Segmented
size="sm"
value={currentFam}
onChange={setActiveFam}
className="models-roletabs"
items={fams.map(f => ({
value: f,
label: `${FAMILY_META[f].label} · ${data[f].active}`,
}))}
/>
)}
{family && (
<Table className="models-table">
<Table.Header columns={COLUMNS} />
<div className="models-table__body">
{family.backends.map(b => {
const isActive = family.active === b.id;
const isSwitching = switching === `${currentFam}:${b.id}`;
return (
<div key={b.id} className={`models-row ${b.available ? 'is-ok' : 'is-off'}`}>
<div className="models-row__cell models-row__name" style={{ flex: 3 }}>
<span className="models-row__title">
{b.display_name}
{isActive && <Badge tone={famTint} size="xs">active</Badge>}
</span>
{isActive && <Badge tone={tint}>active</Badge>}
<span className="models-row__repo">
<code>{b.id}</code>
{!b.available && b.reason && <span className="models-row__note"> · {b.reason}</span>}
</span>
</div>
<div className="models-row__cell" style={{ width: 120, display: 'flex', justifyContent: 'center' }}>
{b.available
? <Badge tone="success" size="xs">ready</Badge>
: <Badge tone="warn" size="xs">unavailable</Badge>}
</div>
<div className="models-row__cell models-row__actions" style={{ width: 90 }}>
{!isActive && b.available && (
<Button
variant="subtle"
size="sm"
onClick={() => onSelect(fam, b.id)}
variant="subtle" size="sm"
onClick={() => onSelect(currentFam, b.id)}
loading={isSwitching}
>
Use
</Button>
)}
{!b.available && b.reason && (
<span className="engines-list__reason">{b.reason}</span>
)}
</li>
);
})}
</ul>
</Panel>
);
})}
</div>
</div>
);
})}
</div>
</Table>
)}
</section>
);
}
@@ -276,29 +546,7 @@ export default function Settings() {
className="settings-tabs-ui"
/>
{activeTab === 'models' && (
<section className="settings-section">
<h2><Cpu size={16} color="#f3a5b6" /> Models</h2>
{info ? (
<>
<Row label="TTS checkpoint" value={info.model_checkpoint} mono />
<Row label="ASR model (Whisper)" value={info.asr_model} mono />
<Row label="Translate provider" value={info.translate_provider} />
<Row label="Device" value={info.device?.toUpperCase()} />
<Row label="Status" value={modelBadge} />
<Row label="Idle timeout" value={`${info.idle_timeout_seconds}s`} />
<Row
label="Hugging Face token"
value={info.has_hf_token
? <Badge tone="success"><CheckCircle size={11} /> Set</Badge>
: <Badge tone="warn"><AlertCircle size={11} /> Missing diarization disabled</Badge>}
/>
</>
) : (
<div className="settings-muted">Loading</div>
)}
</section>
)}
{activeTab === 'models' && <ModelStoreTab info={info} modelBadge={modelBadge} />}
{activeTab === 'engines' && <EnginesTab />}
+192
View File
@@ -0,0 +1,192 @@
.setup-wizard {
width: 100%;
max-width: none;
margin: 0;
padding: 32px 40px 48px;
display: flex;
flex-direction: column;
gap: 16px;
overflow-y: auto;
}
/* Hero stays constrained + centered so the headline doesn't stretch
uncomfortably wide on ultrawide displays; the embedded panels below
get the full width. */
.setup-wizard__hero,
.setup-wizard__steps {
max-width: 720px;
margin-left: auto;
margin-right: auto;
}
/* Guided-flow step header ─────────────────────────────────────────────── */
.setup-wizard__steps {
display: flex;
gap: 8px;
justify-content: center;
margin-bottom: 8px;
}
.setup-wizard__step {
padding: 6px 14px;
border-radius: 999px;
font-size: 0.78rem;
letter-spacing: 0.02em;
color: var(--color-fg-muted);
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
cursor: pointer;
transition: all 0.15s;
}
.setup-wizard__step--active {
background: rgba(243, 165, 182, 0.12);
border-color: rgba(243, 165, 182, 0.35);
color: #f3a5b6;
}
.setup-wizard__step--done {
color: #8ec07c;
border-color: rgba(142, 192, 124, 0.35);
}
.setup-wizard__embed {
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px;
padding: 16px;
margin-top: 4px;
}
.setup-wizard__nav {
display: flex;
justify-content: space-between;
gap: 8px;
margin-top: 8px;
}
.setup-wizard--centered {
max-width: 100%;
height: 100%;
justify-content: center;
align-items: center;
text-align: center;
}
.setup-wizard__hero {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 24px 12px 12px;
text-align: center;
}
.setup-wizard__hero h1 {
margin: 0;
font-size: 1.6rem;
letter-spacing: -0.01em;
}
.setup-wizard__sub {
margin: 0;
color: var(--color-fg-muted);
font-size: 0.85rem;
max-width: 440px;
line-height: 1.5;
}
.setup-wizard__card {
display: flex;
flex-direction: column;
gap: 10px;
}
.setup-wizard__card--error {
background: rgba(251, 73, 52, 0.06);
border-color: rgba(251, 73, 52, 0.3);
color: var(--color-danger);
}
.setup-wizard__card-head {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.72rem;
color: var(--color-fg-muted);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.setup-wizard__row {
display: flex;
align-items: center;
gap: 12px;
}
.setup-wizard__row-body { flex: 1; display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.setup-wizard__row-title { font-weight: 600; font-size: 0.85rem; }
.setup-wizard__muted {
color: var(--color-fg-muted);
font-size: 0.72rem;
}
.setup-wizard__muted code { color: var(--color-fg-subtle); }
.setup-wizard__warn { color: var(--color-warn, #fabd2f); font-weight: 600; }
.setup-wizard__models {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.setup-wizard__models li {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.78rem;
}
.setup-wizard__models code { font-size: 0.68rem; }
.setup-wizard__aggregate {
display: flex;
flex-direction: column;
gap: 6px;
}
.setup-wizard__files {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 280px;
overflow-y: auto;
padding: 4px 2px 2px;
}
.setup-wizard__file {
display: grid;
grid-template-columns: 1fr 120px 44px;
align-items: center;
gap: 10px;
font-size: 0.72rem;
}
.setup-wizard__file-name {
font-family: var(--font-mono);
color: var(--chrome-fg-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.setup-wizard__file-pct {
text-align: right;
font-variant-numeric: tabular-nums;
color: var(--color-fg-muted);
}
.setup-wizard__actions {
display: flex;
gap: 10px;
justify-content: center;
padding-top: 8px;
}
.setup-wizard__footnote {
color: var(--color-fg-subtle);
font-size: 0.65rem;
margin: 4px 0 0;
text-align: center;
}
.setup-wizard__footnote code { font-size: 0.62rem; }
+185
View File
@@ -0,0 +1,185 @@
import React, { useCallback, useEffect, useState } from 'react';
import { CheckCircle, Loader, Sparkles, ArrowRight } from 'lucide-react';
import { Button } from '../ui';
import { setupStatus } from '../api/setup';
import { ModelStoreTab, EnginesTab } from './Settings';
import './SetupWizard.css';
// macOS convention: double-click the title-bar drag region to toggle zoom.
// Same pattern as components/Header.jsx we don't import `@tauri-apps/api`
// eagerly so the browser preview doesn't need it.
const doubleClickMaximize = async () => {
try {
if (!('__TAURI_INTERNALS__' in window)) return;
const { getCurrentWindow } = await import('@tauri-apps/api/window');
getCurrentWindow().toggleMaximize();
} catch { /* non-tauri preview — ignore */ }
};
/**
* First-run / "no models installed" gate.
*
* Re-uses the real Settings Models and Settings Engines panels inside a
* 3-step guided flow so the wizard stays a single source of truth. If either
* tab gets a feature later (new engine, new model column), onboarding
* inherits it for free no parallel UI to maintain.
*
* Flow:
* 1. Welcome hero + explainer + "continue"
* 2. Models embed ModelStoreTab. User installs the required weights
* (OmniVoice + Systran/faster-whisper-large-v3). Polls
* /setup/status in the background the "Finish" button
* unlocks as soon as `models_ready` flips true.
* 3. Engines embed EnginesTab. User picks TTS/ASR/LLM backends
* (or just clicks through on sensible defaults).
* Finish onReady() dismisses the wizard, main studio renders.
*/
export default function SetupWizard({ onReady }) {
const [step, setStep] = useState(0);
const [status, setStatus] = useState(null);
const reload = useCallback(async () => {
try { setStatus(await setupStatus()); }
catch { /* backend warming up — retry on interval */ }
}, []);
useEffect(() => { reload(); }, [reload]);
// Poll while on the Models step so the Finish button unlocks as soon as
// downloads complete, without the user having to click "Recheck".
useEffect(() => {
if (step !== 1) return;
const iv = setInterval(reload, 4000);
return () => clearInterval(iv);
}, [step, reload]);
const modelsReady = !!status?.models_ready;
return (
<div className="setup-wizard">
{/* Title-bar drag region. Covers the whole hero + step pill area so
the user can drag the window by the top of the wizard and
double-click to toggle zoom matches macOS native behaviour. */}
<div
data-tauri-drag-region
onDoubleClick={doubleClickMaximize}
className="setup-wizard__hero"
>
<Sparkles size={36} color="#d3869b" />
<h1 data-tauri-drag-region>Welcome to OmniVoice Studio</h1>
<p className="setup-wizard__sub" data-tauri-drag-region>
Dubbing, voice cloning, and voice design all running locally on
your machine. Three quick steps and you're in.
</p>
</div>
{/* Step indicator */}
<div className="setup-wizard__steps">
{['Welcome', 'Install models', 'Pick engines'].map((label, i) => (
<button
key={label}
className={[
'setup-wizard__step',
step === i ? 'setup-wizard__step--active' : '',
step > i ? 'setup-wizard__step--done' : '',
].filter(Boolean).join(' ')}
onClick={() => setStep(i)}
type="button"
>
{step > i ? '✓ ' : `${i + 1}. `}{label}
</button>
))}
</div>
{/* Step content */}
{step === 0 && (
<div className="setup-wizard__embed" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<strong>What happens next</strong>
<ol style={{ margin: 0, paddingLeft: 20, lineHeight: 1.7, color: 'var(--color-fg-muted)', fontSize: '0.9rem' }}>
<li>
<strong>Install models</strong> we'll download ~5 GB of weights
(OmniVoice TTS + Whisper). You'll see the full catalogue here
required ones first, plus optional engines you can enable now or
later.
</li>
<li>
<strong>Pick engines</strong> choose which TTS / ASR / LLM
backends to use. Defaults work; power users can pin specific
engines per family.
</li>
<li>
<strong>You're in.</strong> First launch takes ~5-10 minutes to
download. After that, every launch is instant and fully offline.
</li>
</ol>
<div>
<Button
variant="primary" size="lg"
onClick={() => setStep(1)}
trailing={<ArrowRight size={14} />}
>
Get started
</Button>
</div>
</div>
)}
{step === 1 && (
<>
<div className="setup-wizard__embed">
<ModelStoreTab info={null} modelBadge={null} />
</div>
<div className="setup-wizard__nav">
<Button variant="ghost" onClick={() => setStep(0)}>Back</Button>
<Button
variant={modelsReady ? 'primary' : 'ghost'}
onClick={() => setStep(2)}
trailing={<ArrowRight size={14} />}
disabled={!modelsReady}
title={modelsReady ? '' : 'Install the required models above to continue.'}
>
{modelsReady
? 'Required models ready — continue'
: 'Waiting for required models…'}
</Button>
</div>
{!modelsReady && status?.missing?.length > 0 && (
<p className="setup-wizard__muted" style={{ textAlign: 'center', fontSize: '0.78rem', margin: 0 }}>
Still needed:{' '}
{status.missing.map(m => m.label).join(', ')}
</p>
)}
</>
)}
{step === 2 && (
<>
<div className="setup-wizard__embed">
<EnginesTab />
</div>
<div className="setup-wizard__nav">
<Button variant="ghost" onClick={() => setStep(1)}>Back</Button>
<Button
variant="primary"
onClick={onReady}
leading={<CheckCircle size={14} />}
>
Enter studio
</Button>
</div>
</>
)}
{!status && step > 0 && (
<div style={{ display: 'flex', gap: 8, alignItems: 'center', justifyContent: 'center', color: 'var(--color-fg-muted)' }}>
<Loader className="spinner" size={14} /> Checking setup
</div>
)}
<p className="setup-wizard__footnote">
Downloads come from <code>huggingface.co</code>. Cache: {' '}
<code>{status?.hf_cache_dir || '~/.cache/huggingface'}</code>
</p>
</div>
);
}
+1
View File
@@ -53,6 +53,7 @@ export const useAppStore = create<AppStore>()(
partialize: (s) => ({
translateQuality: s.translateQuality,
dualSubs: s.dualSubs,
burnSubs: s.burnSubs,
glossaryVisible: s.glossaryVisible,
reviewMode: s.reviewMode,
mode: s.mode,
+4
View File
@@ -13,6 +13,7 @@ export type TranslateQuality = 'fast' | 'cinematic';
export interface PrefsSlice {
translateQuality: TranslateQuality;
dualSubs: boolean;
burnSubs: boolean;
glossaryVisible: boolean;
/**
* Phase 4.3 staged checkpoints. When 'on', between-stage banners nudge
@@ -23,6 +24,7 @@ export interface PrefsSlice {
setTranslateQuality: (q: TranslateQuality) => void;
setDualSubs: (on: boolean) => void;
setBurnSubs: (on: boolean) => void;
setGlossaryVisible: (on: boolean) => void;
setReviewMode: (mode: 'on' | 'off') => void;
}
@@ -30,11 +32,13 @@ export interface PrefsSlice {
export const createPrefsSlice: StateCreator<PrefsSlice, [], [], PrefsSlice> = (set) => ({
translateQuality: 'fast',
dualSubs: false,
burnSubs: false,
glossaryVisible: true,
reviewMode: 'on',
setTranslateQuality: (q) => set({ translateQuality: q }),
setDualSubs: (on) => set({ dualSubs: on }),
setBurnSubs: (on) => set({ burnSubs: on }),
setGlossaryVisible: (on) => set({ glossaryVisible: on }),
setReviewMode: (mode) => set({ reviewMode: mode }),
});
+18 -17
View File
@@ -1,16 +1,16 @@
/* ── Badge primitive ────────────────────────────────────────────── */
/* ── Badge primitive — chrome tokens ─────────────────────────────── */
.ui-badge {
display: inline-flex;
align-items: center;
gap: var(--space-1);
border-radius: var(--radius-pill);
font-family: var(--font-ui);
font-weight: var(--weight-bold);
letter-spacing: 0.03em;
border-radius: var(--chrome-radius-pill);
font-family: var(--chrome-font-mono);
font-weight: 600;
letter-spacing: var(--chrome-label-track);
text-transform: uppercase;
border: 1px solid currentColor;
background: rgba(0, 0, 0, 0.12);
background: transparent;
white-space: nowrap;
user-select: none;
line-height: 1.2;
@@ -21,21 +21,22 @@
height: 5px;
border-radius: 50%;
background: currentColor;
box-shadow: 0 0 6px currentColor;
box-shadow: none;
}
/* sizes */
.ui-badge--size-xs { padding: 0px 5px; font-size: var(--text-2xs); }
.ui-badge--size-sm { padding: 1px 6px; font-size: var(--text-xs); }
.ui-badge--size-xs { padding: 0px 6px; font-size: var(--chrome-label-size); }
.ui-badge--size-sm { padding: 1px 7px; font-size: var(--chrome-label-size); }
/* tones — colour applied via `color` so dot + border follow */
.ui-badge--neutral { color: var(--color-fg-subtle); border-color: var(--color-border); background: rgba(255,255,255,0.04); }
.ui-badge--brand { color: var(--color-brand); border-color: rgba(211,134,155,0.4); background: rgba(211,134,155,0.12); }
.ui-badge--success { color: var(--color-success); border-color: rgba(142,192,124,0.4); background: rgba(142,192,124,0.10); }
.ui-badge--warn { color: var(--color-accent); border-color: rgba(250,189,47,0.4); background: rgba(250,189,47,0.10); }
.ui-badge--danger { color: var(--color-danger); border-color: rgba(251,73,52,0.4); background: rgba(251,73,52,0.10); }
.ui-badge--info { color: var(--color-info); border-color: rgba(131,165,152,0.4); background: rgba(131,165,152,0.10); }
.ui-badge--violet { color: #a78bfa; border-color: rgba(167,139,250,0.3); background: rgba(94,106,210,0.12); }
/* tones color applied via `color` so dot + border follow. Fill stays near-
transparent so the badge reads as a chrome chip, not a filled pill. */
.ui-badge--neutral { color: var(--chrome-fg-muted); border-color: var(--chrome-border-strong); background: transparent; }
.ui-badge--brand { color: var(--chrome-accent); border-color: var(--chrome-accent-border); background: var(--chrome-accent-bg); }
.ui-badge--success { color: var(--chrome-severity-ok); border-color: color-mix(in srgb, var(--chrome-severity-ok) 45%, transparent); background: color-mix(in srgb, var(--chrome-severity-ok) 10%, transparent); }
.ui-badge--warn { color: var(--chrome-severity-warn); border-color: color-mix(in srgb, var(--chrome-severity-warn) 45%, transparent); background: color-mix(in srgb, var(--chrome-severity-warn) 10%, transparent); }
.ui-badge--danger { color: var(--chrome-severity-err); border-color: color-mix(in srgb, var(--chrome-severity-err) 45%, transparent); background: color-mix(in srgb, var(--chrome-severity-err) 10%, transparent); }
.ui-badge--info { color: #83a598; border-color: color-mix(in srgb, #83a598 45%, transparent); background: color-mix(in srgb, #83a598 10%, transparent); }
.ui-badge--violet { color: var(--chrome-fg-muted); border-color: var(--chrome-border-strong); background: transparent; }
/* pulse modifier — for "loading" / "thinking" states */
.ui-badge--pulse .ui-badge__dot { animation: pulse 1.5s ease-in-out infinite; }
+78 -87
View File
@@ -1,4 +1,4 @@
/* ── Button primitive ────────────────────────────────────────────── */
/* ── Button primitive — chrome tokens only ────────────────────────── */
.ui-btn {
display: inline-flex;
@@ -6,29 +6,26 @@
justify-content: center;
gap: var(--space-3);
border: 1px solid transparent;
border-radius: var(--radius-md);
border-radius: var(--chrome-radius-pill);
background: transparent;
color: var(--color-fg);
color: var(--chrome-fg);
font-family: var(--font-sans);
font-weight: var(--weight-semibold);
font-weight: 500;
letter-spacing: 0.02em;
cursor: pointer;
white-space: nowrap;
transition:
background var(--dur-base) var(--ease-out),
border-color var(--dur-base) var(--ease-out),
color var(--dur-base) var(--ease-out),
transform var(--dur-base) var(--ease-spring),
box-shadow var(--dur-base) var(--ease-out),
filter var(--dur-base) var(--ease-out);
background var(--dur-fast) var(--ease-out),
border-color var(--dur-fast) var(--ease-out),
color var(--dur-fast) var(--ease-out);
position: relative;
overflow: hidden;
user-select: none;
letter-spacing: 0.01em;
}
.ui-btn:disabled {
opacity: 0.35;
opacity: 0.4;
cursor: not-allowed;
filter: grayscale(0.5);
filter: none;
}
.ui-btn:focus-visible {
outline: none;
@@ -40,137 +37,131 @@
.ui-btn.is-loading { cursor: wait; }
/* ── Size scale ──────────────────────────────────────────────────── */
.ui-btn--size-sm { padding: 3px 8px; font-size: var(--text-xs); border-radius: var(--radius-sm); }
.ui-btn--size-md { padding: 6px 10px; font-size: var(--text-md); }
.ui-btn--size-sm { padding: 3px 10px; font-size: var(--text-xs); border-radius: var(--chrome-radius-pill); }
.ui-btn--size-md { padding: 6px 12px; font-size: var(--text-md); border-radius: var(--chrome-radius-pill); }
/* ── Variants ────────────────────────────────────────────────────── */
/* primary — gradient CTA, supersedes legacy .btn-primary */
/* primary — flat chrome accent, no gradient / shimmer */
.ui-btn--primary {
background: linear-gradient(135deg, var(--color-brand) 0%, var(--color-brand-hover) 100%);
color: var(--color-fg-inverse);
border-color: var(--color-border-strong);
box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.2);
background: var(--chrome-accent-bg);
color: var(--chrome-accent);
border-color: var(--chrome-accent-border);
box-shadow: none;
}
.ui-btn--primary::before {
content: '';
position: absolute;
inset: 0 auto 0 -100%;
width: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
transition: left var(--dur-xslow) var(--ease-out);
}
.ui-btn--primary:hover:not(:disabled)::before { left: 100%; }
.ui-btn--primary::before { display: none; }
.ui-btn--primary:hover:not(:disabled) {
filter: brightness(1.15);
box-shadow: 0 4px 12px var(--color-brand-glow), inset 0 1px 0 rgba(255, 255, 255, 0.3);
transform: translateY(-1px);
filter: none;
background: color-mix(in srgb, var(--chrome-accent) 22%, transparent);
box-shadow: none;
transform: none;
}
.ui-btn--primary:active:not(:disabled) {
transform: scale(0.97) translateY(1px);
filter: brightness(0.9);
box-shadow: var(--shadow-sm);
transform: none;
filter: none;
box-shadow: none;
}
/* subtle — default neutral surface action */
.ui-btn--subtle {
background: rgba(255, 255, 255, 0.04);
border-color: var(--color-border);
color: var(--color-fg-muted);
background: transparent;
border-color: var(--chrome-border);
color: var(--chrome-fg-muted);
}
.ui-btn--subtle:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.10);
color: var(--color-fg);
border-color: var(--color-border-strong);
transform: translateY(-1px);
background: var(--chrome-hover-bg);
color: var(--chrome-fg);
border-color: var(--chrome-border-strong);
transform: none;
}
.ui-btn--subtle:active:not(:disabled) { transform: translateY(0); }
.ui-btn--subtle:active:not(:disabled) { transform: none; }
/* ghost — transparent, hover surfaces */
.ui-btn--ghost {
background: transparent;
border-color: transparent;
color: var(--color-fg-muted);
color: var(--chrome-fg-muted);
}
.ui-btn--ghost:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.05);
color: var(--color-fg);
background: var(--chrome-hover-bg);
color: var(--chrome-fg);
}
/* danger — destructive */
.ui-btn--danger {
background: rgba(251, 73, 52, 0.12);
border-color: rgba(251, 73, 52, 0.30);
color: var(--color-danger);
background: color-mix(in srgb, var(--chrome-severity-err) 10%, transparent);
border-color: color-mix(in srgb, var(--chrome-severity-err) 45%, transparent);
color: var(--chrome-severity-err);
}
.ui-btn--danger:hover:not(:disabled) {
background: rgba(251, 73, 52, 0.22);
border-color: var(--color-danger);
background: color-mix(in srgb, var(--chrome-severity-err) 18%, transparent);
border-color: var(--chrome-severity-err);
}
/* chip — selectable tag */
.ui-btn--chip {
padding: 2px 6px;
padding: 2px 8px;
font-size: var(--text-xs);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.04);
border-color: var(--color-border);
color: var(--color-fg-muted);
border-radius: var(--chrome-radius-pill);
background: transparent;
border-color: var(--chrome-border);
color: var(--chrome-fg-muted);
font-family: var(--font-sans);
font-weight: var(--weight-medium);
font-weight: 500;
}
.ui-btn--chip:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.10);
color: var(--color-fg);
border-color: rgba(255, 255, 255, 0.20);
transform: translateY(-1px);
background: var(--chrome-hover-bg);
color: var(--chrome-fg);
border-color: var(--chrome-border-strong);
transform: none;
}
.ui-btn--chip.is-active {
background: rgba(142, 192, 124, 0.12);
color: var(--color-success);
border-color: var(--color-success);
background: color-mix(in srgb, var(--chrome-severity-ok) 12%, transparent);
color: var(--chrome-severity-ok);
border-color: color-mix(in srgb, var(--chrome-severity-ok) 45%, transparent);
}
/* preset — violet-tinted picker tile */
/* preset — neutral chrome tile, no violet tint */
.ui-btn--preset {
padding: 3px 6px;
padding: 3px 8px;
font-size: var(--text-xs);
border-radius: var(--radius-md);
background: rgba(94, 106, 210, 0.06);
border-color: rgba(94, 106, 210, 0.15);
color: #c7d2fe;
border-radius: var(--chrome-radius-pill);
background: transparent;
border-color: var(--chrome-border);
color: var(--chrome-fg-muted);
text-align: left;
justify-content: flex-start;
}
.ui-btn--preset:hover:not(:disabled) {
background: rgba(94, 106, 210, 0.15);
border-color: #a78bfa;
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(94, 106, 210, 0.15);
background: var(--chrome-hover-bg);
color: var(--chrome-fg);
border-color: var(--chrome-border-strong);
transform: none;
box-shadow: none;
}
.ui-btn--preset.is-active {
background: rgba(94, 106, 210, 0.20);
border-color: #a78bfa;
color: #e0e7ff;
background: var(--chrome-accent-bg);
border-color: var(--chrome-accent-border);
color: var(--chrome-accent);
}
/* icon — square, centered, for toolbar/inline actions */
.ui-btn--icon {
padding: 0;
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.04);
border-color: var(--color-border);
color: var(--color-fg-muted);
border-radius: var(--chrome-radius-pill);
background: transparent;
border-color: var(--chrome-border);
color: var(--chrome-fg-muted);
}
.ui-btn--icon-sm { width: 20px; height: 20px; }
.ui-btn--icon-md { width: 22px; height: 22px; }
.ui-btn--icon:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.10);
color: var(--color-fg);
border-color: var(--color-border-strong);
background: var(--chrome-hover-bg);
color: var(--chrome-fg);
border-color: var(--chrome-border-strong);
}
.ui-btn--icon.is-active {
background: rgba(211, 134, 155, 0.15);
color: var(--color-brand);
border-color: rgba(211, 134, 155, 0.30);
background: var(--chrome-accent-bg);
color: var(--chrome-accent);
border-color: var(--chrome-accent-border);
}
+16 -12
View File
@@ -5,8 +5,8 @@
flex-direction: column;
min-height: 0;
flex: 1;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
border: 1px solid var(--chrome-border);
border-radius: var(--chrome-radius-pill);
overflow: hidden;
}
@@ -16,8 +16,8 @@
align-items: center;
gap: var(--space-3);
padding: var(--space-2) var(--space-3);
background: rgba(0, 0, 0, 0.15);
border-bottom: 1px solid var(--color-border);
background: var(--chrome-bg);
border-bottom: 1px solid var(--chrome-border);
flex-shrink: 0;
}
@@ -31,14 +31,16 @@
.ui-table-toolbar__search-icon {
position: absolute;
left: 6px;
color: var(--color-fg-subtle);
color: var(--chrome-fg-dim);
pointer-events: none;
}
.ui-table-toolbar__search-input {
width: 100%;
padding-left: 22px;
padding-right: 22px;
font-family: var(--font-sans);
font-size: var(--text-xs);
border-radius: var(--chrome-radius-pill);
}
.ui-table-toolbar__search-clear {
position: absolute !important;
@@ -50,8 +52,9 @@
}
.ui-table-toolbar__meta {
font-family: var(--chrome-font-mono);
font-size: var(--text-xs);
color: var(--color-fg-subtle);
color: var(--chrome-fg-dim);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
@@ -62,15 +65,16 @@
align-items: center;
gap: var(--space-2);
padding: 3px var(--space-3);
background: rgba(0, 0, 0, 0.35);
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
color: var(--color-fg-muted);
background: var(--chrome-bg);
font-family: var(--chrome-font-mono);
font-size: var(--chrome-label-size);
font-weight: 600;
color: var(--chrome-fg-muted);
position: sticky;
top: 0;
z-index: 1;
border-bottom: 1px solid var(--color-border);
letter-spacing: 0.03em;
border-bottom: 1px solid var(--chrome-border);
letter-spacing: var(--chrome-label-track);
text-transform: uppercase;
flex-shrink: 0;
}
+32 -40
View File
@@ -1,90 +1,82 @@
/* ── Tabs primitive ─────────────────────────────────────────────── */
/* ── Tabs primitive — chrome tokens ────────────────────────────── */
.ui-tabs {
display: inline-flex;
gap: 3px;
flex-shrink: 0;
--ui-tab-accent: var(--color-brand);
--ui-tab-accent: var(--chrome-accent);
}
/* pill variant — floating segmented bar */
/* pill variant — flat chrome segmented bar */
.ui-tabs--pill {
background: rgba(0, 0, 0, 0.3);
background: var(--chrome-bg);
padding: 3px;
border-radius: var(--radius-pill);
border: 1px solid var(--color-border);
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.45);
border-radius: var(--chrome-radius-pill);
border: 1px solid var(--chrome-border);
box-shadow: none;
}
.ui-tabs--pill .ui-tabs__tab {
flex: 0 0 auto;
padding: 5px 14px;
border: 1px solid transparent;
border-radius: var(--radius-pill);
border-radius: var(--chrome-radius-pill);
background: transparent;
color: var(--color-fg-muted);
font-family: var(--font-ui);
font-weight: var(--weight-bold);
color: var(--chrome-fg-muted);
font-family: var(--font-sans);
font-weight: 500;
font-size: var(--text-base);
letter-spacing: 0.01em;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-3);
letter-spacing: 0.005em;
transition:
transform 0.22s var(--ease-spring),
background var(--dur-base) var(--ease-out),
color var(--dur-base) var(--ease-out),
box-shadow var(--dur-base) var(--ease-out);
background var(--dur-fast) var(--ease-out),
color var(--dur-fast) var(--ease-out),
border-color var(--dur-fast) var(--ease-out);
position: relative;
}
.ui-tabs--pill .ui-tabs__tab:hover:not(.is-active) {
color: #f5e6c5;
background: rgba(255, 255, 255, 0.04);
transform: translateY(-1px);
color: var(--chrome-fg);
background: var(--chrome-hover-bg);
transform: none;
}
.ui-tabs--pill .ui-tabs__tab.is-active {
background: linear-gradient(
140deg,
color-mix(in srgb, var(--ui-tab-accent) 22%, transparent) 0%,
rgba(250, 189, 47, 0.18) 100%
);
color: #fff9ef;
border-color: color-mix(in srgb, var(--ui-tab-accent) 40%, transparent);
box-shadow:
0 2px 0 rgba(0, 0, 0, 0.2),
0 6px 16px -8px color-mix(in srgb, var(--ui-tab-accent) 50%, transparent),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
font-weight: var(--weight-black);
transform: translateY(-1px);
background: var(--chrome-accent-bg);
color: var(--chrome-accent);
border-color: var(--chrome-accent-border);
box-shadow: none;
font-weight: 600;
transform: none;
}
.ui-tabs--pill .ui-tabs__tab.is-active .ui-tabs__icon { color: var(--ui-tab-accent); }
.ui-tabs--pill .ui-tabs__tab.is-active .ui-tabs__icon { color: var(--chrome-accent); }
/* underline variant — compact secondary nav */
.ui-tabs--underline {
gap: var(--space-5);
border-bottom: 1px solid var(--color-border);
border-bottom: 1px solid var(--chrome-border);
}
.ui-tabs--underline .ui-tabs__tab {
padding: 6px 2px;
background: transparent;
border: 0;
border-bottom: 2px solid transparent;
color: var(--color-fg-muted);
color: var(--chrome-fg-muted);
font-family: var(--font-sans);
font-weight: var(--weight-semibold);
font-weight: 500;
font-size: var(--text-base);
cursor: pointer;
display: flex;
align-items: center;
gap: var(--space-2);
transition: color var(--dur-base), border-color var(--dur-base);
transition: color var(--dur-fast), border-color var(--dur-fast);
margin-bottom: -1px;
}
.ui-tabs--underline .ui-tabs__tab:hover:not(.is-active) { color: var(--color-fg); }
.ui-tabs--underline .ui-tabs__tab:hover:not(.is-active) { color: var(--chrome-fg); }
.ui-tabs--underline .ui-tabs__tab.is-active {
color: var(--color-fg);
border-color: var(--ui-tab-accent);
color: var(--chrome-accent);
border-color: var(--chrome-accent);
}
/* size */
+10 -5
View File
@@ -53,11 +53,16 @@
--radius-xl: 10px;
--radius-pill: 999px;
/* ── Typography ─────────────────────────────────────────────────── */
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
--font-display:'Fraunces', Georgia, serif;
--font-ui: 'Nunito', 'Inter', sans-serif;
--font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
/* Typography (Option A Codex-inspired stack)
Inter for UI/body (closest OSS Söhne analog), IBM Plex Mono for chrome
labels + data + logs (warmer than Geist Mono, pairs with OpenAI's feel),
Source Serif 4 for hero moments only. Variable faces where possible so
weight 400700 comes from a single file download. */
--font-sans: 'Inter Variable', 'Inter', -apple-system, BlinkMacSystemFont, 'Söhne', 'Helvetica Neue', Arial, sans-serif;
--font-mono: 'IBM Plex Mono', ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, monospace;
--font-serif: 'Source Serif 4 Variable', 'Source Serif 4', 'Söhne Breit', Georgia, serif;
--font-display: var(--font-serif);
--font-ui: var(--font-sans);
--text-2xs: 0.58rem;
--text-xs: 0.62rem;