From 57a907eca5774558256e2cb49dfaef43fa869139 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sun, 19 Jul 2026 15:21:55 +0200 Subject: [PATCH] feat(theme+comments): runtime colour theme and COMMENTS_ENABLED switch Colour theme is configurable at runtime from two seed colours (brand + accent); neutrals stay fixed for contrast safety. Tailwind v4 var()-based tokens let a :root:root override recolour everything with no rebuild; the 50->950 ramps are derived via a color-mix ladder. Config lives in the DB config table (admin UI: Config > Farbschema, presets + custom pickers + live preview), served on the public /event endpoint with env defaults (THEME_PRESET/PRIMARY/ACCENT), propagated live via event-updated SSE, and cached in localStorage for a no-flash boot. The keepsake export mirrors the same ladder in Rust so offline archives match the event theme. COMMENTS_ENABLED (env, default true) is a boot-time kill-switch: the backend rejects new comments with 403 and the frontend hides the comment button (feed card) and panel/composer (lightbox). Existing comments stay in the DB, hidden, and return when re-enabled. Co-Authored-By: Claude Opus 4.8 --- backend/src/config.rs | 31 +++ backend/src/handlers/admin.rs | 56 +++- backend/src/handlers/public.rs | 28 +- backend/src/handlers/social.rs | 6 + backend/src/services/export.rs | 113 ++++++++- frontend/src/app.html | 13 + .../src/lib/components/FeedListCard.svelte | 35 ++- .../src/lib/components/LightboxModal.svelte | 153 +++++------ frontend/src/lib/event-config-store.ts | 60 +++++ frontend/src/lib/theme/palette.ts | 120 +++++++++ frontend/src/routes/+layout.svelte | 10 +- frontend/src/routes/admin/+page.svelte | 239 ++++++++++++++++++ 12 files changed, 765 insertions(+), 99 deletions(-) create mode 100644 frontend/src/lib/event-config-store.ts create mode 100644 frontend/src/lib/theme/palette.ts diff --git a/backend/src/config.rs b/backend/src/config.rs index 0eb4529..6b9c382 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -63,8 +63,25 @@ pub struct AppConfig { pub app_port: u16, /// Number of concurrent media compression workers (read once at boot). pub compression_concurrency: usize, + /// Master switch for the comment feature (env `COMMENTS_ENABLED`, default true). + /// When false the backend rejects new comments and the frontend hides the whole + /// comment UI. Existing comments stay in the DB (hidden), so flipping it back + /// restores them. Boot-time immutable, like `compression_concurrency`. + pub comments_enabled: bool, + /// Default colour theme, used as the fallback when the DB config keys are unset. + /// Runtime overrides live in the `config` table (admin UI); these env vars only + /// seed the initial default. `preset` is an id the frontend knows (e.g. + /// "champagne-gold", "rose", … or "custom"); the two seeds are `#rrggbb` brand + + /// accent colours the whole palette is derived from. + pub default_theme_preset: String, + pub default_theme_primary: String, + pub default_theme_accent: String, } +/// The shipped default brand/accent seed (champagne gold — matches the hand-tuned +/// ramp in tailwind-theme.css). Kept here so an unset env still yields the current look. +const DEFAULT_THEME_SEED: &str = "#8a6a2b"; + impl AppConfig { pub fn from_env() -> Result { let app_env = std::env::var("APP_ENV").unwrap_or_else(|_| "development".to_string()); @@ -100,6 +117,20 @@ impl AppConfig { .and_then(|v| v.parse().ok()) .filter(|&n| n >= 1) .unwrap_or(2), + comments_enabled: std::env::var("COMMENTS_ENABLED") + .map(|v| { + !matches!( + v.trim().to_ascii_lowercase().as_str(), + "false" | "0" | "no" | "off" + ) + }) + .unwrap_or(true), + default_theme_preset: std::env::var("THEME_PRESET") + .unwrap_or_else(|_| "champagne-gold".to_string()), + default_theme_primary: std::env::var("THEME_PRIMARY") + .unwrap_or_else(|_| DEFAULT_THEME_SEED.to_string()), + default_theme_accent: std::env::var("THEME_ACCENT") + .unwrap_or_else(|_| DEFAULT_THEME_SEED.to_string()), }) } } diff --git a/backend/src/handlers/admin.rs b/backend/src/handlers/admin.rs index 700184d..fb519c1 100644 --- a/backend/src/handlers/admin.rs +++ b/backend/src/handlers/admin.rs @@ -138,10 +138,27 @@ pub async fn patch_config( "storage_quota_enabled", "upload_count_quota_enabled", ]; - const TEXT_KEYS: &[&str] = &["privacy_note"]; + const TEXT_KEYS: &[&str] = &[ + "privacy_note", + "theme_preset", + "theme_primary", + "theme_accent", + ]; const PRIVACY_NOTE_MAX_LEN: usize = 16 * 1024; // 16 KiB free text is plenty + // Preset ids the frontend knows how to render (mirror of PRESETS in + // frontend/src/lib/theme/palette.ts). "custom" means "use the theme_primary/accent + // seeds verbatim". Kept in sync by hand — a new preset must be added in both places. + const THEME_PRESETS: &[&str] = &[ + "champagne-gold", + "rose", + "sage", + "dusk-blue", + "classic-silver", + "custom", + ]; let mut privacy_note_changed = false; + let mut theme_changed = false; // Validate every key first so a bad value in the batch can't leave a partial // update behind — validation must fully precede any write. @@ -186,8 +203,23 @@ pub async fn patch_config( "Wert für {key} ist zu lang (max. {PRIVACY_NOTE_MAX_LEN} Zeichen)." ))); } - if key_str == "privacy_note" { - privacy_note_changed = true; + match key_str { + "privacy_note" => privacy_note_changed = true, + "theme_preset" => { + if !THEME_PRESETS.contains(&value.trim()) { + return Err(AppError::BadRequest(format!("Ungültiges Theme: {value}."))); + } + theme_changed = true; + } + "theme_primary" | "theme_accent" => { + if !is_hex_color(value.trim()) { + return Err(AppError::BadRequest(format!( + "Ungültige Farbe für {key}: muss #rrggbb sein." + ))); + } + theme_changed = true; + } + _ => {} } } else { return Err(AppError::BadRequest(format!( @@ -217,16 +249,30 @@ pub async fn patch_config( // Notify all clients that a publicly-readable config value changed so their stores // (e.g. the privacy note in My Account) refresh without a manual reload. - if privacy_note_changed { + if privacy_note_changed || theme_changed { + let mut keys: Vec<&str> = Vec::new(); + if privacy_note_changed { + keys.push("privacy_note"); + } + if theme_changed { + keys.push("theme"); + } let _ = state.sse_tx.send(crate::state::SseEvent::new( "event-updated", - serde_json::json!({ "keys": ["privacy_note"] }).to_string(), + serde_json::json!({ "keys": keys }).to_string(), )); } Ok(StatusCode::NO_CONTENT) } +/// A strict `#rrggbb` hex-colour check (6 hex digits, leading `#`). Deliberately not +/// accepting shorthand/`#rgba` so the value is safe to drop straight into CSS. +fn is_hex_color(s: &str) -> bool { + let bytes = s.as_bytes(); + bytes.len() == 7 && bytes[0] == b'#' && bytes[1..].iter().all(|b| b.is_ascii_hexdigit()) +} + pub async fn get_export_jobs( State(state): State, RequireAdmin(_auth): RequireAdmin, diff --git a/backend/src/handlers/public.rs b/backend/src/handlers/public.rs index a609049..61181ba 100644 --- a/backend/src/handlers/public.rs +++ b/backend/src/handlers/public.rs @@ -4,21 +4,41 @@ use axum::Json; use axum::extract::State; use serde::Serialize; +use crate::services::config; use crate::state::AppState; #[derive(Serialize)] pub struct PublicEventDto { pub name: String, pub slug: String, + /// Whether the comment feature is on (env `COMMENTS_ENABLED`). The frontend hides + /// the whole comment UI when false; exposed here so even the pre-auth shell knows. + pub comments_enabled: bool, + /// Active colour theme. `preset` is an id the frontend maps to a palette (or + /// "custom"); `primary`/`accent` are the `#rrggbb` seeds the ramps derive from. + /// Resolved as DB-config override → env default. Public so the theme applies on + /// the very first (pre-auth) paint without a flash. + pub theme_preset: String, + pub theme_primary: String, + pub theme_accent: String, } -/// Public event identity, used by the pre-auth join/recover screens so a guest can -/// see *which* event they're joining. Only the display name and slug are exposed — -/// nothing user-scoped — so this is safe without a token. Served straight from the -/// instance config (no DB round-trip needed). +/// Public event identity + presentation config, used by the pre-auth join/recover +/// screens (which event am I joining, what does it look like). Only non-user-scoped +/// fields are exposed, so this is safe without a token. Identity comes straight from +/// instance config; the theme is resolved from the runtime `config` table (admin UI) +/// falling back to the env-seeded default. pub async fn get_public_event(State(state): State) -> Json { + let cache = &state.config_cache; Json(PublicEventDto { name: state.config.event_name.clone(), slug: state.config.event_slug.clone(), + comments_enabled: state.config.comments_enabled, + theme_preset: config::get_str(cache, "theme_preset", &state.config.default_theme_preset) + .await, + theme_primary: config::get_str(cache, "theme_primary", &state.config.default_theme_primary) + .await, + theme_accent: config::get_str(cache, "theme_accent", &state.config.default_theme_accent) + .await, }) } diff --git a/backend/src/handlers/social.rs b/backend/src/handlers/social.rs index d43f79b..d1cb7d1 100644 --- a/backend/src/handlers/social.rs +++ b/backend/src/handlers/social.rs @@ -129,6 +129,12 @@ pub async fn add_comment( Path(upload_id): Path, Json(body): Json, ) -> Result<(StatusCode, Json), AppError> { + // Comments can be disabled instance-wide (env COMMENTS_ENABLED). The frontend hides + // the UI, but gate the API too so a stale client or direct call can't slip one in. + if !state.config.comments_enabled { + return Err(AppError::Forbidden("Kommentare sind deaktiviert.".into())); + } + let user = crate::models::user::User::find_by_id(&state.pool, auth.user_id) .await? .ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?; diff --git a/backend/src/services/export.rs b/backend/src/services/export.rs index f196dcf..db8a0ca 100644 --- a/backend/src/services/export.rs +++ b/backend/src/services/export.rs @@ -888,6 +888,10 @@ async fn run_html_export_inner( let data_json = serde_json::to_string_pretty(&viewer_data).context("failed to serialize data.json")?; + // Match the live app's colour theme in the offline keepsake. + let (theme_primary, theme_accent) = resolve_theme_seeds(pool).await; + let theme_css = theme_override_css(&theme_primary, &theme_accent); + let _ = update_progress(pool, event_id, "html", epoch, 72).await; // 5. Create ZIP (per-generation paths — see run_zip_export) @@ -903,7 +907,7 @@ async fn run_html_export_inner( // `window.__EXPORT_DATA__` global into index.html. Guests double-click // index.html (file://), where a cross-origin fetch() of a sibling file is // blocked — so the data must be inlined rather than fetched from data.json. - write_viewer_with_data(&VIEWER_DIR, &mut zip, &data_json).await?; + write_viewer_with_data(&VIEWER_DIR, &mut zip, &data_json, theme_css.as_deref()).await?; let _ = update_progress(pool, event_id, "html", epoch, 75).await; @@ -1313,6 +1317,7 @@ async fn write_viewer_with_data( dir: &include_dir::Dir<'_>, zip: &mut ZipFileWriter, data_json: &str, + theme_css: Option<&str>, ) -> Result<()> { for file in dir.files() { let path = file.path().to_string_lossy().to_string(); @@ -1321,10 +1326,18 @@ async fn write_viewer_with_data( .context("export-viewer index.html is not valid UTF-8")?; // Escape `` can't break out of the tag. let safe = data_json.replace("window.__EXPORT_DATA__={safe};"); + // Match the live app's colour theme: inject the same `:root:root{…}` override + // the app builds at runtime so a rose/sage/custom event exports a rose/sage + // keepsake (not the embedded default gold). The CSS is generated purely from + // hex seeds (theme_override_css), so there's nothing to escape. + let mut head_inject = String::new(); + if let Some(css) = theme_css { + head_inject.push_str(&format!("")); + } + head_inject.push_str(&format!("")); let injected = match html.find("") { - Some(idx) => format!("{}{}{}", &html[..idx], script, &html[idx..]), - None => format!("{script}{html}"), + Some(idx) => format!("{}{}{}", &html[..idx], head_inject, &html[idx..]), + None => format!("{head_inject}{html}"), }; let builder = ZipEntryBuilder::new(path.into(), Compression::Deflate); let mut entry = zip.write_entry_stream(builder).await?; @@ -1340,11 +1353,101 @@ async fn write_viewer_with_data( } } for sub_dir in dir.dirs() { - Box::pin(write_viewer_with_data(sub_dir, zip, data_json)).await?; + Box::pin(write_viewer_with_data(sub_dir, zip, data_json, theme_css)).await?; } Ok(()) } +/// Default champagne-gold seed — matches the hand-tuned ramp already embedded in the +/// viewer, so a default-themed event injects no override. +const KEEPSAKE_DEFAULT_SEED: &str = "#8a6a2b"; + +// stop → color-mix instruction. MIRRORS `LADDER` in frontend/src/lib/theme/palette.ts — +// keep the two in sync so an exported keepsake matches the live app pixel-for-pixel. +const THEME_LADDER: &[(u16, Option<&str>)] = &[ + (50, Some("white 90%")), + (100, Some("white 80%")), + (200, Some("white 62%")), + (300, Some("white 42%")), + (400, Some("white 22%")), + (500, Some("white 9%")), + (600, None), + (700, Some("black 15%")), + (800, Some("black 30%")), + (900, Some("black 45%")), + (950, Some("black 63%")), +]; + +fn theme_stop_value(seed: &str, mix: Option<&str>) -> String { + match mix { + Some(m) => format!("color-mix(in oklab, {seed}, {m})"), + None => seed.to_string(), + } +} + +fn theme_ramp(families: &[&str], seed: &str) -> String { + let mut out = String::new(); + for (stop, mix) in THEME_LADDER { + let val = theme_stop_value(seed, *mix); + for fam in families { + out.push_str(&format!("--color-{fam}-{stop}:{val};")); + } + } + out +} + +fn is_hex_color(s: &str) -> bool { + let b = s.as_bytes(); + b.len() == 7 && b[0] == b'#' && b[1..].iter().all(|c| c.is_ascii_hexdigit()) +} + +/// Build the keepsake's `:root:root{…}` colour override from two seed colours, or None +/// for the default gold (viewer already carries that ramp) or an invalid seed (fall back +/// to the embedded default rather than emit unsafe CSS). MIRRORS `buildPaletteCss` in +/// frontend/src/lib/theme/palette.ts. +fn theme_override_css(primary: &str, accent: &str) -> Option { + if !is_hex_color(primary) || !is_hex_color(accent) { + return None; + } + if primary.eq_ignore_ascii_case(KEEPSAKE_DEFAULT_SEED) + && accent.eq_ignore_ascii_case(KEEPSAKE_DEFAULT_SEED) + { + return None; + } + let accent500 = theme_stop_value(accent, Some("white 9%")); + let mut css = String::from(":root:root{"); + css.push_str(&theme_ramp(&["blue", "primary"], primary)); + css.push_str(&theme_ramp(&["purple"], accent)); + css.push_str(&format!( + "--color-violet-500:{accent500};--color-violet-600:{accent};" + )); + css.push_str(&format!( + "--color-accent-500:{accent500};--color-accent-600:{accent};" + )); + css.push('}'); + Some(css) +} + +/// Read the active theme seeds from the runtime `config` table (set by the admin UI), +/// falling back to the default gold. NOTE: an env-only default (THEME_PRIMARY set but +/// never saved via the admin UI) isn't stored in this table, so such a keepsake would +/// use gold; admin-set themes — the normal path — match the app exactly. +async fn resolve_theme_seeds(pool: &PgPool) -> (String, String) { + async fn read(pool: &PgPool, key: &str) -> String { + sqlx::query_scalar::<_, String>("SELECT value FROM config WHERE key = $1") + .bind(key) + .fetch_optional(pool) + .await + .ok() + .flatten() + .unwrap_or_else(|| KEEPSAKE_DEFAULT_SEED.to_string()) + } + ( + read(pool, "theme_primary").await, + read(pool, "theme_accent").await, + ) +} + fn ext_from_path(path: &str) -> &str { path.rsplit('.').next().unwrap_or("bin") } diff --git a/frontend/src/app.html b/frontend/src/app.html index 157f701..24532de 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -33,6 +33,19 @@ (pref === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches); if (dark) document.documentElement.classList.add('dark'); } catch (_) {} + // Colour-theme FOUC guard: re-apply the cached palette override before paint so + // a custom/preset theme doesn't flash the default gold. The value is a ready-to- + // inject `:root:root{…}` string from event-config-store (empty for the default + // theme); refreshed from /event once the app mounts. + try { + var css = localStorage.getItem('eventsnap_palette_css'); + if (css) { + var s = document.createElement('style'); + s.id = 'es-theme'; + s.textContent = css; + document.head.appendChild(s); + } + } catch (_) {} })(); %sveltekit.head% diff --git a/frontend/src/lib/components/FeedListCard.svelte b/frontend/src/lib/components/FeedListCard.svelte index a230662..dc8aa7a 100644 --- a/frontend/src/lib/components/FeedListCard.svelte +++ b/frontend/src/lib/components/FeedListCard.svelte @@ -7,6 +7,7 @@ import { avatarPalette, initials } from '$lib/avatar'; import { vibrate } from '$lib/haptics'; import { now } from '$lib/now'; + import { commentsEnabled } from '$lib/event-config-store'; import HeartBurst from './HeartBurst.svelte'; interface Props { @@ -205,19 +206,27 @@ {upload.like_count} - + {#if $commentsEnabled} + + {/if} {#if isOwn} Eigener Beitrag {/if} diff --git a/frontend/src/lib/components/LightboxModal.svelte b/frontend/src/lib/components/LightboxModal.svelte index 4aa9acc..fa1d159 100644 --- a/frontend/src/lib/components/LightboxModal.svelte +++ b/frontend/src/lib/components/LightboxModal.svelte @@ -11,6 +11,7 @@ import { modalInert } from '$lib/actions/modal-inert'; import { toastError } from '$lib/toast-store'; import { vibrate } from '$lib/haptics'; + import { commentsEnabled } from '$lib/event-config-store'; import HeartBurst from './HeartBurst.svelte'; const COMMENT_MAX = 500; @@ -224,81 +225,91 @@ {/if} - -
- {#if comments.length === 0} -

Noch keine Kommentare.

- {:else} -
- {#each comments as comment (comment.id)} -
-
- {comment.uploader_name} - {comment.body} -
- {formatTime(comment.created_at)} + + {#if $commentsEnabled} +
+ {#if comments.length === 0} +

+ Noch keine Kommentare. +

+ {:else} +
+ {#each comments as comment (comment.id)} +
+
+ {comment.uploader_name} + {comment.body} +
+ {formatTime(comment.created_at)} +
+ {#if comment.user_id === userId} + + {/if}
- {#if comment.user_id === userId} - - {/if} -
- {/each} -
- {/if} -
+ {/each} +
+ {/if} +
- -
{ - e.preventDefault(); - submitComment(); - }} - class="border-t border-gray-100 p-3 dark:border-gray-800" - > -
- - -
-
= 450 && newComment.length < COMMENT_MAX} - class:dark:text-amber-400={newComment.length >= 450 && newComment.length < COMMENT_MAX} - class:text-red-600={newComment.length >= COMMENT_MAX} - class:dark:text-red-400={newComment.length >= COMMENT_MAX} + + { + e.preventDefault(); + submitComment(); + }} + class="border-t border-gray-100 p-3 dark:border-gray-800" > - {newComment.length}/{COMMENT_MAX} -
-
+
+ + +
+
= 450 && newComment.length < COMMENT_MAX} + class:dark:text-amber-400={newComment.length >= 450 && newComment.length < COMMENT_MAX} + class:text-red-600={newComment.length >= COMMENT_MAX} + class:dark:text-red-400={newComment.length >= COMMENT_MAX} + > + {newComment.length}/{COMMENT_MAX} +
+ + {/if}
diff --git a/frontend/src/lib/event-config-store.ts b/frontend/src/lib/event-config-store.ts new file mode 100644 index 0000000..a086b7d --- /dev/null +++ b/frontend/src/lib/event-config-store.ts @@ -0,0 +1,60 @@ +/** + * Public event presentation config: the colour theme and the comments feature flag, + * served unauthenticated from `GET /api/v1/event`. Loaded once on boot (and refreshed + * on the `event-updated` SSE) so the theme applies before the user has even joined and + * the comment UI reflects the instance setting. + * + * The resolved theme CSS is cached in localStorage so the boot script in app.html can + * paint the right colours before the JS bundle loads (no flash of the default palette). + */ +import { writable } from 'svelte/store'; +import { buildPaletteCss, applyPaletteCss, type ThemeConfig } from './theme/palette'; + +export const PALETTE_CACHE_KEY = 'eventsnap_palette_css'; + +export type EventConfig = { + name: string; + slug: string; + commentsEnabled: boolean; + theme: ThemeConfig; +}; + +export const eventConfig = writable(null); +/** Convenience flag for the comment UI. Optimistic `true` until /event resolves. */ +export const commentsEnabled = writable(true); + +/** Fetch /event, apply the theme, and cache the resolved CSS for the next boot. */ +export async function loadEventConfig(): Promise { + try { + const res = await fetch('/api/v1/event'); + if (!res.ok) return; + const b = await res.json(); + const theme: ThemeConfig = { + preset: b.theme_preset ?? 'champagne-gold', + primary: b.theme_primary ?? '#8a6a2b', + accent: b.theme_accent ?? '#8a6a2b' + }; + eventConfig.set({ + name: b.name, + slug: b.slug, + commentsEnabled: b.comments_enabled ?? true, + theme + }); + commentsEnabled.set(b.comments_enabled ?? true); + + const css = buildPaletteCss(theme); + applyPaletteCss(css); + try { + localStorage.setItem(PALETTE_CACHE_KEY, css); + } catch { + /* private mode / quota — the theme still applied for this session */ + } + } catch { + /* offline / server down — keep whatever the boot script already painted */ + } +} + +/** Apply a theme immediately without persisting — used by the admin live preview. */ +export function previewTheme(theme: ThemeConfig): void { + applyPaletteCss(buildPaletteCss(theme)); +} diff --git a/frontend/src/lib/theme/palette.ts b/frontend/src/lib/theme/palette.ts new file mode 100644 index 0000000..508af95 --- /dev/null +++ b/frontend/src/lib/theme/palette.ts @@ -0,0 +1,120 @@ +/** + * Runtime colour theming. + * + * The app's colours are Tailwind v4 `@theme` tokens that compile to CSS custom + * properties (`--color-primary-600` …) which every utility references as + * `var(...)`. So we can recolour the whole app at runtime by overriding those + * variables — no rebuild. We derive a full 50→950 ramp for the brand ("primary", + * which the app authored as `blue-*`) and accent (`purple-*`/`violet-*`/`accent-*`) + * families from two seed colours, using CSS `color-mix()` so the browser does the + * tint/shade math. + * + * Neutrals (`gray-*`, the pearl→graphite system) are deliberately NOT themed — + * keeping them fixed guarantees text/background contrast never regresses, and the + * design language treats greys as the constant that "carries the system". + * + * The default (champagne gold) produces an EMPTY override so the hand-tuned ramp in + * tailwind-theme.css shows through verbatim — zero regression for existing installs. + */ + +export type ThemeConfig = { preset: string; primary: string; accent: string }; + +/** Champagne-gold seed — matches `--color-primary-600` in tailwind-theme.css. */ +export const DEFAULT_SEED = '#8a6a2b'; + +export type Preset = { id: string; label: string; primary: string; accent: string }; + +/** + * Curated palettes. The seed is the brand colour at ramp stop 600 (the primary + * button / FAB), chosen dark enough that white button text clears WCAG AA. + * MIRROR: the ids are validated server-side in backend/src/handlers/admin.rs + * (THEME_PRESETS) — add a preset in both places. + */ +export const PRESETS: Preset[] = [ + { id: 'champagne-gold', label: 'Champagner-Gold', primary: '#8a6a2b', accent: '#8a6a2b' }, + { id: 'rose', label: 'Rosé', primary: '#9e3f5c', accent: '#9e3f5c' }, + { id: 'sage', label: 'Salbeigrün', primary: '#4d6a4d', accent: '#4d6a4d' }, + { id: 'dusk-blue', label: 'Dämmerblau', primary: '#395880', accent: '#395880' }, + { id: 'classic-silver', label: 'Klassisch Silber', primary: '#5b5b61', accent: '#5b5b61' } +]; + +/** Resolve a stored theme config to its two effective seed colours. */ +export function resolveSeeds(cfg: ThemeConfig): { primary: string; accent: string } { + if (cfg.preset === 'custom') return { primary: cfg.primary, accent: cfg.accent }; + const p = PRESETS.find((x) => x.id === cfg.preset); + return p + ? { primary: p.primary, accent: p.accent } + : { primary: DEFAULT_SEED, accent: DEFAULT_SEED }; +} + +// stop → color-mix instruction against the seed. 600 is the seed itself; lighter +// stops mix toward white, darker toward black — in oklab for a perceptually even ramp. +const LADDER: Array<[number, string | null]> = [ + [50, 'white 90%'], + [100, 'white 80%'], + [200, 'white 62%'], + [300, 'white 42%'], + [400, 'white 22%'], + [500, 'white 9%'], + [600, null], + [700, 'black 15%'], + [800, 'black 30%'], + [900, 'black 45%'], + [950, 'black 63%'] +]; + +function stopValue(seed: string, mix: string | null): string { + return mix ? `color-mix(in oklab, ${seed}, ${mix})` : seed; +} + +function ramp(families: string[], seed: string): string { + let out = ''; + for (const [stop, mix] of LADDER) { + const val = stopValue(seed, mix); + for (const fam of families) out += `--color-${fam}-${stop}:${val};`; + } + return out; +} + +/** + * Build the `:root:root { … }` override for a theme. `:root:root` (specificity 0,2,0) + * beats Tailwind's `:root` `@theme` defaults regardless of stylesheet order, so the + * override wins whether it's injected before (boot) or after the bundle. + * + * Returns '' for the default gold, meaning "no override" → the verbatim hand-tuned + * ramp. Callers treat '' as "remove any injected style". + */ +export function buildPaletteCss(cfg: ThemeConfig): string { + const { primary, accent } = resolveSeeds(cfg); + const isDefault = primary.toLowerCase() === DEFAULT_SEED && accent.toLowerCase() === DEFAULT_SEED; + if (isDefault) return ''; + + const accent500 = stopValue(accent, 'white 9%'); + return ( + ':root:root{' + + ramp(['blue', 'primary'], primary) + + ramp(['purple'], accent) + + // violet + accent aliases are only used at 500/600 in the app + `--color-violet-500:${accent500};--color-violet-600:${accent};` + + `--color-accent-500:${accent500};--color-accent-600:${accent};` + + '}' + ); +} + +const STYLE_ID = 'es-theme'; + +/** Inject/replace/remove the theme override `