Files
eranos/src/themes.ts
T

563 lines
15 KiB
TypeScript

import type { Theme } from '@/contexts/AppContext';
import { deriveTokensFromCore } from '@/lib/colorUtils';
/**
* The 3 core colors that define a theme.
* All other Tailwind tokens are derived automatically from these.
*/
export interface CoreThemeColors {
/** Background color (HSL string, e.g. "228 20% 10%") */
background: string;
/** Text/foreground color */
text: string;
/** Primary accent color (buttons, links, focus rings) */
primary: string;
}
// ─── Font Types ───────────────────────────────────────────────────────
/** A font reference: family name + optional URL (URL required on Nostr events, optional locally for bundled fonts). */
export interface ThemeFont {
/** CSS font-family name, e.g. "Playfair Display" */
family: string;
/** Direct URL to a font file (.woff2, .ttf, .otf). Required on Nostr events, optional locally. */
url?: string;
}
// ─── Background Types ─────────────────────────────────────────────────
/** Background image/video configuration. */
export interface ThemeBackground {
/** URL to an image or video file */
url: string;
/** Display mode */
mode?: 'cover' | 'tile';
/** Dimensions as "widthxheight", e.g. "1920x1080" */
dimensions?: string;
/** MIME type, e.g. "image/jpeg" */
mimeType?: string;
/** Blurhash placeholder for progressive loading */
blurhash?: string;
}
// ─── ThemeConfig ──────────────────────────────────────────────────────
/**
* Complete theme configuration. Wraps CoreThemeColors with optional
* font and background settings. This is the canonical type stored in
* AppConfig.customTheme, EncryptedSettings, and theme events.
*/
export interface ThemeConfig {
/** Theme name (stored locally AND on events) */
title?: string;
/** The 3 core colors */
colors: CoreThemeColors;
/** Optional custom font (applies globally to all text) */
font?: ThemeFont;
/** Optional background media */
background?: ThemeBackground;
}
/**
* Configured light and dark themes. When set in AppConfig,
* these override the builtin themes for "light" and "dark" modes.
*/
export interface ThemesConfig {
/** Theme config applied when theme resolves to "light". */
light: ThemeConfig;
/** Theme config applied when theme resolves to "dark". */
dark: ThemeConfig;
}
/**
* Full set of CSS token values used internally by Tailwind.
* These are derived from CoreThemeColors via deriveTokensFromCore().
*/
export interface ThemeTokens {
background: string;
foreground: string;
card: string;
cardForeground: string;
popover: string;
popoverForeground: string;
primary: string;
primaryForeground: string;
secondary: string;
secondaryForeground: string;
muted: string;
mutedForeground: string;
accent: string;
accentForeground: string;
destructive: string;
destructiveForeground: string;
border: string;
input: string;
ring: string;
}
/**
* Builtin themes whose colors are defined at build time.
* Self-hosters can customize these values before building.
*/
export const builtinThemes: Record<'light' | 'dark', CoreThemeColors> = {
light: {
background: '270 50% 97%',
text: '270 25% 12%',
primary: '270 65% 55%',
},
dark: {
background: '228 20% 10%',
text: '210 40% 98%',
primary: '258 70% 60%',
},
};
/** Metadata for a theme preset. */
export interface ThemePreset {
/** Display label. */
label: string;
/** Emoji shown in compact theme pickers (dropdowns, cycle buttons). */
emoji: string;
/** Whether to show in compact pickers (sidebar dropdown, mobile drawer). All presets appear in settings. */
featured?: boolean;
/** The 3 core colors. */
colors: CoreThemeColors;
/** Optional custom font for this preset. */
font?: ThemeFont;
/** Optional background for this preset. */
background?: ThemeBackground;
}
/**
* Custom theme presets. Clicking a preset sets theme to "custom"
* and applies the preset's core color values to customTheme.
*/
export const themePresets: Record<string, ThemePreset> = {
pink: {
label: 'Pink',
emoji: '🌸',
featured: true,
colors: {
background: '330 100% 96%',
text: '330 30% 10%',
primary: '330 90% 60%',
},
font: { family: 'Comfortaa' },
background: {
url: 'https://blossom.ditto.pub/2c9d4fe206f39b81655eab559998a89e1dca12f4db81c10fd8f472c69fe9c68a.jpeg',
mode: 'cover',
mimeType: 'image/jpeg',
},
},
toxic: {
label: 'Toxic',
emoji: '☢️',
colors: {
background: '130 30% 7%',
text: '120 40% 92%',
primary: '128 70% 42%',
},
font: { family: 'JetBrains Mono' },
},
sunset: {
label: 'Sunset',
emoji: '🌅',
colors: {
background: '20 40% 96%',
text: '15 30% 12%',
primary: '15 85% 55%',
},
font: { family: 'Lora' },
},
skater: {
label: 'Skater',
emoji: '🛹',
featured: true,
colors: {
background: '0 0% 42%',
text: '0 0% 100%',
primary: '80 100% 50%',
},
font: { family: 'Rubik Maps' },
background: {
url: 'https://blossom.primal.net/9c4262aaa53d8feae41b3b6206647e25c6f388d9e836fb3e8abcf9be72be493e.png',
mode: 'cover',
mimeType: 'image/png',
},
},
kawaii: {
label: 'Kawaii',
emoji: '🌸',
featured: true,
colors: {
background: '340 60% 95%',
text: '345 30% 35%',
primary: '340 100% 76%',
},
font: { family: 'Cherry Bomb One' },
background: {
url: 'https://blossom.ditto.pub/4e11a3ca749f9cc8989b61cb9efe78682533d2836eccaf4bccf104dd7b583e09.png',
mode: 'cover',
mimeType: 'image/png',
},
},
grunge: {
label: 'Grunge',
emoji: '🖤',
featured: true,
colors: {
background: '276 40% 8%',
text: '0 0% 75%',
primary: '328 100% 54%',
},
font: { family: 'Lacquer' },
background: {
url: 'https://blossom.primal.net/9fa0f1f7cd7da344f3e1db6ecfbdbeb2bb0763d3eaccbc0f5368871d0421b50b.png',
mode: 'cover',
mimeType: 'image/png',
},
},
mspaint: {
label: 'MS Paint',
emoji: '🖥️',
featured: true,
colors: {
background: '200 20% 95%',
text: '0 0% 10%',
primary: '240 100% 50%',
},
font: { family: 'Silkscreen' },
background: {
url: 'https://blossom.ditto.pub/946fedd46ec6b283472c0b3a102817ff414a6d640517df5c679bb63830ef21bf.png',
mode: 'cover',
mimeType: 'image/png',
},
},
retropop: {
label: 'Retro Pop',
emoji: '💿',
featured: true,
colors: {
background: '244 100% 92%',
text: '40 40% 10%',
primary: '260 50% 70%',
},
font: { family: 'Bungee Shade' },
background: {
url: 'https://blossom.ditto.pub/3832abebc944668c4c0bd34309b0dfe120054671e20ca8c8e9abbb24114c972e.png',
mode: 'cover',
mimeType: 'image/png',
},
},
bubblegum: {
label: 'Bubblegum',
emoji: '🍬',
featured: true,
colors: {
background: '0 0% 100%',
text: '285 25% 31%',
primary: '279 100% 50%',
},
font: { family: 'Barriecito' },
background: {
url: 'https://blossom.ditto.pub/edd3139e0c4d60b96dcf54edbe7410b1f58d9e5753c8d481fe9bb6812aca00d4.png',
mode: 'cover',
mimeType: 'image/png',
},
},
gamer: {
label: 'Gamer',
emoji: '⚡',
featured: true,
colors: {
background: '140 60% 4%',
text: '120 100% 50%',
primary: '195 100% 50%',
},
font: { family: 'Press Start 2P' },
background: {
url: 'https://blossom.ditto.pub/c5597382d7da762dcce32b5b5dbbd95a719faee5cad7c356df1956648b58be69.png',
mode: 'cover',
mimeType: 'image/png',
},
},
// ─── Themes inspired by MySpace Windows93 ──────────────────────────
cottage: {
label: 'Cottage',
emoji: '🌿',
featured: true,
colors: {
background: '100 25% 92%',
text: '100 20% 12%',
primary: '43 80% 55%',
},
font: { family: 'Lora' },
background: {
url: 'https://images.unsplash.com/photo-1470252649378-9c29740c9fa8?w=1920&q=80',
mode: 'cover',
mimeType: 'image/jpeg',
},
},
midnight: {
label: 'Midnight',
emoji: '🌃',
featured: true,
colors: {
background: '0 0% 9%',
text: '0 0% 95%',
primary: '190 100% 50%',
},
font: { family: 'Inter' },
background: {
url: 'https://images.unsplash.com/photo-1519501025264-65ba15a82390?w=1920&q=80',
mode: 'cover',
mimeType: 'image/jpeg',
},
},
sky: {
label: 'Sky',
emoji: '☁️',
featured: true,
colors: {
background: '200 60% 88%',
text: '220 30% 15%',
primary: '280 55% 65%',
},
font: { family: 'Nunito' },
background: {
url: 'https://images.unsplash.com/photo-1517483000871-1dbf64a6e1c6?w=1920&q=80',
mode: 'cover',
mimeType: 'image/jpeg',
},
},
motherboard: {
label: 'Motherboard',
emoji: '🪟',
featured: true,
colors: {
background: '0 0% 75%',
text: '0 0% 5%',
primary: '240 100% 30%',
},
font: { family: 'Courier Prime' },
background: {
url: 'https://images.unsplash.com/photo-1518770660439-4636190af475?w=1920&q=80',
mode: 'cover',
mimeType: 'image/jpeg',
},
},
plush: {
label: 'Plush',
emoji: '🧸',
colors: {
background: '265 55% 72%',
text: '55 100% 50%',
primary: '210 90% 55%',
},
font: { family: 'Comic Neue' },
background: {
url: 'https://images.unsplash.com/photo-1550684376-efcbd6e3f031?w=1920&q=80',
mode: 'cover',
mimeType: 'image/jpeg',
},
},
galaxy: {
label: 'Galaxy',
emoji: '🌌',
featured: true,
colors: {
background: '260 40% 8%',
text: '220 30% 95%',
primary: '270 80% 65%',
},
font: { family: 'DM Sans' },
background: {
url: 'https://images.unsplash.com/photo-1462331940025-496dfbfc7564?w=1920&q=80',
mode: 'cover',
mimeType: 'image/jpeg',
},
},
ocean: {
label: 'Ocean',
emoji: '🌊',
featured: true,
colors: {
background: '195 50% 12%',
text: '185 30% 92%',
primary: '175 70% 50%',
},
font: { family: 'Nunito' },
background: {
url: 'https://images.unsplash.com/photo-1507525428034-b723cf961d3e?w=1920&q=80',
mode: 'cover',
mimeType: 'image/jpeg',
},
},
forest: {
label: 'Forest',
emoji: '🌲',
featured: true,
colors: {
background: '150 30% 10%',
text: '120 20% 90%',
primary: '150 60% 45%',
},
font: { family: 'Merriweather' },
background: {
url: 'https://images.unsplash.com/photo-1448375240586-882707db888b?w=1920&q=80',
mode: 'cover',
mimeType: 'image/jpeg',
},
},
clearsky: {
label: 'Clear Sky Vibes',
emoji: '✨',
featured: true,
colors: {
background: '228 37% 8%',
text: '185 100% 72%',
primary: '300 100% 60%',
},
font: { family: 'Comfortaa' },
background: {
url: 'https://images.unsplash.com/photo-1519681393784-d120267933ba?w=1920&q=80',
mode: 'cover',
mimeType: 'image/jpeg',
},
},
silenttorii: {
label: 'Silent Torii',
emoji: '⛩️',
featured: true,
colors: {
primary: '3 62% 50%',
text: '0 0% 100%',
background: '0 7% 22%',
},
font: { family: 'DM Sans' },
background: {
url: 'https://blossom.ditto.pub/7a609544b62918264b6cfd1f05ae38f9ed9a7922465a4ecc2edbb1a769f887d0.jpeg',
mode: 'cover',
mimeType: 'image/jpeg',
},
},
quiethorizon: {
label: 'Quiet Horizon',
emoji: '🌅',
featured: true,
colors: {
primary: '170 15% 39%',
text: '0 0% 100%',
background: '0 7% 22%',
},
font: { family: 'Lora' },
background: {
url: 'https://blossom.ditto.pub/3fc3800e0551340c000a0ac75496e642f6dbd5bddf17c5ba7f4c4ebfc8bae55f.jpeg',
mode: 'cover',
mimeType: 'image/jpeg',
},
},
wherepathsmeet: {
label: 'Where Paths Meet',
emoji: '🌉',
featured: true,
colors: {
primary: '180 11% 78%',
text: '120 3% 81%',
background: '180 19% 15%',
},
font: { family: 'DM Sans' },
background: {
url: 'https://blossom.ditto.pub/a9f38f1bda2d7d984167f3b1197d3d2c7280a650ad609d24f97e35795f7cfd17.jpeg',
mode: 'cover',
mimeType: 'image/jpeg',
},
},
once: {
label: 'Once',
emoji: '🎲',
featured: true,
colors: {
primary: '217 94% 72%',
text: '120 3% 81%',
background: '210 17% 7%',
},
font: { family: 'Outfit' },
background: {
url: 'https://picsum.photos/1920/1080',
mode: 'cover',
mimeType: 'image/jpeg',
},
},
};
/** Converts a camelCase key to a CSS custom property name, e.g. primaryForeground → --primary-foreground */
export function toThemeVar(key: string): string {
return `--${key.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`)}`;
}
/** Builds a CSS :root block string from a ThemeTokens object */
export function buildThemeCss(tokens: ThemeTokens): string {
const vars = (Object.entries(tokens) as [string, string][])
.map(([k, v]) => `${toThemeVar(k)}: ${v};`)
.join(' ');
return `:root { ${vars} }`;
}
/** Derive full ThemeTokens from CoreThemeColors */
export function coreToTokens(colors: CoreThemeColors): ThemeTokens {
return deriveTokensFromCore(colors.background, colors.text, colors.primary);
}
/** Build CSS from CoreThemeColors (convenience) */
export function buildThemeCssFromCore(colors: CoreThemeColors): string {
return buildThemeCss(coreToTokens(colors));
}
/**
* Resolves a theme preference to the concrete builtin theme name.
* - "system" → "light" or "dark" based on OS preference.
* - "custom" → returns "custom" (caller must supply colors from config.customTheme).
* - "light" / "dark" → returned as-is.
*/
export function resolveTheme(theme: Theme): 'light' | 'dark' | 'custom' {
if (theme === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
return theme;
}
/**
* Resolves the effective ThemeConfig for a "light" or "dark" mode.
* Uses configured themes from AppConfig if available, otherwise falls back
* to the builtin themes (colors only, no font/background).
*/
export function resolveThemeConfig(mode: 'light' | 'dark', themes?: ThemesConfig): ThemeConfig {
return themes?.[mode] ?? { colors: builtinThemes[mode] };
}