diff --git a/tests/page-smoke.js b/tests/page-smoke.js index 98b0319..b243729 100644 --- a/tests/page-smoke.js +++ b/tests/page-smoke.js @@ -82,7 +82,7 @@ if (PAGE === 'index.html') { // theme.ts keeps the Settings theme controls in step when Settings is open, and looks before it // touches them. The admin page has no Settings, so those are the ones it may ask for and not find. -const OPTIONAL = new Set(['#stheme', '#smode', '#smodefield', '#sfont']); +const OPTIONAL = new Set(['#stheme', '#smode', '#smodefield', '#sfont', '#srfont']); missing.splice(0, missing.length, ...missing.filter(sel => !OPTIONAL.has(sel))); if (missing.length) { console.error('FAIL: handlers wired to elements that do not exist: ' + [...new Set(missing)].join(', ')); diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index 6467145..2830898 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -171,6 +171,31 @@ test('a font chosen in Settings is kept in this browser, over any theme\'s own', expect(await face()).toMatch(/^Inter/); }); +test('the reading font is an item\'s title and text, and the interface keeps its own', async ({ page }) => { + const fam = (sel) => page.locator(sel).first().evaluate(el => getComputedStyle(el).fontFamily); + await page.locator('.feed', { hasText: 'Picture Blog' }).click(); + await page.locator('.ep', { hasText: 'An Article' }).click(); + await expect(page.locator('#detail .dt')).toHaveText('An Article'); + await page.locator('#prefs').click(); + await page.locator('#sfont').selectOption('atkinson'); + await expect(page.locator('#srfont'), 'the same as the interface until chosen').toHaveValue('same'); + expect(await fam('#detail .dbody')).toMatch(/^"Atkinson/); + + await page.locator('#srfont').selectOption('literata'); + expect((await page.context().cookies()).find(c => c.name === 'ipx_rfont')?.value).toBe('literata'); + expect(await fam('#detail .dt')).toMatch(/^Literata/); + expect(await fam('#detail .dbody')).toMatch(/^Literata/); + expect(await fam('#feedlist')).toMatch(/^"Atkinson/); + // A serif gets more room between its lines (1.75 of the text's size, against 1.65). + const lh = await page.locator('#detail .dbody').evaluate(el => { const c = getComputedStyle(el); return parseFloat(c.lineHeight) / parseFloat(c.fontSize); }); + expect(lh).toBeCloseTo(1.75, 2); + + await page.locator('#srfont').selectOption('same'); + await page.locator('#sfont').selectOption('theme'); + expect(await fam('#detail .dbody')).toMatch(/^Inter/); + await page.locator('#modal .cardx').click(); +}); + test('a theme this browser kept in localStorage becomes its cookie once', async ({ browser }) => { const who = `theme-${Date.now()}@example.com`; const ctx = await browser.newContext({ extraHTTPHeaders: { 'X-Test-User': who } }); diff --git a/web/CHANGELOG.md b/web/CHANGELOG.md index c2a57d8..e99914e 100644 --- a/web/CHANGELOG.md +++ b/web/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- A font of your choosing in Settings, beside the theme, kept by each browser as the theme is: Inter, your system's, the reading fonts Atkinson Hyperlegible, Lexend, Literata, Source Serif and OpenDyslexic, your device's serif, or monospace. The default keeps each theme's face as it was (#359). +- Fonts of your choosing in Settings, beside the theme, kept by each browser as the theme is: one for the interface and one for reading an item's title and text, which is the interface's until chosen, with a little more room between the lines in a serif. Inter, your system's, the reading fonts Atkinson Hyperlegible, Lexend, Literata, Source Serif and OpenDyslexic, your device's serif, or monospace. The default keeps each theme's face as it was (#359). ## [1.3.0] - 2026-10-23 diff --git a/web/app.css b/web/app.css index cda4326..4dcc534 100644 --- a/web/app.css +++ b/web/app.css @@ -1653,14 +1653,40 @@ body:has(#player.on) #status{padding-bottom:3px} @font-face{font-family:"Source Serif 4";src:url(/fonts/source-serif.woff2) format("woff2");font-weight:200 900;font-display:swap} @font-face{font-family:OpenDyslexic;src:url(/fonts/opendyslexic-400.woff2) format("woff2");font-weight:400;font-display:swap} @font-face{font-family:OpenDyslexic;src:url(/fonts/opendyslexic-700.woff2) format("woff2");font-weight:700;font-display:swap} +/* Each face once, for the interface's font (data-font) and the reading font (data-rfont) both. */ +:root{ + --f-inter:Inter,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif; + --f-system:-apple-system,BlinkMacSystemFont,system-ui,"Segoe UI",Roboto,"Noto Sans",sans-serif; + --f-atkinson:"Atkinson Hyperlegible Next",system-ui,sans-serif; + --f-lexend:Lexend,system-ui,sans-serif; + --f-literata:Literata,Georgia,serif; + --f-source:"Source Serif 4",Georgia,serif; + --f-dyslexic:OpenDyslexic,system-ui,sans-serif; + --f-serif:Charter,"Iowan Old Style","Bitstream Charter",Georgia,Cambria,"Noto Serif",serif; + --f-mono:ui-monospace,"SF Mono",SFMono-Regular,Menlo,Consolas,"Noto Sans Mono",monospace; +} /* Last in the file: each rule ties with a theme's own body font, and the later one wins. No rule for the default, which leaves each theme as it is. */ -:root[data-font="inter"] body{font-family:Inter,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif} -:root[data-font="system"] body{font-family:-apple-system,BlinkMacSystemFont,system-ui,"Segoe UI",Roboto,"Noto Sans",sans-serif} -:root[data-font="atkinson"] body{font-family:"Atkinson Hyperlegible Next",system-ui,sans-serif} -:root[data-font="lexend"] body{font-family:Lexend,system-ui,sans-serif} -:root[data-font="literata"] body{font-family:Literata,Georgia,serif} -:root[data-font="source"] body{font-family:"Source Serif 4",Georgia,serif} -:root[data-font="dyslexic"] body{font-family:OpenDyslexic,system-ui,sans-serif} -:root[data-font="serif"] body{font-family:Charter,"Iowan Old Style","Bitstream Charter",Georgia,Cambria,"Noto Serif",serif} -:root[data-font="mono"] body{font-family:ui-monospace,"SF Mono",SFMono-Regular,Menlo,Consolas,"Noto Sans Mono",monospace} +:root[data-font="inter"] body{font-family:var(--f-inter)} +:root[data-font="system"] body{font-family:var(--f-system)} +:root[data-font="atkinson"] body{font-family:var(--f-atkinson)} +:root[data-font="lexend"] body{font-family:var(--f-lexend)} +:root[data-font="literata"] body{font-family:var(--f-literata)} +:root[data-font="source"] body{font-family:var(--f-source)} +:root[data-font="dyslexic"] body{font-family:var(--f-dyslexic)} +:root[data-font="serif"] body{font-family:var(--f-serif)} +:root[data-font="mono"] body{font-family:var(--f-mono)} +/* The reading font: an item's title and text. Without one they are in the interface's. */ +:root[data-rfont="inter"] :is(.dt,.dbody){font-family:var(--f-inter)} +:root[data-rfont="system"] :is(.dt,.dbody){font-family:var(--f-system)} +:root[data-rfont="atkinson"] :is(.dt,.dbody){font-family:var(--f-atkinson)} +:root[data-rfont="lexend"] :is(.dt,.dbody){font-family:var(--f-lexend)} +:root[data-rfont="literata"] :is(.dt,.dbody){font-family:var(--f-literata)} +:root[data-rfont="source"] :is(.dt,.dbody){font-family:var(--f-source)} +:root[data-rfont="dyslexic"] :is(.dt,.dbody){font-family:var(--f-dyslexic)} +:root[data-rfont="serif"] :is(.dt,.dbody){font-family:var(--f-serif)} +:root[data-rfont="mono"] :is(.dt,.dbody){font-family:var(--f-mono)} +/* A serif wants more room between its lines than a sans: the text read in one, whether chosen for + reading or the interface's and not overruled, gets it, on a phone too. */ +:root:is([data-rfont="literata"],[data-rfont="source"],[data-rfont="serif"], + [data-font="literata"]:not([data-rfont]),[data-font="source"]:not([data-rfont]),[data-font="serif"]:not([data-rfont])) .dbody{line-height:1.75} diff --git a/web/boot.js b/web/boot.js index 9553c78..63844c4 100644 --- a/web/boot.js +++ b/web/boot.js @@ -4,4 +4,5 @@ (()=>{const r=document.documentElement,c=/(?:^|; )ipx_theme=([a-z]{1,20})\.(light|dark|auto)(?:;|$)/.exec(document.cookie); if(c){r.dataset.theme=c[1];r.dataset.choice=c[2];if(c[2]!=='auto')r.dataset.mode=c[2];} const f=/(?:^|; )ipx_font=([a-z]{1,10})(?:;|$)/.exec(document.cookie);if(f)r.dataset.font=f[1]; +const g=/(?:^|; )ipx_rfont=([a-z]{1,10})(?:;|$)/.exec(document.cookie);if(g)r.dataset.rfont=g[1]; try{if(localStorage.getItem('ipx.admin')==='1')r.dataset.admin='';}catch{}})(); diff --git a/web/src/dialogs.ts b/web/src/dialogs.ts index 1cecf4f..d542fcf 100644 --- a/web/src/dialogs.ts +++ b/web/src/dialogs.ts @@ -448,9 +448,13 @@ async function prefsModal(){ Auto follows your system's light/dark setting. -