Choose a font in Settings, beside the theme (#359)
Kept as the theme is, in a cookie each browser its own, and put on the page by boot.js before the first frame. Theme's own, the default, adds no rule, so Modern's Inter, Classic's Lucida Grande and Glass's system face are as they were. The others are on the device already or served by ipx (Inter), so choosing one downloads nothing. Their rules are last in app.css, where they tie with each theme's own body font and win. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
// 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.
|
// 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)));
|
missing.splice(0, missing.length, ...missing.filter(sel => !OPTIONAL.has(sel)));
|
||||||
if (missing.length) {
|
if (missing.length) {
|
||||||
console.error('FAIL: handlers wired to elements that do not exist: ' + [...new Set(missing)].join(', '));
|
console.error('FAIL: handlers wired to elements that do not exist: ' + [...new Set(missing)].join(', '));
|
||||||
|
|||||||
@@ -139,6 +139,28 @@ test('each browser keeps its own theme, in a cookie, not on the account', async
|
|||||||
await other.close();
|
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 }) => {
|
test('a theme this browser kept in localStorage becomes its cookie once', async ({ browser }) => {
|
||||||
const who = `theme-${Date.now()}@example.com`;
|
const who = `theme-${Date.now()}@example.com`;
|
||||||
const ctx = await browser.newContext({ extraHTTPHeaders: { 'X-Test-User': who } });
|
const ctx = await browser.newContext({ extraHTTPHeaders: { 'X-Test-User': who } });
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [1.3.0] - 2026-10-23
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -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"] .ephead{background:var(--bg);-webkit-backdrop-filter:none;backdrop-filter:none}
|
||||||
:root[data-theme="glass"] #modal{background:rgba(0,0,0,.6)}
|
: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}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Runs in <head>, before the first frame (#203): this browser's theme, from its cookie, and
|
// Runs in <head>, 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
|
// 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.
|
// 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);
|
(()=>{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];}
|
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{}})();
|
try{if(localStorage.getItem('ipx.admin')==='1')r.dataset.admin='';}catch{}})();
|
||||||
|
|||||||
@@ -448,6 +448,9 @@ async function prefsModal(){
|
|||||||
<select id="smode">${Object.entries(MODES).map(([k,t])=>
|
<select id="smode">${Object.entries(MODES).map(([k,t])=>
|
||||||
`<option value="${k}"${theme.mode===k?' selected':''}>${esc(t)}</option>`).join('')}</select>
|
`<option value="${k}"${theme.mode===k?' selected':''}>${esc(t)}</option>`).join('')}</select>
|
||||||
<span class="hint">Auto follows your system's light/dark setting.</span></div>
|
<span class="hint">Auto follows your system's light/dark setting.</span></div>
|
||||||
|
<div class="field"><label for="sfont">Font</label>
|
||||||
|
<select id="sfont">${Object.entries(FONTS).map(([k,t])=>
|
||||||
|
`<option value="${k}"${font===k?' selected':''}>${esc(t)}</option>`).join('')}</select></div>
|
||||||
<div class="field"><label>DeadAir</label>
|
<div class="field"><label>DeadAir</label>
|
||||||
<span class="hint" id="sdeadsaved">${deadSavedText(S.me)}</span></div>
|
<span class="hint" id="sdeadsaved">${deadSavedText(S.me)}</span></div>
|
||||||
<div class="field"><label>Subscriptions</label>
|
<div class="field"><label>Subscriptions</label>
|
||||||
@@ -488,6 +491,7 @@ async function prefsModal(){
|
|||||||
<div class="field"><span class="hint" id="sversion">${S.me?`iPX ${esc(S.me.version)}, page __WEB_VERSION__`:''}</span></div>`);
|
<div class="field"><span class="hint" id="sversion">${S.me?`iPX ${esc(S.me.version)}, page __WEB_VERSION__`:''}</span></div>`);
|
||||||
$<HTMLSelectElement>('#stheme').onchange=(e: Event)=>setTheme((e.target as HTMLSelectElement).value,undefined,true);
|
$<HTMLSelectElement>('#stheme').onchange=(e: Event)=>setTheme((e.target as HTMLSelectElement).value,undefined,true);
|
||||||
$<HTMLSelectElement>('#smode').onchange=(e: Event)=>setTheme(undefined,(e.target as HTMLSelectElement).value,true);
|
$<HTMLSelectElement>('#smode').onchange=(e: Event)=>setTheme(undefined,(e.target as HTMLSelectElement).value,true);
|
||||||
|
$<HTMLSelectElement>('#sfont').onchange=(e: Event)=>setFont((e.target as HTMLSelectElement).value,true);
|
||||||
$<HTMLButtonElement>('#gopml').onclick=opmlModal;
|
$<HTMLButtonElement>('#gopml').onclick=opmlModal;
|
||||||
drawTokens();
|
drawTokens();
|
||||||
$<HTMLButtonElement>('#tokadd').onclick=async()=>{
|
$<HTMLButtonElement>('#tokadd').onclick=async()=>{
|
||||||
|
|||||||
@@ -52,6 +52,21 @@ function setTheme(name = theme.name, mode = theme.mode, save = false){
|
|||||||
function saveTheme(){
|
function saveTheme(){
|
||||||
document.cookie = `ipx_theme=${theme.name}.${theme.mode}; Path=/; Max-Age=31536000; SameSite=Lax`;
|
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<string, string> = {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 = $<HTMLSelectElement>('#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(); });
|
systemDark?.addEventListener?.('change', () => { if(theme.mode === 'auto') setTheme(); });
|
||||||
(() => {
|
(() => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
|
|||||||
Reference in New Issue
Block a user