From ae7cb563fdbb74d2c80920c5e9ae554bef54c88c Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Sun, 26 Apr 2026 10:33:50 +0200 Subject: [PATCH] feat: add warm-cream light mode with system + manual toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For summer / direct-sunlight visibility. Drives off CSS custom properties so theme switches at runtime; Sass variables alias into the same custom properties so existing partials compile unchanged. - _variables.scss: hoist 18 theme tokens to CSS custom properties (--bg, --surface, --text, --accent, etc.). Sass vars become aliases (e.g. `$bg: var(--bg)`) so existing references keep working - _base.scss: replace single `lighten($accent, 12%)` call with `$accent-hover` (Sass color functions can't operate on var()) - _base.scss: light-mode background — hide #fractal-bg + the blue blueprint grid overlay; render a faint dot-grid on body (engineering paper, calm in sunlight) - _nav.scss: theme-toggle button (sun in dark, moon in light) - base.html: inline bootstrapper in applies saved preference pre-render to avoid flash-of-wrong-theme; toggle button in nav; theme-toggle.js handles click + localStorage persistence - index.html: swap inline #e1e4ed / #808698 hex literals for var(--text) / var(--text-faint) so landing page reads in light mode - blog-page.html: mermaid initialised with theme-matched palette; light variant uses theme: 'default' (rather than 'base') so derived colors don't fall back to dark defaults Light palette: cream paper #f4efe4 background, deep navy #1a2235 text, darkened accent #3a5fce for AA contrast. Resolution rule: data-theme="dark" → dark always data-theme="light" → light always no data-theme + prefers-color-scheme: light → light otherwise → dark Manual toggle persists in localStorage so OS-pref auto-switch stops auto-applying once the user has chosen. Known follow-ups: re-rendering mermaid on toggle (currently requires page reload); templates/docs-styles.html still has hardcoded colors (docs nav is hidden so lower priority); the projects-cube template also got light-mode fixes locally but is still WIP and stays local. Co-Authored-By: Claude Opus 4.7 (1M context) --- sass/_base.scss | 30 ++++++++- sass/_nav.scss | 50 +++++++++++++++ sass/_variables.scss | 129 +++++++++++++++++++++++++++++++-------- static/theme-toggle.js | 35 +++++++++++ templates/base.html | 25 ++++++++ templates/blog-page.html | 107 +++++++++++++++++++++++++------- templates/index.html | 10 +-- 7 files changed, 334 insertions(+), 52 deletions(-) create mode 100644 static/theme-toggle.js diff --git a/sass/_base.scss b/sass/_base.scss index 445fbc2..857035f 100644 --- a/sass/_base.scss +++ b/sass/_base.scss @@ -19,6 +19,34 @@ body { min-height: 100vh; } +// ─── Light mode background ───────────────────────────────────────────── +// In dark mode the animated canvas (#fractal-bg) IS the visual ground. +// In light mode we hide the canvas (its palettes are tuned for dark) +// and render a faint blueprint dot-grid on the body instead — the engineering +// equivalent of paper, calm enough to read in direct sunlight. + +@mixin light-background { + #fractal-bg { display: none; } + + // Hide the blue-tinted blueprint grid overlay too (defined in _background.scss); + // its blue lines were tuned for dark and read poorly on cream. + body::after { display: none; } + + body { + background-color: $bg; + background-image: radial-gradient(circle, var(--grid-dot) 1px, transparent 1.2px); + background-size: 28px 28px; + background-position: 0 0; + background-attachment: fixed; + } +} + +:root[data-theme="light"] { @include light-background; } + +@media (prefers-color-scheme: light) { + :root:not([data-theme="dark"]) { @include light-background; } +} + // Focus indicators for keyboard navigation :focus-visible { outline: 2px solid $accent; @@ -74,7 +102,7 @@ a { transition: color 0.2s; &:hover { - color: lighten($accent, 12%); + color: $accent-hover; } } diff --git a/sass/_nav.scss b/sass/_nav.scss index 628bf36..1f6975e 100644 --- a/sass/_nav.scss +++ b/sass/_nav.scss @@ -47,3 +47,53 @@ border-bottom-color: $accent; } } + +// Theme toggle button — sun in dark mode, moon in light mode. +// The two SVG icons live inside the button; CSS hides whichever is +// not relevant to the current theme so the button silhouette is stable. +.theme-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + margin-left: 0.5rem; + padding: 0; + background: transparent; + border: 1px solid transparent; + border-radius: 6px; + color: $text-dim; + cursor: pointer; + transition: color 0.2s, border-color 0.2s, background 0.2s; + + &:hover { + color: $text; + border-color: $border-subtle; + background: $surface; + } + + &:focus-visible { + color: $text; + border-color: $accent; + } + + svg { + width: 18px; + height: 18px; + } +} + +// Show sun in dark mode (click → switch to light), moon in light mode. +.theme-toggle .icon-sun { display: inline; } +.theme-toggle .icon-moon { display: none; } + +@mixin light-toggle { + .theme-toggle .icon-sun { display: none; } + .theme-toggle .icon-moon { display: inline; } +} + +:root[data-theme="light"] { @include light-toggle; } + +@media (prefers-color-scheme: light) { + :root:not([data-theme="dark"]) { @include light-toggle; } +} diff --git a/sass/_variables.scss b/sass/_variables.scss index 542fefb..2f2d788 100644 --- a/sass/_variables.scss +++ b/sass/_variables.scss @@ -1,33 +1,112 @@ -// Colors — aligned with thrum unified dashboard (style.css) -$bg: #0f1117; -$bg-subtle: #13161f; -$surface: #1a1d27; -$surface-raised: #242836; -$surface-glass: rgba(26, 29, 39, 0.72); -$border: #4a5068; -$border-subtle: #3d4258; -$text: #e1e4ed; -$text-dim: #8b90a0; -$text-faint: #808698; -$accent: #6c8cff; -$accent-glow: rgba(108, 140, 255, 0.12); -$green: #4ade80; -$red: #f87171; -$amber: #fbbf24; -$cyan: #22d3ee; -$purple: #c084fc; - -// Typography — Braille Institute's Atkinson Hyperlegible -$font-sans: 'Atkinson Hyperlegible Next', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; -$font-mono: 'Atkinson Hyperlegible Mono', 'Fira Code', monospace; -$font-size: 16px; +// Theme tokens are CSS custom properties (so they can swap at runtime), +// with Sass variables aliasing into them so the existing `$bg`-style +// references in the rest of the partials keep compiling unchanged. +// +// Dark is the default; light is opt-in via `:root[data-theme="light"]` +// or auto-applied when the user's OS reports `prefers-color-scheme: light` +// AND no explicit `data-theme` was chosen. The manual override (set via +// the nav button + localStorage) always wins. +// +// Note on Sass color functions: `lighten`, `darken`, `mix` etc. cannot +// operate on `var(--token)` because Sass evaluates them at compile time +// while CSS variables resolve at render time. The one place that used +// `lighten` (link hover) has been replaced with a dedicated `--accent-hover`. + +// ─── Dark theme (default) ───────────────────────────────────────────── +:root { + --bg: #0f1117; + --bg-subtle: #13161f; + --surface: #1a1d27; + --surface-raised: #242836; + --surface-glass: rgba(26, 29, 39, 0.72); + --border: #4a5068; + --border-subtle: #3d4258; + --text: #e1e4ed; + --text-dim: #8b90a0; + --text-faint: #808698; + --accent: #6c8cff; + --accent-hover: #8aa3ff; + --accent-glow: rgba(108, 140, 255, 0.12); + --green: #4ade80; + --red: #f87171; + --amber: #fbbf24; + --cyan: #22d3ee; + --purple: #c084fc; + + // Light-mode-only background pattern (no-op in dark mode) + --grid-dot: transparent; + + color-scheme: dark; +} + +// ─── Light theme — warm cream / engineering paper ───────────────────── +// Triggered when the user explicitly chose light, OR when no choice +// was made and the OS prefers light. The manual `data-theme="dark"` +// attribute always shadows this. + +@mixin light-tokens { + --bg: #f4efe4; // cream paper + --bg-subtle: #ebe5d6; + --surface: #fbf7ee; // card body + --surface-raised: #fefcf6; + --surface-glass: rgba(251, 247, 238, 0.88); // mostly opaque — translucency reads poorly on light + --border: #b8ac8c; // warm beige + --border-subtle: #d9d1bd; + --text: #1a2235; // deep navy — high AA on cream + --text-dim: #44516a; + --text-faint: #6b7280; + --accent: #3a5fce; // darkened accent for AA on cream + --accent-hover: #2747ad; + --accent-glow: rgba(58, 95, 206, 0.10); + --green: #15803d; + --red: #b91c1c; + --amber: #a16207; + --cyan: #0e7490; + --purple: #7c3aed; + + --grid-dot: rgba(68, 81, 106, 0.10); + + color-scheme: light; +} + +:root[data-theme="light"] { @include light-tokens; } + +@media (prefers-color-scheme: light) { + :root:not([data-theme="dark"]) { @include light-tokens; } +} + +// ─── Sass aliases that resolve to the live CSS variables ────────────── +// Existing partials reference these via `color: $text` etc. — the +// substitution emits `color: var(--text)` in the output CSS, so theme +// switching works without touching every partial. +$bg: var(--bg); +$bg-subtle: var(--bg-subtle); +$surface: var(--surface); +$surface-raised: var(--surface-raised); +$surface-glass: var(--surface-glass); +$border: var(--border); +$border-subtle: var(--border-subtle); +$text: var(--text); +$text-dim: var(--text-dim); +$text-faint: var(--text-faint); +$accent: var(--accent); +$accent-hover: var(--accent-hover); +$accent-glow: var(--accent-glow); +$green: var(--green); +$red: var(--red); +$amber: var(--amber); +$cyan: var(--cyan); +$purple: var(--purple); + +// ─── Non-theme tokens (sizes, fonts) — stay as plain Sass values ────── +$font-sans: 'Atkinson Hyperlegible Next', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; +$font-mono: 'Atkinson Hyperlegible Mono', 'Fira Code', monospace; +$font-size: 16px; $line-height: 1.65; -// Glass $glass-blur: blur(12px); $glass-radius: 12px; -// Layout $max-width: 1100px; $content-width: 720px; $gap: 20px; diff --git a/static/theme-toggle.js b/static/theme-toggle.js new file mode 100644 index 0000000..1b70fb8 --- /dev/null +++ b/static/theme-toggle.js @@ -0,0 +1,35 @@ +// Theme toggle — clicking the button cycles light ↔ dark and persists +// the choice in localStorage. The bootstrapper in already +// applied the saved preference before render, so this script only +// needs to handle the click and the post-load attribute update. +// +// Resolution order on each click: +// 1. If data-theme is currently "dark" → switch to "light" +// 2. Else (data-theme="light", or unset and OS-prefers-light, or unset +// and OS-prefers-dark) → switch to "dark" +// In other words: click always inverts the *currently rendered* theme, +// then pins that choice in localStorage so the OS preference no longer +// auto-applies. + +(function () { + 'use strict'; + + var STORAGE_KEY = 'pulseengine-theme'; + var btn = document.querySelector('.theme-toggle'); + if (!btn) return; + + function currentTheme() { + var explicit = document.documentElement.getAttribute('data-theme'); + if (explicit === 'light' || explicit === 'dark') return explicit; + if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) { + return 'light'; + } + return 'dark'; + } + + btn.addEventListener('click', function () { + var next = currentTheme() === 'dark' ? 'light' : 'dark'; + document.documentElement.setAttribute('data-theme', next); + try { localStorage.setItem(STORAGE_KEY, next); } catch (e) { /* private mode */ } + }); +})(); diff --git a/templates/base.html b/templates/base.html index 7760351..d4ffaf1 100644 --- a/templates/base.html +++ b/templates/base.html @@ -69,6 +69,21 @@ {% endif %} {% block head_extra %}{% endblock %} + + {# Inline theme bootstrapper — runs before renders so we set + the data-theme attribute (if the user has a saved preference) + without a flash of wrong theme. Auto-detect via prefers-color-scheme + happens in CSS via @media — no JS needed for that path. #} + @@ -84,6 +99,15 @@ {# Docs #} Reports GitHub + @@ -101,5 +125,6 @@ + diff --git a/templates/blog-page.html b/templates/blog-page.html index 12d788b..ba8b113 100644 --- a/templates/blog-page.html +++ b/templates/blog-page.html @@ -67,29 +67,94 @@

{{ page.title }}

{% if page.content is containing("mermaid") %}