feat(theme): five themes, and a picker that shows them
Release / Build and publish image (push) Failing after 9m58s
Release / Build and publish image (push) Failing after 9m58s
Two themes and a toggle between them. Now five, chosen from a grid under Settings → Konto: the two that existed, a warm dark (Skumring) with amber where the green was, a warm light (Papir), and a high-contrast one. Each swatch is painted in its own theme rather than described, so the choice is made by looking. data-theme sits on the swatch and not on the card — it redefines --ink too, and a card wearing a dark theme's ink on a light page has a name nobody can read. Every theme states its full palette rather than inheriting and patching. A partial theme looks finished until somebody adds a token, at which point four themes silently fall back to a colour from a fifth. That is also why the dark palette is repeated under [data-theme='dark']: :root carries it as the default and :root only ever matches <html>, so a swatch asking for it by name would otherwise show whatever the page was wearing. The theme-color meta is updated too, so an installed PWA's frame and a phone's address bar follow rather than staying whichever colour shipped in the markup. Contrast measured for all five. The new ones clear 4.5:1 down to the quietest tier — which is where dates and counts live, and the first thing to disappear. Kontrast reaches 8.9:1 there. The two original themes are left as they are: their faint ink sits at 3.1–3.8, which is worth revisiting, but not as a side effect of adding colours. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
committed by
Kristian Wind
co-authored by
Claude Opus 5
parent
f15c440942
commit
1529c87231
+151
-1
@@ -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 <html> — 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;
|
||||
|
||||
@@ -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 <html>, 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 };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
/** Konto: who you are, your password, and the second factor. */
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { app, theme, THEMES } from '$lib/stores.svelte.js';
|
||||
|
||||
// --- profile ---------------------------------------------------------------
|
||||
|
||||
@@ -204,6 +204,39 @@
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>Udseende</h2>
|
||||
<p class="hint">
|
||||
Gemmes i denne browser. Det er en egenskab ved den skærm, du sidder ved —
|
||||
en bærbar i solen og en skærm i et mørkt rum vil have hvert sit svar.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="themes">
|
||||
{#each THEMES as option (option.id)}
|
||||
<button
|
||||
class="theme-card"
|
||||
class:chosen={theme.current === option.id}
|
||||
onclick={() => theme.set(option.id)}
|
||||
aria-pressed={theme.current === option.id}
|
||||
>
|
||||
<!-- data-theme goes on the swatch and not on the card: it redefines
|
||||
--ink too, and a card wearing a dark theme's ink on a light page
|
||||
has a name nobody can read. The swatch shows the colours; the
|
||||
label stays in the page's. -->
|
||||
<span class="swatch" data-theme={option.id} aria-hidden="true">
|
||||
<span class="swatch-line"></span>
|
||||
<span class="swatch-line short"></span>
|
||||
<span class="swatch-dot"></span>
|
||||
</span>
|
||||
<span class="theme-name">{option.name}</span>
|
||||
<span class="theme-note">{option.note}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>Adgangskode</h2>
|
||||
@@ -353,6 +386,82 @@
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.themes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: var(--s3);
|
||||
}
|
||||
|
||||
.theme-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--s1);
|
||||
padding: var(--s3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
text-align: left;
|
||||
transition: border-color var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.theme-card:hover {
|
||||
border-color: var(--line-strong);
|
||||
}
|
||||
|
||||
.theme-card.chosen {
|
||||
border-color: var(--accent);
|
||||
box-shadow: inset 0 0 0 1px var(--accent);
|
||||
}
|
||||
|
||||
/* Painted in the card's own theme rather than the page's — that is the whole
|
||||
point of putting data-theme on the button. */
|
||||
.swatch {
|
||||
width: 100%;
|
||||
height: 54px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--ground);
|
||||
border: 1px solid var(--line);
|
||||
padding: var(--s2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
margin-bottom: var(--s1);
|
||||
}
|
||||
|
||||
.swatch-line {
|
||||
height: 5px;
|
||||
width: 70%;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--ink-muted);
|
||||
}
|
||||
|
||||
.swatch-line.short {
|
||||
width: 45%;
|
||||
background: var(--ink-faint);
|
||||
}
|
||||
|
||||
.swatch-dot {
|
||||
position: absolute;
|
||||
right: var(--s2);
|
||||
top: var(--s2);
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.theme-name {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.theme-note {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.secret {
|
||||
margin: 0;
|
||||
padding: var(--s3);
|
||||
|
||||
Reference in New Issue
Block a user