An interface font and a reading font in Settings (#359)
The font chosen before is the interface's, under the same cookie, so a choice already made stays as it was. The reading font, ipx_rfont, sets an item's title and text and is the interface's until chosen. Text read in a serif gets a line height of 1.75 for 1.65, whichever setting put it there. Each face's stack is a custom property, used by both. 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', '#sfont']);
|
const OPTIONAL = new Set(['#stheme', '#smode', '#smodefield', '#sfont', '#srfont']);
|
||||||
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(', '));
|
||||||
|
|||||||
@@ -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/);
|
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 }) => {
|
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 } });
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### 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
|
## [1.3.0] - 2026-10-23
|
||||||
|
|
||||||
|
|||||||
44
web/app.css
44
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:"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-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}
|
@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
|
/* 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. */
|
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="inter"] body{font-family:var(--f-inter)}
|
||||||
:root[data-font="system"] body{font-family:-apple-system,BlinkMacSystemFont,system-ui,"Segoe UI",Roboto,"Noto Sans",sans-serif}
|
:root[data-font="system"] body{font-family:var(--f-system)}
|
||||||
:root[data-font="atkinson"] body{font-family:"Atkinson Hyperlegible Next",system-ui,sans-serif}
|
:root[data-font="atkinson"] body{font-family:var(--f-atkinson)}
|
||||||
:root[data-font="lexend"] body{font-family:Lexend,system-ui,sans-serif}
|
:root[data-font="lexend"] body{font-family:var(--f-lexend)}
|
||||||
:root[data-font="literata"] body{font-family:Literata,Georgia,serif}
|
:root[data-font="literata"] body{font-family:var(--f-literata)}
|
||||||
:root[data-font="source"] body{font-family:"Source Serif 4",Georgia,serif}
|
:root[data-font="source"] body{font-family:var(--f-source)}
|
||||||
:root[data-font="dyslexic"] body{font-family:OpenDyslexic,system-ui,sans-serif}
|
:root[data-font="dyslexic"] body{font-family:var(--f-dyslexic)}
|
||||||
:root[data-font="serif"] body{font-family:Charter,"Iowan Old Style","Bitstream Charter",Georgia,Cambria,"Noto Serif",serif}
|
:root[data-font="serif"] body{font-family:var(--f-serif)}
|
||||||
:root[data-font="mono"] body{font-family:ui-monospace,"SF Mono",SFMono-Regular,Menlo,Consolas,"Noto Sans Mono",monospace}
|
: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}
|
||||||
|
|||||||
@@ -4,4 +4,5 @@
|
|||||||
(()=>{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];
|
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{}})();
|
try{if(localStorage.getItem('ipx.admin')==='1')r.dataset.admin='';}catch{}})();
|
||||||
|
|||||||
@@ -448,9 +448,13 @@ 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>
|
<div class="field"><label for="sfont">Interface font</label>
|
||||||
<select id="sfont">${Object.entries(FONTS).map(([k,t])=>
|
<select id="sfont">${Object.entries(FONTS).map(([k,t])=>
|
||||||
`<option value="${k}"${font===k?' selected':''}>${esc(t)}</option>`).join('')}</select></div>
|
`<option value="${k}"${font===k?' selected':''}>${esc(t)}</option>`).join('')}</select></div>
|
||||||
|
<div class="field"><label for="srfont">Reading font</label>
|
||||||
|
<select id="srfont" aria-describedby="srfonthint">${Object.entries(READING_FONTS).map(([k,t])=>
|
||||||
|
`<option value="${k}"${readingFont===k?' selected':''}>${esc(t)}</option>`).join('')}</select>
|
||||||
|
<span class="hint" id="srfonthint">For an item's title and text.</span></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>
|
||||||
@@ -492,6 +496,7 @@ async function prefsModal(){
|
|||||||
$<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);
|
$<HTMLSelectElement>('#sfont').onchange=(e: Event)=>setFont((e.target as HTMLSelectElement).value,true);
|
||||||
|
$<HTMLSelectElement>('#srfont').onchange=(e: Event)=>setReadingFont((e.target as HTMLSelectElement).value,true);
|
||||||
$<HTMLButtonElement>('#gopml').onclick=opmlModal;
|
$<HTMLButtonElement>('#gopml').onclick=opmlModal;
|
||||||
drawTokens();
|
drawTokens();
|
||||||
$<HTMLButtonElement>('#tokadd').onclick=async()=>{
|
$<HTMLButtonElement>('#tokadd').onclick=async()=>{
|
||||||
|
|||||||
@@ -60,7 +60,11 @@ const FONTS: Record<string, string> = {
|
|||||||
atkinson: 'Atkinson Hyperlegible', lexend: 'Lexend', literata: 'Literata', source: 'Source Serif',
|
atkinson: 'Atkinson Hyperlegible', lexend: 'Lexend', literata: 'Literata', source: 'Source Serif',
|
||||||
dyslexic: 'OpenDyslexic', serif: "Serif (your device's)", mono: 'Monospace',
|
dyslexic: 'OpenDyslexic', serif: "Serif (your device's)", mono: 'Monospace',
|
||||||
};
|
};
|
||||||
let font = 'theme';
|
// The reading font is an item's title and text; the same as the interface's until one is chosen.
|
||||||
|
const READING_FONTS: Record<string, string> = {
|
||||||
|
same: 'Same as the interface', ...Object.fromEntries(Object.entries(FONTS).filter(([k]) => k !== 'theme')),
|
||||||
|
};
|
||||||
|
let font = 'theme', readingFont = 'same';
|
||||||
|
|
||||||
function setFont(name = font, save = false){
|
function setFont(name = font, save = false){
|
||||||
font = FONTS[name] ? name : 'theme';
|
font = FONTS[name] ? name : 'theme';
|
||||||
@@ -69,7 +73,15 @@ function setFont(name = font, save = false){
|
|||||||
const sel = $<HTMLSelectElement>('#sfont'); if(sel) sel.value = font;
|
const sel = $<HTMLSelectElement>('#sfont'); if(sel) sel.value = font;
|
||||||
if(save) document.cookie = `ipx_font=${font}; Path=/; Max-Age=31536000; SameSite=Lax`;
|
if(save) document.cookie = `ipx_font=${font}; Path=/; Max-Age=31536000; SameSite=Lax`;
|
||||||
}
|
}
|
||||||
|
function setReadingFont(name = readingFont, save = false){
|
||||||
|
readingFont = READING_FONTS[name] ? name : 'same';
|
||||||
|
const root = document.documentElement;
|
||||||
|
if(readingFont === 'same') delete root.dataset.rfont; else root.dataset.rfont = readingFont;
|
||||||
|
const sel = $<HTMLSelectElement>('#srfont'); if(sel) sel.value = readingFont;
|
||||||
|
if(save) document.cookie = `ipx_rfont=${readingFont}; Path=/; Max-Age=31536000; SameSite=Lax`;
|
||||||
|
}
|
||||||
setFont(document.documentElement.dataset.font);
|
setFont(document.documentElement.dataset.font);
|
||||||
|
setReadingFont(document.documentElement.dataset.rfont);
|
||||||
|
|
||||||
systemDark?.addEventListener?.('change', () => { if(theme.mode === 'auto') setTheme(); });
|
systemDark?.addEventListener?.('change', () => { if(theme.mode === 'auto') setTheme(); });
|
||||||
(() => {
|
(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user