AkurAI Build
Menu

Public repository / b5d7e59c5e73adb2277d3f8db004d57d118be321

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