// Which palette the page paints in. // // Four palettes ship with the page, and the account may publish more as // dev.pdsjs.app.theme records. A published one carries colours by semantic // name, which are set as custom properties on the root; the stylesheet's own // names are the fallback for anything it leaves out. // // A reader's choice is theirs alone: it is remembered in this browser and // never written back to the repo. import { account } from './git.js'; const THEME_COLLECTION = 'dev.pdsjs.app.theme'; const REMEMBERED = 'git-ui:theme'; /** * @typedef {Object} Theme * @property {string} id - the data-theme value, or the record key * @property {string} name - what a picker calls it * @property {string} scheme - 'light' or 'dark' * @property {Record} [colors] - a published theme's own * @property {boolean} [published] - written by the account rather than shipped */ /** * The palettes the stylesheet carries. Following the system is the default, * and the only one that answers to the reader's own light and dark setting. * @type {Theme[]} */ export const BUILT_IN = [ { id: '', name: 'System', scheme: 'system' }, { id: 'light', name: 'Instrument light', scheme: 'light' }, { id: 'dark', name: 'Instrument dark', scheme: 'dark' }, { id: 'github-light', name: 'GitHub light', scheme: 'light', }, { id: 'github', name: 'GitHub dark', scheme: 'dark' }, { id: 'catppuccin-latte', name: 'Catppuccin Latte', scheme: 'light', }, { id: 'catppuccin-mocha', name: 'Catppuccin Mocha', scheme: 'dark', }, { id: 'cyberpunk', name: 'Cyberpunk', scheme: 'dark' }, { id: 'dracula', name: 'Dracula', scheme: 'dark' }, { id: 'tokyo-night', name: 'Tokyo Night', scheme: 'dark' }, ]; /** * The names a published theme may set. A record naming anything else is * ignored rather than trusted: these are the whole of what the page paints * with, and a palette has no business reaching past them. */ const TOKENS = new Set([ 'background', 'card', 'muted', 'accent', 'border', 'input', 'foreground', 'muted-foreground', 'faint', 'primary', 'primary-foreground', 'secondary', 'secondary-foreground', 'accent-foreground', 'destructive', 'destructive-dim', 'success', 'success-dim', 'ring', 'key', 'diff-add', 'diff-add-ink', 'diff-del', 'diff-del-ink', 'diff-meta', 'code-comment', 'code-keyword', 'code-string', 'code-number', 'code-title', 'code-attr', 'code-meta', ]); /** * A colour the page will set. Custom properties are set as values rather * than as text, so a stylesheet cannot be broken open through one; this * keeps a published palette to colours all the same, so a record cannot * point the page at anything it has to fetch. * @param {unknown} value * @returns {boolean} */ function isColour(value) { return ( typeof value === 'string' && value.length <= 64 && /^(#[0-9a-f]{3,8}|(rgb|hsl|oklch|lab|lch)a?\([0-9a-z.,%/\s-]+\))$/i.test( value.trim(), ) ); } /** Themes by id, filled by loadThemes() and read by the picker. */ /** @type {Map} */ const known = new Map(BUILT_IN.map((theme) => [theme.id, theme])); let current = read(); /** @type {Set<() => void>} */ const listeners = new Set(); function read() { try { return localStorage.getItem(REMEMBERED) ?? ''; } catch { // A page that cannot read storage follows the system. return ''; } } /** * How saturated a colour is, 0 to 1. Only the spread matters here, not the * exact figure, so this reads the hex the page is painting with rather than * reaching for a colour library. * @param {string} colour */ function saturation(colour) { const hex = colour.trim().replace('#', ''); if (hex.length < 6) return 0; const [r, g, b] = [0, 2, 4].map((at) => { return Number.parseInt(hex.slice(at, at + 2), 16) / 255; }); const high = Math.max(r, g, b); const low = Math.min(r, g, b); if (high === low) return 0; const light = (high + low) / 2; return (high - low) / (light > 0.5 ? 2 - high - low : high + low); } /** * Whether the palette in force has hue to spend. A language gauge asks this * to decide between linguist's colour per language and one ink stepped back * by rank; anything else that wants to colour a set of things can ask the * same. * * The palette answers rather than declares. A theme that said it had colour * and did not would leave a gauge unreadable, and the colours it paints with * are the honest account of it. The key is what the page reaches for first * when it wants to stand something out, so it is the one to ask. * @returns {boolean} */ export function hasHue() { const key = getComputedStyle(document.documentElement).getPropertyValue( '--key', ); return saturation(key) > 0.25; } /** The theme in force, as a whole record rather than an id. */ export function theme() { return known.get(current) ?? BUILT_IN[0]; } /** @param {() => void} listener */ export function onTheme(listener) { listeners.add(listener); return () => listeners.delete(listener); } export function themeId() { return current; } /** Every palette a reader may pick, shipped and published alike. */ export function themes() { return [...known.values()]; } /** * Paint the page in one palette. A shipped one is a name the stylesheet * answers to; a published one is that name plus its own colours, set over * the top. * @param {string} id */ export function applyTheme(id) { const root = document.documentElement; // The choice is remembered as given, even where nothing answers to it yet: // a palette the account publishes is unknown until its record is read, and // forgetting the name here would lose the reader's pick every reload. current = id ?? ''; const chosen = known.get(current) ?? BUILT_IN[0]; for (const token of TOKENS) root.style.removeProperty(`--${token}`); root.style.removeProperty('color-scheme'); if (chosen.id) root.dataset.theme = chosen.id; else delete root.dataset.theme; if (chosen.published) { // The record names no stylesheet block, so it starts from the shipped // palette that runs the same way round and paints its own over it. root.dataset.theme = chosen.scheme === 'light' ? 'light' : 'dark'; root.style.setProperty('color-scheme', chosen.scheme); for (const [token, colour] of Object.entries(chosen.colors ?? {})) { if (TOKENS.has(token) && isColour(colour)) { root.style.setProperty(`--${token}`, colour.trim()); } } } try { if (current) localStorage.setItem(REMEMBERED, current); else localStorage.removeItem(REMEMBERED); } catch { // A page that cannot write storage still paints; it just forgets. } for (const listener of listeners) listener(); } /** * The palettes the account publishes, added to the ones the page ships. * Every failure reads as none: a theme is decoration, and a reader who * cannot have one should still get the page. * @returns {Promise} */ export async function loadThemes() { try { const params = new URLSearchParams({ repo: account.did, collection: THEME_COLLECTION, limit: '50', }); const res = await fetch( `${account.pds}/xrpc/com.atproto.repo.listRecords?${params}`, ); if (!res.ok) return []; const rows = /** @type {{records?: {uri: string, value: Record}[]}} */ ( await res.json() ).records; /** @type {Theme[]} */ const published = []; for (const row of rows ?? []) { const id = row.uri.split('/').pop(); const value = row.value ?? {}; if (!id || typeof value.name !== 'string') continue; published.push({ id, name: value.name, scheme: value.scheme === 'light' ? 'light' : 'dark', colors: /** @type {Record} */ (value.colors ?? {}), published: true, }); } for (const one of published) known.set(one.id, one); // A reader whose remembered choice is one of these was looking at the // shipped fallback until now. if (published.some((one) => one.id === current)) applyTheme(current); for (const listener of listeners) listener(); return published; } catch { return []; } }