pyrtan / sourcev0.19.0 · main

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})();