Files
Harbor/harbor/web/themes.js
T
KeelrLeek dbfd1720c5 Harbor as of 2026-10-05: the coordination hub for the Open Sea's decomp team
Messages, threads, acknowledgements, claims, bands, tasks, review items and
stop flags for a team of AI agents; the chat page and board; host hooks and
MCP server; autopilot; tools/watch.py. Put under version control after the
weekend's changes so they have history and can be rolled back. Local only.
2026-10-05 10:44:23 -05:00

78 lines
5.5 KiB
JavaScript

/* Harbor's theme picker, shared by the chat and the board. A theme is a palette family (themes.css; Harbor's own look in
harbor.css is the default) and a mode: system, light or dark. Both are kept in localStorage (harbor.palette,
harbor.theme), set on <html data-palette data-theme> before the first paint by each page's head script, and kept in
step across open tabs. Any element with data-theme-open opens the Appearance dialog; radios named "theme" set the
mode; elements with data-theme-name show the current theme's name. */
(function () {
var THEMES = [
{ id: "harbor", name: "Harbor", note: "Default" },
{ id: "dmg", name: "DMG" }, { id: "graphite", name: "Duke" }, { id: "atlas", name: "FLUDD" },
{ id: "rosepine", name: "Flowey" }, { id: "gruvbox", name: "Gerudo" }, { id: "contrast", name: "Kill Screen" },
{ id: "snes", name: "Mode 7" }, { id: "terra", name: "Shovel Knight" }, { id: "nord", name: "Snowdin" },
{ id: "solarized", name: "Solaire" }, { id: "tokyo", name: "Tokyo-to" }, { id: "dracula", name: "Vampire Killer" }
];
var MODES = [["auto", "System", "monitor"], ["light", "Light", "sun"], ["dark", "Dark", "moon"]];
var root = document.documentElement, dark = matchMedia("(prefers-color-scheme: dark)"), dialog = null;
function get(k, d) { try { var v = localStorage.getItem("harbor." + k); return v === null ? d : JSON.parse(v); } catch (e) { return d; } }
function put(k, v) { try { localStorage.setItem("harbor." + k, JSON.stringify(v)); } catch (e) {} }
function mode() { var m = get("theme", "auto"); return m === "light" || m === "dark" ? m : "auto"; }
function palette() { var p = get("palette", "harbor"); return THEMES.some(function (t) { return t.id === p; }) ? p : "harbor"; }
function side() { var m = mode(); return m === "auto" ? (dark.matches ? "dark" : "light") : m; }
function icon(n) { return '<svg class="i sm" aria-hidden="true"><use href="static/icons.svg#' + n + '"></use></svg>'; }
function name() { var p = palette(); return THEMES.filter(function (t) { return t.id === p; })[0].name; }
function apply() {
var m = mode(), p = palette();
if (m === "auto") delete root.dataset.theme; else root.dataset.theme = m;
if (p === "harbor") delete root.dataset.palette; else root.dataset.palette = p;
paint();
}
function paint() {
var m = mode(), p = palette(), s = side();
document.querySelectorAll('input[name="theme"], input[name="themeMode"]').forEach(function (r) { r.checked = r.value === m; });
document.querySelectorAll("[data-theme-name]").forEach(function (e) { e.textContent = name(); });
if (!dialog) return;
dialog.querySelectorAll("[data-pick]").forEach(function (b) {
b.setAttribute("aria-pressed", String(b.dataset.pick === p));
b.querySelector(".tprev").dataset.p = b.dataset.pick + "-" + s;
});
}
function set(key, value) { put(key, value); apply(); }
function build() {
dialog = document.createElement("dialog");
dialog.className = "theme-dialog";
dialog.setAttribute("aria-labelledby", "themeDialogTitle");
dialog.innerHTML =
'<header><h2 id="themeDialogTitle">Appearance</h2><button type="button" class="btn icon sm ghost" data-theme-close aria-label="Close">' +
'<svg class="i" aria-hidden="true"><use href="static/icons.svg#x"></use></svg></button></header>' +
'<div class="body"><div class="theme-row"><span class="field-label">Mode</span><div class="segmented" role="radiogroup" aria-label="Mode">' +
MODES.map(function (x) { return '<label><input type="radio" name="themeMode" value="' + x[0] + '"><span>' + icon(x[2]) + x[1] + "</span></label>"; }).join("") +
'</div></div><div class="theme-grid" role="list">' +
THEMES.map(function (t) {
return '<button type="button" class="tcard" role="listitem" data-pick="' + t.id + '" aria-pressed="false"><span class="tprev" data-p="' + t.id + '-light" aria-hidden="true">' +
'<i class="ps"><b></b><b></b><b></b></i><i class="ph"></i><span class="pb"><span class="pc"><b></b><b></b><em></em></span></span></span>' +
'<span class="tname">' + t.name + (t.note ? ' <span class="note">' + t.note + "</span>" : "") + "</span></button>";
}).join("") +
'</div></div><footer><span class="grow note">Every theme has a light and a dark side; the mode picks which one you see. The choice carries over to the chat and the board.</span>' +
'<button type="button" class="btn primary" data-theme-close>Done</button></footer>';
document.body.appendChild(dialog);
dialog.addEventListener("click", function (e) {
if (e.target === dialog || e.target.closest("[data-theme-close]")) return dialog.close();
var card = e.target.closest("[data-pick]");
if (card) set("palette", card.dataset.pick);
});
}
function open() { if (!dialog) build(); paint(); dialog.showModal(); var on = dialog.querySelector('[aria-pressed="true"]'); if (on) on.focus(); }
document.addEventListener("change", function (e) {
var r = e.target;
if (r.matches && r.matches('input[name="theme"], input[name="themeMode"]')) set("theme", r.value);
});
document.addEventListener("click", function (e) { if (e.target.closest && e.target.closest("[data-theme-open]")) open(); });
window.addEventListener("storage", function (e) { if (e.key === "harbor.theme" || e.key === "harbor.palette") apply(); });
if (dark.addEventListener) dark.addEventListener("change", paint);
window.HarborTheme = { themes: THEMES, apply: apply, open: open, mode: mode, palette: palette, side: side };
apply();
})();