mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-18 06:10:43 -07:00
feat(ui): theme settings, stories polish, track editor restructure
- add dark/light/system theme with persisted choice + OS change listener - restyle stories sidebar (search, item layout, border) to match captures - move floating generate box to right column of stories, add top fade mask - story track editor: sticky track labels aligned via flex rows, custom scrollbar with left/right zoom handles - capture pill light mode pass, fix inline waveform progress color - pull mcp_server hidden imports into the pyinstaller spec - notarization doc draft
This commit is contained in:
+17
-1
@@ -1,10 +1,26 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>voicebox</title>
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
var theme = 'system';
|
||||
var raw = localStorage.getItem('voicebox-ui');
|
||||
if (raw) {
|
||||
var parsed = JSON.parse(raw);
|
||||
if (parsed && parsed.state && parsed.state.theme) theme = parsed.state.theme;
|
||||
}
|
||||
var resolved = theme === 'system'
|
||||
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
|
||||
: theme;
|
||||
if (resolved === 'dark') document.documentElement.classList.add('dark');
|
||||
} catch (_) {}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { DictateWindow } from '@/components/DictateWindow/DictateWindow';
|
||||
import ShinyText from '@/components/ShinyText';
|
||||
import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
|
||||
import { useAutoUpdater } from '@/hooks/useAutoUpdater';
|
||||
import { useThemeSync } from '@/hooks/useThemeSync';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type { HealthResponse } from '@/lib/api/types';
|
||||
import { useChordSync } from '@/lib/hooks/useChordSync';
|
||||
@@ -71,6 +72,8 @@ const LOADING_MESSAGES = [
|
||||
];
|
||||
|
||||
function App() {
|
||||
useThemeSync();
|
||||
|
||||
// The dictate window runs in a separate Tauri webview that must skip
|
||||
// server bootstrap (the main window owns that lifecycle) and render only
|
||||
// the floating recording surface. Split into a sibling component so the
|
||||
|
||||
@@ -135,8 +135,9 @@ export function CapturePill({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center gap-3 px-4 h-10 rounded-full',
|
||||
'bg-black/55 backdrop-blur-md text-accent',
|
||||
'inline-flex items-center gap-3 px-4 h-10 rounded-full text-accent',
|
||||
'bg-white/80 ring-1 ring-black/5 shadow-lg backdrop-blur-xl',
|
||||
'dark:bg-black/55 dark:ring-0 dark:shadow-none dark:backdrop-blur-md',
|
||||
completedStroke,
|
||||
'transition-opacity duration-300 ease-out',
|
||||
visible ? 'opacity-100' : 'opacity-0 pointer-events-none',
|
||||
@@ -182,8 +183,9 @@ function ErrorPill({
|
||||
title={t('captures.pill.errorCopyTooltip')}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2.5 px-4 h-10 rounded-full',
|
||||
'bg-black/65 backdrop-blur-md text-red-300',
|
||||
'max-w-[380px] hover:bg-black/80 transition-colors',
|
||||
'bg-white/85 ring-1 ring-destructive/25 shadow-lg backdrop-blur-xl text-red-600 hover:bg-white',
|
||||
'dark:bg-black/65 dark:ring-0 dark:shadow-none dark:backdrop-blur-md dark:text-red-300 dark:hover:bg-black/80',
|
||||
'max-w-[380px] transition-colors',
|
||||
'focus:outline-none focus:ring-2 focus:ring-red-400/50',
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -37,12 +37,15 @@ export function CaptureInlinePlayer({
|
||||
const root = document.documentElement;
|
||||
const cssHsla = (varName: string, alpha: number) => {
|
||||
const value = getComputedStyle(root).getPropertyValue(varName).trim();
|
||||
return value ? `hsl(${value} / ${alpha})` : '';
|
||||
if (!value) return '';
|
||||
const [h, s, l] = value.split(/\s+/);
|
||||
if (!h || !s || !l) return '';
|
||||
return `hsla(${h}, ${s}, ${l}, ${alpha})`;
|
||||
};
|
||||
|
||||
const ws = WaveSurfer.create({
|
||||
container,
|
||||
waveColor: cssHsla('--foreground', 0.25),
|
||||
waveColor: cssHsla('--muted-foreground', 1),
|
||||
progressColor: cssHsla('--accent', 1),
|
||||
cursorColor: 'transparent',
|
||||
barWidth: 2,
|
||||
|
||||
@@ -255,10 +255,10 @@ export function FloatingGenerateBox({
|
||||
<motion.div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
'fixed right-auto',
|
||||
'fixed',
|
||||
isStoriesRoute
|
||||
? // Position aligned with story list: after sidebar + padding, width 360px
|
||||
'left-[calc(5rem+2rem)] w-[360px]'
|
||||
? // Aligned with StoryContent: sidebar + list width + gap (tab bleeds with -mx-8)
|
||||
'left-[calc(5rem+360px+1.5rem)] right-8'
|
||||
: 'left-[calc(5rem+2rem)] right-8 lg:right-auto lg:w-[calc((100%-5rem-4rem)/2-1rem)]',
|
||||
)}
|
||||
style={{
|
||||
|
||||
@@ -16,6 +16,7 @@ import { usePlatform } from '@/platform/PlatformContext';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
import { LanguageSelect } from './LanguageSelect';
|
||||
import { SettingRow, SettingSection } from './SettingRow';
|
||||
import { ThemeSelect } from './ThemeSelect';
|
||||
|
||||
function makeConnectionSchema(invalidUrl: string) {
|
||||
return z.object({
|
||||
@@ -198,6 +199,12 @@ export function GeneralPage() {
|
||||
description={t('settings.language.description')}
|
||||
action={<LanguageSelect />}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
title={t('settings.theme.label')}
|
||||
description={t('settings.theme.description')}
|
||||
action={<ThemeSelect />}
|
||||
/>
|
||||
</SettingSection>
|
||||
|
||||
<ApiReferenceCard serverUrl={serverUrl} />
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { type Theme, useUIStore } from '@/stores/uiStore';
|
||||
|
||||
export function ThemeSelect() {
|
||||
const { t } = useTranslation();
|
||||
const theme = useUIStore((s) => s.theme);
|
||||
const setTheme = useUIStore((s) => s.setTheme);
|
||||
|
||||
return (
|
||||
<Select value={theme} onValueChange={(value) => setTheme(value as Theme)}>
|
||||
<SelectTrigger className="h-9 w-[180px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="system">{t('settings.theme.options.system')}</SelectItem>
|
||||
<SelectItem value="light">{t('settings.theme.options.light')}</SelectItem>
|
||||
<SelectItem value="dark">{t('settings.theme.options.dark')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -47,15 +47,7 @@ export function Sidebar({ isMacOS }: SidebarProps) {
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="mb-2">
|
||||
<img
|
||||
src={voiceboxLogo}
|
||||
alt="Voicebox"
|
||||
className="w-12 h-12 object-contain"
|
||||
style={{
|
||||
filter:
|
||||
'drop-shadow(0 0 6px hsl(var(--accent) / 0.5)) drop-shadow(0 0 14px hsl(var(--accent) / 0.35)) drop-shadow(0 0 28px hsl(var(--accent) / 0.2))',
|
||||
}}
|
||||
/>
|
||||
<img src={voiceboxLogo} alt="Voicebox" className="sidebar-logo w-12 h-12 object-contain" />
|
||||
</div>
|
||||
|
||||
{/* Navigation Buttons */}
|
||||
|
||||
@@ -7,7 +7,7 @@ export function StoriesTab() {
|
||||
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0 overflow-hidden">
|
||||
<div className="flex flex-col h-full min-h-0 overflow-hidden -mx-8">
|
||||
{/* Main content area */}
|
||||
<div className="flex-1 min-h-0 flex gap-6 overflow-hidden relative">
|
||||
{/* Left Column - Story List */}
|
||||
@@ -16,7 +16,7 @@ export function StoriesTab() {
|
||||
</div>
|
||||
|
||||
{/* Right Column - Story Content */}
|
||||
<div className="flex flex-col min-h-0 overflow-hidden flex-1">
|
||||
<div className="flex flex-col min-h-0 overflow-hidden flex-1 pr-8">
|
||||
<StoryContent />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -72,8 +72,12 @@ export function StoryContent() {
|
||||
// Track editor is shown when story has items
|
||||
const hasBottomBar = story && story.items.length > 0;
|
||||
|
||||
// Calculate dynamic bottom padding: track editor + gap
|
||||
const bottomPadding = hasBottomBar ? trackEditorHeight + 24 : 0;
|
||||
// Clear the floating generate box (always visible on this route) and the
|
||||
// track editor bar when it's showing.
|
||||
const FLOATING_BOX_CLEARANCE = 140;
|
||||
const bottomPadding = hasBottomBar
|
||||
? trackEditorHeight + FLOATING_BOX_CLEARANCE
|
||||
: FLOATING_BOX_CLEARANCE;
|
||||
|
||||
// Drag and drop sensors
|
||||
const sensors = useSensors(
|
||||
@@ -265,9 +269,12 @@ export function StoryContent() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex flex-col h-full min-h-0 relative overflow-hidden">
|
||||
{/* Scroll Mask */}
|
||||
<div className="absolute top-0 left-0 right-0 h-20 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4 px-1">
|
||||
<div className="absolute top-0 left-0 right-0 z-20 flex items-center justify-between px-1">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">{story.name}</h2>
|
||||
{story.description && (
|
||||
@@ -357,7 +364,7 @@ export function StoryContent() {
|
||||
{/* Content */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 min-h-0 overflow-y-auto space-y-3"
|
||||
className="flex-1 min-h-0 overflow-y-auto space-y-3 pt-14 scroll-pt-14 relative z-0"
|
||||
style={{ paddingBottom: bottomPadding > 0 ? `${bottomPadding}px` : undefined }}
|
||||
>
|
||||
{sortedItems.length === 0 ? (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BookOpen, MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -62,6 +63,7 @@ export function StoryList() {
|
||||
const [deletingStoryId, setDeletingStoryId] = useState<string | null>(null);
|
||||
const [newStoryName, setNewStoryName] = useState('');
|
||||
const [newStoryDescription, setNewStoryDescription] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const { toast } = useToast();
|
||||
|
||||
// Auto-select the first story when the list loads with no selection
|
||||
@@ -178,6 +180,19 @@ export function StoryList() {
|
||||
});
|
||||
};
|
||||
|
||||
const storyList = stories || [];
|
||||
const hasTrackEditor = selectedStoryId && selectedStory && selectedStory.items.length > 0;
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return storyList;
|
||||
return storyList.filter((s) => {
|
||||
const name = (s.name || '').toLowerCase();
|
||||
const description = (s.description || '').toLowerCase();
|
||||
return name.includes(q) || description.includes(q);
|
||||
});
|
||||
}, [search, storyList]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
@@ -186,77 +201,91 @@ export function StoryList() {
|
||||
);
|
||||
}
|
||||
|
||||
const storyList = stories || [];
|
||||
const hasTrackEditor = selectedStoryId && selectedStory && selectedStory.items.length > 0;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col relative overflow-hidden">
|
||||
<div className="h-full flex flex-col relative overflow-hidden border-r border-border">
|
||||
{/* Scroll Mask */}
|
||||
<div className="absolute top-0 left-0 right-0 h-16 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
|
||||
<div className="absolute top-0 left-0 right-0 h-20 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
|
||||
|
||||
{/* Fixed Header */}
|
||||
<div className="absolute top-0 left-0 right-0 z-20">
|
||||
<div className="flex items-center justify-between mb-4 px-1">
|
||||
<h2 className="text-2xl font-bold">{t('stories.title')}</h2>
|
||||
<div className="absolute top-0 left-0 right-0 z-20 pl-4 pr-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="text-2xl px-4 font-bold">{t('stories.title')}</h2>
|
||||
<Button onClick={() => setCreateDialogOpen(true)} size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('stories.newStory')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
placeholder={t('stories.searchPlaceholder')}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="h-9 text-sm rounded-full focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Story List */}
|
||||
<div
|
||||
className="flex-1 overflow-y-auto pt-14 relative z-0"
|
||||
className="flex-1 overflow-y-auto pt-24 relative z-0"
|
||||
style={{ paddingBottom: hasTrackEditor ? `${trackEditorHeight + 140}px` : '170px' }}
|
||||
>
|
||||
{storyList.length === 0 ? (
|
||||
<div className="text-center py-12 px-5 border-2 border-dashed border-muted rounded-2xl text-muted-foreground">
|
||||
<div className="mx-4 text-center py-12 px-5 border-2 border-dashed border-muted rounded-2xl text-muted-foreground">
|
||||
<BookOpen className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p className="text-sm">{t('stories.empty.title')}</p>
|
||||
<p className="text-xs mt-2">{t('stories.empty.hint')}</p>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="px-4 py-12 text-center text-sm text-muted-foreground">
|
||||
<p>{t('stories.empty.noMatches', { query: search })}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{storyList.map((story) => (
|
||||
<div
|
||||
key={story.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
'px-5 py-3 rounded-lg transition-colors group flex items-center cursor-pointer',
|
||||
selectedStoryId === story.id ? 'bg-muted' : 'hover:bg-muted/50',
|
||||
)}
|
||||
aria-label={t('stories.row.ariaLabel', {
|
||||
name: story.name,
|
||||
count: story.item_count,
|
||||
updated: formatDate(story.updated_at),
|
||||
})}
|
||||
aria-pressed={selectedStoryId === story.id}
|
||||
onClick={() => setSelectedStoryId(story.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setSelectedStoryId(story.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 w-full min-w-0">
|
||||
<div className="flex-1 min-w-0 text-left overflow-hidden">
|
||||
<h3 className="text-sm font-medium truncate">{story.name}</h3>
|
||||
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
|
||||
<span>{t('stories.row.itemCount', { count: story.item_count })}</span>
|
||||
<span>·</span>
|
||||
<span>{formatDate(story.updated_at)}</span>
|
||||
<div className="px-4 pb-6 space-y-1">
|
||||
{filtered.map((story) => {
|
||||
const isActive = selectedStoryId === story.id;
|
||||
return (
|
||||
<div key={story.id} className="relative group">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedStoryId(story.id)}
|
||||
aria-label={t('stories.row.ariaLabel', {
|
||||
name: story.name,
|
||||
count: story.item_count,
|
||||
updated: formatDate(story.updated_at),
|
||||
})}
|
||||
aria-pressed={isActive}
|
||||
className={cn(
|
||||
'w-full text-left p-3 rounded-lg transition-colors block',
|
||||
isActive
|
||||
? 'bg-muted/70 border border-border'
|
||||
: 'border border-transparent hover:bg-muted/30',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span className="text-[11px] text-muted-foreground font-medium">
|
||||
{formatDate(story.updated_at)}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[13px] text-foreground/90 line-clamp-2 leading-snug mb-2">
|
||||
{story.name}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-5 px-1.5 text-[10px] gap-1 font-medium bg-muted/60 text-muted-foreground"
|
||||
>
|
||||
{t('stories.row.itemCount', { count: story.item_count })}
|
||||
</Badge>
|
||||
</div>
|
||||
</button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
className="absolute top-2 right-2 h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={t('stories.row.actionsLabel', { name: story.name })}
|
||||
>
|
||||
@@ -278,8 +307,8 @@ export function StoryList() {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -125,6 +125,8 @@ interface StoryTrackEditorProps {
|
||||
|
||||
const TRACK_HEIGHT = 48;
|
||||
const TIME_RULER_HEIGHT = 24; // h-6 = 1.5rem = 24px
|
||||
const SCRUB_BAR_HEIGHT = 16;
|
||||
const LABEL_COL_WIDTH = 64; // w-16 = 4rem = 64px
|
||||
const MIN_PIXELS_PER_SECOND = 10;
|
||||
const MAX_PIXELS_PER_SECOND = 200;
|
||||
const DEFAULT_PIXELS_PER_SECOND = 50;
|
||||
@@ -282,6 +284,44 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Horizontal scrollbar state
|
||||
const [timelineScrollLeft, setTimelineScrollLeft] = useState(0);
|
||||
const [scrollbarTrackWidth, setScrollbarTrackWidth] = useState(0);
|
||||
const scrollbarTrackRef = useRef<HTMLDivElement>(null);
|
||||
const scrollbarDragRef = useRef<{
|
||||
mode: 'pan' | 'left' | 'right';
|
||||
startX: number;
|
||||
startScrollLeft: number;
|
||||
startPixelsPerSecond: number;
|
||||
} | null>(null);
|
||||
// Anchor the visible left/right edge time during a zoom drag so the edge
|
||||
// the user isn't dragging stays pinned in place across pixelsPerSecond changes.
|
||||
const zoomAnchorRef = useRef<{ type: 'left' | 'right'; timeMs: number } | null>(null);
|
||||
|
||||
// Mirror the timeline's scrollLeft into state so the scrollbar thumb tracks it
|
||||
useEffect(() => {
|
||||
const el = tracksRef.current;
|
||||
if (!el) return;
|
||||
const onScroll = () => setTimelineScrollLeft(el.scrollLeft);
|
||||
el.addEventListener('scroll', onScroll);
|
||||
setTimelineScrollLeft(el.scrollLeft);
|
||||
return () => el.removeEventListener('scroll', onScroll);
|
||||
}, []);
|
||||
|
||||
// Track scrollbar track width for thumb sizing
|
||||
useEffect(() => {
|
||||
const el = scrollbarTrackRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
setScrollbarTrackWidth(entry.contentRect.width);
|
||||
}
|
||||
});
|
||||
ro.observe(el);
|
||||
setScrollbarTrackWidth(el.clientWidth);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// Calculate effective duration (accounting for trims)
|
||||
const getEffectiveDuration = (item: StoryItemDetail) => {
|
||||
return item.duration * 1000 - (item.trim_start_ms || 0) - (item.trim_end_ms || 0);
|
||||
@@ -374,7 +414,7 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
const handleTimelineClick = (e: React.MouseEvent<HTMLElement>) => {
|
||||
if (!tracksRef.current || draggingItem || trimmingItem) return;
|
||||
const rect = tracksRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left + tracksRef.current.scrollLeft;
|
||||
const x = e.clientX - rect.left + tracksRef.current.scrollLeft - LABEL_COL_WIDTH;
|
||||
const timeMs = Math.max(0, pixelsToMs(x));
|
||||
seek(timeMs);
|
||||
// Deselect clip when clicking on timeline
|
||||
@@ -654,7 +694,13 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
y: e.clientY - rect.top,
|
||||
});
|
||||
setDragPosition({
|
||||
x: rect.left - tracksRef.current.getBoundingClientRect().left + tracksRef.current.scrollLeft,
|
||||
// Subtract label column width because clips live in a sub-container offset
|
||||
// by LABEL_COL_WIDTH, so dragPosition.x is stored in timeline-local coords.
|
||||
x:
|
||||
rect.left -
|
||||
tracksRef.current.getBoundingClientRect().left +
|
||||
tracksRef.current.scrollLeft -
|
||||
LABEL_COL_WIDTH,
|
||||
// Subtract ruler height since clips are positioned relative to tracks area, not the scrollable container
|
||||
y: rect.top - tracksRef.current.getBoundingClientRect().top - TIME_RULER_HEIGHT,
|
||||
});
|
||||
@@ -666,7 +712,12 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
if (!draggingItem || !tracksRef.current) return;
|
||||
|
||||
const rect = tracksRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left + tracksRef.current.scrollLeft - dragOffset.x;
|
||||
const x =
|
||||
e.clientX -
|
||||
rect.left +
|
||||
tracksRef.current.scrollLeft -
|
||||
dragOffset.x -
|
||||
LABEL_COL_WIDTH;
|
||||
// Subtract ruler height since clips are positioned relative to tracks area
|
||||
const y = e.clientY - rect.top - dragOffset.y - TIME_RULER_HEIGHT;
|
||||
|
||||
@@ -762,7 +813,109 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
|
||||
// Calculate tracks area height
|
||||
const tracksAreaHeight = tracks.length * TRACK_HEIGHT;
|
||||
const timelineContainerHeight = editorHeight - 40; // Subtract toolbar height
|
||||
const timelineContainerHeight = editorHeight - 40 - SCRUB_BAR_HEIGHT;
|
||||
|
||||
// Scrollbar thumb geometry
|
||||
const maxTimelineScroll = Math.max(0, timelineWidth - containerWidth);
|
||||
const visibleRatio = timelineWidth > 0 ? Math.min(1, containerWidth / timelineWidth) : 1;
|
||||
const thumbWidth = Math.max(24, visibleRatio * scrollbarTrackWidth);
|
||||
const thumbRange = Math.max(0, scrollbarTrackWidth - thumbWidth);
|
||||
const thumbLeft =
|
||||
maxTimelineScroll > 0 && thumbRange > 0
|
||||
? (timelineScrollLeft / maxTimelineScroll) * thumbRange
|
||||
: 0;
|
||||
const canScrollHorizontally = maxTimelineScroll > 0;
|
||||
|
||||
const handleScrollbarMouseDown = useCallback(
|
||||
(mode: 'pan' | 'left' | 'right') => (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
scrollbarDragRef.current = {
|
||||
mode,
|
||||
startX: e.clientX,
|
||||
startScrollLeft: timelineScrollLeft,
|
||||
startPixelsPerSecond: pixelsPerSecond,
|
||||
};
|
||||
},
|
||||
[timelineScrollLeft, pixelsPerSecond],
|
||||
);
|
||||
|
||||
// After a zoom drag updates pixelsPerSecond, snap scrollLeft so the anchored
|
||||
// edge (left or right of the visible window) stays at the same time.
|
||||
useEffect(() => {
|
||||
const anchor = zoomAnchorRef.current;
|
||||
if (!anchor || !tracksRef.current) return;
|
||||
const timePx = (anchor.timeMs / 1000) * pixelsPerSecond;
|
||||
tracksRef.current.scrollLeft =
|
||||
anchor.type === 'left' ? Math.max(0, timePx) : Math.max(0, timePx - containerWidth);
|
||||
}, [pixelsPerSecond, containerWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
const drag = scrollbarDragRef.current;
|
||||
if (!drag || !tracksRef.current) return;
|
||||
const deltaX = e.clientX - drag.startX;
|
||||
|
||||
if (drag.mode === 'pan') {
|
||||
if (thumbRange <= 0) return;
|
||||
const deltaScroll = (deltaX / thumbRange) * maxTimelineScroll;
|
||||
tracksRef.current.scrollLeft = Math.max(
|
||||
0,
|
||||
Math.min(maxTimelineScroll, drag.startScrollLeft + deltaScroll),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (scrollbarTrackWidth <= 0 || containerWidth <= 0) return;
|
||||
|
||||
// Recompute the thumb width that corresponded to the drag start, then
|
||||
// apply the mouse delta to the dragged edge.
|
||||
const startTimelinePx =
|
||||
(totalDurationMs / 1000) * drag.startPixelsPerSecond + 200;
|
||||
const startThumbWidth = Math.max(
|
||||
30,
|
||||
Math.min(scrollbarTrackWidth, (containerWidth / startTimelinePx) * scrollbarTrackWidth),
|
||||
);
|
||||
const newThumbWidth = Math.max(
|
||||
30,
|
||||
Math.min(
|
||||
scrollbarTrackWidth,
|
||||
drag.mode === 'right' ? startThumbWidth + deltaX : startThumbWidth - deltaX,
|
||||
),
|
||||
);
|
||||
|
||||
const newTimelinePx = (containerWidth / newThumbWidth) * scrollbarTrackWidth;
|
||||
const rawPps = (newTimelinePx - 200) / (totalDurationMs / 1000);
|
||||
const newPps = Math.max(
|
||||
MIN_PIXELS_PER_SECOND,
|
||||
Math.min(MAX_PIXELS_PER_SECOND, rawPps),
|
||||
);
|
||||
|
||||
zoomAnchorRef.current =
|
||||
drag.mode === 'right'
|
||||
? {
|
||||
type: 'left',
|
||||
timeMs: (drag.startScrollLeft / drag.startPixelsPerSecond) * 1000,
|
||||
}
|
||||
: {
|
||||
type: 'right',
|
||||
timeMs:
|
||||
((drag.startScrollLeft + containerWidth) / drag.startPixelsPerSecond) * 1000,
|
||||
};
|
||||
|
||||
setPixelsPerSecond(newPps);
|
||||
};
|
||||
const onMouseUp = () => {
|
||||
scrollbarDragRef.current = null;
|
||||
zoomAnchorRef.current = null;
|
||||
};
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', onMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
}, [maxTimelineScroll, thumbRange, scrollbarTrackWidth, containerWidth, totalDurationMs]);
|
||||
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
@@ -916,44 +1069,25 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline container with track labels sidebar */}
|
||||
<div className="flex" style={{ height: `${timelineContainerHeight}px` }}>
|
||||
{/* Track labels sidebar - fixed width */}
|
||||
<div className="w-16 shrink-0 border-r bg-muted/20 overflow-hidden">
|
||||
{/* Spacer for time ruler */}
|
||||
<div className="h-6 border-b bg-muted/30" />
|
||||
{/* Track labels */}
|
||||
<div style={{ height: `${tracksAreaHeight}px` }}>
|
||||
{tracks.map((trackNumber, index) => (
|
||||
<div
|
||||
key={trackNumber}
|
||||
className={cn(
|
||||
'border-b flex items-center justify-center',
|
||||
index % 2 === 0 ? 'bg-background' : 'bg-muted/10',
|
||||
)}
|
||||
style={{ height: `${TRACK_HEIGHT}px` }}
|
||||
>
|
||||
<span className="text-[10px] text-muted-foreground select-none">
|
||||
{trackNumber}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable timeline area */}
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: Container handles drag events for child clips */}
|
||||
{/* Timeline scroll container */}
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: Container handles drag events for child clips */}
|
||||
<div
|
||||
ref={tracksRef}
|
||||
className="overflow-auto relative"
|
||||
style={{ height: `${timelineContainerHeight}px` }}
|
||||
onMouseMove={draggingItem ? handleDragMove : undefined}
|
||||
onMouseUp={draggingItem ? handleDragEnd : undefined}
|
||||
onMouseLeave={draggingItem ? handleDragEnd : undefined}
|
||||
>
|
||||
{/* Ruler row: corner spacer + time ruler, sticky to top */}
|
||||
<div
|
||||
ref={tracksRef}
|
||||
className="overflow-auto relative flex-1"
|
||||
onMouseMove={draggingItem ? handleDragMove : undefined}
|
||||
onMouseUp={draggingItem ? handleDragEnd : undefined}
|
||||
onMouseLeave={draggingItem ? handleDragEnd : undefined}
|
||||
className="flex sticky top-0 z-30"
|
||||
style={{ width: `${timelineWidth + LABEL_COL_WIDTH}px` }}
|
||||
>
|
||||
{/* Time ruler - clickable to seek */}
|
||||
<div className="w-16 h-6 shrink-0 border-b border-r bg-muted/30 sticky left-0 z-40" />
|
||||
<button
|
||||
type="button"
|
||||
className="h-6 border-b bg-muted/20 sticky top-0 z-10 cursor-pointer text-left"
|
||||
className="h-6 border-b bg-muted/20 cursor-pointer text-left relative"
|
||||
style={{ width: `${timelineWidth}px` }}
|
||||
onClick={handleTimelineClick}
|
||||
aria-label="Seek timeline"
|
||||
@@ -971,27 +1105,46 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
</div>
|
||||
))}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tracks area */}
|
||||
<div
|
||||
className="relative"
|
||||
style={{ width: `${timelineWidth}px`, height: `${tracksAreaHeight}px` }}
|
||||
>
|
||||
{/* Track backgrounds - pointer-events-none to allow clicks to pass through */}
|
||||
{tracks.map((trackNumber, index) => (
|
||||
{/* Tracks area (rows with sticky labels + clips sub-container) */}
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
width: `${timelineWidth + LABEL_COL_WIDTH}px`,
|
||||
height: `${tracksAreaHeight}px`,
|
||||
}}
|
||||
>
|
||||
{/* Per-track rows: label and background as flex siblings guarantee alignment */}
|
||||
{tracks.map((trackNumber, index) => (
|
||||
<div
|
||||
key={trackNumber}
|
||||
className="absolute left-0 right-0 flex"
|
||||
style={{
|
||||
top: `${index * TRACK_HEIGHT}px`,
|
||||
height: `${TRACK_HEIGHT}px`,
|
||||
}}
|
||||
>
|
||||
<div className="w-16 shrink-0 border-b border-r flex items-center justify-center sticky left-0 z-20 h-full bg-background">
|
||||
<div className="absolute inset-0 bg-muted/20 pointer-events-none" />
|
||||
<span className="relative text-[10px] text-muted-foreground select-none">
|
||||
{trackNumber}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
key={trackNumber}
|
||||
className={cn(
|
||||
'absolute left-0 right-0 border-b pointer-events-none',
|
||||
'border-b flex-1 pointer-events-none',
|
||||
index % 2 === 0 ? 'bg-background' : 'bg-muted/10',
|
||||
)}
|
||||
style={{
|
||||
top: `${index * TRACK_HEIGHT}px`,
|
||||
height: `${TRACK_HEIGHT}px`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Clip/playhead/seek layer offset past the label column */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0"
|
||||
style={{ left: `${LABEL_COL_WIDTH}px`, width: `${timelineWidth}px` }}
|
||||
>
|
||||
{/* Click area for seeking - z-index lower than clips */}
|
||||
<button
|
||||
type="button"
|
||||
@@ -1101,6 +1254,55 @@ export function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Horizontal timeline scrollbar + zoom handles */}
|
||||
<div
|
||||
className="flex border-t bg-background/40"
|
||||
style={{ height: `${SCRUB_BAR_HEIGHT}px` }}
|
||||
>
|
||||
<div className="w-16 shrink-0 border-r" />
|
||||
<div
|
||||
ref={scrollbarTrackRef}
|
||||
className="relative flex-1 overflow-hidden select-none px-1"
|
||||
>
|
||||
<div
|
||||
className="absolute top-1 bottom-1 bg-foreground/10 hover:bg-foreground/15 transition-colors group rounded-full"
|
||||
style={{ width: `${thumbWidth}px`, left: `${thumbLeft}px` }}
|
||||
>
|
||||
{/* Left zoom handle */}
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: mouse-driven edge handle */}
|
||||
<div
|
||||
role="slider"
|
||||
aria-label="Zoom from left edge"
|
||||
aria-valuenow={Math.round(pixelsPerSecond)}
|
||||
aria-valuemin={MIN_PIXELS_PER_SECOND}
|
||||
aria-valuemax={MAX_PIXELS_PER_SECOND}
|
||||
className="absolute top-0 bottom-0 left-0 w-1.5 cursor-ew-resize bg-foreground/25 hover:bg-foreground/40 transition-colors rounded-l-full"
|
||||
onMouseDown={handleScrollbarMouseDown('left')}
|
||||
/>
|
||||
{/* Pan area */}
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: mouse-driven drag area */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-0 bottom-0 left-1.5 right-1.5',
|
||||
canScrollHorizontally ? 'cursor-grab active:cursor-grabbing' : 'cursor-default',
|
||||
)}
|
||||
onMouseDown={canScrollHorizontally ? handleScrollbarMouseDown('pan') : undefined}
|
||||
/>
|
||||
{/* Right zoom handle */}
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: mouse-driven edge handle */}
|
||||
<div
|
||||
role="slider"
|
||||
aria-label="Zoom from right edge"
|
||||
aria-valuenow={Math.round(pixelsPerSecond)}
|
||||
aria-valuemin={MIN_PIXELS_PER_SECOND}
|
||||
aria-valuemax={MAX_PIXELS_PER_SECOND}
|
||||
className="absolute top-0 bottom-0 right-0 w-1.5 cursor-ew-resize bg-foreground/25 hover:bg-foreground/40 transition-colors rounded-r-full"
|
||||
onMouseDown={handleScrollbarMouseDown('right')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -91,7 +91,7 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) {
|
||||
className={cn(
|
||||
'cursor-pointer transition-all flex flex-col h-[162px]',
|
||||
disabled ? 'opacity-40 hover:opacity-60' : 'hover:shadow-md',
|
||||
isSelected && !disabled && 'ring-2 ring-accent shadow-md',
|
||||
isSelected && !disabled && 'ring-2 border-transparent ring-accent shadow-md',
|
||||
)}
|
||||
onClick={handleSelect}
|
||||
tabIndex={0}
|
||||
|
||||
@@ -9,7 +9,7 @@ const badgeVariants = cva(
|
||||
variant: {
|
||||
default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
|
||||
secondary:
|
||||
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
'border-border bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
destructive:
|
||||
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
|
||||
outline: 'text-foreground',
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
|
||||
export function useThemeSync() {
|
||||
const theme = useUIStore((s) => s.theme);
|
||||
|
||||
useEffect(() => {
|
||||
if (theme !== 'system') {
|
||||
document.documentElement.classList.toggle('dark', theme === 'dark');
|
||||
return;
|
||||
}
|
||||
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const apply = () => {
|
||||
document.documentElement.classList.toggle('dark', mq.matches);
|
||||
};
|
||||
|
||||
apply();
|
||||
mq.addEventListener('change', apply);
|
||||
return () => mq.removeEventListener('change', apply);
|
||||
}, [theme]);
|
||||
}
|
||||
@@ -551,9 +551,11 @@
|
||||
"title": "Stories",
|
||||
"newStory": "New Story",
|
||||
"loading": "Loading stories…",
|
||||
"searchPlaceholder": "Search stories…",
|
||||
"empty": {
|
||||
"title": "No stories yet",
|
||||
"hint": "Create your first story to get started"
|
||||
"hint": "Create your first story to get started",
|
||||
"noMatches": "No stories match \"{{query}}\""
|
||||
},
|
||||
"row": {
|
||||
"itemCount_one": "{{count}} item",
|
||||
@@ -730,6 +732,15 @@
|
||||
"label": "Language",
|
||||
"description": "Choose the display language for Voicebox."
|
||||
},
|
||||
"theme": {
|
||||
"label": "Theme",
|
||||
"description": "Match your system, or pick a fixed light or dark appearance.",
|
||||
"options": {
|
||||
"system": "System",
|
||||
"light": "Light",
|
||||
"dark": "Dark"
|
||||
}
|
||||
},
|
||||
"general": {
|
||||
"docs": { "title": "Read the Docs" },
|
||||
"discord": { "title": "Join the Discord", "subtitle": "Get help & share voices" },
|
||||
|
||||
@@ -551,9 +551,11 @@
|
||||
"title": "ストーリー",
|
||||
"newStory": "新しいストーリー",
|
||||
"loading": "ストーリーを読み込み中…",
|
||||
"searchPlaceholder": "ストーリーを検索…",
|
||||
"empty": {
|
||||
"title": "ストーリーがまだありません",
|
||||
"hint": "最初のストーリーを作成して始めましょう"
|
||||
"hint": "最初のストーリーを作成して始めましょう",
|
||||
"noMatches": "「{{query}}」に一致するストーリーはありません"
|
||||
},
|
||||
"row": {
|
||||
"itemCount_one": "{{count}} 項目",
|
||||
@@ -730,6 +732,15 @@
|
||||
"label": "言語",
|
||||
"description": "Voicebox の表示言語を選択します。"
|
||||
},
|
||||
"theme": {
|
||||
"label": "テーマ",
|
||||
"description": "システム設定に合わせるか、ライト / ダークを固定します。",
|
||||
"options": {
|
||||
"system": "システム",
|
||||
"light": "ライト",
|
||||
"dark": "ダーク"
|
||||
}
|
||||
},
|
||||
"general": {
|
||||
"docs": { "title": "ドキュメントを読む" },
|
||||
"discord": { "title": "Discord に参加", "subtitle": "ヘルプやボイスの共有" },
|
||||
|
||||
@@ -551,9 +551,11 @@
|
||||
"title": "故事",
|
||||
"newStory": "新建故事",
|
||||
"loading": "加载故事中…",
|
||||
"searchPlaceholder": "搜索故事…",
|
||||
"empty": {
|
||||
"title": "暂无故事",
|
||||
"hint": "创建您的第一个故事以开始"
|
||||
"hint": "创建您的第一个故事以开始",
|
||||
"noMatches": "没有故事匹配 “{{query}}”"
|
||||
},
|
||||
"row": {
|
||||
"itemCount_one": "{{count}} 项",
|
||||
@@ -730,6 +732,15 @@
|
||||
"label": "语言",
|
||||
"description": "选择 Voicebox 的显示语言。"
|
||||
},
|
||||
"theme": {
|
||||
"label": "主题",
|
||||
"description": "跟随系统外观,或固定为浅色 / 深色模式。",
|
||||
"options": {
|
||||
"system": "跟随系统",
|
||||
"light": "浅色",
|
||||
"dark": "深色"
|
||||
}
|
||||
},
|
||||
"general": {
|
||||
"docs": { "title": "阅读文档" },
|
||||
"discord": { "title": "加入 Discord", "subtitle": "获取帮助 & 分享声音" },
|
||||
|
||||
@@ -551,9 +551,11 @@
|
||||
"title": "故事",
|
||||
"newStory": "新增故事",
|
||||
"loading": "載入故事中…",
|
||||
"searchPlaceholder": "搜尋故事…",
|
||||
"empty": {
|
||||
"title": "尚無故事",
|
||||
"hint": "建立您的第一個故事以開始"
|
||||
"hint": "建立您的第一個故事以開始",
|
||||
"noMatches": "沒有故事符合「{{query}}」"
|
||||
},
|
||||
"row": {
|
||||
"itemCount_one": "{{count}} 項",
|
||||
@@ -730,6 +732,15 @@
|
||||
"label": "語言",
|
||||
"description": "選擇 Voicebox 的顯示語言。"
|
||||
},
|
||||
"theme": {
|
||||
"label": "佈景主題",
|
||||
"description": "跟隨系統外觀,或固定為淺色 / 深色模式。",
|
||||
"options": {
|
||||
"system": "跟隨系統",
|
||||
"light": "淺色",
|
||||
"dark": "深色"
|
||||
}
|
||||
},
|
||||
"general": {
|
||||
"docs": { "title": "閱讀文件" },
|
||||
"discord": { "title": "加入 Discord", "subtitle": "取得協助與分享聲音" },
|
||||
|
||||
+20
-15
@@ -44,24 +44,24 @@
|
||||
|
||||
:root {
|
||||
--background: 0 0% 95%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--foreground: 0 0% 5%;
|
||||
--card: 0 0% 97%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--card-foreground: 0 0% 5%;
|
||||
--popover: 0 0% 97%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 92%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 90%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 43 50% 50%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--popover-foreground: 0 0% 5%;
|
||||
--primary: 43 55% 58%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 0 0% 92%;
|
||||
--secondary-foreground: 0 0% 11%;
|
||||
--muted: 0 0% 90%;
|
||||
--muted-foreground: 0 0% 47%;
|
||||
--accent: 43 55% 58%;
|
||||
--accent-foreground: 0 0% 100%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 85%;
|
||||
--input: 214.3 31.8% 88%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 85%;
|
||||
--input: 0 0% 88%;
|
||||
--ring: 0 0% 5%;
|
||||
--sidebar: 0 0% 92%;
|
||||
--radius: 0.5rem;
|
||||
--chart-1: 12 76% 61%;
|
||||
@@ -157,6 +157,11 @@
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.dark .sidebar-logo {
|
||||
filter: drop-shadow(0 0 6px hsl(var(--accent) / 0.5))
|
||||
drop-shadow(0 0 14px hsl(var(--accent) / 0.35)) drop-shadow(0 0 28px hsl(var(--accent) / 0.2));
|
||||
}
|
||||
|
||||
/* react-loaders */
|
||||
.line-scale-pulse-out-rapid > div,
|
||||
.line-scale > div {
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
function resolveTheme(theme: Theme): 'light' | 'dark' {
|
||||
if (theme !== 'system') return theme;
|
||||
if (typeof window === 'undefined') return 'dark';
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
function applyTheme(theme: Theme) {
|
||||
if (typeof document === 'undefined') return;
|
||||
document.documentElement.classList.toggle('dark', resolveTheme(theme) === 'dark');
|
||||
}
|
||||
|
||||
// Draft state for the create voice profile form
|
||||
export interface ProfileFormDraft {
|
||||
name: string;
|
||||
@@ -46,8 +59,8 @@ interface UIStore {
|
||||
setProfileFormDraft: (draft: ProfileFormDraft | null) => void;
|
||||
|
||||
// Theme
|
||||
theme: 'light' | 'dark';
|
||||
setTheme: (theme: 'light' | 'dark') => void;
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
}
|
||||
|
||||
export const useUIStore = create<UIStore>()(
|
||||
@@ -76,15 +89,21 @@ export const useUIStore = create<UIStore>()(
|
||||
profileFormDraft: null,
|
||||
setProfileFormDraft: (draft) => set({ profileFormDraft: draft }),
|
||||
|
||||
theme: 'light',
|
||||
theme: 'system',
|
||||
setTheme: (theme) => {
|
||||
set({ theme });
|
||||
document.documentElement.classList.toggle('dark', theme === 'dark');
|
||||
applyTheme(theme);
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'voicebox-ui',
|
||||
partialize: (state) => ({ selectedProfileId: state.selectedProfileId }),
|
||||
partialize: (state) => ({
|
||||
selectedProfileId: state.selectedProfileId,
|
||||
theme: state.theme,
|
||||
}),
|
||||
onRehydrateStorage: () => (state) => {
|
||||
if (state) applyTheme(state.theme);
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ from PyInstaller.utils.hooks import copy_metadata
|
||||
|
||||
datas = []
|
||||
binaries = []
|
||||
hiddenimports = ['backend', 'backend.main', 'backend.config', 'backend.database', 'backend.models', 'backend.services.profiles', 'backend.services.history', 'backend.services.tts', 'backend.services.transcribe', 'backend.utils.platform_detect', 'backend.backends', 'backend.backends.pytorch_backend', 'backend.backends.qwen_custom_voice_backend', 'backend.utils.audio', 'backend.utils.cache', 'backend.utils.progress', 'backend.utils.hf_progress', 'backend.services.cuda', 'backend.services.effects', 'backend.utils.effects', 'backend.services.versions', 'pedalboard', 'chatterbox', 'chatterbox.tts_turbo', 'chatterbox.mtl_tts', 'backend.backends.chatterbox_backend', 'backend.backends.chatterbox_turbo_backend', 'backend.backends.luxtts_backend', 'zipvoice', 'zipvoice.luxvoice', 'torch', 'transformers', 'fastapi', 'uvicorn', 'sqlalchemy', 'soundfile', 'qwen_tts', 'qwen_tts.inference', 'qwen_tts.inference.qwen3_tts_model', 'qwen_tts.inference.qwen3_tts_tokenizer', 'qwen_tts.core', 'qwen_tts.cli', 'requests', 'pkg_resources.extern', 'backend.backends.hume_backend', 'tada', 'tada.modules', 'tada.modules.tada', 'tada.modules.encoder', 'tada.modules.decoder', 'tada.modules.aligner', 'tada.modules.acoustic_spkr_verf', 'tada.nn', 'tada.nn.vibevoice', 'tada.utils', 'tada.utils.gray_code', 'tada.utils.text', 'backend.utils.dac_shim', 'torchaudio', 'backend.backends.kokoro_backend', 'en_core_web_sm', 'loguru', 'backend.backends.mlx_backend', 'mlx', 'mlx.core', 'mlx.nn', 'mlx_audio', 'mlx_audio.tts', 'mlx_audio.stt']
|
||||
hiddenimports = ['backend', 'backend.main', 'backend.config', 'backend.database', 'backend.models', 'backend.services.profiles', 'backend.services.history', 'backend.services.tts', 'backend.services.transcribe', 'backend.utils.platform_detect', 'backend.backends', 'backend.backends.pytorch_backend', 'backend.backends.qwen_custom_voice_backend', 'backend.utils.audio', 'backend.utils.cache', 'backend.utils.progress', 'backend.utils.hf_progress', 'backend.services.cuda', 'backend.services.effects', 'backend.utils.effects', 'backend.services.versions', 'pedalboard', 'chatterbox', 'chatterbox.tts_turbo', 'chatterbox.mtl_tts', 'backend.backends.chatterbox_backend', 'backend.backends.chatterbox_turbo_backend', 'backend.backends.luxtts_backend', 'zipvoice', 'zipvoice.luxvoice', 'torch', 'transformers', 'fastapi', 'uvicorn', 'sqlalchemy', 'soundfile', 'qwen_tts', 'qwen_tts.inference', 'qwen_tts.inference.qwen3_tts_model', 'qwen_tts.inference.qwen3_tts_tokenizer', 'qwen_tts.core', 'qwen_tts.cli', 'requests', 'pkg_resources.extern', 'backend.backends.hume_backend', 'tada', 'tada.modules', 'tada.modules.tada', 'tada.modules.encoder', 'tada.modules.decoder', 'tada.modules.aligner', 'tada.modules.acoustic_spkr_verf', 'tada.nn', 'tada.nn.vibevoice', 'tada.utils', 'tada.utils.gray_code', 'tada.utils.text', 'backend.utils.dac_shim', 'torchaudio', 'backend.backends.kokoro_backend', 'en_core_web_sm', 'loguru', 'backend.mcp_server', 'backend.mcp_server.server', 'backend.mcp_server.tools', 'backend.mcp_server.context', 'backend.mcp_server.resolve', 'backend.mcp_server.events', 'sse_starlette', 'backend.backends.mlx_backend', 'mlx', 'mlx.core', 'mlx.nn', 'mlx_audio', 'mlx_audio.tts', 'mlx_audio.stt']
|
||||
datas += copy_metadata('qwen-tts')
|
||||
datas += copy_metadata('requests')
|
||||
datas += copy_metadata('transformers')
|
||||
@@ -48,6 +48,10 @@ tmp_ret = collect_all('en_core_web_sm')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('unidic_lite')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('fastmcp')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('mcp')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('mlx')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('mlx_audio')
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# macOS Notarization & Gatekeeper
|
||||
|
||||
**Status:** Diagnosis — Homebrew Cask CI rejects v0.4.5 on macOS 15 (Sequoia); fix pending
|
||||
**Touches:** `.github/workflows/release.yml`, Tauri bundler config, sidecar signing
|
||||
**Last reviewed:** 2026-04-24
|
||||
|
||||
## Context
|
||||
|
||||
Homebrew Cask PR [#260314](https://github.com/Homebrew/homebrew-cask/pull/260314) adds `brew install --cask voicebox`. CI is green on macOS 14 and macOS 26 (arm + intel) but fails on macOS 15 (arm + intel). The 0.4.3 release added DMG-level stapling to address this, and it didn't move CI — 0.4.5 still fails. A maintainer reproduced the failure in a fresh Sequoia VM.
|
||||
|
||||
This document is the working diagnosis plus the ordered fix plan.
|
||||
|
||||
## What the failing check actually does
|
||||
|
||||
The failing step is `brew audit --cask --online --signing --new voicebox`, not `brew install`. `brew install` succeeds end-to-end in CI (the log shows `Uninstalling Cask voicebox` after the install phase). The `--signing` audit:
|
||||
|
||||
1. Downloads the cask's `url`
|
||||
2. Mounts the DMG
|
||||
3. Runs `spctl --assess -t open --context context:primary-signature` against the `.app` inside
|
||||
|
||||
That policy tests the first-launch Gatekeeper path on the extracted bundle. It reads the `.app`'s own code signature and notarization ticket — the DMG wrapper is not involved. The staple added in 0.4.3 covers the DMG, so it has no effect on this audit.
|
||||
|
||||
## Why Sequoia and not Sonoma
|
||||
|
||||
`spctl -t open` on macOS 15 enforces checks that 14 tolerated:
|
||||
|
||||
- Secure timestamp required on hardened-runtime signatures. Untimestamped signatures pass on 14, fail on 15.
|
||||
- Deep verification of nested Mach-Os. If any embedded `.dylib` or helper binary carries an ad-hoc signature (or a signature with a different Team ID), 15 rejects the whole bundle; 14 often accepted it.
|
||||
- Hardened runtime must be set on every nested executable, not just the top-level app binary. Entitlements declared on the outer app do not propagate.
|
||||
|
||||
Local dev machines pass `spctl` because the first-party developer context and cached notarization tickets mask these failures. A fresh Sequoia VM with no prior trust state does not.
|
||||
|
||||
## Where the gap is likely to be
|
||||
|
||||
Voicebox ships PyInstaller sidecars declared in `tauri.conf.json` under `externalBin`:
|
||||
|
||||
- **0.4.x:** `voicebox-server` only (single `--onefile` Mach-O on macOS)
|
||||
- **0.5.0+:** `voicebox-server` and `voicebox-mcp` (`voicebox-mcp` is new in 0.5.0)
|
||||
|
||||
Tauri's bundler signs each `externalBin` with the configured identity but does not apply `--options=runtime` or `--timestamp` automatically, and does not merge the outer app's entitlements into the sidecar signature. The outer `Voicebox` binary is correctly signed with hardened runtime + `disable-library-validation`; the sidecars likely are not.
|
||||
|
||||
Order of likelihood:
|
||||
|
||||
1. Sidecar `voicebox-server` lacks hardened runtime or a secure timestamp in its signature.
|
||||
2. The sidecar inherits the identity but was signed before tauri-action's final notarization pass, so the notarization ticket doesn't actually cover it.
|
||||
3. Something inside the sidecar's PyInstaller archive unpacks to a `.dylib` at runtime that Gatekeeper inspects during assessment.
|
||||
|
||||
The 0.5.0 fix must cover both sidecars.
|
||||
|
||||
## Diagnostic commands
|
||||
|
||||
Run against a freshly downloaded release DMG (not a dev build, and from a machine that has never opened the app before):
|
||||
|
||||
```
|
||||
hdiutil attach Voicebox_0.4.5_aarch64.dmg
|
||||
xcrun stapler validate "/Volumes/Voicebox 0.4.5/Voicebox.app"
|
||||
spctl -a -vvv -t open --context context:primary-signature "/Volumes/Voicebox 0.4.5/Voicebox.app"
|
||||
codesign --verify --deep --strict --verbose=2 "/Volumes/Voicebox 0.4.5/Voicebox.app"
|
||||
codesign -dv --verbose=4 "/Volumes/Voicebox 0.4.5/Voicebox.app/Contents/MacOS/voicebox-server"
|
||||
```
|
||||
|
||||
The last command is the tell — look for `flags=0x10000(runtime)` and a `Timestamp=` line. If either is missing, the sidecar is the failure.
|
||||
|
||||
`spctl -t install` (what 0.4.3 verified with) is a different policy and can pass while `-t open` fails — any future verification should use `-t open --context context:primary-signature` to match what Homebrew's audit runs.
|
||||
|
||||
## Phases
|
||||
|
||||
### Phase 1 — Confirm the failure mode
|
||||
|
||||
Pull the 0.4.5 DMG on a fresh Sequoia environment or a VM snapshot with no trust state. Run the diagnostic block above. Record the exact failing command and its CSSMERR / rejection reason. This disambiguates between the three hypotheses before we change the workflow.
|
||||
|
||||
### Phase 2 — Sign sidecars explicitly in the release workflow
|
||||
|
||||
Between tauri-action's build step and the DMG-notarization step already in `release.yml`, add a step that re-signs every `externalBin` present under `Voicebox.app/Contents/MacOS/` with:
|
||||
|
||||
- `--options=runtime` (hardened runtime)
|
||||
- `--timestamp` (secure timestamp)
|
||||
- `--entitlements` pointing at `Entitlements.plist` or a sidecar-specific subset
|
||||
- The same `APPLE_SIGNING_IDENTITY` the outer app uses
|
||||
|
||||
Re-sign the outer `.app` afterward so its seal covers the updated nested signatures.
|
||||
|
||||
Covers `voicebox-server` on 0.4.x and both sidecars from 0.5.0 forward.
|
||||
|
||||
### Phase 3 — Re-notarize and staple the `.app`
|
||||
|
||||
After sidecars are re-signed the outer bundle's notarization ticket is stale. Submit the `.app` (zipped) to `notarytool`, wait, then `xcrun stapler staple Voicebox.app`. This puts the ticket directly on the `.app` so the `spctl -t open` audit passes without any online ticket lookup.
|
||||
|
||||
Then rebuild the DMG from the stapled `.app` and keep the existing DMG-level notarize/staple step — it still helps Finder drag-install.
|
||||
|
||||
### Phase 4 — CI verification gate in the release workflow
|
||||
|
||||
Before upload, run the same four diagnostic commands against the built artifact inside the workflow. If any fail, fail the release job rather than shipping a DMG that Homebrew (and Sequoia Finder users) will reject. This is the check that would have caught the 0.4.3 and 0.4.5 attempts before they cost PR review cycles.
|
||||
|
||||
### Phase 5 — Re-request Homebrew CI
|
||||
|
||||
Once a tagged release passes Phase 4 locally, push a cask update to #260314. Expect `test voicebox (macos-15, arm)` and `test voicebox (macos-15-intel, intel)` to go green.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Does tauri-action v0.6 pass `APPLE_API_KEY_PATH` to the bundler's notarize path, or does it rely on the `~/.appstoreconnect/private_keys/AuthKey_*.p8` auto-discovery the staple step already sets up? If the former isn't working, tauri may be signing but never notarizing the `.app`, which would make the ticket absent entirely rather than stale. Worth a `grep -i notariz` on a full release job log.
|
||||
- If Phase 2 resolves the macOS 15 failure, revisit whether the 0.4.3 DMG staple step is still needed. It's cheap to keep and helps the Finder-open case, so default to leaving it.
|
||||
@@ -1,36 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { ArrowRight, MessageSquareReply, PenLine, Sparkles } from 'lucide-react';
|
||||
import { ArrowRight, Dices, Wand2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
// ─── Modes ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type Mode = {
|
||||
id: 'compose' | 'rewrite' | 'respond';
|
||||
id: 'compose' | 'rewrite';
|
||||
label: string;
|
||||
icon: typeof Sparkles;
|
||||
inputLabel: string;
|
||||
icon: typeof Dices;
|
||||
outputLabel: string;
|
||||
input: string;
|
||||
output: string;
|
||||
};
|
||||
} & (
|
||||
| { inputLabel: string; input: string }
|
||||
| { inputLabel?: undefined; input?: undefined }
|
||||
);
|
||||
|
||||
const MODES: Mode[] = [
|
||||
{
|
||||
id: 'compose',
|
||||
label: 'Compose',
|
||||
icon: Sparkles,
|
||||
inputLabel: 'Prompt',
|
||||
outputLabel: "Marlowe, in character",
|
||||
input: 'celebrate the deploy going green',
|
||||
output:
|
||||
"She came through clean. Not a single test casting a shadow. In this town, that's usually when you start worrying.",
|
||||
},
|
||||
{
|
||||
id: 'rewrite',
|
||||
label: 'Rewrite',
|
||||
icon: PenLine,
|
||||
icon: Wand2,
|
||||
inputLabel: 'Your text',
|
||||
outputLabel: "Marlowe, in character",
|
||||
input: 'the build is done and we shipped to production',
|
||||
@@ -38,14 +29,12 @@ const MODES: Mode[] = [
|
||||
"Build's wrapped, ship's left the dock. Another stack of code makes its way into prod, another row of green checks lining the wall.",
|
||||
},
|
||||
{
|
||||
id: 'respond',
|
||||
label: 'Respond',
|
||||
icon: MessageSquareReply,
|
||||
inputLabel: 'Question',
|
||||
id: 'compose',
|
||||
label: 'Compose',
|
||||
icon: Dices,
|
||||
outputLabel: "Marlowe, in character",
|
||||
input: 'should I refactor this before merging or do it after?',
|
||||
output:
|
||||
"Listen, kid. You can polish the brass on the door, or you can open it. Open the door — refactor in daylight.",
|
||||
"She came through clean. Not a single test casting a shadow. In this town, that's usually when you start worrying.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -116,14 +105,26 @@ function ModeDemo({ mode, cycleKey }: { mode: Mode; cycleKey: number }) {
|
||||
className="flex flex-col gap-4 flex-1"
|
||||
>
|
||||
{/* Input */}
|
||||
<div>
|
||||
<div className="text-[10px] font-mono uppercase tracking-[0.2em] text-ink-faint/70 mb-1.5">
|
||||
{mode.inputLabel}
|
||||
{mode.input ? (
|
||||
<div>
|
||||
<div className="text-[10px] font-mono uppercase tracking-[0.2em] text-ink-faint/70 mb-1.5">
|
||||
{mode.inputLabel}
|
||||
</div>
|
||||
<div className="text-[13px] leading-relaxed text-ink-dull/90 font-mono bg-black/20 rounded-md border border-app-line/60 px-3 py-2.5">
|
||||
{mode.input}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[13px] leading-relaxed text-ink-dull/90 font-mono bg-black/20 rounded-md border border-app-line/60 px-3 py-2.5">
|
||||
{mode.input}
|
||||
) : (
|
||||
<div>
|
||||
<div className="text-[10px] font-mono uppercase tracking-[0.2em] text-ink-faint/70 mb-1.5">
|
||||
No input
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 text-[13px] text-ink-dull/90 bg-black/20 rounded-md border border-app-line/60 px-3 py-2.5">
|
||||
<Dices className="h-4 w-4 text-accent shrink-0" />
|
||||
<span>Click Compose — the character improvises a fresh line.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Arrow */}
|
||||
<div className="flex items-center justify-center gap-2 text-[10px] font-mono uppercase tracking-[0.2em] text-ink-faint/50">
|
||||
@@ -151,22 +152,16 @@ function ModeDemo({ mode, cycleKey }: { mode: Mode; cycleKey: number }) {
|
||||
|
||||
const BULLETS = [
|
||||
{
|
||||
icon: Sparkles,
|
||||
title: 'Compose',
|
||||
description:
|
||||
'Generate a fresh utterance in the character’s voice from a short prompt. Useful for game dialogue, narration cues, or character barks.',
|
||||
},
|
||||
{
|
||||
icon: PenLine,
|
||||
icon: Wand2,
|
||||
title: 'Rewrite',
|
||||
description:
|
||||
'Restate your text in their voice while preserving every idea. Same content, their delivery — for scripts, dubs, and consistent character voice across long-form work.',
|
||||
},
|
||||
{
|
||||
icon: MessageSquareReply,
|
||||
title: 'Respond',
|
||||
icon: Dices,
|
||||
title: 'Compose',
|
||||
description:
|
||||
'Treat your text as a prompt and produce the character’s reply. The persona half of the dictation → speak loop.',
|
||||
'No input needed — hit the button and the character improvises a fresh line of their own. Roll again for another take. Useful for game dialogue, narration cues, or character barks.',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -197,9 +192,8 @@ export function Personalities() {
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-base md:text-lg leading-relaxed">
|
||||
Give any voice profile a free-form personality. Then{' '}
|
||||
<b className="text-foreground/90">Compose</b>,{' '}
|
||||
<b className="text-foreground/90">Rewrite</b>, or{' '}
|
||||
<b className="text-foreground/90">Respond</b> — your cloned voice, in full character.
|
||||
<b className="text-foreground/90">Rewrite</b> your text in their voice, or let them{' '}
|
||||
<b className="text-foreground/90">Compose</b> a fresh line of their own — your cloned voice, in full character.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -210,7 +204,7 @@ export function Personalities() {
|
||||
</div>
|
||||
|
||||
{/* Bullets */}
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
{BULLETS.map((bullet) => {
|
||||
const Icon = bullet.icon;
|
||||
return (
|
||||
|
||||
+17
-1
@@ -1,9 +1,25 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>voicebox</title>
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
var theme = 'system';
|
||||
var raw = localStorage.getItem('voicebox-ui');
|
||||
if (raw) {
|
||||
var parsed = JSON.parse(raw);
|
||||
if (parsed && parsed.state && parsed.state.theme) theme = parsed.state.theme;
|
||||
}
|
||||
var resolved = theme === 'system'
|
||||
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
|
||||
: theme;
|
||||
if (resolved === 'dark') document.documentElement.classList.add('dark');
|
||||
} catch (_) {}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Reference in New Issue
Block a user