openvibers/openvibe.shared Commits

Every theme in light and dark: 25 pairs (13 new light themes), Midnight Pumpkin and Hayride for October, Light / Dark / Auto in the account menu, and a smaller theme loader (#105)

OP OpenVibers committed 19 days ago · 6e3217072a · 8 files, +702 −87

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 2a9180b..ab49738 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -6,6 +6,25 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## 4.12.0 — 2026-10-26
++- **Every theme in light and dark.** The owner, 2026-10-25: "each theme should have a light counterpart instead of … a
++ mixed bag", with an easy light and dark switch in the account menu.
++ - **Pairs:** `builtin-themes.js` `PAIRS` gives every theme its other half, and each theme carries `pair`. That makes 25
++ pairs and 50 themes. Thirteen are new light ones: Dawn, Tokyo Day, Alucard, Monokai Light, Shallows, Ash, Vapor
++ Day, Greenbar, Nord Light, Bright, High Contrast Light, Hayride and Snowfall. Cloud is now named "Vibe Light", the
++ default's light half.
++ - **October** (the owner's picks): Midnight Pumpkin (`halloween`), pumpkin orange on OpenVibe's navy, replaces the
++ violet Pumpkin Night, and **Hayride** is its light half. December gets **Snowfall** beside Frost. `SEASONS` entries
++ carry `light`. A season stands in for the default theme in either mode (Vibe or Vibe Light).
++ - **Light / Dark / Auto** (`theme-loader.js`, `OpenVibeThemeLoader.scheme.get()/set()`; the account menu and the
++ launcher's Display box):
++ - Light or Dark saves the picked theme's other half (the default's half when a community theme has none). The
++ season stays as it was.
++ - Auto (the display preference `scheme: auto`, synced like the others) paints the half matching the device, and
++ follows it when it changes.
++ - `pairOf(id)` and `modeOf(id)` are exported too.
++ - **Smaller loader:** the theme map is now rows of values against one list of token names, expanded at load into the
++ same full sets. `theme-loader.js` went from 59.5 KB to 50.7 KB with thirteen more themes.
++
- **The OpenVibe mark wears the season; the pumpkin button is gone.** The owner, 2026-10-25: the badge beside the brand
"takes up a lot of space … especially on mobile"; integrate it into the logo instead.
- **The mark's look** (`ov-mark.js`): `<html data-ov-season-mark="halloween|winter">` (set by `theme-loader.js` for
ddiff --git a/builtin-themes.js b/builtin-themes.js
iindex 14f21ea..73689e2 100644
---- a/builtin-themes.js
++++ b/builtin-themes.js
@@@ -548,10 +548,10 @@ const RAW_BUILTIN_THEMES = [
},
},
{
-- name: 'Cloud',
++ name: 'Vibe Light',
slug: 'cloud',
mode: 'light',
-- description: 'Airy blue-white with sky accents.',
++ description: 'OpenVibe\'s default in daylight: airy blue-white with the same blue.',
tags: ['blue', 'airy', 'fresh'],
variables: {
'--bg-primary': '#f0f5ff',
@@@ -898,34 +898,34 @@ const RAW_BUILTIN_THEMES = [
// calendar, both days included) for everyone on the default theme who has not turned seasonal themes off
// (theme-loader.js; display preference `seasonal`). Anyone can also pick one like any other theme, any time.
{
-- name: 'Pumpkin Night',
++ name: 'Midnight Pumpkin',
slug: 'halloween',
mode: 'dark',
-- description: 'Halloween: pumpkin orange on a haunted violet night. On by itself through October.',
-- tags: ['seasonal', 'halloween', 'orange', 'purple'],
++ description: 'Halloween: pumpkin orange on OpenVibe\'s midnight navy. On by itself through October (Hayride in light).',
++ tags: ['seasonal', 'halloween', 'orange'],
season: { id: 'halloween', name: 'Halloween', from: '10-01', to: '10-31' },
variables: {
-- '--bg-primary': '#0e0a14',
-- '--bg-secondary': '#160f21',
-- '--bg-tertiary': '#20162e',
-- '--bg-card': '#191125',
-- '--bg-hover': '#291d3a',
-- '--bg-input': '#110b1a',
-- '--text-primary': '#f4eef8',
-- '--text-secondary': '#bba9cc',
-- '--text-muted': '#9784ab',
++ '--bg-primary': '#0a0f1c',
++ '--bg-secondary': '#0f1626',
++ '--bg-tertiary': '#151e33',
++ '--bg-card': '#111a2b',
++ '--bg-hover': '#1a2540',
++ '--bg-input': '#0c1322',
++ '--text-primary': '#eef1f6',
++ '--text-secondary': '#a9b4c8',
++ '--text-muted': '#8c99b0',
'--accent': '#ff7a1a',
'--accent-light': '#ffa14f',
'--accent-dark': '#d65d00',
-- '--border': '#2d2040',
-- '--border-light': '#3e2c56',
++ '--border': '#1c2840',
++ '--border-light': '#2a3858',
'--live-red': '#ef4444',
-- '--success': '#7ee26b',
++ '--success': '#4ade80',
'--warning': '#fbbf24',
-- '--danger': '#f43f5e',
-- '--info': '#b08cff',
-- '--shadow': '0 2px 12px rgba(5,0,12,0.55)',
-- '--shadow-lg': '0 8px 32px rgba(5,0,12,0.7)',
++ '--danger': '#f87171',
++ '--info': '#60a5fa',
++ '--shadow': '0 2px 12px rgba(0,4,14,0.5)',
++ '--shadow-lg': '0 8px 32px rgba(0,4,14,0.65)',
},
},
{
@@@ -959,6 +959,331 @@ const RAW_BUILTIN_THEMES = [
'--shadow-lg': '0 8px 32px rgba(0,6,14,0.65)',
},
},
++ {
++ name: 'Dawn',
++ slug: 'dawn',
++ mode: 'light',
++ description: 'Midnight\'s indigo in the first light.',
++ tags: ['cool', 'minimal', 'indigo'],
++ variables: {
++ '--bg-primary': '#f5f6fc',
++ '--bg-secondary': '#eceefa',
++ '--bg-tertiary': '#e1e4f5',
++ '--bg-card': '#fafbff',
++ '--bg-hover': '#e6e9f8',
++ '--bg-input': '#f0f2fb',
++ '--text-primary': '#161a33',
++ '--text-secondary': '#474d6b',
++ '--text-muted': '#646a87',
++ '--accent': '#4f46e5',
++ '--accent-light': '#6366f1',
++ '--accent-dark': '#3730a3',
++ '--border': '#d6daef',
++ '--border-light': '#c3c8e6',
++ '--shadow': '0 2px 12px rgba(10,14,40,0.07)',
++ '--shadow-lg': '0 8px 32px rgba(10,14,40,0.12)',
++ },
++ },
++ {
++ name: 'Tokyo Day',
++ slug: 'tokyo-day',
++ mode: 'light',
++ description: 'Neon Tokyo by day: hot pink on soft white.',
++ tags: ['vibrant', 'pink', 'city'],
++ variables: {
++ '--bg-primary': '#fff6fa',
++ '--bg-secondary': '#ffedf4',
++ '--bg-tertiary': '#fde1ec',
++ '--bg-card': '#fffafc',
++ '--bg-hover': '#fde7f0',
++ '--bg-input': '#fff0f6',
++ '--text-primary': '#24101a',
++ '--text-secondary': '#5c3a4a',
++ '--text-muted': '#7a5566',
++ '--accent': '#d1005a',
++ '--accent-light': '#ff2d7f',
++ '--accent-dark': '#9c0043',
++ '--border': '#f3d3e0',
++ '--border-light': '#ebbfd1',
++ '--shadow': '0 2px 12px rgba(40,0,16,0.07)',
++ '--shadow-lg': '0 8px 32px rgba(40,0,16,0.12)',
++ },
++ },
++ {
++ name: 'Alucard',
++ slug: 'alucard',
++ mode: 'light',
++ description: 'Dracula\'s daylight twin: purple ink on warm parchment.',
++ tags: ['popular', 'purple', 'warm'],
++ variables: {
++ '--bg-primary': '#fffbeb',
++ '--bg-secondary': '#f6f0d8',
++ '--bg-tertiary': '#ece4c6',
++ '--bg-card': '#fffdf3',
++ '--bg-hover': '#f2ead0',
++ '--bg-input': '#fbf6e2',
++ '--text-primary': '#1f1f1f',
++ '--text-secondary': '#4f4b38',
++ '--text-muted': '#6c664b',
++ '--accent': '#644ac9',
++ '--accent-light': '#7f63e0',
++ '--accent-dark': '#4a33a3',
++ '--border': '#e3dab8',
++ '--border-light': '#d6cba3',
++ '--shadow': '0 2px 12px rgba(40,30,0,0.07)',
++ '--shadow-lg': '0 8px 32px rgba(40,30,0,0.12)',
++ },
++ },
++ {
++ name: 'Monokai Light',
++ slug: 'monokai-light',
++ mode: 'light',
++ description: 'Monokai\'s greens on a calm off-white page.',
++ tags: ['developer', 'green', 'classic'],
++ variables: {
++ '--bg-primary': '#fafaf6',
++ '--bg-secondary': '#f1f1ea',
++ '--bg-tertiary': '#e7e7de',
++ '--bg-card': '#fdfdfa',
++ '--bg-hover': '#ecece3',
++ '--bg-input': '#f6f6f0',
++ '--text-primary': '#272822',
++ '--text-secondary': '#4e4f44',
++ '--text-muted': '#6e6f62',
++ '--accent': '#5c8a00',
++ '--accent-light': '#7aa61a',
++ '--accent-dark': '#436600',
++ '--border': '#dcdcd0',
++ '--border-light': '#cdcdbf',
++ '--shadow': '0 2px 12px rgba(20,20,10,0.07)',
++ '--shadow-lg': '0 8px 32px rgba(20,20,10,0.12)',
++ },
++ },
++ {
++ name: 'Shallows',
++ slug: 'shallows',
++ mode: 'light',
++ description: 'Trench in the sun: clear teal water over white sand.',
++ tags: ['ocean', 'teal', 'calm'],
++ variables: {
++ '--bg-primary': '#f0fafb',
++ '--bg-secondary': '#e4f4f6',
++ '--bg-tertiary': '#d5ecef',
++ '--bg-card': '#f7fcfd',
++ '--bg-hover': '#dcf0f2',
++ '--bg-input': '#ebf7f8',
++ '--text-primary': '#0c2226',
++ '--text-secondary': '#3a5458',
++ '--text-muted': '#567277',
++ '--accent': '#00788a',
++ '--accent-light': '#0097a7',
++ '--accent-dark': '#005662',
++ '--border': '#c9e3e7',
++ '--border-light': '#b4d6db',
++ '--shadow': '0 2px 12px rgba(0,30,36,0.07)',
++ '--shadow-lg': '0 8px 32px rgba(0,30,36,0.12)',
++ },
++ },
++ {
++ name: 'Ash',
++ slug: 'ash',
++ mode: 'light',
++ description: 'Ember cooled to ash: warm greys with a red glow.',
++ tags: ['red', 'warm', 'soft'],
++ variables: {
++ '--bg-primary': '#faf7f6',
++ '--bg-secondary': '#f3eeec',
++ '--bg-tertiary': '#eae2df',
++ '--bg-card': '#fdfbfa',
++ '--bg-hover': '#efe7e4',
++ '--bg-input': '#f7f3f1',
++ '--text-primary': '#231716',
++ '--text-secondary': '#54403d',
++ '--text-muted': '#725b57',
++ '--accent': '#c62828',
++ '--accent-light': '#e53935',
++ '--accent-dark': '#8e1b1b',
++ '--border': '#e2d6d2',
++ '--border-light': '#d4c4bf',
++ '--shadow': '0 2px 12px rgba(40,10,8,0.07)',
++ '--shadow-lg': '0 8px 32px rgba(40,10,8,0.12)',
++ },
++ },
++ {
++ name: 'Vapor Day',
++ slug: 'vapor-day',
++ mode: 'light',
++ description: 'Vapor in pastel daylight: magenta on lilac.',
++ tags: ['retro', 'aesthetic', 'pink'],
++ variables: {
++ '--bg-primary': '#fcf6ff',
++ '--bg-secondary': '#f6ecfc',
++ '--bg-tertiary': '#eedff8',
++ '--bg-card': '#fefaff',
++ '--bg-hover': '#f2e5fa',
++ '--bg-input': '#f9f1fe',
++ '--text-primary': '#22122c',
++ '--text-secondary': '#533f5f',
++ '--text-muted': '#715c7e',
++ '--accent': '#a21caf',
++ '--accent-light': '#c026d3',
++ '--accent-dark': '#701a75',
++ '--border': '#e6d3f1',
++ '--border-light': '#dbc0ea',
++ '--shadow': '0 2px 12px rgba(30,0,40,0.07)',
++ '--shadow-lg': '0 8px 32px rgba(30,0,40,0.12)',
++ },
++ },
++ {
++ name: 'Greenbar',
++ slug: 'greenbar',
++ mode: 'light',
++ description: 'Matrix on paper: green-bar printout green on white.',
++ tags: ['retro', 'terminal', 'green'],
++ variables: {
++ '--bg-primary': '#f4faf3',
++ '--bg-secondary': '#e9f4e7',
++ '--bg-tertiary': '#dcecd9',
++ '--bg-card': '#f9fdf8',
++ '--bg-hover': '#e2f0df',
++ '--bg-input': '#eff7ed',
++ '--text-primary': '#0d1f10',
++ '--text-secondary': '#3b5240',
++ '--text-muted': '#57705c',
++ '--accent': '#137a2c',
++ '--accent-light': '#1f9d3a',
++ '--accent-dark': '#0b5a1f',
++ '--border': '#cfe3cb',
++ '--border-light': '#bcd7b7',
++ '--shadow': '0 2px 12px rgba(0,30,6,0.07)',
++ '--shadow-lg': '0 8px 32px rgba(0,30,6,0.12)',
++ },
++ },
++ {
++ name: 'Nord Light',
++ slug: 'nord-light',
++ mode: 'light',
++ description: 'Nord\'s Snow Storm: frost blues on arctic white.',
++ tags: ['popular', 'blue', 'soft'],
++ variables: {
++ '--bg-primary': '#eceff4',
++ '--bg-secondary': '#e5e9f0',
++ '--bg-tertiary': '#d8dee9',
++ '--bg-card': '#f2f4f8',
++ '--bg-hover': '#dfe4ec',
++ '--bg-input': '#e8ecf2',
++ '--text-primary': '#2e3440',
++ '--text-secondary': '#434c5e',
++ '--text-muted': '#4c566a',
++ '--accent': '#5e81ac',
++ '--accent-light': '#81a1c1',
++ '--accent-dark': '#4c6a91',
++ '--border': '#d0d7e2',
++ '--border-light': '#c2cad8',
++ '--shadow': '0 2px 12px rgba(20,26,40,0.07)',
++ '--shadow-lg': '0 8px 32px rgba(20,26,40,0.12)',
++ },
++ },
++ {
++ name: 'Bright',
++ slug: 'bright',
++ mode: 'light',
++ description: 'Abyss turned inside out: pure white, ink black, nothing else.',
++ tags: ['minimal', 'monochrome', 'clean'],
++ variables: {
++ '--bg-primary': '#ffffff',
++ '--bg-secondary': '#f6f6f6',
++ '--bg-tertiary': '#ececec',
++ '--bg-card': '#ffffff',
++ '--bg-hover': '#f0f0f0',
++ '--bg-input': '#fafafa',
++ '--text-primary': '#0a0a0a',
++ '--text-secondary': '#3d3d3d',
++ '--text-muted': '#5c5c5c',
++ '--accent': '#111111',
++ '--accent-light': '#333333',
++ '--accent-dark': '#000000',
++ '--border': '#e2e2e2',
++ '--border-light': '#d0d0d0',
++ '--shadow': '0 2px 12px rgba(0,0,0,0.07)',
++ '--shadow-lg': '0 8px 32px rgba(0,0,0,0.12)',
++ },
++ },
++ {
++ name: 'High Contrast Light',
++ slug: 'high-contrast-light',
++ mode: 'light',
++ description: 'Black on white with strong edges and deep blue links.',
++ tags: ['accessibility', 'high-contrast', 'bold'],
++ variables: {
++ '--bg-primary': '#ffffff',
++ '--bg-secondary': '#ffffff',
++ '--bg-tertiary': '#f2f2f2',
++ '--bg-card': '#ffffff',
++ '--bg-hover': '#e8e8e8',
++ '--bg-input': '#ffffff',
++ '--text-primary': '#000000',
++ '--text-secondary': '#1a1a1a',
++ '--text-muted': '#2b2b2b',
++ '--accent': '#0000c8',
++ '--accent-light': '#1a1aff',
++ '--accent-dark': '#00008b',
++ '--border': '#000000',
++ '--border-light': '#333333',
++ '--shadow': '0 2px 12px rgba(0,0,0,0.07)',
++ '--shadow-lg': '0 8px 32px rgba(0,0,0,0.12)',
++ },
++ },
++ {
++ name: 'Hayride',
++ slug: 'hayride',
++ mode: 'light',
++ description: 'Halloween in daylight: pumpkin orange on warm hay. Midnight Pumpkin\'s light half.',
++ tags: ['seasonal', 'halloween', 'orange'],
++ variables: {
++ '--bg-primary': '#fbf6ee',
++ '--bg-secondary': '#f6eee2',
++ '--bg-tertiary': '#efe3d1',
++ '--bg-card': '#ffffff',
++ '--bg-hover': '#f3e9da',
++ '--bg-input': '#fffaf2',
++ '--text-primary': '#23180f',
++ '--text-secondary': '#5a4838',
++ '--text-muted': '#6b5a4a',
++ '--accent': '#c2410c',
++ '--accent-light': '#ea580c',
++ '--accent-dark': '#9a3412',
++ '--border': '#eadfcd',
++ '--border-light': '#e0cfb6',
++ '--shadow': '0 2px 12px rgba(40,24,8,0.07)',
++ '--shadow-lg': '0 8px 32px rgba(40,24,8,0.12)',
++ },
++ },
++ {
++ name: 'Snowfall',
++ slug: 'snowfall',
++ mode: 'light',
++ description: 'Winter in daylight: frosted teal on fresh snow. Frost\'s light half.',
++ tags: ['seasonal', 'winter', 'teal'],
++ variables: {
++ '--bg-primary': '#f2f8fb',
++ '--bg-secondary': '#e8f2f7',
++ '--bg-tertiary': '#dbeaf1',
++ '--bg-card': '#f8fbfd',
++ '--bg-hover': '#e1eef4',
++ '--bg-input': '#edf5f9',
++ '--text-primary': '#0e1d26',
++ '--text-secondary': '#3b5260',
++ '--text-muted': '#57707e',
++ '--accent': '#0f766e',
++ '--accent-light': '#14b8a6',
++ '--accent-dark': '#115e59',
++ '--border': '#cfe2ea',
++ '--border-light': '#bcd5df',
++ '--shadow': '0 2px 12px rgba(0,20,30,0.07)',
++ '--shadow-lg': '0 8px 32px rgba(0,20,30,0.12)',
++ },
++ },
];
// ── Derived tokens ──────────────────────────────────────────────────────────────
@@@ -1057,16 +1382,33 @@ function deriveTokens(vars, mode) {
return out;
}
++/**
++ * Every theme has a light and a dark half (the owner, 2026-10-25: "each theme should have a light counterpart instead of
++ * … a mixed bag"): `pair` names the other one. The account menu's Light / Dark switches to it, and Auto follows the
++ * device (theme-loader.js). The seasons follow too: Midnight Pumpkin's light half is Hayride, Frost's is Snowfall.
++ */
++const PAIRS = [
++ ['vibe', 'cloud'], ['violet-hour', 'daylight'], ['midnight', 'dawn'], ['forest', 'meadow'], ['neon-tokyo', 'tokyo-day'],
++ ['dracula', 'alucard'], ['monokai', 'monokai-light'], ['ocean-deep', 'shallows'], ['sunset', 'peach'], ['arctic', 'snow'],
++ ['ember', 'ash'], ['vapor', 'vapor-day'], ['slate', 'lavender'], ['matrix', 'greenbar'], ['nord', 'nord-light'],
++ ['gruvbox-dark', 'gruvbox-light'], ['abyss', 'bright'], ['copper', 'sand'], ['sakura-night', 'rose'], ['hacker', 'paper'],
++ ['solarized-dark', 'solarized-light'], ['catppuccin-mocha', 'catppuccin-latte'], ['high-contrast', 'high-contrast-light'],
++ ['halloween', 'hayride'], ['frost', 'snowfall'],
++];
++const PAIR_OF = Object.fromEntries(PAIRS.flatMap(([dark, light]) => [[dark, light], [light, dark]]));
++
const BUILTIN_THEMES = RAW_BUILTIN_THEMES.map((theme) => ({
id: theme.slug,
++ pair: PAIR_OF[theme.slug] || null,
...theme,
tags: Array.isArray(theme.tags) ? [...theme.tags] : [],
variables: deriveTokens(theme.variables || {}, theme.mode),
}));
const DEFAULT_THEME = BUILTIN_THEMES.find((theme) => theme.id === 'vibe') || BUILTIN_THEMES[0];
--// The seasons, in calendar order: { id, name, theme, from: 'MM-DD', to: 'MM-DD' } (theme-loader.js carries a copy).
--const SEASONS = BUILTIN_THEMES.filter((theme) => theme.season).map((theme) => ({ id: theme.season.id, name: theme.season.name, theme: theme.id, from: theme.season.from, to: theme.season.to }))
++// The seasons, in calendar order: { id, name, theme, light, from: 'MM-DD', to: 'MM-DD' }: `theme` is the dark half and
++// `light` its light half (theme-loader.js carries a copy).
++const SEASONS = BUILTIN_THEMES.filter((theme) => theme.season).map((theme) => ({ id: theme.season.id, name: theme.season.name, theme: theme.id, light: theme.pair, from: theme.season.from, to: theme.season.to }))
.sort((a, b) => a.from.localeCompare(b.from));
/** The season `date` falls in (the visitor's local calendar), or null. A season may run over New Year (from > to). */
function seasonOn(date = new Date()) {
@@@ -1077,6 +1419,7 @@ const DEFAULT_VARS = Object.freeze({ ...(DEFAULT_THEME?.variables || {}) });
module.exports = {
BUILTIN_THEMES,
++ PAIRS,
SEASONS,
seasonOn,
DEFAULT_THEME,
ddiff --git a/navbar.js b/navbar.js
iindex f905851..b7d5d77 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -278,6 +278,12 @@
}
.openvibe-navbar-dropdown-menu a:hover, .openvibe-navbar-dropdown-menu button:hover { background: var(--bg-hover, #2f2f3d); }
.openvibe-navbar-dropdown-menu .danger { color: var(--live-red-text, var(--live-red, #e74c3c)); }
++ /* Light / Dark / Auto (theme-loader.js scheme): three equal segments, the one in use filled. */
++ .openvibe-navbar-dropdown-menu .ud-scheme { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 3px; margin: 2px 0 6px; padding: 3px; border-radius: 9px; background: var(--bg-tertiary, rgba(255,255,255,.06)); }
++ .openvibe-navbar-dropdown-menu .ud-scheme button { justify-content: center; gap: 6px; padding: 7px 4px; border-radius: 7px; font-weight: 650; color: var(--text-secondary, #a8b3c4); }
++ .openvibe-navbar-dropdown-menu .ud-scheme button[aria-pressed="true"] { background: var(--accent-strong, var(--accent, #3472d8)); color: var(--on-accent-strong, var(--on-accent, #fff)); }
++ .openvibe-navbar-dropdown-menu .ud-scheme button:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }
++ .openvibe-navbar-dropdown-menu .ud-scheme .icon { width: auto; font-size: 13px; }
.openvibe-navbar-dropdown-menu .icon { width: 18px; text-align: center; font-size: 14px; }
.openvibe-navbar .openvibe-network-badge {
@@@ -332,14 +338,17 @@
'fa-user': [448, 'M224 267Q259 267 288 250Q317 233 335 203Q352 173 352 139Q352 105 335 75Q317 45 288 28Q259 11 224 11Q189 11 160 28Q131 45 113 75Q96 105 96 139Q96 173 113 203Q131 233 160 250Q189 267 224 267ZM178 315Q103 317 52 367Q2 418 0 493Q0 506 9 514Q17 523 30 523H418Q431 523 439 514Q448 506 448 493Q446 418 396 367Q345 317 270 315H178Z'],
'fa-user-secret': [448, 'M224 27Q215 26 209 21Q208 21 208 21Q201 13 176 11Q153 12 137 39Q120 65 110 100Q74 107 53 117Q32 127 32 139Q35 161 97 175Q96 181 96 187Q96 213 105 235H45Q33 236 32 248Q32 251 33 253L72 350Q39 375 20 412Q0 449 0 493Q0 506 9 514Q17 523 30 523H418Q431 523 439 514Q448 506 448 493Q448 449 428 412Q409 375 376 350L415 253Q416 251 416 248Q415 236 403 235H343Q352 213 352 187Q352 181 351 175Q413 161 416 139Q416 127 395 117Q374 107 338 100Q328 65 311 39Q295 12 272 11Q247 13 240 21Q240 21 240 21Q239 21 239 21Q233 26 224 27ZM280 235H268H280H268Q241 234 231 209Q229 204 224 204Q219 204 217 209Q207 234 181 235H168Q151 235 140 223Q128 212 128 195V181Q171 187 224 187Q277 187 320 181V195Q320 212 308 223Q297 235 280 235ZM192 331 208 363 192 331 208 363 176 491 128 299 192 331ZM320 299 272 491 320 299 272 491 240 363 256 331 320 299Z'],
'fa-wand-magic-sparkles': [576, 'M235 54 197 68 235 54 197 68Q192 70 192 75Q192 80 197 82L235 96L249 134Q251 139 256 139Q261 139 263 134L277 96L315 82Q320 80 320 75Q320 70 315 68L277 54L263 16Q261 11 256 11Q251 11 249 16L235 54ZM46 406Q32 421 32 440Q32 459 46 474L81 509Q95 523 115 523Q134 523 149 509L530 128Q544 113 544 94Q544 74 530 60L495 25Q481 11 461 11Q442 11 427 25L46 406ZM485 94 380 199 485 94 380 199 356 175 461 70 485 94ZM8 128Q0 131 0 139Q0 147 8 150L64 171L85 228Q88 235 96 235Q104 235 107 228L128 171L185 150Q192 147 192 139Q192 131 185 128L128 107L107 51Q104 43 96 43Q88 43 85 51L64 107L8 128ZM360 384Q352 387 352 395Q352 403 360 406L416 427L437 484Q440 491 448 491Q456 491 459 484L480 427L537 406Q544 403 544 395Q544 387 537 384L480 363L459 307Q456 299 448 299Q440 299 437 307L416 363L360 384Z'],
++ 'fa-circle-half-stroke': [512, 'M448 267Q446 185 392 131Q338 77 256 75V459Q338 457 392 403Q446 349 448 267ZM0 267Q1 197 34 139Q68 81 128 45Q189 11 256 11Q323 11 384 45Q444 81 478 139Q511 197 512 267Q511 337 478 395Q444 453 384 489Q323 523 256 523Q189 523 128 489Q68 453 34 395Q1 337 0 267Z'],
++ 'fa-moon': [384, 'M224 43Q161 44 111 74Q60 103 30 154Q1 204 0 267Q1 330 31 380Q60 431 111 460Q161 490 224 491Q317 489 379 428Q387 419 382 409Q377 399 365 400Q351 403 335 403Q261 401 211 351Q162 302 160 227Q160 177 184 137Q209 97 249 74Q259 68 257 56Q254 46 242 44Q233 43 223 43Z'],
++ 'fa-sun': [512, 'M362 12Q369 16 371 24L391 132L499 152Q507 154 511 161Q514 169 509 177L447 267L509 357Q514 365 511 373Q507 380 499 382L391 402L371 510Q369 518 362 522Q354 525 346 520L256 458L166 520Q158 525 151 522Q143 518 141 510L121 402L13 382Q5 380 1 373Q-2 365 3 357L65 267L3 177Q-2 169 1 162Q5 154 13 152L121 132L141 24Q143 16 151 12Q158 9 166 14L256 76L346 14Q354 9 362 12ZM160 267Q160 241 173 219Q186 197 208 184Q231 171 256 171Q281 171 304 184Q326 197 339 219Q352 241 352 267Q352 293 339 315Q326 337 304 350Q281 363 256 363Q231 363 208 350Q186 337 173 315Q160 293 160 267ZM384 267Q384 232 367 203Q350 174 320 156Q290 139 256 139Q222 139 192 156Q162 174 145 203Q128 232 128 267Q128 302 145 331Q162 360 192 378Q222 395 256 395Q290 395 320 378Q350 360 367 331Q384 302 384 267Z'],
'fa-brain': [512, 'M184 11Q208 12 224 27Q239 43 240 67V467Q239 491 224 507Q208 522 184 523Q162 523 146 509Q131 495 128 473Q120 475 112 475Q85 474 67 456Q49 438 48 411Q48 400 52 390Q29 381 14 361Q0 341 0 315Q0 291 13 272Q25 253 46 243Q32 226 32 203Q32 180 46 163Q60 146 82 140Q80 132 80 123Q80 100 94 83Q107 67 128 61Q131 39 146 25Q162 11 184 11ZM328 11Q350 11 366 25Q381 39 384 61Q405 67 418 83Q432 100 432 123Q432 132 430 140Q452 146 466 163Q480 180 480 203Q480 226 466 243Q487 253 499 272Q512 291 512 315Q512 341 498 361Q483 381 460 390Q464 400 464 411Q463 438 445 456Q427 474 400 475Q392 475 384 473Q381 495 366 509Q350 523 328 523Q304 522 288 507Q273 491 272 467V67Q273 43 288 27Q304 12 328 11Z'],
};
// Every other glyph in nav-icons.js, grouped by advance width (sizes its slot before the path arrives).
const NAV_ICONS_LAZY = {
320: 'fa-chevron-right',
-- 384: 'fa-xmark fa-moon fa-arrow-down fa-play fa-plug fa-file-lines fa-location-dot',
++ 384: 'fa-xmark fa-arrow-down fa-play fa-plug fa-file-lines fa-location-dot',
448: 'fa-book fa-hand-fist fa-bars fa-arrow-right fa-check fa-tag fa-bolt fa-font fa-lock fa-utensils',
-- 512: 'fa-camera fa-gauge-high fa-list-check fa-paste fa-scissors fa-screwdriver-wrench fa-wallet fa-arrow-up-right-from-square fa-chevron-down fa-magnifying-glass fa-gear fa-circle-half-stroke fa-sun fa-file-pdf fa-clock fa-envelope fa-heart fa-circle-info fa-triangle-exclamation fa-globe fa-toolbox fa-comment-dots fa-server fa-newspaper fa-chart-line fa-pen-nib fa-key fa-satellite-dish fa-microchip fa-circle-nodes fa-download fa-upload fa-image fa-music',
++ 512: 'fa-camera fa-gauge-high fa-list-check fa-paste fa-scissors fa-screwdriver-wrench fa-wallet fa-arrow-up-right-from-square fa-chevron-down fa-magnifying-glass fa-gear fa-file-pdf fa-clock fa-envelope fa-heart fa-circle-info fa-triangle-exclamation fa-globe fa-toolbox fa-comment-dots fa-server fa-newspaper fa-chart-line fa-pen-nib fa-key fa-satellite-dish fa-microchip fa-circle-nodes fa-download fa-upload fa-image fa-music',
576: 'fa-house fa-video fa-star fa-tower-cell fa-diagram-project fa-money-bill-wave fa-crown fa-ticket fa-map',
640: 'fa-comments fa-gamepad fa-network-wired fa-people-arrows fa-people-group fa-shirt fa-code fa-robot fa-photo-film fa-wave-square fa-user-shield',
};
@@@ -784,8 +793,19 @@
return list.map(n => `<a href="${escapeAttr(n.url)}"><span class="icon"><span class="ov-icon" data-icon="${escapeAttr(n.icon)}" data-size="20" data-fx="none"></span></span> ${escapeAttr(n.name)}</a>`).join('');
}
++ /** Light / Dark / Auto: the theme's other half, or the device's (theme-loader.js scheme). */
++ function bindScheme(scope) {
++ const L = root.OpenVibeThemeLoader, box = scope.querySelector('.ud-scheme');
++ if (!box || !L || !L.scheme) return;
++ const paint = () => { const now = L.scheme.get(); box.querySelectorAll('[data-ov-scheme]').forEach((b) => b.setAttribute('aria-pressed', String(b.getAttribute('data-ov-scheme') === now))); };
++ box.addEventListener('click', (e) => { const b = e.target.closest('[data-ov-scheme]'); if (!b) return; e.stopPropagation(); L.scheme.set(b.getAttribute('data-ov-scheme')); paint(); });
++ ['ov:theme', 'ov:display'].forEach((ev) => root.addEventListener(ev, paint, onDocument()));
++ box.hidden = false; paint();
++ }
++
/** Display rows (text size, animations) cycle their value; state lives in theme-loader.js. */
function bindDisplayRows(dropdown) {
++ bindScheme(dropdown);
const L = root.OpenVibeThemeLoader; const rows = dropdown.querySelectorAll('[data-ov-display]');
if (!L || !L.display) { rows.forEach(r => { r.hidden = true; }); return; }
const LABEL = { text: { 100: 'Default', 112: 'Large', 125: 'Largest' }, motion: { auto: 'On', reduced: 'Calm' }, seasonal: { on: 'On', off: 'Off' } };
@@@ -831,11 +851,17 @@
const paint = () => {
const d = L.display.get();
const seg = (key, val, label, title) => `<button type="button" data-k="${key}" data-v="${val}" aria-pressed="${String(d[key]) === val}" title="${title}">${label}</button>`;
-- box.innerHTML = `<span class="ovl-dl">Display</span><span class="ovl-seg" role="group" aria-label="Text size">${seg('text', '100', 'A', 'Default text size')}${seg('text', '112', 'A+', 'Larger text')}${seg('text', '125', 'A++', 'Largest text')}</span>
++ const sch = L.scheme ? L.scheme.get() : null;
++ const ss = (val, label, title) => `<button type="button" data-s="${val}" aria-pressed="${sch === val}" title="${title}">${label}</button>`;
++ box.innerHTML = `<span class="ovl-dl">Display</span>${sch ? `<span class="ovl-seg" role="group" aria-label="Light or dark">${ss('light', 'Light', 'Light theme')}${ss('dark', 'Dark', 'Dark theme')}${ss('auto', 'Auto', 'Follow this device')}</span>` : ''}<span class="ovl-seg" role="group" aria-label="Text size">${seg('text', '100', 'A', 'Default text size')}${seg('text', '112', 'A+', 'Larger text')}${seg('text', '125', 'A++', 'Largest text')}</span>
<span class="ovl-seg" role="group" aria-label="Motion">${seg('motion', 'auto', 'Motion', 'Animations on')}${seg('motion', 'reduced', 'Calm', 'Reduce animations')}</span>${seasonState() ? `<span class="ovl-seg" role="group" aria-label="Seasonal themes">${seg('seasonal', 'on', seasonState().s.name, 'Seasonal themes on')}${seg('seasonal', 'off', 'Off', 'Seasonal themes off')}</span>` : ''}<a href="https://openvibe.network/themes">Themes</a>`;
box.hidden = false;
};
-- box.addEventListener('click', (e) => { const b = e.target.closest('button[data-k]'); if (!b) return; L.display.set({ [b.dataset.k]: b.dataset.v }); paint(); });
++ box.addEventListener('click', (e) => {
++ const s2 = e.target.closest('button[data-s]'); if (s2 && L.scheme) { L.scheme.set(s2.dataset.s); paint(); return; }
++ const b = e.target.closest('button[data-k]'); if (!b) return; L.display.set({ [b.dataset.k]: b.dataset.v }); paint();
++ });
++ root.addEventListener('ov:theme', paint, onDocument());
root.addEventListener('ov:display', paint, onDocument());
paint();
}
@@@ -1470,6 +1496,7 @@
${!isAnon ? `<a href="https://openvibe.actor/console" data-ov-ask-actor><span class="icon">${navIcon('fa-brain')}</span> Ask Actor</a>` : ''}
<div class="sep"></div>`}
<div class="ud-label">Display</div>
++ <div class="ud-scheme" role="group" aria-label="Light or dark" hidden><button type="button" data-ov-scheme="light" aria-pressed="false"><span class="icon">${navIcon('fa-sun')}</span> Light</button><button type="button" data-ov-scheme="dark" aria-pressed="false"><span class="icon">${navIcon('fa-moon')}</span> Dark</button><button type="button" data-ov-scheme="auto" aria-pressed="false" title="Follow this device's light or dark setting"><span class="icon">${navIcon('fa-circle-half-stroke')}</span> Auto</button></div>
<button type="button" data-ov-display="text"><span class="icon">${navIcon('fa-text-height')}</span> Text size <b class="ud-val"></b></button>
<button type="button" data-ov-display="motion"><span class="icon">${navIcon('fa-wand-magic-sparkles')}</span> Animations <b class="ud-val"></b></button>
<button type="button" data-ov-display="seasonal"><span class="icon">${navIcon('fa-palette')}</span> Seasonal themes <b class="ud-val"></b></button>
ddiff --git a/scripts/build-theme-loader.js b/scripts/build-theme-loader.js
iindex f59934d..719dd67 100644
---- a/scripts/build-theme-loader.js
++++ b/scripts/build-theme-loader.js
@@@ -9,11 +9,13 @@
* node scripts/build-theme-loader.js # rewrite
* node scripts/build-theme-loader.js --check # exit 1 if stale
*
-- * The seasons (builtin-themes.js SEASONS) come along as SEASONS: which theme is the default when.
++ * The seasons (builtin-themes.js SEASONS) come along as SEASONS: which theme is the default when (dark and light), and
++ * PAIRS / MODES: each theme's other half and its own mode, for the Light / Dark / Auto switch.
*
* Every entry carries the FULL variable set (the theme's own values over the default theme's), so
* switching themes replaces every token — a theme that does not name --success no longer keeps the
-- * previous theme's --success on <html>.
++ * previous theme's --success on <html>. They are written as rows of values against one list of names and expanded
++ * at load (the names were most of the file).
*/
const fs = require('fs');
const path = require('path');
@@@ -24,13 +26,41 @@ const BEGIN = ' // THEMES:BEGIN — generated by scripts/build-theme-loader.j
const END = ' // THEMES:END';
function render() {
-- const lines = BUILTIN_THEMES.map((t) => {
++ // One list of token names and, per theme, its values in that order: the names were most of the file (50 themes × 45
++ // names), and every site loads it before first paint. '' means the default theme's value. The loader expands each row
++ // into the FULL set at load, so switching themes still replaces every token.
++ const keys = [...new Set([...Object.keys(DEFAULT_VARS), ...BUILTIN_THEMES.flatMap((t) => Object.keys(t.variables))])];
++ const q = (v) => `'${String(v).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
++ const rows = BUILTIN_THEMES.map((t) => {
const vars = { ...DEFAULT_VARS, ...t.variables };
-- const body = Object.entries(vars).map(([k, v]) => `'${k}':'${String(v).replace(/'/g, "\\'")}'`).join(',');
-- return ` '${t.id}': { ${body} },`;
++ const vals = keys.map((k) => (t.id !== 'vibe' && vars[k] === DEFAULT_VARS[k] ? "''" : q(vars[k] == null ? '' : vars[k])));
++ while (vals.length && vals[vals.length - 1] === "''") vals.pop();
++ return ` '${t.id}': [${vals.join(',')}],`;
});
-- const seasons = SEASONS.map((s) => ` { id: '${s.id}', name: '${s.name.replace(/'/g, "\\'")}', theme: '${s.theme}', from: '${s.from}', to: '${s.to}' },`);
-- return `${BEGIN}\n const THEMES = {\n${lines.join('\n')}\n };\n const SEASONS = [\n${seasons.join('\n')}\n ];\n${END}`;
++ const seasons = SEASONS.map((s) => ` { id: '${s.id}', name: '${s.name.replace(/'/g, "\\'")}', theme: '${s.theme}', light: '${s.light}', from: '${s.from}', to: '${s.to}' },`);
++ // Each theme's light or dark half (builtin-themes.js PAIRS) and its own mode, for the Light / Dark / Auto switch.
++ const pairs = BUILTIN_THEMES.filter((t) => t.pair).map((t) => `'${t.id}':'${t.pair}'`).join(',');
++ const modes = BUILTIN_THEMES.map((t) => `'${t.id}':'${t.mode === 'light' ? 'light' : 'dark'}'`).join(',');
++ return `${BEGIN}
++ const THEME_KEYS = [${keys.map(q).join(',')}];
++ const THEME_ROWS = {
++${rows.join('\n')}
++ };
++ const THEMES = (function () {
++ const out = {}, base = THEME_ROWS.vibe;
++ for (const id in THEME_ROWS) {
++ const row = THEME_ROWS[id], vars = {};
++ for (let i = 0; i < THEME_KEYS.length; i++) { const v = row[i] || base[i]; if (v) vars[THEME_KEYS[i]] = v; }
++ out[id] = vars;
++ }
++ return out;
++ })();
++ const SEASONS = [
++${seasons.join('\n')}
++ ];
++ const PAIRS = { ${pairs} };
++ const MODES = { ${modes} };
++${END}`;
}
function currentBlock(src) {
ddiff --git a/test/navbar-brand.test.js b/test/navbar-brand.test.js
iindex f95bd57..34234de 100644
---- a/test/navbar-brand.test.js
++++ b/test/navbar-brand.test.js
@@@ -205,6 +205,7 @@ function load(hostname, opts = {}) {
assert.ok(dd.innerHTML.includes('/my#history'), 'History link present');
assert.ok(dd.innerHTML.includes('href="https://openvibe.network/@x"') && dd.innerHTML.includes('My Profile'), 'the public profile (openvibe.network/@username) is one tap from the account menu');
assert.ok(dd.innerHTML.includes('<a class="name ud-me" href="https://openvibe.network/@x"'), 'pressing your name in the menu opens your profile');
++ assert.ok(['light', 'dark', 'auto'].every((m) => dd.innerHTML.includes(`data-ov-scheme="${m}"`)) && dd.innerHTML.includes('aria-label="Light or dark"'), 'Light, Dark and Auto are one press away in the account menu');
assert.ok(dd.innerHTML.includes('<a class="ud-me" href="https://openvibe.network/@x" tabindex="-1" aria-hidden="true"><img'), 'and so does your picture (one stop for keyboards)');
const id = navbar.addMenuItem({ label: 'Runtime row', href: '/r', position: 'before' });
assert.ok(id, 'addMenuItem returns an id');
ddiff --git a/test/theme-pairs.test.js b/test/theme-pairs.test.js
nnew file mode 100644
iindex 0000000..ba96068
---- /dev/null
++++ b/test/theme-pairs.test.js
@@@ -0,0 +1,104 @@
++'use strict';
++// Light and dark halves (builtin-themes.js PAIRS, theme-loader.js Light, dark and Auto): every theme has a counterpart of
++// the other mode, the loader's copy knows them, the Light / Dark switch saves the other half (the default's for a theme
++// without one) and keeps the season, Auto paints the half the device asks for, and a season stands in for the default
++// theme in either mode (Midnight Pumpkin in dark, Hayride in light). theme-loader.js runs in a small fake browser.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const vm = require('vm');
++const { BUILTIN_THEMES, PAIRS, SEASONS } = require('../builtin-themes');
++
++const by = Object.fromEntries(BUILTIN_THEMES.map((t) => [t.id, t]));
++const SRC = fs.readFileSync(path.join(__dirname, '..', 'theme-loader.js'), 'utf8');
++
++// 1. The catalog: every theme has a counterpart of the other mode, both ways; 25 of each.
++for (const t of BUILTIN_THEMES) {
++ const p = by[t.pair];
++ assert.ok(p, `${t.id} has no light or dark half`);
++ assert.notStrictEqual(p.mode, t.mode, `${t.id} and ${p.id} are both ${t.mode}`);
++ assert.strictEqual(p.pair, t.id, `${t.id} ↔ ${p.id} is not mutual`);
++}
++assert.strictEqual(BUILTIN_THEMES.filter((t) => t.mode === 'light').length, BUILTIN_THEMES.filter((t) => t.mode !== 'light').length);
++assert.strictEqual(PAIRS.length * 2, BUILTIN_THEMES.length);
++assert.deepStrictEqual(SEASONS.map((s) => [s.id, s.theme, s.light]), [['halloween', 'halloween', 'hayride'], ['winter', 'frost', 'snowfall']]);
++assert.strictEqual(by.halloween.name, 'Midnight Pumpkin');
++assert.strictEqual(by.cloud.name, 'Vibe Light', 'the default has a light half by name');
++assert.strictEqual(by.vibe.pair, 'cloud');
++
++/** A page load on `day` with the device in dark (or light) mode. → { L, html, puts, store } */
++function load(day, store = new Map(), { dark = true } = {}) {
++ const [y, m, d] = day.split('-').map(Number);
++ const fixed = new Date(y, m - 1, d, 12).getTime();
++ class FixedDate extends Date { constructor(...a) { if (a.length) super(...a); else super(fixed); } static now() { return fixed; } }
++ const attrs = new Map(); const props = new Map(); const puts = [];
++ const html = { style: { setProperty: (k, v) => props.set(k, v), removeProperty: (k) => props.delete(k) }, setAttribute: (k, v) => attrs.set(k, String(v)), getAttribute: (k) => (attrs.has(k) ? attrs.get(k) : null), removeAttribute: (k) => attrs.delete(k), props };
++ const sandbox = {
++ Date: FixedDate, JSON, Object, String, Number, Math, Array, Promise, Error, URL, setTimeout() {}, clearTimeout() {},
++ document: { documentElement: html, readyState: 'complete', cookie: '', head: null, querySelector: () => null, getElementById: () => null, referrer: '', visibilityState: 'visible', addEventListener() {} },
++ localStorage: { getItem: (k) => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, String(v)), removeItem: (k) => store.delete(k) },
++ location: { hostname: 'openvibe.live', protocol: 'https:' },
++ getComputedStyle: () => ({ getPropertyValue: () => '' }),
++ matchMedia: (q) => ({ matches: /dark/.test(q) ? dark : false, addEventListener() {} }),
++ fetch: (url, o) => { if (o && o.method === 'PUT') puts.push({ url, body: JSON.parse(o.body) }); return Promise.resolve({ ok: false, json: () => Promise.resolve(null) }); },
++ CustomEvent: class { constructor(type, init) { this.type = type; this.detail = init && init.detail; } },
++ addEventListener() {}, dispatchEvent() {},
++ };
++ sandbox.window = sandbox;
++ sandbox.globalThis = sandbox;
++ vm.runInNewContext(SRC, sandbox);
++ return { L: sandbox.OpenVibeThemeLoader, html, puts, store };
++}
++const painted = (p) => p.html.getAttribute('data-theme');
++const picked = (id) => new Map([['ov_theme', JSON.stringify({ id, variables: by[id].variables })]]);
++
++// 2. The loader knows the halves.
++let p = load('2026-11-05');
++assert.strictEqual(p.L.pairOf('nord'), 'nord-light');
++assert.strictEqual(p.L.modeOf('hayride'), 'light');
++assert.strictEqual(p.L.scheme.get(), 'dark', 'Vibe out of season: dark');
++
++// 3. Light / Dark: the other half is saved as the theme; Auto goes off.
++p = load('2026-11-05', picked('nord'));
++assert.strictEqual(p.L.scheme.set('light'), 'light');
++assert.strictEqual(painted(p), 'nord-light');
++assert.strictEqual(p.L.getCurrent(), 'nord-light', 'saved as the person\'s theme');
++assert.strictEqual(p.L.scheme.get(), 'light');
++p.L.scheme.set('dark');
++assert.strictEqual(painted(p), 'nord');
++p.L.scheme.set('dark');
++assert.strictEqual(painted(p), 'nord', 'already dark: nothing changes');
++// A community theme without a half gives way to the default's.
++p = load('2026-11-05', new Map([['ov_theme', JSON.stringify({ id: 'my-own', variables: { ...by.vibe.variables, '--accent': '#ff00aa' } })]]));
++p.L.scheme.set('light');
++assert.strictEqual(painted(p), 'cloud');
++
++// 4. In October the season stands in for the default in either mode, and the switch keeps it.
++p = load('2026-10-25');
++assert.strictEqual(painted(p), 'halloween', 'Midnight Pumpkin for the default in dark');
++p.L.scheme.set('light');
++assert.strictEqual(p.L.getCurrent(), 'cloud');
++assert.strictEqual(painted(p), 'hayride', 'Hayride for the default in light');
++assert.strictEqual(p.L.display.get().seasonal, 'on', 'switching modes is not turning the season off');
++assert.strictEqual(p.html.getAttribute('data-ov-season'), 'halloween');
++assert.strictEqual(painted(load('2026-12-10', picked('cloud'))), 'snowfall', 'December in light: Snowfall');
++
++// 5. Auto paints the half the device asks for, and remembers the picked theme.
++p = load('2026-11-05', picked('paper'), { dark: true });
++p.L.scheme.set('auto');
++assert.strictEqual(painted(p), 'hacker', 'Paper on a dark device: its dark half, Amber');
++assert.strictEqual(p.L.getCurrent(), 'paper', 'the pick stays the person\'s');
++assert.strictEqual(p.L.scheme.get(), 'auto');
++const reload = load('2026-11-05', p.store, { dark: false });
++assert.strictEqual(painted(reload), 'paper', 'the same person on a light device: Paper');
++assert.strictEqual(painted(load('2026-10-25', p.store, { dark: false })), 'paper', 'a picked theme is not the default: no season');
++const auto = new Map([['ov_display', JSON.stringify({ motion: 'auto', text: '100', seasonal: 'on', scheme: 'auto' })]]);
++assert.strictEqual(painted(load('2026-10-25', auto, { dark: false })), 'hayride', 'the default under Auto in light, in October');
++assert.strictEqual(painted(load('2026-10-25', new Map(auto), { dark: true })), 'halloween');
++// Light / Dark from Auto turns Auto off.
++p = load('2026-11-05', new Map(auto), { dark: true });
++p.L.scheme.set('light');
++assert.strictEqual(p.L.display.get().scheme, 'manual');
++assert.strictEqual(painted(p), 'cloud');
++
++console.log('theme pairs: all checks passed');
ddiff --git a/test/theme-parity.test.js b/test/theme-parity.test.js
iindex 9682f28..1a244f3 100644
---- a/test/theme-parity.test.js
++++ b/test/theme-parity.test.js
@@@ -34,8 +34,21 @@ for (const t of BUILTIN_THEMES) {
// 4. Loader: full sets and stale-token clearing are present.
const loader = require('fs').readFileSync(path.join(__dirname, '..', 'theme-loader.js'), 'utf8');
--assert.ok(loader.includes("'violet-hour': {"), 'loader knows Violet Hour');
--assert.ok(/'arctic': \{[^\n]*'--success'/.test(loader), 'loader entries carry the full token set');
++// The loader writes rows of values against one list of names and expands them at load: run it and compare.
++const vm = require('vm');
++const sandbox = { document: { documentElement: { style: { setProperty() {}, removeProperty() {} }, setAttribute() {}, getAttribute() { return null; }, removeAttribute() {} }, readyState: 'complete', cookie: '', head: null, querySelector: () => null, getElementById: () => null, addEventListener() {} },
++ localStorage: { getItem: () => null, setItem() {}, removeItem() {} }, location: { hostname: 'openvibe.live', protocol: 'https:' }, getComputedStyle: () => ({ getPropertyValue: () => '' }),
++ fetch: () => Promise.resolve({ ok: false }), CustomEvent: class {}, addEventListener() {}, dispatchEvent() {}, setTimeout() {}, URL, JSON, Object, String, Number, Math, Array, Promise, Error, Date };
++sandbox.window = sandbox;
++vm.runInNewContext(loader, sandbox);
++const LT = sandbox.OpenVibeThemeLoader.THEMES;
++assert.ok(LT['violet-hour'], 'loader knows Violet Hour');
++for (const t of BUILTIN_THEMES) {
++ const want = { ...DEFAULT_VARS, ...t.variables };
++ for (const k of Object.keys(want)) assert.strictEqual(LT[t.id][k], String(want[k]), `loader's ${t.id} carries ${k} as the catalog has it`);
++}
++assert.ok(LT.arctic['--success'], 'loader entries carry the full token set');
++assert.ok(loader.length < 60000, `theme-loader.js stays small for first paint (${loader.length} bytes)`);
assert.ok(loader.includes('KNOWN_PROPS'), 'loader clears tokens the new theme does not set');
// 5. Derived tokens: text on the accent always reads, on every theme.
ddiff --git a/theme-loader.js b/theme-loader.js
iindex 09977a0..9115d34 100644
---- a/theme-loader.js
++++ b/theme-loader.js
@@@ -20,49 +20,74 @@
// ── Condensed built-in theme variable maps ───────────────
// Kept in sync with packages/openvibe-shared/builtin-themes.js
// THEMES:BEGIN — generated by scripts/build-theme-loader.js from builtin-themes.js; do not edit by hand
-- const THEMES = {
-- 'vibe': { '--bg-primary':'#0a0f1c','--bg-secondary':'#101828','--bg-tertiary':'#172238','--bg-card':'#131c2e','--bg-hover':'#1c2a44','--bg-input':'#0d1424','--text-primary':'#e6edf7','--text-secondary':'#96a7c2','--text-muted':'#8192ac','--accent':'#3b82f6','--accent-light':'#60a5fa','--accent-dark':'#1d4ed8','--border':'#1f2d47','--border-light':'#2c3d5c','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--success-text':'#22c55e','--warning-text':'#f59e0b','--danger-text':'#f26262','--info-text':'#38bdf8','--live-red-text':'#f26262','--accent-strong':'#3472d8','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'59,130,246','--accent-glow':'rgba(59,130,246,0.35)','--color-scheme':'dark' },
-- 'violet-hour': { '--bg-primary':'#0d0d0f','--bg-secondary':'#16161a','--bg-tertiary':'#1e1e24','--bg-card':'#1a1a20','--bg-hover':'#242430','--bg-input':'#12121a','--text-primary':'#e8e6e3','--text-secondary':'#9a9a9a','--text-muted':'#8d8c8c','--accent':'#8b5cf6','--accent-light':'#a78bfa','--accent-dark':'#6d28d9','--border':'#2a2a32','--border-light':'#3a3a44','--live-red':'#e74c3c','--success':'#2ecc71','--warning':'#f39c12','--danger':'#e74c3c','--info':'#3498db','--shadow':'0 2px 12px rgba(0,0,0,0.4)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--success-text':'#2ecc71','--warning-text':'#f39c12','--danger-text':'#e95e50','--info-text':'#3498db','--live-red-text':'#e95e50','--accent-strong':'#8558ec','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'139,92,246','--accent-glow':'rgba(139,92,246,0.35)','--color-scheme':'dark' },
-- 'midnight': { '--bg-primary':'#0a0e17','--bg-secondary':'#111827','--bg-tertiary':'#1a2235','--bg-card':'#151d2e','--bg-hover':'#1e2a42','--bg-input':'#0d1220','--text-primary':'#e2e8f0','--text-secondary':'#94a3b8','--text-muted':'#8693a5','--accent':'#6366f1','--accent-light':'#818cf8','--accent-dark':'#4f46e5','--border':'#1e293b','--border-light':'#334155','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--success-text':'#22c55e','--warning-text':'#f59e0b','--danger-text':'#f26262','--info-text':'#38bdf8','--live-red-text':'#f26262','--accent-strong':'#6164ec','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'99,102,241','--accent-glow':'rgba(99,102,241,0.35)','--color-scheme':'dark' },
-- 'forest': { '--bg-primary':'#0a120a','--bg-secondary':'#121e12','--bg-tertiary':'#1a2a1a','--bg-card':'#152015','--bg-hover':'#1e3420','--bg-input':'#0d160d','--text-primary':'#d4e8d4','--text-secondary':'#8aaa8a','--text-muted':'#849e84','--accent':'#4ade80','--accent-light':'#86efac','--accent-dark':'#22c55e','--border':'#1a3a1a','--border-light':'#2a4a2a','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--success-text':'#22c55e','--warning-text':'#f59e0b','--danger-text':'#f36d6d','--info-text':'#38bdf8','--live-red-text':'#f36d6d','--accent-strong':'#4ade80','--on-accent-strong':'#0b0d10','--on-accent':'#0b0d10','--accent-rgb':'74,222,128','--accent-glow':'rgba(74,222,128,0.35)','--color-scheme':'dark' },
-- 'neon-tokyo': { '--bg-primary':'#0a0a0f','--bg-secondary':'#12101a','--bg-tertiary':'#1a1625','--bg-card':'#15111f','--bg-hover':'#221a32','--bg-input':'#0d0b14','--text-primary':'#f0e6ff','--text-secondary':'#b4a0d0','--text-muted':'#8e81a2','--accent':'#ff006e','--accent-light':'#ff4d94','--accent-dark':'#c9005a','--border':'#2a1a3a','--border-light':'#3a2a4a','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--success-text':'#22c55e','--warning-text':'#f59e0b','--danger-text':'#ef4848','--info-text':'#38bdf8','--live-red-text':'#ef4848','--accent-strong':'#e60063','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'255,0,110','--accent-glow':'rgba(255,0,110,0.35)','--color-scheme':'dark' },
-- 'dracula': { '--bg-primary':'#282a36','--bg-secondary':'#1e1f29','--bg-tertiary':'#343746','--bg-card':'#2c2e3c','--bg-hover':'#3a3d50','--bg-input':'#21222c','--text-primary':'#f8f8f2','--text-secondary':'#bfbfb2','--text-muted':'#9ea8c5','--accent':'#bd93f9','--accent-light':'#d4b8ff','--accent-dark':'#9b6ddb','--border':'#44475a','--border-light':'#565970','--live-red':'#ff5555','--success':'#50fa7b','--warning':'#f1fa8c','--danger':'#ff5555','--info':'#8be9fd','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--success-text':'#50fa7b','--warning-text':'#f1fa8c','--danger-text':'#ff8585','--info-text':'#8be9fd','--live-red-text':'#ff8585','--accent-strong':'#bd93f9','--on-accent-strong':'#0b0d10','--on-accent':'#0b0d10','--accent-rgb':'189,147,249','--accent-glow':'rgba(189,147,249,0.35)','--color-scheme':'dark' },
-- 'monokai': { '--bg-primary':'#272822','--bg-secondary':'#1e1f1c','--bg-tertiary':'#3e3d32','--bg-card':'#2d2e27','--bg-hover':'#49483e','--bg-input':'#22231e','--text-primary':'#f8f8f2','--text-secondary':'#cfcfc2','--text-muted':'#b8b6ab','--accent':'#a6e22e','--accent-light':'#c4ff50','--accent-dark':'#7dac1c','--border':'#49483e','--border-light':'#5b5a50','--live-red':'#ef4444','--success':'#22c55e','--warning':'#e6db74','--danger':'#f92672','--info':'#66d9ef','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--success-text':'#45ce78','--warning-text':'#e6db74','--danger-text':'#fc97bb','--info-text':'#66d9ef','--live-red-text':'#f79e9e','--accent-strong':'#a6e22e','--on-accent-strong':'#0b0d10','--on-accent':'#0b0d10','--accent-rgb':'166,226,46','--accent-glow':'rgba(166,226,46,0.35)','--color-scheme':'dark' },
-- 'ocean-deep': { '--bg-primary':'#0a1215','--bg-secondary':'#0f1a1f','--bg-tertiary':'#162228','--bg-card':'#121c22','--bg-hover':'#1a2a32','--bg-input':'#0c1418','--text-primary':'#d0e8ef','--text-secondary':'#7aa8ba','--text-muted':'#7494a1','--accent':'#00bcd4','--accent-light':'#4dd0e1','--accent-dark':'#0097a7','--border':'#1a3040','--border-light':'#2a4050','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--success-text':'#22c55e','--warning-text':'#f59e0b','--danger-text':'#f15e5e','--info-text':'#38bdf8','--live-red-text':'#f15e5e','--accent-strong':'#00bcd4','--on-accent-strong':'#0b0d10','--on-accent':'#0b0d10','--accent-rgb':'0,188,212','--accent-glow':'rgba(0,188,212,0.35)','--color-scheme':'dark' },
-- 'sunset': { '--bg-primary':'#120a0a','--bg-secondary':'#1a1010','--bg-tertiary':'#241616','--bg-card':'#1e1212','--bg-hover':'#2e1a1a','--bg-input':'#140c0c','--text-primary':'#ffe8e0','--text-secondary':'#c49080','--text-muted':'#9d8179','--accent':'#ff6b35','--accent-light':'#ff8f5e','--accent-dark':'#d04a1a','--border':'#3a2020','--border-light':'#4a3030','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--success-text':'#22c55e','--warning-text':'#f59e0b','--danger-text':'#f04b4b','--info-text':'#38bdf8','--live-red-text':'#f04b4b','--accent-strong':'#ff6b35','--on-accent-strong':'#0b0d10','--on-accent':'#0b0d10','--accent-rgb':'255,107,53','--accent-glow':'rgba(255,107,53,0.35)','--color-scheme':'dark' },
-- 'arctic': { '--bg-primary':'#0b1620','--bg-secondary':'#0f1e2b','--bg-tertiary':'#163040','--bg-card':'#122534','--bg-hover':'#1a3a4c','--bg-input':'#0c1a26','--text-primary':'#eaf6ff','--text-secondary':'#9ec3d8','--text-muted':'#81a5bb','--accent':'#22d3ee','--accent-light':'#a5f3fc','--accent-dark':'#0891b2','--border':'#1e3f52','--border-light':'#2a5268','--live-red':'#ef4444','--success':'#34d399','--warning':'#f59e0b','--danger':'#ef4444','--info':'#7dd3fc','--shadow':'0 2px 12px rgba(2,12,20,0.5)','--shadow-lg':'0 8px 32px rgba(2,12,20,0.65)','--success-text':'#34d399','--warning-text':'#f59e0b','--danger-text':'#f47c7c','--info-text':'#7dd3fc','--live-red-text':'#f47c7c','--accent-strong':'#22d3ee','--on-accent-strong':'#0b0d10','--on-accent':'#0b0d10','--accent-rgb':'34,211,238','--accent-glow':'rgba(34,211,238,0.35)','--color-scheme':'dark' },
-- 'ember': { '--bg-primary':'#120808','--bg-secondary':'#1a0e0e','--bg-tertiary':'#241414','--bg-card':'#1e1010','--bg-hover':'#2e1818','--bg-input':'#140a0a','--text-primary':'#f0dada','--text-secondary':'#b0808a','--text-muted':'#9b8084','--accent':'#ef4444','--accent-light':'#f87171','--accent-dark':'#b91c1c','--border':'#3a1a20','--border-light':'#4a2a30','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--success-text':'#22c55e','--warning-text':'#f59e0b','--danger-text':'#ef4848','--info-text':'#38bdf8','--live-red-text':'#ef4848','--accent-strong':'#d73d3d','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'239,68,68','--accent-glow':'rgba(239,68,68,0.35)','--color-scheme':'dark' },
-- 'vapor': { '--bg-primary':'#0e0a14','--bg-secondary':'#14101c','--bg-tertiary':'#1c1628','--bg-card':'#181220','--bg-hover':'#241a34','--bg-input':'#100c16','--text-primary':'#f0e0ff','--text-secondary':'#c090e0','--text-muted':'#967bbb','--accent':'#e040fb','--accent-light':'#ea80fc','--accent-dark':'#aa00d4','--border':'#2a1a40','--border-light':'#3a2a50','--live-red':'#ef4444','--success':'#69f0ae','--warning':'#f59e0b','--danger':'#ef4444','--info':'#00e5ff','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--success-text':'#69f0ae','--warning-text':'#f59e0b','--danger-text':'#f04b4b','--info-text':'#00e5ff','--live-red-text':'#f04b4b','--accent-strong':'#bc36d3','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'224,64,251','--accent-glow':'rgba(224,64,251,0.35)','--color-scheme':'dark' },
-- 'slate': { '--bg-primary':'#0f1118','--bg-secondary':'#161820','--bg-tertiary':'#1e2028','--bg-card':'#1a1c24','--bg-hover':'#24262e','--bg-input':'#12141c','--text-primary':'#e4e6ea','--text-secondary':'#a0a4b0','--text-muted':'#8a8d97','--accent':'#a78bfa','--accent-light':'#c4b5fd','--accent-dark':'#7c3aed','--border':'#2a2c38','--border-light':'#3a3c48','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--success-text':'#22c55e','--warning-text':'#f59e0b','--danger-text':'#f15a5a','--info-text':'#38bdf8','--live-red-text':'#f15a5a','--accent-strong':'#a78bfa','--on-accent-strong':'#0b0d10','--on-accent':'#0b0d10','--accent-rgb':'167,139,250','--accent-glow':'rgba(167,139,250,0.35)','--color-scheme':'dark' },
-- 'matrix': { '--bg-primary':'#000000','--bg-secondary':'#050a05','--bg-tertiary':'#0a140a','--bg-card':'#071007','--bg-hover':'#0e1e0e','--bg-input':'#030803','--text-primary':'#00ff41','--text-secondary':'#00b030','--text-muted':'#149836','--accent':'#00ff41','--accent-light':'#66ff8a','--accent-dark':'#00aa2a','--border':'#0a2a0a','--border-light':'#144014','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--success-text':'#22c55e','--warning-text':'#f59e0b','--danger-text':'#ef4444','--info-text':'#38bdf8','--live-red-text':'#ef4444','--accent-strong':'#00ff41','--on-accent-strong':'#0b0d10','--on-accent':'#0b0d10','--accent-rgb':'0,255,65','--accent-glow':'rgba(0,255,65,0.35)','--color-scheme':'dark' },
-- 'nord': { '--bg-primary':'#2e3440','--bg-secondary':'#272c36','--bg-tertiary':'#3b4252','--bg-card':'#333a48','--bg-hover':'#434c5e','--bg-input':'#2a303c','--text-primary':'#eceff4','--text-secondary':'#d8dee9','--text-muted':'#b5bcca','--accent':'#88c0d0','--accent-light':'#8fbcbb','--accent-dark':'#5e81ac','--border':'#3b4252','--border-light':'#4c566a','--live-red':'#ef4444','--success':'#a3be8c','--warning':'#ebcb8b','--danger':'#bf616a','--info':'#81a1c1','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--success-text':'#acc598','--warning-text':'#ebcb8b','--danger-text':'#dfb0b5','--info-text':'#a9bfd5','--live-red-text':'#f8a9a9','--accent-strong':'#88c0d0','--on-accent-strong':'#0b0d10','--on-accent':'#0b0d10','--accent-rgb':'136,192,208','--accent-glow':'rgba(136,192,208,0.35)','--color-scheme':'dark' },
-- 'gruvbox-dark': { '--bg-primary':'#1d2021','--bg-secondary':'#282828','--bg-tertiary':'#3c3836','--bg-card':'#32302f','--bg-hover':'#504945','--bg-input':'#242424','--text-primary':'#ebdbb2','--text-secondary':'#d5c4a1','--text-muted':'#c2b8aa','--accent':'#fabd2f','--accent-light':'#fce566','--accent-dark':'#d79921','--border':'#3c3836','--border-light':'#504945','--live-red':'#ef4444','--success':'#b8bb26','--warning':'#fe8019','--danger':'#fb4934','--info':'#83a598','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--success-text':'#bec037','--warning-text':'#fea65e','--danger-text':'#fda49a','--info-text':'#a8c0b7','--live-red-text':'#f7a5a5','--accent-strong':'#fabd2f','--on-accent-strong':'#0b0d10','--on-accent':'#0b0d10','--accent-rgb':'250,189,47','--accent-glow':'rgba(250,189,47,0.35)','--color-scheme':'dark' },
-- 'abyss': { '--bg-primary':'#000000','--bg-secondary':'#080808','--bg-tertiary':'#111111','--bg-card':'#0a0a0a','--bg-hover':'#181818','--bg-input':'#050505','--text-primary':'#cccccc','--text-secondary':'#888888','--text-muted':'#828282','--accent':'#ffffff','--accent-light':'#ffffff','--accent-dark':'#aaaaaa','--border':'#1a1a1a','--border-light':'#252525','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--success-text':'#22c55e','--warning-text':'#f59e0b','--danger-text':'#ef4444','--info-text':'#38bdf8','--live-red-text':'#ef4444','--accent-strong':'#ffffff','--on-accent-strong':'#0b0d10','--on-accent':'#0b0d10','--accent-rgb':'255,255,255','--accent-glow':'rgba(255,255,255,0.35)','--color-scheme':'dark' },
-- 'copper': { '--bg-primary':'#110d09','--bg-secondary':'#1a140e','--bg-tertiary':'#231c14','--bg-card':'#1e1710','--bg-hover':'#2c2218','--bg-input':'#13100b','--text-primary':'#e8ddd0','--text-secondary':'#b49a80','--text-muted':'#978976','--accent':'#cd7f32','--accent-light':'#daa060','--accent-dark':'#a06020','--border':'#2a2016','--border-light':'#3a3020','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--success-text':'#22c55e','--warning-text':'#f59e0b','--danger-text':'#f15757','--info-text':'#38bdf8','--live-red-text':'#f15757','--accent-strong':'#a46628','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'205,127,50','--accent-glow':'rgba(205,127,50,0.35)','--color-scheme':'dark' },
-- 'sakura-night': { '--bg-primary':'#0e0a14','--bg-secondary':'#150f1c','--bg-tertiary':'#1c1526','--bg-card':'#181020','--bg-hover':'#221830','--bg-input':'#100c16','--text-primary':'#f0e0f0','--text-secondary':'#c0a0c0','--text-muted':'#997d99','--accent':'#f472b6','--accent-light':'#f9a8d4','--accent-dark':'#db2777','--border':'#2a1a30','--border-light':'#3a2a40','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--success-text':'#22c55e','--warning-text':'#f59e0b','--danger-text':'#ef4848','--info-text':'#38bdf8','--live-red-text':'#ef4848','--accent-strong':'#f472b6','--on-accent-strong':'#0b0d10','--on-accent':'#0b0d10','--accent-rgb':'244,114,182','--accent-glow':'rgba(244,114,182,0.35)','--color-scheme':'dark' },
-- 'hacker': { '--bg-primary':'#000000','--bg-secondary':'#0a0800','--bg-tertiary':'#141000','--bg-card':'#0e0c00','--bg-hover':'#1c1800','--bg-input':'#060400','--text-primary':'#ffb000','--text-secondary':'#cc8d00','--text-muted':'#a97a0f','--accent':'#ffb000','--accent-light':'#ffd050','--accent-dark':'#cc8d00','--border':'#1a1400','--border-light':'#2a2200','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--success-text':'#22c55e','--warning-text':'#f59e0b','--danger-text':'#ef4444','--info-text':'#38bdf8','--live-red-text':'#ef4444','--accent-strong':'#ffb000','--on-accent-strong':'#0b0d10','--on-accent':'#0b0d10','--accent-rgb':'255,176,0','--accent-glow':'rgba(255,176,0,0.35)','--color-scheme':'dark' },
-- 'daylight': { '--bg-primary':'#ffffff','--bg-secondary':'#f7f7f8','--bg-tertiary':'#eeeef0','--bg-card':'#ffffff','--bg-hover':'#f0f0f2','--bg-input':'#f5f5f7','--text-primary':'#1a1a2e','--text-secondary':'#4a4a6a','--text-muted':'#68687b','--accent':'#7a4ddb','--accent-light':'#725faa','--accent-dark':'#6d28d9','--border':'#e0e0e6','--border-light':'#d0d0d8','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.08)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.12)','--success-text':'#157a3a','--warning-text':'#935f07','--danger-text':'#c43838','--info-text':'#227195','--live-red-text':'#c43838','--accent-strong':'#7a4ddb','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'122,77,219','--accent-glow':'rgba(122,77,219,0.35)','--color-scheme':'light' },
-- 'paper': { '--bg-primary':'#faf8f5','--bg-secondary':'#f0ece6','--bg-tertiary':'#e8e2d8','--bg-card':'#faf8f5','--bg-hover':'#f0ece6','--bg-input':'#f5f2ed','--text-primary':'#2c2416','--text-secondary':'#5a4e3e','--text-muted':'#6c6355','--accent':'#7d5f12','--accent-light':'#7b6122','--accent-dark':'#6a5010','--border':'#ddd6c8','--border-light':'#ccc4b0','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(60,40,0,0.06)','--shadow-lg':'0 8px 32px rgba(60,40,0,0.10)','--success-text':'#147237','--warning-text':'#895806','--danger-text':'#b63434','--info-text':'#1f6a8b','--live-red-text':'#b63434','--accent-strong':'#7d5f12','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'125,95,18','--accent-glow':'rgba(125,95,18,0.35)','--color-scheme':'light' },
-- 'cloud': { '--bg-primary':'#f0f5ff','--bg-secondary':'#e8eef8','--bg-tertiary':'#dde5f0','--bg-card':'#f5f8ff','--bg-hover':'#e4ecf8','--bg-input':'#edf2fa','--text-primary':'#1a2040','--text-secondary':'#4a5070','--text-muted':'#5e637b','--accent':'#245dd5','--accent-light':'#3c669b','--accent-dark':'#2563eb','--border':'#d0d8ea','--border-light':'#bcc8e0','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,20,60,0.06)','--shadow-lg':'0 8px 32px rgba(0,20,60,0.10)','--success-text':'#147237','--warning-text':'#895806','--danger-text':'#ba3535','--info-text':'#1f6a8b','--live-red-text':'#ba3535','--accent-strong':'#245dd5','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'36,93,213','--accent-glow':'rgba(36,93,213,0.35)','--color-scheme':'light' },
-- 'meadow': { '--bg-primary':'#f2f8f0','--bg-secondary':'#e8f0e4','--bg-tertiary':'#dce8d6','--bg-card':'#f5faf2','--bg-hover':'#e4f0de','--bg-input':'#edf5ea','--text-primary':'#1a2e16','--text-secondary':'#3a5a32','--text-muted':'#526b4a','--accent':'#137538','--accent-light':'#147638','--accent-dark':'#15803d','--border':'#c8dcc0','--border-light':'#b0ccaa','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,40,0,0.06)','--shadow-lg':'0 8px 32px rgba(0,40,0,0.10)','--success-text':'#147638','--warning-text':'#895806','--danger-text':'#ba3535','--info-text':'#1f6a8b','--live-red-text':'#ba3535','--accent-strong':'#137538','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'19,117,56','--accent-glow':'rgba(19,117,56,0.35)','--color-scheme':'light' },
-- 'peach': { '--bg-primary':'#fff5f0','--bg-secondary':'#fbeae2','--bg-tertiary':'#f5ddd2','--bg-card':'#fff7f2','--bg-hover':'#fce8dc','--bg-input':'#fdf0ea','--text-primary':'#2e1a14','--text-secondary':'#6a4438','--text-muted':'#7e5d51','--accent':'#ad4107','--accent-light':'#925523','--accent-dark':'#ea580c','--border':'#f0d0c0','--border-light':'#e4bfab','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(60,20,0,0.06)','--shadow-lg':'0 8px 32px rgba(60,20,0,0.10)','--success-text':'#147237','--warning-text':'#895806','--danger-text':'#b63434','--info-text':'#1f6a8b','--live-red-text':'#b63434','--accent-strong':'#ad4107','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'173,65,7','--accent-glow':'rgba(173,65,7,0.35)','--color-scheme':'light' },
-- 'lavender': { '--bg-primary':'#f8f5ff','--bg-secondary':'#f0eaf8','--bg-tertiary':'#e6ddf0','--bg-card':'#faf7ff','--bg-hover':'#eee6f8','--bg-input':'#f4f0fa','--text-primary':'#1e1430','--text-secondary':'#4a3a60','--text-muted':'#6a5d80','--accent':'#7443d5','--accent-light':'#68569b','--accent-dark':'#7c3aed','--border':'#ddd0ee','--border-light':'#ccc0dd','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(40,0,60,0.06)','--shadow-lg':'0 8px 32px rgba(40,0,60,0.10)','--success-text':'#147237','--warning-text':'#895806','--danger-text':'#b63434','--info-text':'#1f6a8b','--live-red-text':'#b63434','--accent-strong':'#7443d5','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'116,67,213','--accent-glow':'rgba(116,67,213,0.35)','--color-scheme':'light' },
-- 'gruvbox-light': { '--bg-primary':'#fbf1c7','--bg-secondary':'#f2e5bc','--bg-tertiary':'#ebdbb2','--bg-card':'#fbf1c7','--bg-hover':'#f2e5bc','--bg-input':'#f5ecc4','--text-primary':'#3c3836','--text-secondary':'#504945','--text-muted':'#685e54','--accent':'#a73e06','--accent-light':'#934a0e','--accent-dark':'#af3a03','--border':'#d5c4a1','--border-light':'#bdae93','--live-red':'#ef4444','--success':'#98971a','--warning':'#d79921','--danger':'#cc241d','--info':'#458588','--shadow':'0 2px 12px rgba(40,30,0,0.08)','--shadow-lg':'0 8px 32px rgba(40,30,0,0.12)','--success-text':'#646411','--warning-text':'#7d5913','--danger-text':'#bc211b','--info-text':'#36686a','--live-red-text':'#b13232','--accent-strong':'#a73e06','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'167,62,6','--accent-glow':'rgba(167,62,6,0.35)','--color-scheme':'light' },
-- 'snow': { '--bg-primary':'#ffffff','--bg-secondary':'#f8fafc','--bg-tertiary':'#f1f5f9','--bg-card':'#ffffff','--bg-hover':'#f1f5f9','--bg-input':'#f8fafc','--text-primary':'#0f172a','--text-secondary':'#334155','--text-muted':'#657185','--accent':'#0275b0','--accent-light':'#23759a','--accent-dark':'#0284c7','--border':'#e2e8f0','--border-light':'#cbd5e1','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(0,0,0,0.05)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.08)','--success-text':'#167e3c','--warning-text':'#986207','--danger-text':'#c93939','--info-text':'#23759a','--live-red-text':'#c93939','--accent-strong':'#0275b0','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'2,117,176','--accent-glow':'rgba(2,117,176,0.35)','--color-scheme':'light' },
-- 'sand': { '--bg-primary':'#f8f4ef','--bg-secondary':'#f0e8de','--bg-tertiary':'#e8ddd0','--bg-card':'#faf6f0','--bg-hover':'#f0e8de','--bg-input':'#f5f0e8','--text-primary':'#2e2418','--text-secondary':'#5a4e3e','--text-muted':'#6b6154','--accent':'#7e5d06','--accent-light':'#7a5c12','--accent-dark':'#8b6508','--border':'#ddd0be','--border-light':'#ccc0aa','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(60,40,0,0.06)','--shadow-lg':'0 8px 32px rgba(60,40,0,0.10)','--success-text':'#136e35','--warning-text':'#895806','--danger-text':'#b13232','--info-text':'#1e6686','--live-red-text':'#b13232','--accent-strong':'#7e5d06','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'126,93,6','--accent-glow':'rgba(126,93,6,0.35)','--color-scheme':'light' },
-- 'rose': { '--bg-primary':'#fff5f7','--bg-secondary':'#fce7ec','--bg-tertiary':'#f5d5de','--bg-card':'#fff7f9','--bg-hover':'#fce0e8','--bg-input':'#fdeef2','--text-primary':'#2e1420','--text-secondary':'#6a3850','--text-muted':'#81536c','--accent':'#bf183d','--accent-light':'#b02d44','--accent-dark':'#be123c','--border':'#f0c8d4','--border-light':'#e4b0c0','--live-red':'#ef4444','--success':'#22c55e','--warning':'#f59e0b','--danger':'#ef4444','--info':'#38bdf8','--shadow':'0 2px 12px rgba(60,0,20,0.06)','--shadow-lg':'0 8px 32px rgba(60,0,20,0.10)','--success-text':'#136e35','--warning-text':'#845506','--danger-text':'#b13232','--info-text':'#1e6686','--live-red-text':'#b13232','--accent-strong':'#bf183d','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'191,24,61','--accent-glow':'rgba(191,24,61,0.35)','--color-scheme':'light' },
-- 'solarized-dark': { '--bg-primary':'#002b36','--bg-secondary':'#073642','--bg-tertiary':'#0a3f4e','--bg-card':'#053542','--bg-hover':'#0d4a58','--bg-input':'#01303d','--text-primary':'#fdf6e3','--text-secondary':'#a6b2b2','--text-muted':'#a8b2b0','--accent':'#b58900','--accent-light':'#d4a017','--accent-dark':'#8a6a00','--border':'#0a4050','--border-light':'#105060','--live-red':'#ef4444','--success':'#859900','--warning':'#cb4b16','--danger':'#dc322f','--info':'#268bd2','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--success-text':'#aab84d','--warning-text':'#e4a186','--danger-text':'#ee9997','--info-text':'#78b7e3','--live-red-text':'#f69696','--accent-strong':'#947000','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'181,137,0','--accent-glow':'rgba(181,137,0,0.35)','--color-scheme':'dark' },
-- 'solarized-light': { '--bg-primary':'#fdf6e3','--bg-secondary':'#eee8d5','--bg-tertiary':'#e4ddc6','--bg-card':'#fdf6e3','--bg-hover':'#eee8d5','--bg-input':'#f5eedb','--text-primary':'#002b36','--text-secondary':'#51656c','--text-muted':'#4e6467','--accent':'#795d00','--accent-light':'#7b5d0d','--accent-dark':'#8a6a00','--border':'#d6ceb5','--border-light':'#c8c0a5','--live-red':'#ef4444','--success':'#859900','--warning':'#cb4b16','--danger':'#dc322f','--info':'#268bd2','--shadow':'0 2px 12px rgba(0,30,40,0.08)','--shadow-lg':'0 8px 32px rgba(0,30,40,0.12)','--success-text':'#5a6800','--warning-text':'#a63e12','--danger-text':'#b92a27','--info-text':'#1b6497','--live-red-text':'#b13232','--accent-strong':'#795d00','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'121,93,0','--accent-glow':'rgba(121,93,0,0.35)','--color-scheme':'light' },
-- 'catppuccin-mocha': { '--bg-primary':'#1e1e2e','--bg-secondary':'#181825','--bg-tertiary':'#313244','--bg-card':'#1e1e2e','--bg-hover':'#313244','--bg-input':'#1a1a2c','--text-primary':'#cdd6f4','--text-secondary':'#bac2de','--text-muted':'#989caf','--accent':'#cba6f7','--accent-light':'#dcc0ff','--accent-dark':'#b07de0','--border':'#313244','--border-light':'#45475a','--live-red':'#f38ba8','--success':'#a6e3a1','--warning':'#fab387','--danger':'#f38ba8','--info':'#89b4fa','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--success-text':'#a6e3a1','--warning-text':'#fab387','--danger-text':'#f38ba8','--info-text':'#89b4fa','--live-red-text':'#f38ba8','--accent-strong':'#cba6f7','--on-accent-strong':'#0b0d10','--on-accent':'#0b0d10','--accent-rgb':'203,166,247','--accent-glow':'rgba(203,166,247,0.35)','--color-scheme':'dark' },
-- 'catppuccin-latte': { '--bg-primary':'#eff1f5','--bg-secondary':'#e6e9ef','--bg-tertiary':'#dce0e8','--bg-card':'#eff1f5','--bg-hover':'#dce0e8','--bg-input':'#e8ebf0','--text-primary':'#4c4f69','--text-secondary':'#5c5f77','--text-muted':'#5f6176','--accent':'#7d34dc','--accent-light':'#7a46c2','--accent-dark':'#7028d4','--border':'#ccd0da','--border-light':'#bcc0cc','--live-red':'#d20f39','--success':'#40a02b','--warning':'#fe640b','--danger':'#d20f39','--info':'#1e66f5','--shadow':'0 2px 12px rgba(0,0,0,0.06)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.10)','--success-text':'#2d701e','--warning-text':'#a84207','--danger-text':'#c50e36','--info-text':'#1a5ad8','--live-red-text':'#c50e36','--accent-strong':'#7d34dc','--on-accent-strong':'#ffffff','--on-accent':'#ffffff','--accent-rgb':'125,52,220','--accent-glow':'rgba(125,52,220,0.35)','--color-scheme':'light' },
-- 'high-contrast': { '--bg-primary':'#000000','--bg-secondary':'#0a0a0a','--bg-tertiary':'#1a1a1a','--bg-card':'#0f0f0f','--bg-hover':'#222222','--bg-input':'#0a0a0a','--text-primary':'#ffffff','--text-secondary':'#f0f0f0','--text-muted':'#aaaaaa','--accent':'#ffd700','--accent-light':'#ffed4a','--accent-dark':'#ccac00','--border':'#444444','--border-light':'#666666','--live-red':'#ff0000','--success':'#00ff00','--warning':'#ffff00','--danger':'#ff0000','--info':'#00ffff','--shadow':'0 2px 12px rgba(0,0,0,0.45)','--shadow-lg':'0 8px 32px rgba(0,0,0,0.6)','--success-text':'#00ff00','--warning-text':'#ffff00','--danger-text':'#ff3d3d','--info-text':'#00ffff','--live-red-text':'#ff3d3d','--accent-strong':'#ffd700','--on-accent-strong':'#0b0d10','--on-accent':'#0b0d10','--accent-rgb':'255,215,0','--accent-glow':'rgba(255,215,0,0.35)','--color-scheme':'dark' },
-- 'halloween': { '--bg-primary':'#0e0a14','--bg-secondary':'#160f21','--bg-tertiary':'#20162e','--bg-card':'#191125','--bg-hover':'#291d3a','--bg-input':'#110b1a','--text-primary':'#f4eef8','--text-secondary':'#bba9cc','--text-muted':'#9784ab','--accent':'#ff7a1a','--accent-light':'#ffa14f','--accent-dark':'#d65d00','--border':'#2d2040','--border-light':'#3e2c56','--live-red':'#ef4444','--success':'#7ee26b','--warning':'#fbbf24','--danger':'#f43f5e','--info':'#b08cff','--shadow':'0 2px 12px rgba(5,0,12,0.55)','--shadow-lg':'0 8px 32px rgba(5,0,12,0.7)','--success-text':'#7ee26b','--warning-text':'#fbbf24','--danger-text':'#f54b68','--info-text':'#b08cff','--live-red-text':'#f05353','--accent-strong':'#ff7a1a','--on-accent-strong':'#0b0d10','--on-accent':'#0b0d10','--accent-rgb':'255,122,26','--accent-glow':'rgba(255,122,26,0.35)','--color-scheme':'dark' },
-- 'frost': { '--bg-primary':'#08111b','--bg-secondary':'#0d1926','--bg-tertiary':'#132334','--bg-card':'#0f1c2b','--bg-hover':'#18293d','--bg-input':'#0a1520','--text-primary':'#eaf4fb','--text-secondary':'#a3b9cc','--text-muted':'#7f97ad','--accent':'#2dd4bf','--accent-light':'#7ee8da','--accent-dark':'#0f9e8c','--border':'#1b2d41','--border-light':'#294057','--live-red':'#ef4444','--success':'#4ade80','--warning':'#fbbf24','--danger':'#f87171','--info':'#93c5fd','--shadow':'0 2px 12px rgba(0,6,14,0.5)','--shadow-lg':'0 8px 32px rgba(0,6,14,0.65)','--success-text':'#4ade80','--warning-text':'#fbbf24','--danger-text':'#f87171','--info-text':'#93c5fd','--live-red-text':'#f15e5e','--accent-strong':'#2dd4bf','--on-accent-strong':'#0b0d10','--on-accent':'#0b0d10','--accent-rgb':'45,212,191','--accent-glow':'rgba(45,212,191,0.35)','--color-scheme':'dark' },
++ const THEME_KEYS = ['--bg-primary','--bg-secondary','--bg-tertiary','--bg-card','--bg-hover','--bg-input','--text-primary','--text-secondary','--text-muted','--accent','--accent-light','--accent-dark','--border','--border-light','--live-red','--success','--warning','--danger','--info','--shadow','--shadow-lg','--success-text','--warning-text','--danger-text','--info-text','--live-red-text','--accent-strong','--on-accent-strong','--on-accent','--accent-rgb','--accent-glow','--color-scheme'];
++ const THEME_ROWS = {
++ 'vibe': ['#0a0f1c','#101828','#172238','#131c2e','#1c2a44','#0d1424','#e6edf7','#96a7c2','#8192ac','#3b82f6','#60a5fa','#1d4ed8','#1f2d47','#2c3d5c','#ef4444','#22c55e','#f59e0b','#ef4444','#38bdf8','0 2px 12px rgba(0,0,0,0.45)','0 8px 32px rgba(0,0,0,0.6)','#22c55e','#f59e0b','#f26262','#38bdf8','#f26262','#3472d8','#ffffff','#ffffff','59,130,246','rgba(59,130,246,0.35)','dark'],
++ 'violet-hour': ['#0d0d0f','#16161a','#1e1e24','#1a1a20','#242430','#12121a','#e8e6e3','#9a9a9a','#8d8c8c','#8b5cf6','#a78bfa','#6d28d9','#2a2a32','#3a3a44','#e74c3c','#2ecc71','#f39c12','#e74c3c','#3498db','0 2px 12px rgba(0,0,0,0.4)','','#2ecc71','#f39c12','#e95e50','#3498db','#e95e50','#8558ec','','','139,92,246','rgba(139,92,246,0.35)'],
++ 'midnight': ['#0a0e17','#111827','#1a2235','#151d2e','#1e2a42','#0d1220','#e2e8f0','#94a3b8','#8693a5','#6366f1','#818cf8','#4f46e5','#1e293b','#334155','','','','','','','','','','','','','#6164ec','','','99,102,241','rgba(99,102,241,0.35)'],
++ 'forest': ['#0a120a','#121e12','#1a2a1a','#152015','#1e3420','#0d160d','#d4e8d4','#8aaa8a','#849e84','#4ade80','#86efac','#22c55e','#1a3a1a','#2a4a2a','','','','','','','','','','#f36d6d','','#f36d6d','#4ade80','#0b0d10','#0b0d10','74,222,128','rgba(74,222,128,0.35)'],
++ 'neon-tokyo': ['#0a0a0f','#12101a','#1a1625','#15111f','#221a32','#0d0b14','#f0e6ff','#b4a0d0','#8e81a2','#ff006e','#ff4d94','#c9005a','#2a1a3a','#3a2a4a','','','','','','','','','','#ef4848','','#ef4848','#e60063','','','255,0,110','rgba(255,0,110,0.35)'],
++ 'dracula': ['#282a36','#1e1f29','#343746','#2c2e3c','#3a3d50','#21222c','#f8f8f2','#bfbfb2','#9ea8c5','#bd93f9','#d4b8ff','#9b6ddb','#44475a','#565970','#ff5555','#50fa7b','#f1fa8c','#ff5555','#8be9fd','','','#50fa7b','#f1fa8c','#ff8585','#8be9fd','#ff8585','#bd93f9','#0b0d10','#0b0d10','189,147,249','rgba(189,147,249,0.35)'],
++ 'monokai': ['#272822','#1e1f1c','#3e3d32','#2d2e27','#49483e','#22231e','#f8f8f2','#cfcfc2','#b8b6ab','#a6e22e','#c4ff50','#7dac1c','#49483e','#5b5a50','','','#e6db74','#f92672','#66d9ef','','','#45ce78','#e6db74','#fc97bb','#66d9ef','#f79e9e','#a6e22e','#0b0d10','#0b0d10','166,226,46','rgba(166,226,46,0.35)'],
++ 'ocean-deep': ['#0a1215','#0f1a1f','#162228','#121c22','#1a2a32','#0c1418','#d0e8ef','#7aa8ba','#7494a1','#00bcd4','#4dd0e1','#0097a7','#1a3040','#2a4050','','','','','','','','','','#f15e5e','','#f15e5e','#00bcd4','#0b0d10','#0b0d10','0,188,212','rgba(0,188,212,0.35)'],
++ 'sunset': ['#120a0a','#1a1010','#241616','#1e1212','#2e1a1a','#140c0c','#ffe8e0','#c49080','#9d8179','#ff6b35','#ff8f5e','#d04a1a','#3a2020','#4a3030','','','','','','','','','','#f04b4b','','#f04b4b','#ff6b35','#0b0d10','#0b0d10','255,107,53','rgba(255,107,53,0.35)'],
++ 'arctic': ['#0b1620','#0f1e2b','#163040','#122534','#1a3a4c','#0c1a26','#eaf6ff','#9ec3d8','#81a5bb','#22d3ee','#a5f3fc','#0891b2','#1e3f52','#2a5268','','#34d399','','','#7dd3fc','0 2px 12px rgba(2,12,20,0.5)','0 8px 32px rgba(2,12,20,0.65)','#34d399','','#f47c7c','#7dd3fc','#f47c7c','#22d3ee','#0b0d10','#0b0d10','34,211,238','rgba(34,211,238,0.35)'],
++ 'ember': ['#120808','#1a0e0e','#241414','#1e1010','#2e1818','#140a0a','#f0dada','#b0808a','#9b8084','#ef4444','#f87171','#b91c1c','#3a1a20','#4a2a30','','','','','','','','','','#ef4848','','#ef4848','#d73d3d','','','239,68,68','rgba(239,68,68,0.35)'],
++ 'vapor': ['#0e0a14','#14101c','#1c1628','#181220','#241a34','#100c16','#f0e0ff','#c090e0','#967bbb','#e040fb','#ea80fc','#aa00d4','#2a1a40','#3a2a50','','#69f0ae','','','#00e5ff','','','#69f0ae','','#f04b4b','#00e5ff','#f04b4b','#bc36d3','','','224,64,251','rgba(224,64,251,0.35)'],
++ 'slate': ['#0f1118','#161820','#1e2028','#1a1c24','#24262e','#12141c','#e4e6ea','#a0a4b0','#8a8d97','#a78bfa','#c4b5fd','#7c3aed','#2a2c38','#3a3c48','','','','','','','','','','#f15a5a','','#f15a5a','#a78bfa','#0b0d10','#0b0d10','167,139,250','rgba(167,139,250,0.35)'],
++ 'matrix': ['#000000','#050a05','#0a140a','#071007','#0e1e0e','#030803','#00ff41','#00b030','#149836','#00ff41','#66ff8a','#00aa2a','#0a2a0a','#144014','','','','','','','','','','#ef4444','','#ef4444','#00ff41','#0b0d10','#0b0d10','0,255,65','rgba(0,255,65,0.35)'],
++ 'nord': ['#2e3440','#272c36','#3b4252','#333a48','#434c5e','#2a303c','#eceff4','#d8dee9','#b5bcca','#88c0d0','#8fbcbb','#5e81ac','#3b4252','#4c566a','','#a3be8c','#ebcb8b','#bf616a','#81a1c1','','','#acc598','#ebcb8b','#dfb0b5','#a9bfd5','#f8a9a9','#88c0d0','#0b0d10','#0b0d10','136,192,208','rgba(136,192,208,0.35)'],
++ 'gruvbox-dark': ['#1d2021','#282828','#3c3836','#32302f','#504945','#242424','#ebdbb2','#d5c4a1','#c2b8aa','#fabd2f','#fce566','#d79921','#3c3836','#504945','','#b8bb26','#fe8019','#fb4934','#83a598','','','#bec037','#fea65e','#fda49a','#a8c0b7','#f7a5a5','#fabd2f','#0b0d10','#0b0d10','250,189,47','rgba(250,189,47,0.35)'],
++ 'abyss': ['#000000','#080808','#111111','#0a0a0a','#181818','#050505','#cccccc','#888888','#828282','#ffffff','#ffffff','#aaaaaa','#1a1a1a','#252525','','','','','','','','','','#ef4444','','#ef4444','#ffffff','#0b0d10','#0b0d10','255,255,255','rgba(255,255,255,0.35)'],
++ 'copper': ['#110d09','#1a140e','#231c14','#1e1710','#2c2218','#13100b','#e8ddd0','#b49a80','#978976','#cd7f32','#daa060','#a06020','#2a2016','#3a3020','','','','','','','','','','#f15757','','#f15757','#a46628','','','205,127,50','rgba(205,127,50,0.35)'],
++ 'sakura-night': ['#0e0a14','#150f1c','#1c1526','#181020','#221830','#100c16','#f0e0f0','#c0a0c0','#997d99','#f472b6','#f9a8d4','#db2777','#2a1a30','#3a2a40','','','','','','','','','','#ef4848','','#ef4848','#f472b6','#0b0d10','#0b0d10','244,114,182','rgba(244,114,182,0.35)'],
++ 'hacker': ['#000000','#0a0800','#141000','#0e0c00','#1c1800','#060400','#ffb000','#cc8d00','#a97a0f','#ffb000','#ffd050','#cc8d00','#1a1400','#2a2200','','','','','','','','','','#ef4444','','#ef4444','#ffb000','#0b0d10','#0b0d10','255,176,0','rgba(255,176,0,0.35)'],
++ 'daylight': ['#ffffff','#f7f7f8','#eeeef0','#ffffff','#f0f0f2','#f5f5f7','#1a1a2e','#4a4a6a','#68687b','#7a4ddb','#725faa','#6d28d9','#e0e0e6','#d0d0d8','','','','','','0 2px 12px rgba(0,0,0,0.08)','0 8px 32px rgba(0,0,0,0.12)','#157a3a','#935f07','#c43838','#227195','#c43838','#7a4ddb','','','122,77,219','rgba(122,77,219,0.35)','light'],
++ 'paper': ['#faf8f5','#f0ece6','#e8e2d8','#faf8f5','#f0ece6','#f5f2ed','#2c2416','#5a4e3e','#6c6355','#7d5f12','#7b6122','#6a5010','#ddd6c8','#ccc4b0','','','','','','0 2px 12px rgba(60,40,0,0.06)','0 8px 32px rgba(60,40,0,0.10)','#147237','#895806','#b63434','#1f6a8b','#b63434','#7d5f12','','','125,95,18','rgba(125,95,18,0.35)','light'],
++ 'cloud': ['#f0f5ff','#e8eef8','#dde5f0','#f5f8ff','#e4ecf8','#edf2fa','#1a2040','#4a5070','#5e637b','#245dd5','#3c669b','#2563eb','#d0d8ea','#bcc8e0','','','','','','0 2px 12px rgba(0,20,60,0.06)','0 8px 32px rgba(0,20,60,0.10)','#147237','#895806','#ba3535','#1f6a8b','#ba3535','#245dd5','','','36,93,213','rgba(36,93,213,0.35)','light'],
++ 'meadow': ['#f2f8f0','#e8f0e4','#dce8d6','#f5faf2','#e4f0de','#edf5ea','#1a2e16','#3a5a32','#526b4a','#137538','#147638','#15803d','#c8dcc0','#b0ccaa','','','','','','0 2px 12px rgba(0,40,0,0.06)','0 8px 32px rgba(0,40,0,0.10)','#147638','#895806','#ba3535','#1f6a8b','#ba3535','#137538','','','19,117,56','rgba(19,117,56,0.35)','light'],
++ 'peach': ['#fff5f0','#fbeae2','#f5ddd2','#fff7f2','#fce8dc','#fdf0ea','#2e1a14','#6a4438','#7e5d51','#ad4107','#925523','#ea580c','#f0d0c0','#e4bfab','','','','','','0 2px 12px rgba(60,20,0,0.06)','0 8px 32px rgba(60,20,0,0.10)','#147237','#895806','#b63434','#1f6a8b','#b63434','#ad4107','','','173,65,7','rgba(173,65,7,0.35)','light'],
++ 'lavender': ['#f8f5ff','#f0eaf8','#e6ddf0','#faf7ff','#eee6f8','#f4f0fa','#1e1430','#4a3a60','#6a5d80','#7443d5','#68569b','#7c3aed','#ddd0ee','#ccc0dd','','','','','','0 2px 12px rgba(40,0,60,0.06)','0 8px 32px rgba(40,0,60,0.10)','#147237','#895806','#b63434','#1f6a8b','#b63434','#7443d5','','','116,67,213','rgba(116,67,213,0.35)','light'],
++ 'gruvbox-light': ['#fbf1c7','#f2e5bc','#ebdbb2','#fbf1c7','#f2e5bc','#f5ecc4','#3c3836','#504945','#685e54','#a73e06','#934a0e','#af3a03','#d5c4a1','#bdae93','','#98971a','#d79921','#cc241d','#458588','0 2px 12px rgba(40,30,0,0.08)','0 8px 32px rgba(40,30,0,0.12)','#646411','#7d5913','#bc211b','#36686a','#b13232','#a73e06','','','167,62,6','rgba(167,62,6,0.35)','light'],
++ 'snow': ['#ffffff','#f8fafc','#f1f5f9','#ffffff','#f1f5f9','#f8fafc','#0f172a','#334155','#657185','#0275b0','#23759a','#0284c7','#e2e8f0','#cbd5e1','','','','','','0 2px 12px rgba(0,0,0,0.05)','0 8px 32px rgba(0,0,0,0.08)','#167e3c','#986207','#c93939','#23759a','#c93939','#0275b0','','','2,117,176','rgba(2,117,176,0.35)','light'],
++ 'sand': ['#f8f4ef','#f0e8de','#e8ddd0','#faf6f0','#f0e8de','#f5f0e8','#2e2418','#5a4e3e','#6b6154','#7e5d06','#7a5c12','#8b6508','#ddd0be','#ccc0aa','','','','','','0 2px 12px rgba(60,40,0,0.06)','0 8px 32px rgba(60,40,0,0.10)','#136e35','#895806','#b13232','#1e6686','#b13232','#7e5d06','','','126,93,6','rgba(126,93,6,0.35)','light'],
++ 'rose': ['#fff5f7','#fce7ec','#f5d5de','#fff7f9','#fce0e8','#fdeef2','#2e1420','#6a3850','#81536c','#bf183d','#b02d44','#be123c','#f0c8d4','#e4b0c0','','','','','','0 2px 12px rgba(60,0,20,0.06)','0 8px 32px rgba(60,0,20,0.10)','#136e35','#845506','#b13232','#1e6686','#b13232','#bf183d','','','191,24,61','rgba(191,24,61,0.35)','light'],
++ 'solarized-dark': ['#002b36','#073642','#0a3f4e','#053542','#0d4a58','#01303d','#fdf6e3','#a6b2b2','#a8b2b0','#b58900','#d4a017','#8a6a00','#0a4050','#105060','','#859900','#cb4b16','#dc322f','#268bd2','','','#aab84d','#e4a186','#ee9997','#78b7e3','#f69696','#947000','','','181,137,0','rgba(181,137,0,0.35)'],
++ 'solarized-light': ['#fdf6e3','#eee8d5','#e4ddc6','#fdf6e3','#eee8d5','#f5eedb','#002b36','#51656c','#4e6467','#795d00','#7b5d0d','#8a6a00','#d6ceb5','#c8c0a5','','#859900','#cb4b16','#dc322f','#268bd2','0 2px 12px rgba(0,30,40,0.08)','0 8px 32px rgba(0,30,40,0.12)','#5a6800','#a63e12','#b92a27','#1b6497','#b13232','#795d00','','','121,93,0','rgba(121,93,0,0.35)','light'],
++ 'catppuccin-mocha': ['#1e1e2e','#181825','#313244','#1e1e2e','#313244','#1a1a2c','#cdd6f4','#bac2de','#989caf','#cba6f7','#dcc0ff','#b07de0','#313244','#45475a','#f38ba8','#a6e3a1','#fab387','#f38ba8','#89b4fa','','','#a6e3a1','#fab387','#f38ba8','#89b4fa','#f38ba8','#cba6f7','#0b0d10','#0b0d10','203,166,247','rgba(203,166,247,0.35)'],
++ 'catppuccin-latte': ['#eff1f5','#e6e9ef','#dce0e8','#eff1f5','#dce0e8','#e8ebf0','#4c4f69','#5c5f77','#5f6176','#7d34dc','#7a46c2','#7028d4','#ccd0da','#bcc0cc','#d20f39','#40a02b','#fe640b','#d20f39','#1e66f5','0 2px 12px rgba(0,0,0,0.06)','0 8px 32px rgba(0,0,0,0.10)','#2d701e','#a84207','#c50e36','#1a5ad8','#c50e36','#7d34dc','','','125,52,220','rgba(125,52,220,0.35)','light'],
++ 'high-contrast': ['#000000','#0a0a0a','#1a1a1a','#0f0f0f','#222222','#0a0a0a','#ffffff','#f0f0f0','#aaaaaa','#ffd700','#ffed4a','#ccac00','#444444','#666666','#ff0000','#00ff00','#ffff00','#ff0000','#00ffff','','','#00ff00','#ffff00','#ff3d3d','#00ffff','#ff3d3d','#ffd700','#0b0d10','#0b0d10','255,215,0','rgba(255,215,0,0.35)'],
++ 'halloween': ['','#0f1626','#151e33','#111a2b','#1a2540','#0c1322','#eef1f6','#a9b4c8','#8c99b0','#ff7a1a','#ffa14f','#d65d00','#1c2840','#2a3858','','#4ade80','#fbbf24','#f87171','#60a5fa','0 2px 12px rgba(0,4,14,0.5)','0 8px 32px rgba(0,4,14,0.65)','#4ade80','#fbbf24','#f87171','#60a5fa','#f15757','#ff7a1a','#0b0d10','#0b0d10','255,122,26','rgba(255,122,26,0.35)'],
++ 'frost': ['#08111b','#0d1926','#132334','#0f1c2b','#18293d','#0a1520','#eaf4fb','#a3b9cc','#7f97ad','#2dd4bf','#7ee8da','#0f9e8c','#1b2d41','#294057','','#4ade80','#fbbf24','#f87171','#93c5fd','0 2px 12px rgba(0,6,14,0.5)','0 8px 32px rgba(0,6,14,0.65)','#4ade80','#fbbf24','#f87171','#93c5fd','#f15e5e','#2dd4bf','#0b0d10','#0b0d10','45,212,191','rgba(45,212,191,0.35)'],
++ 'dawn': ['#f5f6fc','#eceefa','#e1e4f5','#fafbff','#e6e9f8','#f0f2fb','#161a33','#474d6b','#5e647f','#4f46e5','#5558cf','#3730a3','#d6daef','#c3c8e6','','','','','','0 2px 12px rgba(10,14,40,0.07)','0 8px 32px rgba(10,14,40,0.12)','#147638','#8e5c06','#ba3535','#1f6a8b','#ba3535','#4f46e5','','','79,70,229','rgba(79,70,229,0.35)','light'],
++ 'tokyo-day': ['#fff6fa','#ffedf4','#fde1ec','#fffafc','#fde7f0','#fff0f6','#24101a','#5c3a4a','#7a5566','#cd0058','#c22261','#9c0043','#f3d3e0','#ebbfd1','','','','','','0 2px 12px rgba(40,0,16,0.07)','0 8px 32px rgba(40,0,16,0.12)','#147638','#8e5c06','#bf3636','#206e90','#bf3636','#cd0058','','','205,0,88','rgba(205,0,88,0.35)','light'],
++ 'alucard': ['#fffbeb','#f6f0d8','#ece4c6','#fffdf3','#f2ead0','#fbf6e2','#1f1f1f','#4f4b38','#6c664b','#644ac9','#6b53bc','#4a33a3','#e3dab8','#d6cba3','','','','','','0 2px 12px rgba(40,30,0,0.07)','0 8px 32px rgba(40,30,0,0.12)','#147237','#895806','#ba3535','#1f6a8b','#ba3535','#644ac9','','','100,74,201','rgba(100,74,201,0.35)','light'],
++ 'monokai-light': ['#fafaf6','#f1f1ea','#e7e7de','#fdfdfa','#ecece3','#f6f6f0','#272822','#4e4f44','#67685c','#4b7100','#537112','#436600','#dcdcd0','#cdcdbf','','','','','','0 2px 12px rgba(20,20,10,0.07)','0 8px 32px rgba(20,20,10,0.12)','#147638','#8e5c06','#ba3535','#206e90','#ba3535','#4b7100','','','75,113,0','rgba(75,113,0,0.35)','light'],
++ 'shallows': ['#f0fafb','#e4f4f6','#d5ecef','#f7fcfd','#dcf0f2','#ebf7f8','#0c2226','#3a5458','#516b70','#007384','#00737f','#005662','#c9e3e7','#b4d6db','','','','','','0 2px 12px rgba(0,30,36,0.07)','0 8px 32px rgba(0,30,36,0.12)','#147638','#8e5c06','#ba3535','#206e90','#ba3535','#007384','','','0,115,132','rgba(0,115,132,0.35)','light'],
++ 'ash': ['#faf7f6','#f3eeec','#eae2df','#fdfbfa','#efe7e4','#f7f3f1','#231716','#54403d','#725b57','#c22727','#bc2f2b','#8e1b1b','#e2d6d2','#d4c4bf','','','','','','0 2px 12px rgba(40,10,8,0.07)','0 8px 32px rgba(40,10,8,0.12)','#147237','#895806','#ba3535','#1f6a8b','#ba3535','#c22727','','','194,39,39','rgba(194,39,39,0.35)','light'],
++ 'vapor-day': ['#fcf6ff','#f6ecfc','#eedff8','#fefaff','#f2e5fa','#f9f1fe','#22122c','#533f5f','#715c7e','#a21caf','#a921ba','#701a75','#e6d3f1','#dbc0ea','','','','','','0 2px 12px rgba(30,0,40,0.07)','0 8px 32px rgba(30,0,40,0.12)','#147237','#895806','#ba3535','#1f6a8b','#ba3535','#a21caf','','','162,28,175','rgba(162,28,175,0.35)','light'],
++ 'greenbar': ['#f4faf3','#e9f4e7','#dcecd9','#f9fdf8','#e2f0df','#eff7ed','#0d1f10','#3b5240','#556e5a','#13782b','#18772c','#0b5a1f','#cfe3cb','#bcd7b7','','','','','','0 2px 12px rgba(0,30,6,0.07)','0 8px 32px rgba(0,30,6,0.12)','#147638','#8e5c06','#ba3535','#206e90','#ba3535','#13782b','','','19,120,43','rgba(19,120,43,0.35)','light'],
++ 'nord-light': ['#eceff4','#e5e9f0','#d8dee9','#f2f4f8','#dfe4ec','#e8ecf2','#2e3440','#434c5e','#4c566a','#476283','#506478','#4c6a91','#d0d7e2','#c2cad8','','','','','','0 2px 12px rgba(20,26,40,0.07)','0 8px 32px rgba(20,26,40,0.12)','#136e35','#845506','#b13232','#1e6686','#b13232','#476283','','','71,98,131','rgba(71,98,131,0.35)','light'],
++ 'bright': ['#ffffff','#f6f6f6','#ececec','#ffffff','#f0f0f0','#fafafa','#0a0a0a','#3d3d3d','#5c5c5c','#111111','#333333','#000000','#e2e2e2','#d0d0d0','','','','','','0 2px 12px rgba(0,0,0,0.07)','0 8px 32px rgba(0,0,0,0.12)','#157a3a','#935f07','#bf3636','#227195','#bf3636','#111111','','','17,17,17','rgba(17,17,17,0.35)','light'],
++ 'high-contrast-light': ['#ffffff','#ffffff','#f2f2f2','#ffffff','#e8e8e8','#ffffff','#000000','#1a1a1a','#2b2b2b','#0000c8','#1a1aff','#00008b','#000000','#333333','','','','','','0 2px 12px rgba(0,0,0,0.07)','0 8px 32px rgba(0,0,0,0.12)','#147638','#8e5c06','#bf3636','#206e90','#bf3636','#0000c8','','','0,0,200','rgba(0,0,200,0.35)','light'],
++ 'hayride': ['#fbf6ee','#f6eee2','#efe3d1','#ffffff','#f3e9da','#fffaf2','#23180f','#5a4838','#6b5a4a','#b63d0b','#ad4109','#9a3412','#eadfcd','#e0cfb6','','','','','','0 2px 12px rgba(40,24,8,0.07)','0 8px 32px rgba(40,24,8,0.12)','#147638','#8e5c06','#ba3535','#1f6a8b','#ba3535','#b63d0b','','','182,61,11','rgba(182,61,11,0.35)','light'],
++ 'snowfall': ['#f2f8fb','#e8f2f7','#dbeaf1','#f8fbfd','#e1eef4','#edf5f9','#0e1d26','#3b5260','#526976','#0f746c','#0c7267','#115e59','#cfe2ea','#bcd5df','','','','','','0 2px 12px rgba(0,20,30,0.07)','0 8px 32px rgba(0,20,30,0.12)','#147638','#8e5c06','#ba3535','#206e90','#ba3535','#0f746c','','','15,116,108','rgba(15,116,108,0.35)','light'],
};
++ const THEMES = (function () {
++ const out = {}, base = THEME_ROWS.vibe;
++ for (const id in THEME_ROWS) {
++ const row = THEME_ROWS[id], vars = {};
++ for (let i = 0; i < THEME_KEYS.length; i++) { const v = row[i] || base[i]; if (v) vars[THEME_KEYS[i]] = v; }
++ out[id] = vars;
++ }
++ return out;
++ })();
const SEASONS = [
-- { id: 'halloween', name: 'Halloween', theme: 'halloween', from: '10-01', to: '10-31' },
-- { id: 'winter', name: 'Winter', theme: 'frost', from: '12-01', to: '12-31' },
++ { id: 'halloween', name: 'Halloween', theme: 'halloween', light: 'hayride', from: '10-01', to: '10-31' },
++ { id: 'winter', name: 'Winter', theme: 'frost', light: 'snowfall', from: '12-01', to: '12-31' },
];
++ const PAIRS = { 'vibe':'cloud','violet-hour':'daylight','midnight':'dawn','forest':'meadow','neon-tokyo':'tokyo-day','dracula':'alucard','monokai':'monokai-light','ocean-deep':'shallows','sunset':'peach','arctic':'snow','ember':'ash','vapor':'vapor-day','slate':'lavender','matrix':'greenbar','nord':'nord-light','gruvbox-dark':'gruvbox-light','abyss':'bright','copper':'sand','sakura-night':'rose','hacker':'paper','daylight':'violet-hour','paper':'hacker','cloud':'vibe','meadow':'forest','peach':'sunset','lavender':'slate','gruvbox-light':'gruvbox-dark','snow':'arctic','sand':'copper','rose':'sakura-night','solarized-dark':'solarized-light','solarized-light':'solarized-dark','catppuccin-mocha':'catppuccin-latte','catppuccin-latte':'catppuccin-mocha','high-contrast':'high-contrast-light','halloween':'hayride','frost':'snowfall','dawn':'midnight','tokyo-day':'neon-tokyo','alucard':'dracula','monokai-light':'monokai','shallows':'ocean-deep','ash':'ember','vapor-day':'vapor','greenbar':'matrix','nord-light':'nord','bright':'abyss','high-contrast-light':'high-contrast','hayride':'halloween','snowfall':'frost' };
++ const MODES = { 'vibe':'dark','violet-hour':'dark','midnight':'dark','forest':'dark','neon-tokyo':'dark','dracula':'dark','monokai':'dark','ocean-deep':'dark','sunset':'dark','arctic':'dark','ember':'dark','vapor':'dark','slate':'dark','matrix':'dark','nord':'dark','gruvbox-dark':'dark','abyss':'dark','copper':'dark','sakura-night':'dark','hacker':'dark','daylight':'light','paper':'light','cloud':'light','meadow':'light','peach':'light','lavender':'light','gruvbox-light':'light','snow':'light','sand':'light','rose':'light','solarized-dark':'dark','solarized-light':'light','catppuccin-mocha':'dark','catppuccin-latte':'light','high-contrast':'dark','halloween':'dark','frost':'dark','dawn':'light','tokyo-day':'light','alucard':'light','monokai-light':'light','shallows':'light','ash':'light','vapor-day':'light','greenbar':'light','nord-light':'light','bright':'light','high-contrast-light':'light','hayride':'light','snowfall':'light' };
// THEMES:END
// ── Seasons ──────────────────────────────────────────────
@@@ -85,11 +110,35 @@
let _season = null;
let _shown = { id: DEFAULT_ID, vars: null, custom: false };
/** Paint the theme `themeId` (its variables `vars`), or the season's theme in its place. → the id painted. */
++ // ── Light, dark and Auto ─────────────────────────────────
++ // Every built-in theme has a light and a dark half (PAIRS above). The display preference `scheme` is 'manual' (the
++ // theme someone picked, as it is) or 'auto' (its half that matches the device's light or dark setting). The account
++ // menu's Light / Dark switch saves the other half itself (setScheme). A theme without a half (a community or custom
++ // one) gives way to the default theme's half when Auto wants the other mode.
++ let _mq = null;
++ function deviceMode() {
++ try { _mq = _mq || window.matchMedia('(prefers-color-scheme: dark)'); return _mq.matches ? 'dark' : 'light'; } catch (e) { return 'dark'; }
++ }
++ function modeOf(id, vars) { return MODES[id] || ((vars && vars['--color-scheme']) === 'light' ? 'light' : 'dark'); }
++ function otherHalf(id, mode) { return PAIRS[id] && MODES[PAIRS[id]] === mode ? PAIRS[id] : (mode === 'light' ? PAIRS[DEFAULT_ID] : DEFAULT_ID); }
++ function halfFor(themeId, vars, custom) {
++ if (readDisplay().scheme !== 'auto') return { id: themeId, vars: vars, custom: custom };
++ const want = deviceMode();
++ if (modeOf(themeId, vars) === want) return { id: themeId, vars: vars, custom: custom };
++ const other = otherHalf(themeId, want);
++ return { id: other, vars: THEMES[other], custom: false };
++ }
++ // The default theme in either mode: Vibe, and its light half. A season stands in for both, in the same mode.
++ function isDefault(id) { return id === DEFAULT_ID || id === PAIRS[DEFAULT_ID]; }
++
function show(themeId, vars, custom) {
_shown = { id: themeId, vars: vars, custom: !!custom };
-- const s = themeId === DEFAULT_ID && !custom && readDisplay().seasonal !== 'off' ? seasonNow() : null;
-- const seasonal = s && THEMES[s.theme] ? s : null;
-- applyVars(seasonal ? THEMES[seasonal.theme] : vars);
++ const half = halfFor(themeId, vars, !!custom);
++ const s = isDefault(half.id) && !half.custom && readDisplay().seasonal !== 'off' ? seasonNow() : null;
++ const seasonTheme = s ? (modeOf(half.id) === 'light' && s.light ? s.light : s.theme) : null;
++ const seasonal = seasonTheme && THEMES[seasonTheme] ? s : null;
++ const painted = seasonal ? seasonTheme : half.id;
++ applyVars(seasonal ? THEMES[seasonTheme] : half.vars);
const changed = (_season && _season.id) !== (seasonal && seasonal.id);
_season = seasonal;
try {
@@@ -97,10 +146,26 @@
if (seasonal) el.setAttribute('data-ov-season', seasonal.id); else el.removeAttribute('data-ov-season');
const marked = readDisplay().seasonal !== 'off' ? seasonNow() : null;
if (marked) el.setAttribute('data-ov-season-mark', marked.id); else el.removeAttribute('data-ov-season-mark');
-- el.setAttribute('data-theme', seasonal ? seasonal.theme : themeId);
++ el.setAttribute('data-theme', painted);
} catch { /* no DOM */ }
if (changed) { try { window.dispatchEvent(new CustomEvent('ov:season', { detail: seasonal })); } catch { /* */ } }
-- return seasonal ? seasonal.theme : themeId;
++ return painted;
++ }
++
++ /** The Light / Dark / Auto switch's state: 'auto', else the mode of the theme on screen. */
++ function getScheme() { return readDisplay().scheme === 'auto' ? 'auto' : modeOf(_shown.id, _shown.vars); }
++ /**
++ * Light or Dark: Auto goes off and, when the picked theme is the other mode, its other half is saved as the theme (the
++ * default theme's half for one without); the season stays as it was. Auto: follow the device. → the new state.
++ */
++ function setScheme(mode) {
++ if (mode === 'auto') { setDisplay({ scheme: 'auto' }); return 'auto'; }
++ if (mode !== 'light' && mode !== 'dark') return getScheme();
++ const cur = _shown;
++ if (readDisplay().scheme === 'auto') setDisplay({ scheme: 'manual' }, { noRepaint: true });
++ if (modeOf(cur.id, cur.vars) !== mode) save(otherHalf(cur.id, mode), null, { keepSeasonal: true });
++ else show(cur.id, cur.vars || THEMES[cur.id] || THEMES[DEFAULT_ID], cur.custom);
++ return mode;
}
function getCookie(name) {
@@@ -290,7 +355,7 @@
const vars = custom ? Object.assign({}, base, custom) : base;
if (!vars || !Object.keys(vars).length) return;
// Picking the default theme while a season's stands in for it means "my normal theme": seasonal themes go off.
-- if (themeId === DEFAULT_ID && !custom && _season && !(opts && opts.keepSeasonal)) setDisplay({ seasonal: 'off' }, { noRepaint: true });
++ if (isDefault(themeId) && !custom && _season && !(opts && opts.keepSeasonal)) setDisplay({ seasonal: 'off' }, { noRepaint: true });
cacheLocally(themeId, vars, !!custom);
show(themeId, vars, !!custom); // saving a theme shows it: every token, the canvas and the browser UI follow at once
if (opts && opts.sync === false) return;
@@@ -325,9 +390,10 @@
// Applied as <html data-ov-motion> and --ov-text-scale before first paint; the shared stylesheet
// below makes every shared component honour them, and sites can key their own CSS off the same hooks.
// seasonal: 'on'|'off' the season's theme in place of the default theme (see Seasons above)
-- const DISPLAY_KEY = 'ov_display', DISPLAY_OPTS = { motion: ['auto', 'reduced'], text: ['100', '112', '125'], seasonal: ['on', 'off'] };
++ // scheme: 'manual'|'auto' the picked theme as it is, or its half that matches the device (see Light, dark and Auto)
++ const DISPLAY_KEY = 'ov_display', DISPLAY_OPTS = { motion: ['auto', 'reduced'], text: ['100', '112', '125'], seasonal: ['on', 'off'], scheme: ['manual', 'auto'] };
function readDisplay() {
-- const out = { motion: 'auto', text: '100', seasonal: 'on' };
++ const out = { motion: 'auto', text: '100', seasonal: 'on', scheme: 'manual' };
try { const d = JSON.parse(localStorage.getItem(DISPLAY_KEY) || 'null') || {}; for (const k in DISPLAY_OPTS) if (DISPLAY_OPTS[k].indexOf(String(d[k])) >= 0) out[k] = String(d[k]); } catch (e) { /* */ }
return out;
}
@@@ -345,11 +411,11 @@
}
function setDisplay(patch, opts) {
const d = readDisplay();
-- const wasSeasonal = d.seasonal;
++ const was = d.seasonal + '|' + d.scheme;
for (const k in DISPLAY_OPTS) if (patch && DISPLAY_OPTS[k].indexOf(String(patch[k])) >= 0) d[k] = String(patch[k]);
try { localStorage.setItem(DISPLAY_KEY, JSON.stringify(d)); } catch (e) { /* */ }
applyDisplay(d);
-- if (d.seasonal !== wasSeasonal && !(opts && opts.noRepaint)) show(_shown.id, _shown.vars || THEMES[_shown.id] || THEMES[DEFAULT_ID], _shown.custom);
++ if (d.seasonal + '|' + d.scheme !== was && !(opts && opts.noRepaint)) show(_shown.id, _shown.vars || THEMES[_shown.id] || THEMES[DEFAULT_ID], _shown.custom);
if (!(opts && opts.local) && typeof fetch !== 'undefined') {
const token = authToken(); const headers = { 'Content-Type': 'application/json' }; if (token) headers.Authorization = 'Bearer ' + token;
if (token || /(?:^|; )ov_sso_hint=account/.test(document.cookie)) fetch(_config.apiBase + '/api/themes/me/display', { method: 'PUT', headers: headers, credentials: 'include', keepalive: true, body: JSON.stringify(d) }).catch(function () {});
@@@ -364,6 +430,14 @@
// A theme picked in another tab (or on another OpenVibe page of this site) applies here at once.
if (typeof window !== 'undefined') window.addEventListener('storage', function (e) { if (e.key === 'ov_theme') { try { fadeNext(); resolveAndApply(); } catch (err) { /* */ } } if (e.key === DISPLAY_KEY) { fadeNext(); applyDisplay(readDisplay()); show(_shown.id, _shown.vars || THEMES[_shown.id] || THEMES[DEFAULT_ID], _shown.custom); } });
++ if (typeof window !== 'undefined' && window.matchMedia) {
++ try {
++ const mq = window.matchMedia('(prefers-color-scheme: dark)');
++ const onDevice = function () { if (readDisplay().scheme === 'auto') { fadeNext(); show(_shown.id, _shown.vars || THEMES[_shown.id] || THEMES[DEFAULT_ID], _shown.custom); } };
++ if (mq.addEventListener) mq.addEventListener('change', onDevice); else if (mq.addListener) mq.addListener(onDevice);
++ } catch (e) { /* no media queries */ }
++ }
++
// Look up a theme we don't ship in the built-in map (community/custom themes) from the
// central catalog. Without this, any non-built-in id fell through to the default 'vibe'
// — which is why a custom theme could "disappear" and flash purple on every load.
@@@ -390,7 +464,7 @@
fetch(_config.apiBase + '/api/themes/me/active', { headers: headers, credentials: 'include' })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (data) {
-- if (data && data.display) { const cur = readDisplay(); if (JSON.stringify({ motion: data.display.motion || cur.motion, text: data.display.text || cur.text, seasonal: data.display.seasonal || cur.seasonal }) !== JSON.stringify(cur)) { fadeNext(); setDisplay(data.display, { local: true }); } }
++ if (data && data.display) { const cur = readDisplay(); if (JSON.stringify({ motion: data.display.motion || cur.motion, text: data.display.text || cur.text, seasonal: data.display.seasonal || cur.seasonal, scheme: data.display.scheme || cur.scheme }) !== JSON.stringify(cur)) { fadeNext(); setDisplay(data.display, { local: true }); } }
if (!data || !data.theme_id) return;
const serverId = data.theme_id;
const custom = (data.custom_variables && typeof data.custom_variables === 'object'
@@@ -431,6 +505,10 @@
applyVars: applyVars,
// Seasons: SEASONS, now() → the season today (or null), showing() → the season whose theme is on screen (or null)
seasons: { SEASONS: SEASONS, now: seasonNow, showing: function () { return _season; } },
++ // Light / Dark / Auto: get() → 'light'|'dark'|'auto', set(mode); pairOf(id) → its other half; modeOf(id)
++ scheme: { get: getScheme, set: setScheme, device: deviceMode },
++ pairOf: function (id) { return PAIRS[id] || null; },
++ modeOf: function (id) { return MODES[id] || null; },
onColor: onColor,
save: save,
getCurrent: getCurrent,