pyrtan / src / pyrtan / js / theme.js
11,071 bytes · last changed 2026-10-27 in Lowercase the wordmark, and let tan's picker set light, dark or auto mode
| 1 | // The wordmark and its two colours. "pyr" is the primary colour (buttons, |
| 2 | // links, the current tab); "tan" is the secondary (focus rings, hover edges, |
| 3 | // selected text), and its picker also sets the mode: light, dark, or auto to |
| 4 | // follow the system. Click either half of the wordmark, right-click it, or |
| 5 | // press and hold it on a touch screen to open that half's picker. The choice |
| 6 | // is kept in this browser only, under this origin, and applies to every |
| 7 | // Pyrtan page served from it. |
| 8 | // |
| 9 | // A page gives the defaults as --pyr, --pyr-fg, --tan and --tan-fg on :root, |
| 10 | // with its dark values both under prefers-color-scheme (for |
| 11 | // :root:not([data-theme=light])) and under :root[data-theme=dark]; marks the |
| 12 | // halves with data-theme-part="pyr" and "tan"; and loads this script in |
| 13 | // <head> so a saved choice applies before first paint. |
| 14 | (() => { |
| 15 | const KEY = "pyrtan.theme"; |
| 16 | const PARTS = { |
| 17 | pyr: {label: "pyr", role: "primary", presets: [["ember", "#c2410c"], ["crimson", "#c92a2a"], ["rose", "#c2255c"], ["violet", "#7048e8"], ["blue", "#2b5db8"], ["teal", "#0c8599"], ["green", "#2f9e44"], ["slate", "#495057"]]}, |
| 18 | tan: {label: "tan", role: "secondary", presets: [["tan", "#c19a6b"], ["sand", "#d2b48c"], ["copper", "#b87333"], ["clay", "#a0522d"], ["gold", "#d4a017"], ["khaki", "#b5a642"], ["sage", "#8a9a5b"], ["steel", "#7d8ea3"]]}, |
| 19 | }; |
| 20 | const MODES = [["auto", "Auto"], ["light", "Light"], ["dark", "Dark"]]; |
| 21 | const root = document.documentElement; |
| 22 | const load = () => { try { return JSON.parse(localStorage.getItem(KEY) || "{}") || {}; } catch (e) { return {}; } }; |
| 23 | const save = (t) => { try { if (Object.keys(t).length) localStorage.setItem(KEY, JSON.stringify(t)); else localStorage.removeItem(KEY); } catch (e) {} }; |
| 24 | const valid = (c) => typeof c === "string" && /^#[0-9a-f]{6}$/i.test(c); |
| 25 | // Text on a filled background of colour c: near-black on light colours, white on dark ones. |
| 26 | function ink(c) { |
| 27 | const lin = (i) => { const v = parseInt(c.slice(i, i + 2), 16) / 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }; |
| 28 | return 0.2126 * lin(1) + 0.7152 * lin(3) + 0.0722 * lin(5) > 0.22 ? "#101318" : "#ffffff"; |
| 29 | } |
| 30 | function apply(t) { |
| 31 | for (const part in PARTS) { |
| 32 | if (valid(t[part])) { root.style.setProperty("--" + part, t[part]); root.style.setProperty("--" + part + "-fg", ink(t[part])); } |
| 33 | else { root.style.removeProperty("--" + part); root.style.removeProperty("--" + part + "-fg"); } |
| 34 | } |
| 35 | if (t.mode === "light" || t.mode === "dark") root.dataset.theme = t.mode; else delete root.dataset.theme; |
| 36 | const meta = document.querySelector('meta[name="theme-color"]'); |
| 37 | if (meta) { const c = getComputedStyle(root).getPropertyValue("--pyr").trim(); if (c) meta.content = c; } |
| 38 | } |
| 39 | let theme = load(); |
| 40 | apply(theme); |
| 41 | |
| 42 | const style = document.createElement("style"); |
| 43 | style.textContent = ` |
| 44 | :root[data-theme=light] { color-scheme:light } :root[data-theme=dark] { color-scheme:dark } |
| 45 | [data-theme-part] { cursor:pointer; border-radius:4px; -webkit-user-select:none; user-select:none; -webkit-touch-callout:none; touch-action:manipulation } |
| 46 | [data-theme-part]:hover { text-decoration:underline; text-decoration-thickness:2px; text-underline-offset:4px } |
| 47 | [data-theme-part][aria-expanded=true] { text-decoration:underline; text-decoration-thickness:2px; text-underline-offset:4px } |
| 48 | .theme-pick { position:absolute; z-index:1000; width:min(264px, calc(100vw - 32px)); padding:12px; background:var(--card, #fff); color:var(--fg, #1d1d1b); border:1px solid var(--line, #e3e1da); border-radius:10px; box-shadow:0 8px 28px rgba(0,0,0,.18); font:14px/1.4 system-ui,-apple-system,"Segoe UI",sans-serif; letter-spacing:normal } |
| 49 | .theme-pick[hidden] { display:none } |
| 50 | .theme-pick .theme-head { font-weight:600; margin:0 0 8px } .theme-pick .theme-head span { color:var(--mute, #6b6a64); font-weight:normal } |
| 51 | .theme-pick .theme-swatches { display:grid; grid-template-columns:repeat(4, 1fr); gap:8px; margin:0 0 10px } |
| 52 | .theme-pick .theme-swatches button { aspect-ratio:1; width:100%; min-height:36px; padding:0; border:2px solid var(--line, #e3e1da); border-radius:8px; cursor:pointer } |
| 53 | .theme-pick .theme-swatches button[aria-pressed=true] { border-color:var(--fg, #1d1d1b); box-shadow:inset 0 0 0 2px var(--card, #fff) } |
| 54 | .theme-pick .theme-row { display:flex; align-items:center; justify-content:space-between; gap:8px } |
| 55 | .theme-pick label { display:flex; align-items:center; gap:8px; cursor:pointer } |
| 56 | .theme-pick input[type=color] { width:36px; height:28px; padding:0; border:1px solid var(--line, #e3e1da); border-radius:6px; background:none; cursor:pointer } |
| 57 | .theme-pick .theme-reset { font:inherit; padding:6px 10px; border:1px solid var(--line, #e3e1da); border-radius:6px; background:var(--soft, #f1efe9); color:var(--fg, #1d1d1b); cursor:pointer } |
| 58 | .theme-pick .theme-reset:disabled { opacity:.5; cursor:default } |
| 59 | .theme-pick .theme-mode { display:flex; gap:4px; margin:10px 0 0; padding:3px; background:var(--soft, #f1efe9); border-radius:8px } |
| 60 | .theme-pick .theme-mode[hidden] { display:none } |
| 61 | .theme-pick .theme-mode button { flex:1; font:inherit; padding:5px 0; border:0; border-radius:6px; background:transparent; color:var(--fg, #1d1d1b); cursor:pointer } |
| 62 | .theme-pick .theme-mode button[aria-pressed=true] { background:var(--card, #fff); box-shadow:0 1px 3px rgba(0,0,0,.2); font-weight:600 } |
| 63 | .theme-pick .theme-hint { color:var(--mute, #6b6a64); font-size:12px; margin:8px 0 0 }`; |
| 64 | document.head.appendChild(style); |
| 65 | |
| 66 | let pick = null, openPart = null, openEl = null; |
| 67 | |
| 68 | function current(part) { return valid(theme[part]) ? theme[part].toLowerCase() : null; } |
| 69 | function choose(part, color) { |
| 70 | if (color) theme[part] = color.toLowerCase(); else delete theme[part]; |
| 71 | save(theme); apply(theme); render(); |
| 72 | } |
| 73 | function mode(m) { |
| 74 | if (m === "light" || m === "dark") theme.mode = m; else delete theme.mode; |
| 75 | save(theme); apply(theme); render(); |
| 76 | } |
| 77 | function render() { |
| 78 | if (!pick || !openPart) return; |
| 79 | const p = PARTS[openPart], cur = current(openPart); |
| 80 | pick.setAttribute("aria-label", `${p.label} colour`); |
| 81 | pick.querySelector(".theme-head").innerHTML = `${p.label} colour <span>· ${p.role}</span>`; |
| 82 | pick.querySelector(".theme-swatches").innerHTML = p.presets.map(([name, c]) => `<button type="button" data-color="${c}" style="background:${c}" title="${name}" aria-label="${name}" aria-pressed="${cur === c}"></button>`).join(""); |
| 83 | pick.querySelector("input[type=color]").value = cur || getComputedStyle(root).getPropertyValue("--" + openPart).trim() || "#000000"; |
| 84 | pick.querySelector(".theme-reset").disabled = !cur; |
| 85 | const modes = pick.querySelector(".theme-mode"), on = theme.mode || "auto"; |
| 86 | modes.hidden = openPart !== "tan"; |
| 87 | for (const b of modes.querySelectorAll("button")) b.setAttribute("aria-pressed", String(b.dataset.mode === on)); |
| 88 | } |
| 89 | function build() { |
| 90 | pick = document.createElement("div"); |
| 91 | pick.className = "theme-pick"; pick.hidden = true; pick.setAttribute("role", "dialog"); |
| 92 | pick.innerHTML = `<div class="theme-head"></div><div class="theme-swatches"></div><div class="theme-row"><label><input type="color"> Custom</label><button type="button" class="theme-reset">Default</button></div><div class="theme-mode" role="group" aria-label="Mode">${MODES.map(([m, label]) => `<button type="button" data-mode="${m}">${label}</button>`).join("")}</div><div class="theme-hint">Kept in this browser only.</div>`; |
| 93 | pick.addEventListener("click", (e) => { |
| 94 | const b = e.target.closest("button[data-color]"); |
| 95 | if (b) choose(openPart, b.dataset.color); |
| 96 | else if (e.target.closest(".theme-reset")) choose(openPart, null); |
| 97 | else if (e.target.closest("button[data-mode]")) mode(e.target.closest("button[data-mode]").dataset.mode); |
| 98 | }); |
| 99 | pick.querySelector("input[type=color]").addEventListener("input", (e) => choose(openPart, e.target.value)); |
| 100 | document.body.appendChild(pick); |
| 101 | } |
| 102 | function place() { |
| 103 | const r = openEl.getBoundingClientRect(), w = pick.offsetWidth; |
| 104 | const left = Math.max(16, Math.min(r.left, document.documentElement.clientWidth - w - 16)); |
| 105 | pick.style.left = (left + window.scrollX) + "px"; |
| 106 | pick.style.top = (r.bottom + window.scrollY + 8) + "px"; |
| 107 | } |
| 108 | function open(part, el) { |
| 109 | if (!pick) build(); |
| 110 | if (openEl && openEl !== el) openEl.setAttribute("aria-expanded", "false"); |
| 111 | openPart = part; openEl = el; el.setAttribute("aria-expanded", "true"); |
| 112 | render(); pick.hidden = false; place(); |
| 113 | } |
| 114 | function close(refocus) { |
| 115 | if (!pick || pick.hidden) return; |
| 116 | pick.hidden = true; |
| 117 | if (openEl) { openEl.setAttribute("aria-expanded", "false"); if (refocus) openEl.focus(); } |
| 118 | openPart = null; openEl = null; |
| 119 | } |
| 120 | const toggle = (part, el) => (openEl === el && !pick.hidden) ? close(false) : open(part, el); |
| 121 | |
| 122 | function bind(el) { |
| 123 | const part = el.dataset.themePart; |
| 124 | if (!PARTS[part] || el.dataset.themeBound) return; |
| 125 | el.dataset.themeBound = "1"; |
| 126 | el.setAttribute("role", "button"); el.tabIndex = 0; |
| 127 | el.setAttribute("aria-haspopup", "dialog"); el.setAttribute("aria-expanded", "false"); |
| 128 | el.title = `${PARTS[part].label} colour (${PARTS[part].role}): click, right-click or press and hold`; |
| 129 | // Press and hold on touch and pen; a mouse has click and right-click. |
| 130 | let timer = null, held = false; |
| 131 | const cancel = () => { clearTimeout(timer); timer = null; }; |
| 132 | el.addEventListener("pointerdown", (e) => { |
| 133 | held = false; |
| 134 | if (e.pointerType === "mouse") return; |
| 135 | cancel(); timer = setTimeout(() => { held = true; open(part, el); }, 450); |
| 136 | }); |
| 137 | for (const ev of ["pointerup", "pointercancel", "pointerleave"]) el.addEventListener(ev, cancel); |
| 138 | el.addEventListener("click", (e) => { e.preventDefault(); if (held) { held = false; return; } toggle(part, el); }); |
| 139 | el.addEventListener("contextmenu", (e) => { e.preventDefault(); cancel(); held = e.pointerType !== "mouse"; open(part, el); }); |
| 140 | el.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggle(part, el); } }); |
| 141 | } |
| 142 | |
| 143 | function start() { |
| 144 | document.querySelectorAll("[data-theme-part]").forEach(bind); |
| 145 | document.addEventListener("pointerdown", (e) => { if (pick && !pick.hidden && !pick.contains(e.target) && !e.target.closest("[data-theme-part]")) close(false); }); |
| 146 | document.addEventListener("keydown", (e) => { if (e.key === "Escape") close(true); }); |
| 147 | window.addEventListener("resize", () => { if (pick && !pick.hidden) place(); }); |
| 148 | } |
| 149 | if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start); else start(); |
| 150 | // Auto follows the system; the browser bar's colour follows with it. |
| 151 | try { matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => { apply(theme); render(); }); } catch (e) {} |
| 152 | // Another tab on the same origin changed the colours. |
| 153 | window.addEventListener("storage", (e) => { if (e.key === KEY) { theme = load(); apply(theme); render(); } }); |
| 154 | })(); |