From 90bdf4b7252948be2838720d8a88478c57d9fed8 Mon Sep 17 00:00:00 2001 From: Kristian Wind Date: Wed, 19 Aug 2026 17:07:39 +0200 Subject: [PATCH] =?UTF-8?q?feat(look):=20en=20akse=20mere=20=E2=80=94=20hv?= =?UTF-8?q?ordan=20det=20l=C3=A6ses,=20ikke=20hvor=20lyst=20det=20er?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fire udseender ved siden af de fem temaer: Verdande, Rolig (serif, mere luft), Tæt (flere rækker, mindre luft) og Terminal (én bredde til det hele). De ganges ud til tyve udseender fra otte små blokke CSS frem for at kræve en blok hver. To akser, fordi det er to spørgsmål. Den, der vil have en serif om natten, skal ikke opgive den mørke palet for at få den — og prøven holder netop det fast: at vælge en skrift må ikke trække temaet med sig. Udseendet sættes før første optegning, ligesom temaet. Ellers bliver siden sat op i én skrift og derefter i en anden, og det er et synligt spring. Det er også det ærlige svar på at ligne noget andet. Standarden er bevidst genkendelig — det var det, der blev bedt om — men et program med ét muligt udseende har lånt en form, det ikke kan give tilbage. Med udseender er ligheden en indstilling blandt flere frem for det eneste, det kan være. Co-Authored-By: Claude Opus 5 --- web/e2e/smoke.spec.js | 37 ++++++++++++ web/src/app.css | 69 +++++++++++++++++++++++ web/src/app.html | 6 ++ web/src/lib/locales/da.js | 10 ++++ web/src/lib/locales/en.js | 10 ++++ web/src/lib/stores.svelte.js | 39 +++++++++++++ web/src/routes/indstillinger/+page.svelte | 65 ++++++++++++++++++++- 7 files changed, 235 insertions(+), 1 deletion(-) diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index 756cebb..15677c3 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -2045,3 +2045,40 @@ test('en note kan deles med et projekts folk, og tages tilbage igen', async ({ p expect(trouble).toEqual([]); }); + +test('udseendet kan skiftes uafhængigt af temaet, og overlever en genindlæsning', async ({ + page +}) => { + const trouble = watchForTrouble(page); + await page.goto('/indstillinger'); + + const face = () => + page.evaluate(() => getComputedStyle(document.body).fontFamily); + const ground = () => + page.evaluate(() => getComputedStyle(document.body).backgroundColor); + + const startFace = await face(); + await page.getByRole('button', { name: /^Rolig/ }).click(); + const serif = await face(); + expect(serif, 'skriften skiftede ikke').not.toBe(startFace); + expect(serif.toLowerCase()).toContain('serif'); + + // The other axis is untouched: choosing a face must not drag the palette with + // it. That is the whole reason they are two settings and not one list. + const darkGround = await ground(); + await page.getByRole('button', { name: /^Papir/ }).click(); + expect(await ground(), 'temaet skiftede ikke').not.toBe(darkGround); + expect(await face(), 'temaet ændrede skriften').toBe(serif); + + // And it is on before the first paint, not applied afterwards — otherwise the + // page is laid out once in one face and again in another. + await page.reload(); + expect(await face()).toBe(serif); + expect(await page.evaluate(() => document.documentElement.dataset.look)).toBe('rolig'); + + // Back to the default, which carries no attribute at all. + await page.getByRole('button', { name: /^Verdande/ }).click(); + expect(await page.evaluate(() => document.documentElement.dataset.look)).toBeUndefined(); + + expect(trouble).toEqual([]); +}); diff --git a/web/src/app.css b/web/src/app.css index 0b5851f..d30a856 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -444,3 +444,72 @@ button { white-space: nowrap; border: 0; } + +/* --- looks ----------------------------------------------------------------- + * + * A second axis, beside the colour themes. + * + * A theme answers "how bright"; a look answers "what does it feel like to read". + * Typeface, how tight the rows sit, how round the corners are. They are separate + * because they are separate questions: somebody who wants a serif face at night + * should not have to give up the dark palette to get it. + * + * It is also the honest answer to looking like something else. The default is + * deliberately familiar — that is what was asked for — but a program with one + * possible appearance has borrowed a shape it cannot give back. With looks, the + * resemblance is one setting among several rather than the only thing it can be. + */ + +/* Serif, roomier, softer. For reading and writing rather than scanning — which is + what a program with notes in it is half the time. */ +[data-look='rolig'] { + --font: 'Iowan Old Style', 'Palatino Linotype', Palatino, Georgia, 'Times New Roman', serif; + --text-base: 1.0625rem; + --text-sm: 0.9375rem; + --radius: 10px; + --radius-lg: 16px; + --s2: 0.5625rem; + --s3: 0.875rem; + --s4: 1.25rem; +} + +[data-look='rolig'] body { + line-height: 1.65; + letter-spacing: 0.006em; +} + +/* Tighter. More rows on a screen, squarer corners, less air — for a long list + read at a glance rather than a page read a paragraph at a time. */ +[data-look='taet'] { + --text-base: 0.875rem; + --text-sm: 0.78125rem; + --text-xs: 0.6875rem; + --radius: 5px; + --radius-lg: 8px; + --radius-sm: 3px; + --s1: 0.1875rem; + --s2: 0.375rem; + --s3: 0.5625rem; + --s4: 0.75rem; +} + +[data-look='taet'] body { + line-height: 1.4; +} + +/* One face for everything, including the prose. Not a joke setting: a monospaced + list of tasks lines up in a way a proportional one never does, and some people + read code all day and want the same shapes here. */ +[data-look='terminal'] { + --font: var(--font-mono); + --text-base: 0.875rem; + --text-sm: 0.8125rem; + --radius: 3px; + --radius-lg: 4px; + --radius-sm: 2px; +} + +[data-look='terminal'] body { + line-height: 1.5; + letter-spacing: -0.01em; +} diff --git a/web/src/app.html b/web/src/app.html index 3e8fe18..ddd5df8 100644 --- a/web/src/app.html +++ b/web/src/app.html @@ -22,6 +22,12 @@ stored ?? (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'); document.documentElement.dataset.theme = theme; + + // The look changes the typeface and the spacing, so it has to be on + // before first paint too — otherwise the page is laid out once in one + // face and again in another, which is a visible jump. + const look = localStorage.getItem('verdande:look'); + if (look && look !== 'verdande') document.documentElement.dataset.look = look; } catch (e) { // Private browsing can refuse localStorage; the dark default stands. } diff --git a/web/src/lib/locales/da.js b/web/src/lib/locales/da.js index 3659a46..4aac44e 100644 --- a/web/src/lib/locales/da.js +++ b/web/src/lib/locales/da.js @@ -144,6 +144,16 @@ export const da = { 'account.appearance': 'Udseende', 'account.appearanceHint': '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.', + 'account.look': 'Udseende', + 'account.lookHint': 'Temaet siger hvor lyst. Det her siger hvordan det læses — skrift, luft og hjørner. De to kan kombineres frit.', + 'look.verdande': 'Verdande', + 'look.verdandeNote': 'Som du kender den.', + 'look.rolig': 'Rolig', + 'look.roligNote': 'Serif, mere luft. Til at læse og skrive i.', + 'look.taet': 'Tæt', + 'look.taetNote': 'Flere rækker på skærmen, mindre luft.', + 'look.terminal': 'Terminal', + 'look.terminalNote': 'Én bredde til det hele. Alt flugter.', 'account.password': 'Adgangskode', 'account.passwordHint': 'At skifte den logger alle andre sessioner ud — hvilket for det meste er grunden til at gøre det.', diff --git a/web/src/lib/locales/en.js b/web/src/lib/locales/en.js index 500530a..93df44d 100644 --- a/web/src/lib/locales/en.js +++ b/web/src/lib/locales/en.js @@ -140,6 +140,16 @@ export const en = { 'account.appearance': 'Appearance', 'account.appearanceHint': 'Kept in this browser. It is a property of the screen you are sitting at — a laptop in the sun and a monitor in a dark room want different answers.', + 'account.look': 'Appearance', + 'account.lookHint': 'The theme says how bright. This says how it reads — typeface, air and corners. The two combine freely.', + 'look.verdande': 'Verdande', + 'look.verdandeNote': 'As you know it.', + 'look.rolig': 'Calm', + 'look.roligNote': 'Serif, more air. For reading and writing.', + 'look.taet': 'Tight', + 'look.taetNote': 'More rows on screen, less air.', + 'look.terminal': 'Terminal', + 'look.terminalNote': 'One width for everything. It all lines up.', 'account.password': 'Password', 'account.passwordHint': 'Changing it signs every other session out — which is usually the reason to do it.', diff --git a/web/src/lib/stores.svelte.js b/web/src/lib/stores.svelte.js index f1a7d0e..96f0f78 100644 --- a/web/src/lib/stores.svelte.js +++ b/web/src/lib/stores.svelte.js @@ -844,6 +844,45 @@ export const THEMES = [ { id: 'contrast', name: 'theme.contrast', note: 'theme.contrastNote', dark: false } ]; +/** + * The looks: how it reads, as opposed to how bright it is. + * + * Separate from the themes because they are separate questions. Somebody who + * wants a serif face at night should not have to give up the dark palette to get + * one, and the two multiply out to fifteen appearances from eight small blocks of + * CSS rather than needing one block each. + */ +export const LOOKS = [ + { id: 'verdande', name: 'look.verdande', note: 'look.verdandeNote' }, + { id: 'rolig', name: 'look.rolig', note: 'look.roligNote' }, + { id: 'taet', name: 'look.taet', note: 'look.taetNote' }, + { id: 'terminal', name: 'look.terminal', note: 'look.terminalNote' } +]; + +class Look { + current = $state( + typeof document === 'undefined' + ? 'verdande' + : (document.documentElement.dataset.look ?? 'verdande') + ); + + set(id) { + if (!LOOKS.some((l) => l.id === id)) return; + this.current = id; + // The default carries no attribute, so the plain tokens are the plain tokens + // and there is nothing to override them back to. + if (id === 'verdande') delete document.documentElement.dataset.look; + else document.documentElement.dataset.look = id; + try { + localStorage.setItem('verdande:look', id); + } catch { + // Private browsing; the choice simply will not persist. + } + } +} + +export const look = new Look(); + /** Theme, kept in localStorage and applied to the document element. */ class Theme { /** diff --git a/web/src/routes/indstillinger/+page.svelte b/web/src/routes/indstillinger/+page.svelte index 2b7c917..8aa35ef 100644 --- a/web/src/routes/indstillinger/+page.svelte +++ b/web/src/routes/indstillinger/+page.svelte @@ -1,7 +1,7 @@