From 5784f1f9557340f91b577c33cb50522688586a13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Asger=20Geel=20Weirs=C3=B8e?= Date: Sun, 14 Jun 2026 15:42:00 +0200 Subject: [PATCH] weirsoe-theme: framework-agnostic dark/light card design system Extracted from asger.weirsoe.dk (src/styles/global.css). Tokens (--color-*) + base layer (glow, selection, smooth theme transition) + .wt-* components + .prose, plus a persisted dark/light toggle (weirsoe-theme.js) and a Bulma adapter (adapters/bulma.css) so existing Bulma sites get the card look without markup changes. Pure CSS, system fonts, no external requests. --- README.md | 75 ++++++++++++++++++++ adapters/bulma.css | 103 +++++++++++++++++++++++++++ examples/demo.html | 47 +++++++++++++ weirsoe-theme.css | 168 +++++++++++++++++++++++++++++++++++++++++++++ weirsoe-theme.js | 54 +++++++++++++++ 5 files changed, 447 insertions(+) create mode 100644 README.md create mode 100644 adapters/bulma.css create mode 100644 examples/demo.html create mode 100644 weirsoe-theme.css create mode 100644 weirsoe-theme.js diff --git a/README.md b/README.md new file mode 100644 index 0000000..04b52f8 --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# weirsoe-theme + +A tiny, framework-agnostic dark/light **design system** — the look from +[asger.weirsøe.dk](https://asger.weirsøe.dk), extracted so it can be reused +across sites. + +- **Card-based**, deep-navy surfaces with a subtle blue/indigo glow. +- **Three palettes**: `dark` (default), `light`, `terminal` (green CRT). +- **Pure CSS**, **system fonts** by default → great TTFB, **no external + requests, no trackers, no build step**. +- Use the **CSS variables** directly, the **`.wt-*` components**, or the + **Bulma adapter** to theme an existing Bulma site without touching markup. + +## Files + +| File | What | +|---|---| +| `weirsoe-theme.css` | Tokens (`--color-*`) + base (glow, selection, transitions) + `.wt-card/.wt-btn/.wt-input/.wt-tag` + `.prose` + theme-toggle styles | +| `weirsoe-theme.js` | Persisted dark⇄light toggle + a no-flash snippet | +| `adapters/bulma.css` | Maps Bulma classes → tokens (for Bulma sites) | +| `examples/demo.html` | Standalone preview | + +## Quick start (vanilla) + +```html + + + + + + + +
+

Et kort

+

Med weirsoe-theme.

