AkurAI Build
Menu

Public repository / 68352ecfad2afd0e0444a481d1b5d4fbd277f920

AkurAI-Platform / frontend/theme.js

Latest change ed1696afdb6cc3eba686f123e0b4674d3a81081b - feat: adopt framework v0.8.0 theme system (17 themes, picker, token aliasing) by Olafur Bui

// 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 only chooses the active
// `data-theme` on <html>, persists it, and builds a small picker. To avoid a
// flash of the wrong theme, set the attribute as early as possible — the inline
// snippet in <head> (see `theme-init.js` contents below) runs before paint; this
// module then renders the UI.

const STORAGE_KEY = "akurai-theme";
// Default per polarity when the user has never chosen — honors the OS setting.
const DEFAULT_DARK = "akurai";
const DEFAULT_LIGHT = "akurai-light";

export function prefersLight() {
  return window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches;
}

export function defaultTheme() {
  return prefersLight() ? DEFAULT_LIGHT : DEFAULT_DARK;
}

export function currentTheme() {
  return localStorage.getItem(STORAGE_KEY) || document.documentElement.getAttribute("data-theme") || defaultTheme();
}

export function applyTheme(slug) {
  document.documentElement.setAttribute("data-theme", slug);
  try {
    localStorage.setItem(STORAGE_KEY, slug);
  } catch {
    /* storage may be unavailable (private mode); theme still applies for the session */
  }
}

// Fetch the registry and build a grouped picker. `mount` is the element the
// <select> (and label) are appended to. Returns the registry array.
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");

  // Group by family so light/dark siblings sit together.
  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;
      // Label already carries the name; append a polarity hint when ambiguous.
      opt.textContent = t.label;
      group.appendChild(opt);
    }
    select.appendChild(group);
  }

  const active = currentTheme();
  select.value = active;
  applyTheme(active);

  select.addEventListener("change", () => applyTheme(select.value));
  mount.appendChild(select);
  return themes;
}

// Auto-mount: if a `[data-theme-picker]` element exists, fill it on load.
if (typeof document !== "undefined") {
  const boot = () => {
    const slot = document.querySelector("[data-theme-picker]");
    if (slot) mountPicker(slot);
  };
  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", boot);
  } else {
    boot();
  }
}