Menu
Public repository / 0d1dcaa65fa30faf54fc0b8981eadcff7a0bd853
AkurAI-Mail / static/theme.js
Latest change d1bd6f108abd2caf391ea926dd896369c62446e0 - feat: add suite-wide theme-sync (cookie flash-guard + theme.js) by Ólafur Búi Ólafsson
// AkurAI theme switcher — native ESM, zero deps.
//
// Themes are generated by the framework (`/themes.css`, registry `/themes.json`)
// as `:root[data-theme="…"]` token blocks. This module chooses the active
// `data-theme` on <html>, persists it, builds a picker, and keeps the choice in
// sync across the whole AkurAI suite.
//
// Why a cookie (not just localStorage): localStorage is per-origin, so each
// subdomain app (platform./monitor./mail.…) has its own copy and they can't see
// each other. The theme is therefore mirrored into a cookie scoped to the parent
// domain (`.olibuijr.com`), which every `*.olibuijr.com` app reads — so a choice
// in one app applies globally across the suite. The flash-guard in <head> reads
// the same cookie before first paint, so apps open already themed.
//
// Live sync to OPEN tabs:
// • same app, many tabs → `storage` event + BroadcastChannel (instant)
// • different apps (subs) → re-read the cookie on focus/visibilitychange, so
// switching to another open app snaps to the theme
// • OS scheme change → followed only while the user is on the default
const STORAGE_KEY = "akurai-theme";
const DEFAULT_DARK = "akurai";
const DEFAULT_LIGHT = "akurai-light";
const ONE_YEAR = 31536000;
let selectEl = null; // the picker <select>, kept in sync with external changes
export function prefersLight() {
return window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches;
}
export function defaultTheme() {
return prefersLight() ? DEFAULT_LIGHT : DEFAULT_DARK;
}
// The cookie domain shared by every subdomain app: the registrable parent of the
// current host (e.g. `app.olibuijr.com` → `.olibuijr.com`). Returns "" for a
// bare host / localhost / IP, where a host-only cookie is correct.
function cookieDomain() {
const h = location.hostname;
if (!h || h === "localhost" || /^[0-9.]+$/.test(h)) return "";
const parts = h.split(".");
if (parts.length < 2) return "";
return "." + parts.slice(-2).join(".");
}
function readCookie() {
const m = document.cookie.match(/(?:^|; )akurai-theme=([^;]*)/);
return m ? decodeURIComponent(m[1]) : null;
}
function writeCookie(slug) {
const dom = cookieDomain();
const secure = location.protocol === "https:" ? "; secure" : "";
document.cookie =
`${STORAGE_KEY}=${encodeURIComponent(slug)}; path=/; max-age=${ONE_YEAR}; samesite=lax` +
(dom ? `; domain=${dom}` : "") +
secure;
}
// True only while the user has made no explicit choice (so OS changes may lead).
function hasExplicitChoice() {
if (readCookie()) return true;
try {
return !!localStorage.getItem(STORAGE_KEY);
} catch {
return false;
}
}
export function currentTheme() {
return (
readCookie() ||
safeLocal() ||
document.documentElement.getAttribute("data-theme") ||
defaultTheme()
);
}
function safeLocal() {
try {
return localStorage.getItem(STORAGE_KEY);
} catch {
return null;
}
}
// Cross-tab broadcast within one origin (instant). Cross-subdomain relies on the
// cookie + focus re-read below, since BroadcastChannel/storage are same-origin.
let channel = null;
try {
channel = "BroadcastChannel" in window ? new BroadcastChannel("akurai-theme") : null;
} catch {
channel = null;
}
// Reflect a theme into the DOM + local state WITHOUT re-broadcasting — used by
// the sync listeners so updates don't echo into loops.
function reflect(slug) {
if (!slug || slug === document.documentElement.getAttribute("data-theme")) {
if (selectEl && slug) selectEl.value = slug;
return;
}
document.documentElement.setAttribute("data-theme", slug);
try {
localStorage.setItem(STORAGE_KEY, slug);
} catch {
/* private mode — session-only */
}
if (selectEl) selectEl.value = slug;
}
// The public, user-initiated apply: reflect + persist globally (cookie) + tell
// every other open tab/app.
export function applyTheme(slug) {
reflect(slug);
writeCookie(slug);
if (channel) {
try {
channel.postMessage(slug);
} catch {
/* channel closed */
}
}
}
// Fetch the registry and build a grouped picker.
export async function mountPicker(mount) {
if (!mount) return [];
let themes = [];
try {
const res = await fetch("/themes.json", { headers: { accept: "application/json" } });
themes = await res.json();
} catch {
return [];
}
if (!Array.isArray(themes) || themes.length === 0) return [];
const select = document.createElement("select");
select.className = "theme-select";
select.setAttribute("aria-label", "Color theme");
const families = [];
const byFamily = new Map();
for (const t of themes) {
if (!byFamily.has(t.family)) {
byFamily.set(t.family, []);
families.push(t.family);
}
byFamily.get(t.family).push(t);
}
for (const family of families) {
const group = document.createElement("optgroup");
group.label = family;
for (const t of byFamily.get(family)) {
const opt = document.createElement("option");
opt.value = t.slug;
opt.textContent = t.label;
group.appendChild(opt);
}
select.appendChild(group);
}
selectEl = select;
const active = currentTheme();
select.value = active;
reflect(active);
select.addEventListener("change", () => applyTheme(select.value));
mount.appendChild(select);
return themes;
}
// ---- Live sync wiring (runs once, in the browser) ----
if (typeof window !== "undefined") {
// Same-origin: another tab of this app changed localStorage.
window.addEventListener("storage", (e) => {
if (e.key === STORAGE_KEY && e.newValue) reflect(e.newValue);
});
// Same-origin: instant broadcast from a sibling tab.
if (channel) channel.onmessage = (e) => reflect(e.data);
// Cross-subdomain: when this tab regains focus, adopt the suite-wide cookie.
const adoptCookie = () => {
const c = readCookie();
if (c) reflect(c);
};
window.addEventListener("focus", adoptCookie);
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") adoptCookie();
});
// OS scheme change — followed only while the user is on the default.
const mq = window.matchMedia && window.matchMedia("(prefers-color-scheme: light)");
if (mq && mq.addEventListener) {
mq.addEventListener("change", () => {
if (!hasExplicitChoice()) reflect(defaultTheme());
});
}
}
// Auto-mount the picker if a slot exists.
if (typeof document !== "undefined") {
const boot = () => {
const slot = document.querySelector("[data-theme-picker]");
if (slot) mountPicker(slot);
else reflect(currentTheme());
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot);
} else {
boot();
}
}