diff --git a/web/src/app.css b/web/src/app.css index 1e95ba1..ef795a3 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -87,7 +87,157 @@ --content-max: 720px; } -:root[data-theme='light'] { +/* + * The dark palette again, by name. + * + * :root carries it as the default, and :root only ever matches — so a + * swatch in the settings picker asking for data-theme="dark" would otherwise + * inherit whatever the page is wearing. Repeated rather than restructured: the + * alternative is splitting the token block in two and leaving somebody to work + * out which half a new token belongs in. + */ +[data-theme='dark'] { + --ground: #111312; + --surface: #171a19; + --surface-raised: #1d211f; + --surface-sunken: #0d0f0e; + --line: #262b29; + --line-strong: #343a37; + + --ink: #e8ebe9; + --ink-muted: #9aa39e; + --ink-faint: #6b736f; + + --accent: #5fbf8f; + --accent-hover: #6fcf9f; + --accent-sunken: #1c3329; + --accent-ink: #0d1411; + + --p1: #cf6b6b; + --p2: #d69f5c; + --p3: #6b9ecf; + --p4: var(--ink-faint); + + --danger: #cf6b6b; + --danger-sunken: #33201f; + + --shadow: 0 1px 2px rgb(0 0 0 / 0.2); + --shadow-lg: 0 8px 32px rgb(0 0 0 / 0.35); +} + +/* + * The other themes. + * + * Each one redefines colour and nothing else: no spacing, no type, no radii. + * That is what keeps them themes rather than five separate designs, and it is + * why a component that reaches for a raw hex breaks in four places at once + * instead of one. + * + * Every theme states its full palette rather than inheriting the dark one and + * patching it. A partial theme looks finished until somebody adds a token, at + * which point four themes silently fall back to a colour from a fifth. + */ + +/* Skumring — the same dark, turned warm, with amber where the green was. */ +[data-theme='dusk'] { + --ground: #14110f; + --surface: #1b1714; + --surface-raised: #221d19; + --surface-sunken: #100d0b; + --line: #2b2521; + --line-strong: #3a322c; + + --ink: #ece6df; + --ink-muted: #a3988c; + /* Light enough to clear 4.5:1 against the ground. The quiet tier is where + dates and counts live, and it is the first thing to disappear. */ + --ink-faint: #8d8377; + + --accent: #d99a4e; + --accent-hover: #e8a95c; + --accent-sunken: #33261480; + --accent-ink: #16110a; + + --p1: #cf6b6b; + --p2: #d69f5c; + --p3: #6b9ecf; + --p4: var(--ink-faint); + + --danger: #cf6b6b; + --danger-sunken: #33201f; + + --shadow: 0 1px 2px rgb(0 0 0 / 0.25); + --shadow-lg: 0 8px 32px rgb(0 0 0 / 0.4); +} + +/* Papir — warm light, closer to paper than to a screen. */ +[data-theme='paper'] { + --ground: #f6f1e7; + --surface: #fffcf6; + --surface-raised: #fffcf6; + --surface-sunken: #ece5d7; + --line: #ded5c3; + --line-strong: #c8bda6; + + --ink: #241f18; + --ink-muted: #665c4c; + --ink-faint: #756a55; + + --accent: #8a5a2b; + --accent-hover: #74491f; + --accent-sunken: #ebdfcb; + --accent-ink: #fffcf6; + + --p1: #b0452f; + --p2: #96701f; + --p3: #3f6b8a; + --p4: var(--ink-faint); + + --danger: #b0452f; + --danger-sunken: #f3e2dc; + + --shadow: 0 1px 2px rgb(60 40 10 / 0.08); + --shadow-lg: 0 8px 32px rgb(60 40 10 / 0.14); +} + +/* + * Kontrast — for reading in bright sun, or for eyes that the quiet greys of the + * other four do not serve. Pure black on white, borders that are actually + * visible, and an accent dark enough to pass against white text. + * + * The muted inks are deliberately much darker than elsewhere: "secondary text" + * at 60% grey is the first thing to disappear, and it is where dates live. + */ +[data-theme='contrast'] { + --ground: #ffffff; + --surface: #ffffff; + --surface-raised: #f2f2f2; + --surface-sunken: #e8e8e8; + --line: #767676; + --line-strong: #1a1a1a; + + --ink: #000000; + --ink-muted: #2b2b2b; + --ink-faint: #4a4a4a; + + --accent: #005c2e; + --accent-hover: #00753a; + --accent-sunken: #cfe8da; + --accent-ink: #ffffff; + + --p1: #a10000; + --p2: #7a4a00; + --p3: #00417a; + --p4: var(--ink-faint); + + --danger: #a10000; + --danger-sunken: #f7dcdc; + + --shadow: 0 1px 2px rgb(0 0 0 / 0.3); + --shadow-lg: 0 8px 32px rgb(0 0 0 / 0.35); +} + +[data-theme='light'] { --ground: #faf9f7; --surface: #ffffff; --surface-raised: #ffffff; diff --git a/web/src/lib/stores.svelte.js b/web/src/lib/stores.svelte.js index 7879a87..9e5c32b 100644 --- a/web/src/lib/stores.svelte.js +++ b/web/src/lib/stores.svelte.js @@ -393,21 +393,65 @@ class SidebarLayout { export const sidebar = new SidebarLayout(); +/** + * The themes on offer. + * + * `dark` is the tokens on bare :root; the rest are [data-theme] blocks in + * app.css. The list lives here so the picker and the stylesheet cannot drift — + * an entry with no CSS behind it renders as the default and looks like a bug in + * the picker rather than a missing block. + */ +export const THEMES = [ + { id: 'dark', name: 'Nordlys', note: 'Mørk med grøn accent.', dark: true }, + { id: 'dusk', name: 'Skumring', note: 'Mørk og varm, med rav.', dark: true }, + { id: 'light', name: 'Dagslys', note: 'Lys og køligt neutral.', dark: false }, + { id: 'paper', name: 'Papir', note: 'Lys og varm, som papir.', dark: false }, + { id: 'contrast', name: 'Kontrast', note: 'Sort på hvidt, til skarpt lys.', dark: false } +]; + /** Theme, kept in localStorage and applied to the document element. */ -export const theme = { - get current() { - if (typeof document === 'undefined') return 'dark'; - return document.documentElement.dataset.theme ?? 'dark'; - }, - toggle() { - const next = this.current === 'dark' ? 'light' : 'dark'; - document.documentElement.dataset.theme = next; +class Theme { + /** + * Mirrors the attribute on , which app.html sets before first paint — + * that is the only way to avoid a white flash on load, and it means the + * document, not this object, is where the truth lives. + */ + current = $state( + typeof document === 'undefined' ? 'dark' : (document.documentElement.dataset.theme ?? 'dark') + ); + + set(id) { + if (!THEMES.some((t) => t.id === id)) return; + this.current = id; + document.documentElement.dataset.theme = id; + + // The browser paints its own chrome from this — the address bar on a + // phone, the frame of an installed PWA. Left alone it stays the colour of + // whichever theme shipped in the markup. + const ground = getComputedStyle(document.documentElement).getPropertyValue('--ground').trim(); + document.querySelector('meta[name="theme-color"]')?.setAttribute('content', ground); + try { - localStorage.setItem('verdande:theme', next); + localStorage.setItem('verdande:theme', id); } catch { // Private browsing; the choice simply will not persist. } } -}; + + /** + * The topbar button, which flips between light and dark rather than walking + * all five. Somebody who has chosen Skumring wants the light one to be Papir, + * not to be marched back to the default — so it moves to the other side of + * the list and stays warm or stays cool. + */ + toggle() { + const now = THEMES.find((t) => t.id === this.current) ?? THEMES[0]; + const opposite = THEMES.filter((t) => t.dark !== now.dark); + const index = THEMES.filter((t) => t.dark === now.dark).indexOf(now); + this.set((opposite[index] ?? opposite[0]).id); + } +} + +export const theme = new Theme(); export { ApiError, humanMessage }; diff --git a/web/src/routes/indstillinger/+page.svelte b/web/src/routes/indstillinger/+page.svelte index be8777f..ce6fbaa 100644 --- a/web/src/routes/indstillinger/+page.svelte +++ b/web/src/routes/indstillinger/+page.svelte @@ -1,7 +1,7 @@