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 @@