ui: light theme by default (dark is opt-in)

Swaps the role of `:root` (now light) and the data-theme attribute
(now `[data-theme="dark"]`) in cassandra.css, flips the localStorage
fallback from 'dark' to 'light' in base/login/verify templates, and
updates the theme-toggle label and the branding-consistency test
selectors to match.

Existing users with cassandra.theme=dark in localStorage still see
dark — their explicit preference wins. Only first-time visitors and
users with no stored preference shift to light.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Giorgio Gilestro 2026-05-22 21:51:23 +01:00
parent 9e058144ec
commit 89632e9937
6 changed files with 42 additions and 39 deletions

View file

@ -16,10 +16,11 @@ detection test (`tests/test_branding_consistency.py`) parses
`cassandra.css` and asserts every variable matches. Update both or `cassandra.css` and asserts every variable matches. Update both or
neither. neither.
The light theme is the *default* in emails mail clients can't read The light theme is the *default* everywhere dashboard `:root` block,
`localStorage`, so we can't replicate the dashboard's user-toggled auth pages, and emails. Dark is opt-in via the in-app toggle (which
theme. Clients that honour `prefers-color-scheme` get the dark palette sets `data-theme="dark"` on `<html>` and persists in `localStorage`).
via media query. Mail clients that honour `prefers-color-scheme: dark` get the dark
palette via media query.
""" """
from __future__ import annotations from __future__ import annotations

View file

