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 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-19 15:21:55 +02:00
parent 6e0a760271
commit 57a907eca5
12 changed files with 765 additions and 99 deletions

View File

@@ -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<tokio::fs::File>,
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 `</` so a caption containing `</script>` can't break out of the tag.
let safe = data_json.replace("</", "<\\/");
let script = format!("<script>window.__EXPORT_DATA__={safe};</script>");
// 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!("<style id=\"es-theme\">{css}</style>"));
}
head_inject.push_str(&format!("<script>window.__EXPORT_DATA__={safe};</script>"));
let injected = match html.find("</head>") {
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<String> {
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")
}