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>
99 lines
5.7 KiB
TypeScript
99 lines
5.7 KiB
TypeScript
/* ---------------- theme ---------------- */
|
|
// A theme, and for those that come in both, light, dark or Auto, chosen in Settings and kept in a
|
|
// cookie, so each device has its own: Glass on a phone, Dracula on a desktop (issue #69). The
|
|
// server reads it and writes it onto the page's <html> tag (data-theme, data-choice) so the page
|
|
// is drawn in it from the start. The
|
|
// page gets data-mode, light or dark, which is all the CSS reads: Auto is worked out here, from
|
|
// the system, so no palette is written twice.
|
|
const THEMES: Record<string, {name: string, modes: boolean}> = {
|
|
adwaita: {name: 'Adwaita', modes: true},
|
|
catppuccin: {name: 'Catppuccin', modes: true},
|
|
classic: {name: 'Classic', modes: false},
|
|
dracula: {name: 'Dracula', modes: true},
|
|
flatremix: {name: 'Flat Remix', modes: true},
|
|
glass: {name: 'Glass', modes: true},
|
|
gruvbox: {name: 'Gruvbox', modes: true},
|
|
contrast: {name: 'High contrast', modes: true},
|
|
material: {name: 'Material', modes: true},
|
|
modern: {name: 'Modern', modes: true},
|
|
nordic: {name: 'Nordic', modes: true},
|
|
paper: {name: 'Paper', modes: false},
|
|
solarized: {name: 'Solarized', modes: true},
|
|
};
|
|
const MODES: Record<string, string> = {auto: 'Auto (matches your system)', light: 'Light', dark: 'Dark'};
|
|
// Before themes came in light and dark, ipx.theme in localStorage held one of these.
|
|
const OLD_THEMES: Record<string, [string, string]> = {dark: ['modern', 'dark'], light: ['modern', 'light'], auto: ['modern', 'auto']};
|
|
const systemDark = window.matchMedia?.('(prefers-color-scheme: dark)');
|
|
const theme = {name: 'modern', mode: 'dark'};
|
|
|
|
/// `save` for a choice made in Settings, which goes to this browser's cookie; not for applying one.
|
|
function setTheme(name = theme.name, mode = theme.mode, save = false){
|
|
theme.name = THEMES[name] ? name : 'modern';
|
|
theme.mode = MODES[mode] ? mode : 'dark';
|
|
const both = THEMES[theme.name].modes;
|
|
const root = document.documentElement;
|
|
root.dataset.theme = theme.name;
|
|
// A theme with one palette has it whatever the mode; both of those are light.
|
|
root.dataset.mode = !both ? 'light'
|
|
: theme.mode === 'auto' ? (systemDark && !systemDark.matches ? 'light' : 'dark') : theme.mode;
|
|
// The tab's icon in the same variant as the logo on the page.
|
|
const fav = $<HTMLLinkElement>('#favicon'); if(fav) fav.href = fav.dataset[root.dataset.mode!]!;
|
|
// The top bar's colour for an iPhone before iOS 26 to paint behind the status bar, as Android's
|
|
// toolbar does; the page's where the bar is see-through (Glass). iOS 26 reads the bar itself.
|
|
const tc = $<HTMLMetaElement>('meta[name=theme-color]'), bar = $('#topbar');
|
|
if(tc){ const c = bar ? getComputedStyle(bar).backgroundColor : ''; tc.content = /^rgb\(/.test(c) ? c : getComputedStyle(root).backgroundColor; }
|
|
const sel = $<HTMLSelectElement>('#stheme'); if(sel) sel.value = theme.name;
|
|
const ms = $<HTMLSelectElement>('#smode'); if(ms) ms.value = theme.mode;
|
|
const mf = $('#smodefield'); if(mf) mf.hidden = !both;
|
|
if(save) saveTheme();
|
|
}
|
|
|
|
/// Kept a year, for the whole site: the admin page is drawn in it too.
|
|
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). The default is
|
|
// what each theme sets, Lucida Grande for Classic and the system's for Glass. The reading fonts are
|
|
// served by ipx from /fonts, and a browser fetches one only once it is chosen.
|
|
const FONTS: Record<string, string> = {
|
|
theme: 'Default (set by the theme)', inter: 'Inter', system: 'System',
|
|
atkinson: 'Atkinson Hyperlegible', lexend: 'Lexend', literata: 'Literata', source: 'Source Serif',
|
|
dyslexic: 'OpenDyslexic', serif: "Serif (your device's)", mono: 'Monospace',
|
|
};
|
|
// 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){
|
|
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`;
|
|
}
|
|
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);
|
|
setReadingFont(document.documentElement.dataset.rfont);
|
|
|
|
systemDark?.addEventListener?.('change', () => { if(theme.mode === 'auto') setTheme(); });
|
|
(() => {
|
|
const root = document.documentElement;
|
|
// The head script put this browser's theme from its cookie on the page. Without one, /api/me's
|
|
// answer brings the account's from before (dialogs.ts).
|
|
if(root.dataset.choice) return setTheme(root.dataset.theme, root.dataset.choice);
|
|
// Nothing on the account either. A theme this browser kept in localStorage, from before that,
|
|
// becomes its cookie, once.
|
|
let name: string | null = null, mode: string | null = null;
|
|
try{ name = localStorage.getItem('ipx.theme'); mode = localStorage.getItem('ipx.mode'); }catch{}
|
|
if(name&&OLD_THEMES[name]) [name, mode] = OLD_THEMES[name];
|
|
setTheme(name ?? undefined, mode ?? undefined, !!name);
|
|
})();
|