@ -2,23 +2,7 @@
* Mono for data, headers, terminal feel; sans for prose surfaces (log + chat). */ * Mono for data, headers, terminal feel; sans for prose surfaces (log + chat). */
:root { :root {
/* Dark theme (default) */ /* Light theme (default) */
--bg: #0a0e14;
--surface: #11151c;
--surface-2: #161b25;
--border: #2a3142;
--text: #d4dae8; /* lifted from #c0caf5 for readability */
--muted: #8189a1; /* lifted from #565f89 — was unreadably dim */
--dim: #565f89;
--accent: #00d9ff;
--positive: #50fa7b;
--negative: #ff5b5b;
--alert: #ff8a4a;
--warning: #f1fa8c;
--user-bubble-bg: rgba(0, 217, 255, 0.08);
}
[data-theme="light"] {
--bg: #f5f3ec; /* warm off-white, easier on the eyes than pure white */ --bg: #f5f3ec; /* warm off-white, easier on the eyes than pure white */
--surface: #ffffff; --surface: #ffffff;
--surface-2: #efece3; --surface-2: #efece3;
@ -34,6 +18,22 @@
--user-bubble-bg: rgba(14, 116, 144, 0.07); --user-bubble-bg: rgba(14, 116, 144, 0.07);
} }
[data-theme="dark"] {
--bg: #0a0e14;
--surface: #11151c;
--surface-2: #161b25;
--border: #2a3142;
--text: #d4dae8; /* lifted from #c0caf5 for readability */
--muted: #8189a1; /* lifted from #565f89 — was unreadably dim */
--dim: #565f89;
--accent: #00d9ff;
--positive: #50fa7b;
--negative: #ff5b5b;
--alert: #ff8a4a;
--warning: #f1fa8c;
--user-bubble-bg: rgba(0, 217, 255, 0.08);
}
/* Font stacks. Mono for terminal feel; sans for reading. */ /* Font stacks. Mono for terminal feel; sans for reading. */
:root { :root {
--font-mono: 'JetBrains Mono', 'IBM Plex Mono', 'Fira Code', ui-monospace, monospace; --font-mono: 'JetBrains Mono', 'IBM Plex Mono', 'Fira Code', ui-monospace, monospace;
@ -104,8 +104,8 @@ a:hover { text-decoration: underline; }
text-transform: lowercase; text-transform: lowercase;
} }
.theme-toggle:hover { color: var(--accent); border-color: var(--accent); } .theme-toggle:hover { color: var(--accent); border-color: var(--accent); }
.theme-toggle__label::before { content: "◐ dark"; } .theme-toggle__label::before { content: "◐ light"; }
[data-theme="light"] .theme-toggle__label::before { content: "◐ light"; } [data-theme="dark"] .theme-toggle__label::before { content: "◐ dark"; }
/* Tone toggle (segmented control: Novice | Intermediate) */ /* Tone toggle (segmented control: Novice | Intermediate) */
.tone-toggle { .tone-toggle {

View file

@ -8,9 +8,9 @@
<script> <script>
(function() { (function() {
try { try {
var t = localStorage.getItem('cassandra.theme') || 'dark'; var t = localStorage.getItem('cassandra.theme') || 'light';
document.documentElement.dataset.theme = t; document.documentElement.dataset.theme = t;
} catch (e) { document.documentElement.dataset.theme = 'dark'; } } catch (e) { document.documentElement.dataset.theme = 'light'; }
})(); })();
</script> </script>
<link rel="stylesheet" href="{{ url_for('static', path='/css/cassandra.css') }}" /> <link rel="stylesheet" href="{{ url_for('static', path='/css/cassandra.css') }}" />

View file

@ -6,8 +6,8 @@
<title>{{ BRAND_NAME }} · Sign in</title> <title>{{ BRAND_NAME }} · Sign in</title>
<script> <script>
(function() { (function() {
try { document.documentElement.dataset.theme = localStorage.getItem('cassandra.theme') || 'dark'; } try { document.documentElement.dataset.theme = localStorage.getItem('cassandra.theme') || 'light'; }
catch (e) { document.documentElement.dataset.theme = 'dark'; } catch (e) { document.documentElement.dataset.theme = 'light'; }
})(); })();
</script> </script>
<link rel="stylesheet" href="{{ url_for('static', path='/css/cassandra.css') }}" /> <link rel="stylesheet" href="{{ url_for('static', path='/css/cassandra.css') }}" />

View file

@ -6,8 +6,8 @@
<title>{{ BRAND_NAME }} · Verify email</title> <title>{{ BRAND_NAME }} · Verify email</title>
<script> <script>
(function() { (function() {
try { document.documentElement.dataset.theme = localStorage.getItem('cassandra.theme') || 'dark'; } try { document.documentElement.dataset.theme = localStorage.getItem('cassandra.theme') || 'light'; }
catch (e) { document.documentElement.dataset.theme = 'dark'; } catch (e) { document.documentElement.dataset.theme = 'light'; }
})(); })();
</script> </script>
<link rel="stylesheet" href="{{ url_for('static', path='/css/cassandra.css') }}" /> <link rel="stylesheet" href="{{ url_for('static', path='/css/cassandra.css') }}" />

View file

@ -44,17 +44,10 @@ def css_text() -> str:
return CSS_PATH.read_text(encoding="utf-8") return CSS_PATH.read_text(encoding="utf-8")
def test_dark_palette_matches_css(css_text):
css_dark = _extract_vars(css_text, ":root")
for key, expected in branding.DARK.items():
actual = css_dark.get(key)
assert actual == expected.lower(), (
f"DARK[{key!r}] mismatch: branding.py={expected!r} vs css={actual!r}"
)
def test_light_palette_matches_css(css_text): def test_light_palette_matches_css(css_text):
css_light = _extract_vars(css_text, '[data-theme="light"]') # Light is the default — lives in `:root`. Dark is opt-in via the
# data-theme attribute toggle.
css_light = _extract_vars(css_text, ":root")
for key, expected in branding.LIGHT.items(): for key, expected in branding.LIGHT.items():
actual = css_light.get(key) actual = css_light.get(key)
assert actual == expected.lower(), ( assert actual == expected.lower(), (
@ -62,6 +55,15 @@ def test_light_palette_matches_css(css_text):
) )
def test_dark_palette_matches_css(css_text):
css_dark = _extract_vars(css_text, '[data-theme="dark"]')
for key, expected in branding.DARK.items():
actual = css_dark.get(key)
assert actual == expected.lower(), (
f"DARK[{key!r}] mismatch: branding.py={expected!r} vs css={actual!r}"
)
def test_palette_keys_match_between_themes(): def test_palette_keys_match_between_themes():
"""If a colour is defined in dark, it must also be defined in light """If a colour is defined in dark, it must also be defined in light
(and vice versa) otherwise the theme switch leaves elements (and vice versa) otherwise the theme switch leaves elements