diff --git a/tests/page-smoke.js b/tests/page-smoke.js index 65bb2ef..98b0319 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']); +const OPTIONAL = new Set(['#stheme', '#smode', '#smodefield', '#sfont']); 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 2754b11..e2d1ea7 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -139,6 +139,28 @@ test('each browser keeps its own theme, in a cookie, not on the account', async await other.close(); }); +test('a font chosen in Settings is kept in this browser, over any theme\'s own', async ({ page }) => { + // Theme's own is the default and leaves each theme's face as it was (#359). + const face = () => page.evaluate(() => getComputedStyle(document.body).fontFamily); + await page.locator('#prefs').click(); + await page.locator('#stheme').selectOption('classic'); + await expect(page.locator('#sfont')).toHaveValue('theme'); + expect(await face()).toMatch(/^"Lucida Grande"/); + await page.locator('#sfont').selectOption('serif'); + expect(await face()).toMatch(/^Charter/); + expect((await page.context().cookies()).find(c => c.name === 'ipx_font')?.value).toBe('serif'); + + await page.reload({ waitUntil: 'commit' }); + await page.waitForFunction(() => document.body); + expect(await page.evaluate(() => document.documentElement.dataset.font), 'from the first frame').toBe('serif'); + + await expect(page.locator('#feedlist')).toBeVisible(); + await page.locator('#prefs').click(); + await page.locator('#sfont').selectOption('theme'); + await page.locator('#stheme').selectOption('modern'); + expect(await face()).toMatch(/^Inter/); +}); + 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 0ae6e30..b234d2c 100644 --- a/web/CHANGELOG.md +++ b/web/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- A font of your choosing in Settings, beside the theme: Inter, your system's, serif or monospace, kept by each browser as the theme is. Theme's own, the default, keeps each theme's face as it was (#359). + ## [1.3.0] - 2026-10-23 ### Fixed diff --git a/web/app.css b/web/app.css index df9c26c..9d88813 100644 --- a/web/app.css +++ b/web/app.css @@ -1641,3 +1641,11 @@ body:has(#player.on) #status{padding-bottom:3px} :root[data-theme="glass"] .ephead{background:var(--bg);-webkit-backdrop-filter:none;backdrop-filter:none} :root[data-theme="glass"] #modal{background:rgba(0,0,0,.6)} } + +/* ---------- A font chosen in Settings (#359) ---------- */ +/* Last in the file: each rule ties with a theme's own body font, and the later one wins. No rule + for Theme's own, 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="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} diff --git a/web/boot.js b/web/boot.js index 4a7f9ca..9553c78 100644 --- a/web/boot.js +++ b/web/boot.js @@ -1,6 +1,7 @@ -// Runs in , before the first frame (#203): this browser's theme, from its cookie, and +// Runs in , before the first frame (#203): this browser's theme and font (#359), from their cookies, and // whether the last visit here was an admin's, so the link to Admin is there from the start. The // server wrote both into the page while it served it; the page is a static file now. (()=>{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]; try{if(localStorage.getItem('ipx.admin')==='1')r.dataset.admin='';}catch{}})(); diff --git a/web/src/dialogs.ts b/web/src/dialogs.ts index 345413d..1cecf4f 100644 --- a/web/src/dialogs.ts +++ b/web/src/dialogs.ts @@ -448,6 +448,9 @@ async function prefsModal(){ Auto follows your system's light/dark setting. +
+
${deadSavedText(S.me)}
@@ -488,6 +491,7 @@ async function prefsModal(){
${S.me?`iPX ${esc(S.me.version)}, page __WEB_VERSION__`:''}
`); $('#stheme').onchange=(e: Event)=>setTheme((e.target as HTMLSelectElement).value,undefined,true); $('#smode').onchange=(e: Event)=>setTheme(undefined,(e.target as HTMLSelectElement).value,true); + $('#sfont').onchange=(e: Event)=>setFont((e.target as HTMLSelectElement).value,true); $('#gopml').onclick=opmlModal; drawTokens(); $('#tokadd').onclick=async()=>{ diff --git a/web/src/theme.ts b/web/src/theme.ts index 83fab88..9c20ff3 100644 --- a/web/src/theme.ts +++ b/web/src/theme.ts @@ -52,6 +52,21 @@ function setTheme(name = theme.name, mode = theme.mode, save = false){ function saveTheme(){ document.cookie = `ipx_theme=${theme.name}.${theme.mode}; Path=/; Max-Age=31536000; SameSite=Lax`; } +// A font chosen in Settings, kept as the theme is, each browser its own (#359). Theme's own is +// what each theme sets, Lucida Grande for Classic and the system's for Glass; the rest are on the +// device already or served by ipx, so choosing one downloads nothing. +const FONTS: Record = {theme: "Theme's own", inter: 'Inter', system: 'System', serif: 'Serif', mono: 'Monospace'}; +let font = 'theme'; + +function setFont(name = font, save = false){ + font = FONTS[name] ? name : 'theme'; + const root = document.documentElement; + if(font === 'theme') delete root.dataset.font; else root.dataset.font = font; + const sel = $('#sfont'); if(sel) sel.value = font; + if(save) document.cookie = `ipx_font=${font}; Path=/; Max-Age=31536000; SameSite=Lax`; +} +setFont(document.documentElement.dataset.font); + systemDark?.addEventListener?.('change', () => { if(theme.mode === 'auto') setTheme(); }); (() => { const root = document.documentElement;