+ +
+ +``` + +## Theming an existing Bulma site + +Load the adapter **after** Bulma and **after** `weirsoe-theme.css`: + +```html + + + +``` + +Your existing `.box`, `.button`, `.input`, `.tag`, `.navbar`… are now dark and +card-styled. `wish.weircon.dk` (Christmas-Community fork) consumes it this way. + +## Tokens + +`--color-bg --color-surface --color-surface-2 --color-surface-3 --color-border +--color-text --color-muted --color-accent --color-accent-2 --color-accent-ink +--color-good --color-bad`, plus `--wt-radius --wt-radius-sm --wt-shadow +--wt-shadow-hover --wt-font-sans --wt-font-mono`. + +Override any of them in your own `:root` to re-skin without forking. + +## Theme switching + +Set `data-theme` on ``: `dark` (default), `light`, or `terminal`. +`weirsoe-theme.js` wires any `[data-theme-toggle]` button to cycle dark⇄light and +persists the choice in `localStorage` (`wt-theme`). For a 3-way picker call +`weirsoeTheme.set('terminal')`. + +## Notes + +- Want pixel-exact Inter (like asger.weirsøe.dk)? Self-host + `@fontsource-variable/inter` and set `--wt-font-sans: "Inter Variable", …`. + The default system stack is used to keep TTFB optimal and avoid font requests. +- Source: the design originated in `agw/asger-web` (`src/styles/global.css`). diff --git a/adapters/bulma.css b/adapters/bulma.css new file mode 100644 index 0000000..a6934a9 --- /dev/null +++ b/adapters/bulma.css @@ -0,0 +1,103 @@ +/* ============================================================================ + weirsoe-theme — Bulma adapter. + Maps Bulma's component classes onto the weirsoe-theme tokens so an existing + Bulma site gets the dark card look without rewriting markup. Load AFTER + bulma(swatch) and AFTER weirsoe-theme.css (which defines --color-* tokens). + ========================================================================== */ + +/* Typography */ +.title, h1, h2, h3, h4, strong, .label, label.label { color: var(--color-text) !important; } +.subtitle { color: var(--color-muted) !important; } +.content, p, .navbar-item, td, th, li { color: var(--color-text); } +.has-text-grey, .has-text-grey-light, .is-size-7.has-text-grey { color: var(--color-muted) !important; } +h1.title, .title.is-1, .title.is-2, .title.is-3 { letter-spacing: -0.02em; font-weight: 750; } +a { color: var(--color-accent); } +a:hover { color: var(--color-accent-2); } + +/* Navbar — translucent dark, blurred */ +.navbar, .navbar.is-fixed-top { + background-color: color-mix(in srgb, var(--color-bg) 90%, transparent) !important; + -webkit-backdrop-filter: blur(12px); + backdrop-filter: blur(12px); + border-bottom: 1px solid var(--color-border); + box-shadow: none !important; +} +.navbar-item, .navbar-link { color: var(--color-text) !important; } +.navbar-item:hover, .navbar-link:hover, .navbar-item.is-active { + background-color: color-mix(in srgb, var(--color-text) 6%, transparent) !important; + color: var(--color-accent) !important; +} +.navbar-brand .navbar-item span { font-weight: 700; letter-spacing: -0.01em; } +.navbar-dropdown { + background-color: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--wt-radius-sm); + box-shadow: var(--wt-shadow); +} +.navbar-divider { background-color: var(--color-border); } + +/* Cards */ +.box { + background-color: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--wt-radius); + box-shadow: var(--wt-shadow); + color: var(--color-text); + transition: transform 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease; +} +.box:hover { + border-color: color-mix(in srgb, var(--color-accent) 40%, var(--color-border)); + box-shadow: var(--wt-shadow-hover); + transform: translateY(-2px); +} +.box .box { box-shadow: none; transform: none; } +.notification { border-radius: var(--wt-radius); border: 1px solid var(--color-border); } +hr { background-color: var(--color-border); } + +/* Buttons */ +.button { border-radius: var(--wt-radius-sm); transition: filter 0.15s ease, transform 0.05s ease, background-color 0.15s ease; } +.button:active { transform: translateY(1px); } +.button.is-primary, .button.is-link { background-color: var(--color-accent); border-color: transparent; color: var(--color-accent-ink); font-weight: 650; } +.button.is-primary:hover, .button.is-link:hover { filter: brightness(1.08); color: var(--color-accent-ink); } +.button.is-light, .button:not(.is-primary):not(.is-link):not(.is-danger):not(.is-warning):not(.is-success) { + background-color: var(--color-surface-2); border-color: var(--color-border); color: var(--color-text); +} +.button.is-light:hover { background-color: var(--color-surface-3); color: var(--color-text); } +.button.is-danger.is-light { background-color: #3a1620; color: var(--color-bad); border-color: #5b2230; } +.button.is-warning { background-color: #3a2c10; color: #ffd479; border-color: #5b471d; } +.button.is-success { background-color: #10341f; color: var(--color-good); border-color: #1d5a32; } + +/* Form controls */ +.input, .textarea, .select select { + background-color: var(--color-surface-2); border-color: var(--color-border); + color: var(--color-text); border-radius: var(--wt-radius-sm); +} +.input::placeholder, .textarea::placeholder { color: color-mix(in srgb, var(--color-muted) 80%, transparent); } +.input:focus, .textarea:focus, .select select:focus { + border-color: var(--color-accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 22%, transparent); +} +.select:not(.is-multiple):not(.is-loading)::after { border-color: var(--color-accent); } +.label { font-weight: 600; } + +/* Tags */ +.tag:not(.is-primary):not(.is-link):not(.is-success):not(.is-danger):not(.is-warning) { + background-color: var(--color-surface-2); color: var(--color-text); border: 1px solid var(--color-border); border-radius: 999px; +} +.tag.is-link { background-color: #1b2a44; color: #9ec3ff; } +.tag.is-success { background-color: #10341f; color: var(--color-good); } +.tag.is-danger { background-color: #3a1620; color: var(--color-bad); } + +/* Notifications */ +.notification.is-info.is-light { background-color: #10243a; color: #cfe6ff; border-color: #1d3a5a; } +.notification.is-success.is-light { background-color: #10341f; color: #c8f7d6; border-color: #1d5a32; } +.notification.is-danger.is-light { background-color: #3a1620; color: #ffd0d9; border-color: #5b2230; } +.notification.is-warning.is-light { background-color: #3a2c10; color: #ffe1a8; border-color: #5b471d; } + +/* Media object (list items) */ +.media + .media { border-top: 1px solid var(--color-border); } +.media-left img { border-radius: var(--wt-radius-sm); border: 1px solid var(--color-border); background: #fff; } + +/* Misc */ +details summary { color: var(--color-accent); } +.section { padding-top: 2rem; } diff --git a/examples/demo.html b/examples/demo.html new file mode 100644 index 0000000..cf3c424 --- /dev/null +++ b/examples/demo.html @@ -0,0 +1,47 @@ + + + + + + weirsoe-theme demo + + + + + + +
+
+

weirsoe-theme

+ +
+ +
+ + + + tag + +
+ +
+

Ønske

Et kort med hover-løft.

+

Liste

Kort-baseret layout.

+

Event

Genbrugeligt tema.

+
+
+ + diff --git a/weirsoe-theme.css b/weirsoe-theme.css new file mode 100644 index 0000000..ff40e7f --- /dev/null +++ b/weirsoe-theme.css @@ -0,0 +1,168 @@ +/* ============================================================================ + weirsoe-theme — a tiny, framework-agnostic dark/light design system. + Extracted from asger.weirsøe.dk. Pure CSS, system fonts by default (no + external requests → great TTFB, no trackers). Three palettes: dark (default), + light, terminal. Use the CSS variables directly, or the .wt-* components. + + Switch theme by setting an attribute on : + (or "dark" / "terminal"; default is dark) + Pair with weirsoe-theme.js for a persisted toggle + no-flash on load. + ========================================================================== */ + +:root, +:root[data-theme="dark"] { + color-scheme: dark; + --color-bg: #0b0f17; + --color-surface: #121a27; + --color-surface-2: #182232; + --color-surface-3: #1d293b; + --color-border: #243044; + --color-text: #e6e9ef; + --color-muted: #97a3b6; + --color-accent: #38bdf8; + --color-accent-2: #818cf8; + --color-accent-ink: #06121c; + --color-good: #7dffa8; + --color-bad: #ff8ba0; + + --wt-radius: 0.85rem; + --wt-radius-sm: 0.55rem; + --wt-shadow: 0 10px 30px #00000059; + --wt-shadow-hover: 0 18px 44px #0000006e; + --wt-font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, + Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji", sans-serif; + --wt-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + "Liberation Mono", "Courier New", monospace; +} + +:root[data-theme="light"] { + color-scheme: light; + --color-bg: #f6f8fb; + --color-surface: #ffffff; + --color-surface-2: #eef2f8; + --color-surface-3: #e6ecf5; + --color-border: #dde4ee; + --color-text: #0f1729; + --color-muted: #54617a; + --color-accent: #0284c7; + --color-accent-2: #4f46e5; + --color-accent-ink: #ffffff; +} + +:root[data-theme="terminal"] { + color-scheme: dark; + --color-bg: #050a06; + --color-surface: #0a140c; + --color-surface-2: #102012; + --color-surface-3: #16331c; + --color-border: #1d3a22; + --color-text: #4bff7e; + --color-muted: #2fae57; + --color-accent: #7dffa8; + --color-accent-2: #33cc77; + --color-accent-ink: #04130a; +} + +/* ---------- base ---------- */ +html { scroll-behavior: smooth; } +body { + background-color: var(--color-bg); + color: var(--color-text); + font-family: var(--wt-font-sans); +} +/* smooth cross-fade when toggling theme */ +body, header, a, span, div, p, h1, h2, h3, li, ul, input, button, svg, +section, article, .wt-card { + transition: background-color 0.35s ease, border-color 0.35s ease, color 0.35s ease; +} +::selection { background: color-mix(in srgb, var(--color-accent) 35%, transparent); } + +/* subtle top-glow background (sits behind everything) */ +body::before { + content: ""; + position: fixed; inset: 0; z-index: -1; pointer-events: none; + background: + radial-gradient(60rem 30rem at 70% -10%, color-mix(in srgb, var(--color-accent) 12%, transparent), transparent 70%), + radial-gradient(50rem 30rem at 10% 0%, color-mix(in srgb, var(--color-accent-2) 10%, transparent), transparent 70%); +} + +/* ---------- components ---------- */ +.wt-card { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--wt-radius); + box-shadow: var(--wt-shadow); + padding: 1.25rem; +} +.wt-card--hover:hover { + transform: translateY(-2px); + border-color: color-mix(in srgb, var(--color-accent) 45%, var(--color-border)); + box-shadow: var(--wt-shadow-hover); +} +.wt-btn { + display: inline-flex; align-items: center; gap: 0.5rem; + border-radius: var(--wt-radius-sm); + border: 1px solid var(--color-border); + background: var(--color-surface-2); + color: var(--color-text); + padding: 0.5rem 0.9rem; font: inherit; cursor: pointer; + transition: filter 0.15s, background-color 0.15s, border-color 0.15s; +} +.wt-btn:hover { border-color: color-mix(in srgb, var(--color-accent) 50%, var(--color-border)); } +.wt-btn--primary { background: var(--color-accent); border-color: transparent; color: var(--color-accent-ink); font-weight: 650; } +.wt-btn--primary:hover { filter: brightness(1.08); } +.wt-input { + background: var(--color-surface-2); + border: 1px solid var(--color-border); + color: var(--color-text); + border-radius: var(--wt-radius-sm); + padding: 0.5rem 0.7rem; font: inherit; +} +.wt-input::placeholder { color: color-mix(in srgb, var(--color-muted) 80%, transparent); } +.wt-input:focus { outline: none; border-color: var(--color-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 25%, transparent); } +.wt-tag { + display: inline-flex; align-items: center; + padding: 0.15rem 0.6rem; border-radius: 999px; + background: var(--color-surface-2); border: 1px solid var(--color-border); + color: var(--color-text); font-size: 0.8rem; +} +.wt-muted { color: var(--color-muted); } + +/* ---------- theme toggle (sun ⇄ moon), used by weirsoe-theme.js ---------- */ +.theme-toggle { + display: inline-grid; place-items: center; + width: 2.25rem; height: 2.25rem; border-radius: 0.75rem; + color: var(--color-muted); background: transparent; cursor: pointer; + border: 1px solid transparent; +} +.theme-toggle:hover { color: var(--color-text); background: var(--color-surface-2); } +.theme-toggle:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; } +.sun-and-moon > :is(.moon, .sun, .sun-beams) { transform-origin: center; } +.sun-and-moon > :is(.moon, .sun) { fill: currentColor; } +.sun-and-moon > .sun-beams { stroke: currentColor; stroke-width: 2px; } +.sun-and-moon > .sun { transition: transform 0.5s cubic-bezier(0.5, 1.25, 0.75, 1.25); } +.sun-and-moon > .sun-beams { transition: transform 0.5s cubic-bezier(0.5, 1.5, 0.75, 1.25), opacity 0.5s cubic-bezier(0.25, 0, 0.3, 1); } +.sun-and-moon .moon > circle { transition: transform 0.25s cubic-bezier(0, 0, 0, 1); } +@supports (cx: 1) { .sun-and-moon .moon > circle { transition: cx 0.25s cubic-bezier(0, 0, 0, 1); } } +[data-theme="dark"] .sun-and-moon > .sun, [data-theme="terminal"] .sun-and-moon > .sun { transform: scale(1.75); transition-timing-function: cubic-bezier(0.25, 0, 0.3, 1); transition-duration: 0.25s; } +[data-theme="dark"] .sun-and-moon > .sun-beams, [data-theme="terminal"] .sun-and-moon > .sun-beams { opacity: 0; transform: rotateZ(-25deg); transition-duration: 0.15s; } +[data-theme="dark"] .sun-and-moon > .moon > circle, [data-theme="terminal"] .sun-and-moon > .moon > circle { transform: translateX(-7px); transition-delay: 0.25s; transition-duration: 0.5s; } +@supports (cx: 1) { [data-theme="dark"] .sun-and-moon > .moon > circle, [data-theme="terminal"] .sun-and-moon > .moon > circle { cx: 17; transform: translateX(0); } } +@media (prefers-reduced-motion: reduce) { .sun-and-moon * { transition: none !important; } } + +/* ---------- markdown prose ---------- */ +.prose { color: var(--color-text); line-height: 1.75; font-size: 1.05rem; } +.prose > * + * { margin-top: 1.1em; } +.prose h2 { font-size: 1.6rem; font-weight: 700; margin-top: 2em; letter-spacing: -0.01em; } +.prose h3 { font-size: 1.25rem; font-weight: 700; margin-top: 1.6em; } +.prose a { color: var(--color-accent); text-decoration: underline; text-underline-offset: 3px; } +.prose code { background: var(--color-surface-2); padding: 0.15em 0.4em; border-radius: 0.35rem; font-size: 0.9em; font-family: var(--wt-font-mono); } +.prose pre { background: var(--color-surface-2); border: 1px solid var(--color-border); border-radius: 0.75rem; padding: 1rem 1.1rem; overflow-x: auto; } +.prose pre code { background: none; padding: 0; } +.prose blockquote { border-left: 3px solid var(--color-accent); padding-left: 1rem; color: var(--color-muted); font-style: italic; } +.prose ul { list-style: disc; padding-left: 1.4em; } +.prose ol { list-style: decimal; padding-left: 1.4em; } +.prose img { border-radius: 0.75rem; border: 1px solid var(--color-border); } +.prose hr { border-color: var(--color-border); } +.prose table { width: 100%; border-collapse: collapse; } +.prose th, .prose td { border: 1px solid var(--color-border); padding: 0.5rem 0.7rem; text-align: left; } diff --git a/weirsoe-theme.js b/weirsoe-theme.js new file mode 100644 index 0000000..0a65a00 --- /dev/null +++ b/weirsoe-theme.js @@ -0,0 +1,54 @@ +/* weirsoe-theme.js — persisted theme toggle for weirsoe-theme.css. + * + * 1) Prevent a flash of the wrong theme: put this MINIMAL snippet inline in + * , BEFORE any stylesheet (it runs pre-paint): + * + * + * + * 2) Load this file (defer is fine) and drop a toggle button somewhere: + * + * + * + * The toggle cycles dark -> light (add "terminal" yourself if you want a 3-way). + */ +(function () { + var KEY = 'wt-theme'; + var root = document.documentElement; + + function set(theme) { + root.dataset.theme = theme; + try { localStorage.setItem(KEY, theme); } catch (e) { /* ignore */ } + } + + // Ensure a theme is set even if the inline no-flash snippet was omitted. + if (!root.dataset.theme) { + var saved; + try { saved = localStorage.getItem(KEY); } catch (e) { saved = null; } + root.dataset.theme = saved || 'dark'; + } + + function wire() { + document.querySelectorAll('[data-theme-toggle]').forEach(function (btn) { + btn.addEventListener('click', function () { + set(root.dataset.theme === 'dark' ? 'light' : 'dark'); + }); + }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', wire); + } else { + wire(); + } + + // Expose a tiny API for custom controls (e.g. a 3-way picker). + window.weirsoeTheme = { set: set, get: function () { return root.dataset.theme; } }; +})();