Files
AeroFetch/src/renderer/src/theme.ts
T
debont80 9d816f0c86 Fix L49/L53/L59: error string newlines, dialog null-safety, shared theme constant
L49: Remove newline from missing-binary error message in download.ts; inline
     error spans render it as literal newline, now one sentence with period.
L53: backup.ts exportBackup/importBackup/showMessageBox no longer use win!
     non-null assertions -- each uses a guarded ternary (same pattern index.ts
     already used for chooseFolder).
L59: Extract PAGE_BACKGROUND to src/shared/theme.ts; main/index.ts and
     renderer/src/theme.ts both import from it -- no more "keep in sync" comment.

typecheck + 242 tests + eslint + prettier green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:42:22 -04:00

188 lines
5.3 KiB
TypeScript

import { PAGE_BACKGROUND } from '@shared/theme'
import {
createLightTheme,
createDarkTheme,
type BrandVariants,
type Theme
} from '@fluentui/react-components'
import type { AccentColor } from '@shared/ipc'
// "Sunset-to-sea" accent family. All four ramps are built the same way — one
// shared lightness/saturation curve mapped onto Fluent's 16 BrandVariants stops:
// a deep near-black floor (idx 10), a white-text-safe LIGHT primary at idx 80, a
// bright DARK-mode lift at idx 110 (applied via buildDarkOverrides), and a near-
// white top (idx 160). The idx-80 primaries are tuned to equal luminance so none
// reads louder than the rest. Hues are three analogous warms — rose → coral →
// amber — plus teal as the cool complement that anchors the set.
const rose: BrandVariants = {
10: '#20090f',
20: '#3b0e1a',
30: '#581124',
40: '#75132d',
50: '#921637',
60: '#ae1941',
70: '#c91e4c',
80: '#dc2a5a',
90: '#e34670',
100: '#eb6186',
110: '#f17e9d',
120: '#f197af',
130: '#f0b0c1',
140: '#f0c9d3',
150: '#f2dfe4',
160: '#f8f2f3'
}
const coral: BrandVariants = {
10: '#210e08',
20: '#39150b',
30: '#541c0d',
40: '#6f230e',
50: '#8a2a0f',
60: '#a43111',
70: '#bc3915',
80: '#d3421a',
90: '#e85831',
100: '#f07554',
110: '#f5957a',
120: '#f4a893',
130: '#f3bdae',
140: '#f1d0c7',
150: '#f3e3de',
160: '#f8f3f1'
}
const amber: BrandVariants = {
10: '#221907',
20: '#342507',
30: '#463107',
40: '#593d06',
50: '#6b4905',
60: '#7d5506',
70: '#8d6007',
80: '#9c6b0a',
90: '#c8890a',
100: '#f4a60a',
110: '#f7b634',
120: '#f8c359',
130: '#f3ce83',
140: '#f0daad',
150: '#f1e7d3',
160: '#f9f6f1'
}
const teal: BrandVariants = {
10: '#091f20',
20: '#0a2d2e',
30: '#0b3c3e',
40: '#0c4b4d',
50: '#0d595c',
60: '#0e686b',
70: '#117578',
80: '#148185',
90: '#18a8ad',
100: '#19d2d9',
110: '#33e4ea',
120: '#5ce6ea',
130: '#86e6e9',
140: '#afe7e9',
150: '#d3edee',
160: '#f2f8f8'
}
// Rounder corners — buttons/inputs ~10px, cards ~16px. NOT pills.
const friendlyRadii = {
borderRadiusSmall: '5px',
borderRadiusMedium: '10px',
borderRadiusLarge: '12px',
borderRadiusXLarge: '16px'
} as const
// 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. buildDarkOverrides derives that whole shape — hover/pressed/
// stroke shades and all — directly from each ramp's own stops.
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> = {
rose: buildPreset('Rose', rose, buildDarkOverrides(rose)),
coral: buildPreset('Coral', coral, buildDarkOverrides(coral)),
amber: buildPreset('Amber', amber, buildDarkOverrides(amber)),
teal: buildPreset('Teal', teal, buildDarkOverrides(teal))
}
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.teal
return mode === 'dark' ? preset.dark : preset.light
}
export const pageBackground = PAGE_BACKGROUND
// Per-kind thumbnail tints. Video vs audio differ by ramp DEPTH rather than a
// competing hue, keeping a monochrome look. Theme-aware (light/dark) but
// intentionally NOT accent-aware — a fixed neutral grey that sits quietly on the
// card surface under any of the accent presets, warm or cool.
export const thumbColors = {
light: {
video: { bg: '#eeeef0', fg: '#5e616a' },
audio: { bg: '#e2e2e6', fg: '#4a4d55' }
},
dark: {
video: { bg: '#26262a', fg: '#b5b6bd' },
audio: { bg: '#2c2c31', fg: '#a0a1aa' }
}
} as const