import type { ThemeConfig, SettingsYaml } from './schema'; /** The preset used when none is configured. */ export const DEFAULT_PRESET = 'studio-modern'; export const THEME_PRESETS: Record = { 'studio-modern': { preset: 'studio-modern', accent: '#e60012', fonts: { heading: 'Archivo', body: 'Archivo', caption: 'IBM Plex Mono' }, radius: 0, photoFrame: 'none', grain: false, headerDot: true, heroStyle: 'split', }, studio: { preset: 'studio', accent: '#e60012', fonts: { heading: 'Playfair Display', body: 'DM Sans', caption: 'EB Garamond' }, radius: 0, photoFrame: 'passepartout', grain: true, headerDot: true, heroStyle: 'split', }, minimal: { preset: 'minimal', accent: '#000000', fonts: { heading: 'Geist', body: 'Geist', caption: 'IBM Plex Mono' }, radius: 0, photoFrame: 'none', grain: false, headerDot: false, heroStyle: 'fullbleed', }, editorial: { preset: 'editorial', accent: '#8B2500', fonts: { heading: 'Bodoni Moda', body: 'Newsreader', caption: 'Spectral' }, radius: 0, photoFrame: 'shadow', grain: false, headerDot: false, heroStyle: 'split', }, classic: { preset: 'classic', accent: '#c49a3c', fonts: { heading: 'Cinzel', body: 'Crimson Pro', caption: 'Crimson Pro' }, radius: 12, photoFrame: 'passepartout', grain: false, headerDot: true, heroStyle: 'minimal', }, noir: { preset: 'noir', accent: '#ff6b35', fonts: { heading: 'Libre Baskerville', body: 'Source Sans 3', caption: 'Space Mono' }, radius: 0, photoFrame: 'passepartout', grain: true, headerDot: false, heroStyle: 'fullbleed', }, monograph: { preset: 'monograph', accent: '#333333', fonts: { heading: 'Instrument Serif', body: 'Inter', caption: 'IBM Plex Mono' }, radius: 0, photoFrame: 'none', grain: false, headerDot: false, heroStyle: 'typographic', }, }; export const VALID_PHOTO_FRAMES = ['none', 'passepartout', 'shadow']; export const VALID_HERO_STYLES = [ 'split', 'fullbleed', 'minimal', 'stacked', 'typographic', 'mosaic', 'cover', // EXPERIMENTAL: fullscreen splash with a single "Enter" link ]; export const VALID_LAYOUTS = [ 'masonry', 'uniform', 'showcase', 'filmstrip', 'editorial-flow', 'essay', 'justified', // EXPERIMENTAL: row-based layout with equal heights ]; /** * Corner radius reaches CSS as `${radius}px` (app/layout.tsx: `--radius-sm`) * and `${radius * 1.5}px` (`--radius-md`), so a value that is not actually a * number — `radius: "8px"` in hand-edited YAML, say — does not fail loudly, * it becomes `8pxpx` and `NaNpx` (#633). photoFrame and heroStyle, on either * side of this field, are already checked against an allowlist; this is the * same treatment for the one numeric scalar in ThemeConfig. */ const THEME_RADIUS_MAX = 64; // Presets top out at 16; generous but not unbounded. function resolveRadius(raw: unknown, fallback: number): number { if (typeof raw !== 'number' || !Number.isFinite(raw)) return fallback; return Math.min(THEME_RADIUS_MAX, Math.max(0, raw)); } /** `--accent` and `--accent-dim` (app/layout.tsx) take this verbatim. */ function resolveAccent(raw: unknown, fallback: string): string { return typeof raw === 'string' && raw.trim() ? raw : fallback; } /** `String(theme.grain)`/`String(theme.headerDot)` become a `data-*` attribute * either way, but a non-boolean is a sign the value was never meant for this * field at all — a typo'd key one level up, say — so it falls back rather * than being coerced into something that reads as intentional. */ function resolveThemeBoolean(raw: unknown, fallback: boolean): boolean { return typeof raw === 'boolean' ? raw : fallback; } export function resolveTheme(raw?: SettingsYaml['theme']): ThemeConfig { if (!raw) return { ...THEME_PRESETS[DEFAULT_PRESET] }; if (typeof raw === 'string') { const preset = THEME_PRESETS[raw]; if (!preset) { throw new Error( `Unknown theme preset "${raw}". Valid presets: ${Object.keys(THEME_PRESETS).join(', ')}`, ); } return { ...preset }; } const baseName = raw.preset ?? DEFAULT_PRESET; const base = THEME_PRESETS[baseName]; if (!base) { throw new Error( `Unknown theme preset "${baseName}". Valid presets: ${Object.keys(THEME_PRESETS).join(', ')}`, ); } return { preset: baseName, accent: resolveAccent(raw.accent, base.accent), fonts: { heading: raw.fonts?.heading ?? base.fonts.heading, body: raw.fonts?.body ?? base.fonts.body, caption: raw.fonts?.caption ?? base.fonts.caption, }, radius: resolveRadius(raw.radius, base.radius), photoFrame: VALID_PHOTO_FRAMES.includes(raw.photoFrame ?? '') ? (raw.photoFrame as ThemeConfig['photoFrame']) : base.photoFrame, grain: resolveThemeBoolean(raw.grain, base.grain), headerDot: resolveThemeBoolean(raw.headerDot, base.headerDot), heroStyle: VALID_HERO_STYLES.includes(raw.heroStyle ?? '') ? (raw.heroStyle as ThemeConfig['heroStyle']) : base.heroStyle, }; } export function getGoogleFontsUrl(theme: ThemeConfig): string { const weights = '300;400;500;600'; const families = [...new Set([theme.fonts.heading, theme.fonts.body, theme.fonts.caption])]; const params = families.map((f) => `family=${f.replace(/ /g, '+')}:wght@${weights}`).join('&'); return `https://fonts.googleapis.com/css2?${params}&display=swap`; }