Complete Phase E theming, onboarding, and Windows share integration

Adds theme presets (Toffee/Slate/Evergreen/Lavender) with a "follow system"
mode and high-contrast awareness, a first-run welcome screen, and the
Windows analog of Android's share sheet for a Win32 app: an aerofetch://
protocol handler plus an Explorer "Send to AeroFetch" entry, both routed
through a single-instance lock so a second launch hands its link to the
already-running window instead of opening a duplicate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 08:45:09 -04:00
parent a822a2bb52
commit fa78b13cac
17 changed files with 883 additions and 77 deletions
+33 -14
View File
@@ -4,8 +4,10 @@ import { Sidebar, type TabValue } from './components/Sidebar'
import { DownloadsView } from './components/DownloadsView'
import { HistoryView } from './components/HistoryView'
import { SettingsView } from './components/SettingsView'
import { lightTheme, darkTheme, pageBackground } from './theme'
import { Onboarding } from './components/Onboarding'
import { getTheme, pageBackground } from './theme'
import { useSettings } from './store/settings'
import { useSystemTheme } from './store/systemTheme'
const useStyles = makeStyles({
provider: {
@@ -27,12 +29,22 @@ const useStyles = makeStyles({
function App(): React.JSX.Element {
const styles = useStyles()
const theme = useSettings((s) => s.theme)
const accentColor = useSettings((s) => s.accentColor)
const updateSettings = useSettings((s) => s.update)
const isDark = theme === 'dark'
const systemPrefersDark = useSystemTheme((s) => s.shouldUseDarkColors)
const isDark = theme === 'system' ? systemPrefersDark : theme === 'dark'
const [tab, setTab] = useState<TabValue>('downloads')
// Gate on `loaded` so a returning user's real settings never get clobbered
// by a one-frame flash of the (default-false) onboarding state.
const loaded = useSettings((s) => s.loaded)
const hasCompletedOnboarding = useSettings((s) => s.hasCompletedOnboarding)
const showOnboarding = loaded && !hasCompletedOnboarding
return (
<FluentProvider theme={isDark ? darkTheme : lightTheme} className={styles.provider}>
<FluentProvider
theme={getTheme(accentColor, isDark ? 'dark' : 'light')}
className={styles.provider}
>
<div
className={styles.root}
style={{
@@ -40,18 +52,25 @@ function App(): React.JSX.Element {
colorScheme: isDark ? 'dark' : 'light'
}}
>
<Sidebar
tab={tab}
onTabChange={setTab}
isDark={isDark}
onToggleTheme={() => updateSettings({ theme: isDark ? 'light' : 'dark' })}
/>
{showOnboarding ? (
<Onboarding />
) : (
<>
<Sidebar
tab={tab}
onTabChange={setTab}
isDark={isDark}
followingSystem={theme === 'system'}
onToggleTheme={() => updateSettings({ theme: isDark ? 'light' : 'dark' })}
/>
<main className={styles.content}>
{tab === 'downloads' && <DownloadsView />}
{tab === 'history' && <HistoryView />}
{tab === 'settings' && <SettingsView />}
</main>
<main className={styles.content}>
{tab === 'downloads' && <DownloadsView />}
{tab === 'history' && <HistoryView />}
{tab === 'settings' && <SettingsView />}
</main>
</>
)}
</div>
</FluentProvider>
)
+22 -2
View File
@@ -337,7 +337,10 @@ export function DownloadBar(): React.JSX.Element {
// Clipboard auto-detect: when the window gains focus and the clipboard holds a
// fresh link, offer it (without clobbering anything the user is already typing).
// The same banner also surfaces links handed to AeroFetch from outside — the
// aerofetch:// protocol or a "Send to" .url file (see onExternalUrl below).
const [suggestion, setSuggestion] = useState<string | null>(null)
const [suggestionSource, setSuggestionSource] = useState<'clipboard' | 'external'>('clipboard')
const urlRef = useRef('')
urlRef.current = url
const lastSeen = useRef<string | null>(null)
@@ -354,7 +357,10 @@ export function DownloadBar(): React.JSX.Element {
}
if (!active) return
text = text.trim()
if (looksLikeUrl(text) && text !== lastSeen.current) setSuggestion(text)
if (looksLikeUrl(text) && text !== lastSeen.current) {
setSuggestionSource('clipboard')
setSuggestion(text)
}
}
check()
window.addEventListener('focus', check)
@@ -364,6 +370,17 @@ export function DownloadBar(): React.JSX.Element {
}
}, [])
// A link handed in from outside always takes priority over whatever the
// clipboard banner was showing — it's a direct request, not a guess.
useEffect(
() =>
window.api.onExternalUrl((incomingUrl) => {
setSuggestionSource('external')
setSuggestion(incomingUrl)
}),
[]
)
function acceptSuggestion(): void {
if (!suggestion) return
lastSeen.current = suggestion
@@ -595,7 +612,10 @@ export function DownloadBar(): React.JSX.Element {
{suggestion && (
<div className={styles.suggestion}>
<LinkRegular />
<Caption1 className={styles.suggestionText}>Use copied link? {suggestion}</Caption1>
<Caption1 className={styles.suggestionText}>
{suggestionSource === 'external' ? 'Link received: ' : 'Use copied link? '}
{suggestion}
</Caption1>
<Button size="small" appearance="primary" onClick={acceptSuggestion}>
Use
</Button>
+154
View File
@@ -0,0 +1,154 @@
import {
Card,
Title2,
Body1,
Caption1,
Field,
Input,
Button,
makeStyles,
tokens,
shorthands
} from '@fluentui/react-components'
import {
ArrowDownloadFilled,
FolderRegular,
ClipboardPasteRegular,
HistoryRegular,
OptionsRegular,
RocketRegular
} from '@fluentui/react-icons'
import { useSettings } from '../store/settings'
const useStyles = makeStyles({
root: {
height: '100%',
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflowY: 'auto',
padding: '24px'
},
card: {
display: 'flex',
flexDirection: 'column',
gap: '20px',
padding: '32px',
maxWidth: '460px',
width: '100%',
...shorthands.borderRadius(tokens.borderRadiusXLarge)
},
brandRow: {
display: 'flex',
alignItems: 'center',
gap: '14px'
},
mark: {
width: '48px',
height: '48px',
flexShrink: 0,
...shorthands.borderRadius(tokens.borderRadiusLarge),
backgroundColor: tokens.colorBrandBackground,
color: tokens.colorNeutralForegroundOnBrand,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '26px'
},
folderRow: {
display: 'flex',
gap: '8px',
alignItems: 'flex-end'
},
folderInput: {
flexGrow: 1
},
tips: {
display: 'flex',
flexDirection: 'column',
gap: '10px'
},
tip: {
display: 'flex',
alignItems: 'flex-start',
gap: '10px'
},
tipIcon: {
fontSize: '18px',
flexShrink: 0,
marginTop: '2px',
color: tokens.colorCompoundBrandForeground1
}
})
const TIPS: { icon: React.JSX.Element; text: string }[] = [
{
icon: <ClipboardPasteRegular />,
text: 'Copy a video link and AeroFetch offers to fetch it as soon as you switch back.'
},
{
icon: <HistoryRegular />,
text: 'Downloads queue with a concurrency cap, and every completed file lands in History.'
},
{
icon: <OptionsRegular />,
text: 'Subtitles, SponsorBlock, custom yt-dlp commands, and more live in Settings.'
}
]
export function Onboarding(): React.JSX.Element {
const styles = useStyles()
const outputDir = useSettings((s) => s.outputDir)
const chooseOutputDir = useSettings((s) => s.chooseOutputDir)
const update = useSettings((s) => s.update)
return (
<div className={styles.root}>
<Card className={styles.card}>
<div className={styles.brandRow}>
<div className={styles.mark}>
<ArrowDownloadFilled />
</div>
<Title2>Welcome to AeroFetch</Title2>
</div>
<Body1>
A no-frills frontend for yt-dlp: paste a link, pick a quality, and download video or
audio. yt-dlp and ffmpeg are bundled, so there&apos;s nothing else to install.
</Body1>
<Field label="Download folder">
<div className={styles.folderRow}>
<Input
readOnly
className={styles.folderInput}
value={outputDir}
contentBefore={<FolderRegular />}
/>
<Button icon={<FolderRegular />} onClick={chooseOutputDir}>
Browse
</Button>
</div>
</Field>
<div className={styles.tips}>
{TIPS.map((tip) => (
<div key={tip.text} className={styles.tip}>
<span className={styles.tipIcon}>{tip.icon}</span>
<Caption1>{tip.text}</Caption1>
</div>
))}
</div>
<Button
appearance="primary"
icon={<RocketRegular />}
onClick={() => update({ hasCompletedOnboarding: true })}
>
Get started
</Button>
</Card>
</div>
)
}
+88 -2
View File
@@ -11,6 +11,7 @@ import {
Spinner,
Text,
makeStyles,
mergeClasses,
tokens,
shorthands
} from '@fluentui/react-components'
@@ -28,7 +29,9 @@ import {
DocumentArrowUpRegular,
BugRegular,
CopyRegular,
DeleteRegular
DeleteRegular,
PaintBucketRegular,
AccessibilityRegular
} from '@fluentui/react-icons'
import {
COOKIE_BROWSERS,
@@ -40,15 +43,19 @@ import {
type CookieBrowser,
type CookiesStatus,
type BackupExportResult,
type BackupImportResult
type BackupImportResult,
type ThemeMode,
type AccentColor
} from '@shared/ipc'
import { useSettings } from '../store/settings'
import { useSystemTheme } from '../store/systemTheme'
import { QUALITY_OPTIONS, useDownloads } from '../store/downloads'
import { useTemplates } from '../store/templates'
import { useErrorLog } from '../store/errorlog'
import { Select } from './Select'
import { DownloadOptionsForm } from './DownloadOptionsForm'
import { TemplateManager } from './TemplateManager'
import { ACCENT_OPTIONS } from '../theme'
const useStyles = makeStyles({
root: {
@@ -84,6 +91,23 @@ const useStyles = makeStyles({
hint: {
color: tokens.colorNeutralForeground3
},
swatchRow: {
display: 'flex',
gap: '10px'
},
swatch: {
boxSizing: 'border-box',
width: '28px',
height: '28px',
flexShrink: 0,
...shorthands.borderRadius('50%'),
...shorthands.border('2px', 'solid', 'transparent'),
padding: 0,
cursor: 'pointer'
},
swatchActive: {
...shorthands.borderColor(tokens.colorNeutralForeground1)
},
errorList: {
display: 'flex',
flexDirection: 'column',
@@ -121,6 +145,12 @@ const FORMAT_OPTIONS = [
...QUALITY_OPTIONS.audio.map((q) => ({ value: `audio|${q}`, label: `Audio — ${q}` }))
]
const THEME_MODE_OPTIONS = [
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
{ value: 'system', label: 'Follow system' }
]
const COOKIE_SOURCE_OPTIONS = [
{ value: 'none', label: 'None' },
{ value: 'browser', label: "From a browser's cookie store" },
@@ -147,6 +177,9 @@ export function SettingsView(): React.JSX.Element {
const defaultAudioQuality = useSettings((s) => s.defaultAudioQuality)
const maxConcurrent = useSettings((s) => s.maxConcurrent)
const filenameTemplate = useSettings((s) => s.filenameTemplate)
const theme = useSettings((s) => s.theme)
const accentColor = useSettings((s) => s.accentColor)
const highContrast = useSystemTheme((s) => s.shouldUseHighContrastColors)
const clipboardWatch = useSettings((s) => s.clipboardWatch)
const downloadOptions = useSettings((s) => s.downloadOptions)
const proxy = useSettings((s) => s.proxy)
@@ -360,6 +393,59 @@ export function SettingsView(): React.JSX.Element {
</Field>
</Card>
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<PaintBucketRegular className={styles.sectionIcon} />
<Subtitle2>Appearance</Subtitle2>
</div>
<Field label="Theme">
<Select
aria-label="Theme"
value={theme}
options={THEME_MODE_OPTIONS}
onChange={(v) => update({ theme: v as ThemeMode })}
/>
</Field>
<Field label="Accent color">
<div className={styles.swatchRow}>
{ACCENT_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
className={mergeClasses(
styles.swatch,
accentColor === opt.value && styles.swatchActive
)}
style={{ backgroundColor: opt.swatch }}
onClick={() => update({ accentColor: opt.value as AccentColor })}
aria-pressed={accentColor === opt.value}
aria-label={opt.label}
title={opt.label}
/>
))}
</div>
</Field>
<Caption1 className={styles.hint}>
{highContrast
? 'A Windows high-contrast theme is active — AeroFetch follows your system colors.'
: 'AeroFetch automatically follows a Windows high-contrast theme if you turn one on.'}
</Caption1>
{highContrast && (
<div className={styles.folderRow}>
<Button
size="small"
icon={<AccessibilityRegular />}
onClick={() => window.api.openHighContrastSettings()}
>
Open accessibility settings
</Button>
</div>
)}
</Card>
<Card className={styles.card}>
<div className={styles.sectionHeader}>
<OptionsRegular className={styles.sectionIcon} />
+5 -5
View File
@@ -122,6 +122,8 @@ interface SidebarProps {
tab: TabValue
onTabChange: (t: TabValue) => void
isDark: boolean
/** true when theme is 'system' — the quick toggle still works (it sets an explicit mode) */
followingSystem: boolean
onToggleTheme: () => void
}
@@ -129,6 +131,7 @@ export function Sidebar({
tab,
onTabChange,
isDark,
followingSystem,
onToggleTheme
}: SidebarProps): React.JSX.Element {
const styles = useStyles()
@@ -154,10 +157,7 @@ export function Sidebar({
className={mergeClasses(styles.navItem, active && styles.navItemActive)}
style={
active
? {
backgroundColor: isDark ? '#3b2f24' : '#efe5df',
boxShadow: `inset 3px 0 0 0 ${isDark ? '#c7ac9e' : '#825e4a'}`
}
? { boxShadow: `inset 3px 0 0 0 ${tokens.colorCompoundBrandStroke}` }
: undefined
}
onClick={() => onTabChange(n.value)}
@@ -175,7 +175,7 @@ export function Sidebar({
<div className={styles.themeRow}>
<span className={styles.themeLabel}>
{isDark ? <WeatherMoonRegular fontSize={18} /> : <WeatherSunnyRegular fontSize={18} />}
{isDark ? 'Dark' : 'Light'}
{(isDark ? 'Dark' : 'Light') + (followingSystem ? ' · Auto' : '')}
</span>
<Switch checked={isDark} onChange={onToggleTheme} aria-label="Toggle dark mode" />
</div>
+8 -2
View File
@@ -18,6 +18,7 @@ if (import.meta.env.DEV && !window.api) {
maxConcurrent: 2,
filenameTemplate: '%(title)s.%(ext)s',
theme: 'light',
accentColor: 'toffee',
clipboardWatch: true,
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS,
proxy: '',
@@ -29,7 +30,8 @@ if (import.meta.env.DEV && !window.api) {
downloadArchive: false,
customCommandEnabled: false,
defaultTemplateId: null,
notifyOnComplete: true
notifyOnComplete: true,
hasCompletedOnboarding: true
}
// Stands in for the cookies.txt file's mtime — lets the Cookies card's
// sign-in/clear flow be exercised in this browser-only preview.
@@ -133,7 +135,11 @@ if (import.meta.env.DEV && !window.api) {
clearErrorLog: async () => [],
exportBackup: async () => ({ ok: true, path: 'C:\\Users\\you\\Downloads\\aerofetch-backup.json' }),
importBackup: async () => ({ ok: true }),
onDownloadEvent: () => () => {}
onDownloadEvent: () => () => {},
getSystemTheme: async () => ({ shouldUseDarkColors: false, shouldUseHighContrastColors: false }),
onSystemThemeUpdate: () => () => {},
openHighContrastSettings: async () => {},
onExternalUrl: () => () => {}
}
}
+5 -1
View File
@@ -12,6 +12,7 @@ const FALLBACK: Settings = {
maxConcurrent: 2,
filenameTemplate: '%(title)s.%(ext)s',
theme: 'light',
accentColor: 'toffee',
clipboardWatch: true,
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS,
proxy: '',
@@ -23,7 +24,10 @@ const FALLBACK: Settings = {
downloadArchive: false,
customCommandEnabled: false,
defaultTemplateId: null,
notifyOnComplete: true
notifyOnComplete: true,
// True in preview so design work isn't blocked behind the welcome screen;
// a real first launch gets `false` from main/settings.ts's DEFAULTS instead.
hasCompletedOnboarding: PREVIEW
}
interface SettingsState extends Settings {
+26
View File
@@ -0,0 +1,26 @@
import { create } from 'zustand'
import type { SystemThemeInfo } from '@shared/ipc'
// True in the standalone browser preview (no Electron preload).
const PREVIEW = typeof window === 'undefined' || !window.electron
const prefersDark =
typeof window !== 'undefined' && window.matchMedia
? window.matchMedia('(prefers-color-scheme: dark)')
: null
export const useSystemTheme = create<SystemThemeInfo>(() => ({
shouldUseDarkColors: prefersDark?.matches ?? false,
shouldUseHighContrastColors: false
}))
if (!PREVIEW) {
window.api.getSystemTheme().then((info) => useSystemTheme.setState(info))
window.api.onSystemThemeUpdate((info) => useSystemTheme.setState(info))
} else {
// Browser preview has no nativeTheme bridge — approximate dark-mode
// follow with matchMedia so flipping the OS/devtools preference still works.
prefersDark?.addEventListener('change', (e) =>
useSystemTheme.setState({ shouldUseDarkColors: e.matches })
)
}
+140 -10
View File
@@ -4,11 +4,12 @@ import {
type BrandVariants,
type Theme
} from '@fluentui/react-components'
import type { AccentColor } from '@shared/ipc'
// Toffee-brown brand ramp. The product palette (50950) is remapped onto
// Fluent's 16-stop BrandVariants (10 = darkest … 160 = lightest) so the LIGHT
// primary lands on toffee-600 (#825e4a) at index 80, and the DARK primary on a
// lightened toffee-400 (#b5917d) — applied via darkBrandOverrides below.
// lightened toffee-400 (#b5917d) — applied via toffeeDarkOverrides below.
const toffee: BrandVariants = {
10: '#17100d',
20: '#201713',
@@ -28,6 +29,67 @@ const toffee: BrandVariants = {
160: '#f6f1ef'
}
// Additional accent presets (Phase E). Each ramp reuses toffee's exact
// lightness curve at a different hue, so they read as siblings rather than
// arbitrary colors — only `buildDarkOverrides` below differs from toffee's
// hand-tuned dark lift.
const slate: BrandVariants = {
10: '#0f1215',
20: '#15191e',
30: '#1e242a',
40: '#2b323b',
50: '#343d48',
60: '#404c59',
70: '#4b5968',
80: '#566576',
90: '#617387',
100: '#6b7e94',
110: '#8998a9',
120: '#a6b2bf',
130: '#b7c0cb',
140: '#c4cbd4',
150: '#e1e5ea',
160: '#f1f2f5'
}
const evergreen: BrandVariants = {
10: '#0d1712',
20: '#12211a',
30: '#1a2e25',
40: '#254134',
50: '#2d4f3f',
60: '#37624e',
70: '#40735b',
80: '#498368',
90: '#549576',
100: '#5ca382',
110: '#7cb69b',
120: '#9dc8b4',
130: '#afd2c2',
140: '#bedacd',
150: '#deede6',
160: '#eff6f3'
}
const lavender: BrandVariants = {
10: '#120e16',
20: '#191320',
30: '#231b2d',
40: '#32273f',
50: '#3d2f4d',
60: '#4b3a5f',
70: '#58446f',
80: '#644e7e',
90: '#725890',
100: '#7d619e',
110: '#9781b1',
120: '#b1a0c5',
130: '#c0b2d0',
140: '#cbc0d8',
150: '#e5dfec',
160: '#f2f0f6'
}
// Rounder corners — buttons/inputs ~10px, cards ~16px. NOT pills.
const friendlyRadii = {
borderRadiusSmall: '5px',
@@ -36,10 +98,13 @@ const friendlyRadii = {
borderRadiusXLarge: '16px'
} as const
// Dark mode keeps Fluent's NEUTRAL (charcoal) surfaces and uses toffee only as
// the accent. The default dark brand is too low-contrast on near-black, so lift
// the brand to toffee-400 and flip on-brand text to dark for legible buttons.
const darkBrandOverrides = {
// Dark mode keeps Fluent's NEUTRAL (charcoal) surfaces and uses the accent
// ramp only for buttons/links. The default dark brand is too low-contrast on
// near-black, so lift the brand to ramp-110 and flip on-brand text to dark for
// legible buttons. Toffee's lift was hand-tuned (hover/pressed/stroke shades
// don't land exactly on ramp stops); newer presets use buildDarkOverrides,
// which derives the same shape directly from each ramp's own stops.
const toffeeDarkOverrides = {
colorBrandBackground: '#b5917d',
colorBrandBackgroundHover: '#c19f8c',
colorBrandBackgroundPressed: '#a2755d',
@@ -62,19 +127,84 @@ const darkBrandOverrides = {
colorBrandStroke2: '#5f4636'
} as const
export const lightTheme: Theme = { ...createLightTheme(toffee), ...friendlyRadii }
export const darkTheme: Theme = { ...createDarkTheme(toffee), ...friendlyRadii, ...darkBrandOverrides }
function buildDarkOverrides(ramp: BrandVariants): Record<string, string> {
return {
colorBrandBackground: ramp[110],
colorBrandBackgroundHover: ramp[120],
colorBrandBackgroundPressed: ramp[100],
colorBrandBackgroundSelected: ramp[110],
colorNeutralForegroundOnBrand: ramp[10],
colorCompoundBrandBackground: ramp[110],
colorCompoundBrandBackgroundHover: ramp[120],
colorCompoundBrandBackgroundPressed: ramp[100],
colorCompoundBrandForeground1: ramp[120],
colorCompoundBrandForeground1Hover: ramp[130],
colorCompoundBrandForeground1Pressed: ramp[110],
colorCompoundBrandStroke: ramp[110],
colorCompoundBrandStrokeHover: ramp[120],
colorBrandForeground1: ramp[120],
colorBrandForeground2: ramp[130],
colorBrandForegroundLink: ramp[120],
colorBrandForegroundLinkHover: ramp[130],
colorBrandForegroundLinkPressed: ramp[110],
colorBrandStroke1: ramp[110],
colorBrandStroke2: ramp[60]
}
}
interface AccentPreset {
label: string
light: Theme
dark: Theme
/** ramp-80 — the light-mode primary, used as a swatch dot in the accent picker */
swatch: string
}
function buildPreset(
label: string,
ramp: BrandVariants,
darkOverrides: Record<string, string>
): AccentPreset {
return {
label,
light: { ...createLightTheme(ramp), ...friendlyRadii },
dark: { ...createDarkTheme(ramp), ...friendlyRadii, ...darkOverrides },
swatch: ramp[80]
}
}
export const ACCENT_PRESETS: Record<AccentColor, AccentPreset> = {
toffee: buildPreset('Toffee', toffee, toffeeDarkOverrides),
slate: buildPreset('Slate', slate, buildDarkOverrides(slate)),
evergreen: buildPreset('Evergreen', evergreen, buildDarkOverrides(evergreen)),
lavender: buildPreset('Lavender', lavender, buildDarkOverrides(lavender))
}
export const ACCENT_OPTIONS: { value: AccentColor; label: string; swatch: string }[] = (
Object.keys(ACCENT_PRESETS) as AccentColor[]
).map((value) => ({
value,
label: ACCENT_PRESETS[value].label,
swatch: ACCENT_PRESETS[value].swatch
}))
export function getTheme(accent: AccentColor, mode: 'light' | 'dark'): Theme {
const preset = ACCENT_PRESETS[accent] ?? ACCENT_PRESETS.toffee
return mode === 'dark' ? preset.dark : preset.light
}
// Page background behind the app shell. Light: warm toffee paper. Dark: neutral
// charcoal (NOT brown) — toffee shows up only on accents.
// charcoal (NOT brown) — the accent shows up only on buttons/links, independent
// of which preset is chosen.
export const pageBackground = {
light: '#f6f1ef',
dark: '#161618'
}
// Per-kind thumbnail tints. Video vs audio differ by ramp DEPTH rather than a
// competing hue, keeping the monochrome-warm Studio look. Theme-aware because
// these sit on the neutral card surface.
// competing hue, keeping the monochrome-warm Studio look. Theme-aware (light/
// dark) but intentionally NOT accent-aware — these sit on the neutral card
// surface and shouldn't shift hue when the user picks a different accent.
export const thumbColors = {
light: {
video: { bg: '#ece3df', fg: '#825e4a' },