'use strict'; 'require baseclass'; 'require rpc'; 'require fs-fit as fit'; /* The Appearance axes this file owns; the controls that present them are fs-appearance.js. The * axis list is AXIS_KEYS, which is exactly the fields of snapshotAxes() — what Save-as-default * writes. * All client-side, instant and persisted in localStorage, with head.ut's inline script re-applying * them before paint so a reload never flashes the wrong one; tools/axes.mjs derives the contract * from this file and holds the two copies to it. * * ---- three layers, and the browser always wins ---- * Every axis resolves as localStorage ?? router-default ?? built-in. The router default is * Appearance -> Save to router (written to /etc/config/footstrap, read back into window.__fsSD); * the built-in is a bare :root. A new browser inherits the router default; this browser's own * choice overrides it in either direction. * * ---- every applier stores its choice EXPLICITLY ---- * Once a router default exists, clearing a key means "inherit the router default", not "the * built-in" — so an applier that lsDel'd on the default value could not express "the built-in, not * the router's" (a router-defaulted tint could never be turned back off). Every axis records the * chosen value, including the off/default one. lsDel is reserved for resetToSaved(). */ /* A browser can refuse storage outright (blocked cookies, dom.storage.enabled=false, a partitioned * WebView) and then every access throws. The helpers below swallow it, because an axis that cannot * be remembered must still APPLY, but they record it: otherwise current*() reads null, falls back * to the router's look, and the Save button sits disabled reading "Saved to router" over a page * painted in axes the router default does not carry. */ let _lsBroken = false; function storageBroken() { return _lsBroken; } function lsGet(k) { try { return localStorage.getItem(k); } catch (e) { _lsBroken = true; return null; } } function lsSet(k, v) { try { localStorage.setItem(k, v); } catch (e) { _lsBroken = true; } } function lsDel(k) { try { localStorage.removeItem(k); } catch (e) { _lsBroken = true; } } /* A stored JSON array, or [] — the shape the two remembered lists use (the search palette's recent * paths, the menu's open sections). lsGet owns the try/catch around localStorage; this one covers * JSON.parse over a value another tab may have corrupted, and the Array guard that stops a stored * object being spread into a list. */ function lsGetArr(k) { try { const a = JSON.parse(lsGet(k) || '[]'); return Array.isArray(a) ? a : []; } catch (e) { return []; } } /* the router-wide defaults the server stamped (head.ut), read at runtime so current*() reports the * effective default when this browser has no localStorage */ function sd(k) { try { return (window.__fsSD || {})[k]; } catch (e) { return undefined; } } /* …and the write back: an applier that persists to the router must update the blob the server * stamped, or current*() keeps reporting the old router default until the next full load and * matchesSavedDefault() lies about whether anything is left to save */ function setSD(field, val) { try { (window.__fsSD = window.__fsSD || {})[field] = val; } catch (e) {} } /* ---- every axis owns its ROUTER DEFAULT, and nothing else may restate it ---- * `def()` is the sd() branch of current() alone: the effective value with no localStorage. Exposed * because _resolvedDefault() needs exactly that branch, and a second copy of the same validation * drifts without a symptom — matchesSavedDefault() then lies about the one thing the Save button * is, its own status. */ function modeDefault() { const d = sd('darkmode'); return (d === 'dark' || d === 'light') ? d : 'auto'; } function currentMode() { const s = lsGet('fs-darkmode'); if (s === 'true') return 'dark'; if (s === 'false') return 'light'; if (s === 'auto') return 'auto'; if (s === null) return modeDefault(); return 'auto'; } /* ---- dark mode is announced in three dialects, because apps sniff for it ---- * * An app with its own dark styles has to guess whether the page is dark and there is no standard: * apps read `data-theme="dark"` on :root (luci-app-justclash keys 21 rules off it), Bootstrap's * `data-bs-theme` (luci-app-ssclash), or, failing both, the luminance of the body background. All * three are stamped for the same fact: before that, every one of justclash's [data-theme="dark"] * rules was dead and a dark page rendered its light fills. * * `data-darkmode` is the name the theme's own CSS keys off. The other two are outbound * compatibility, like the `--*-color-*` export tier: nothing in `styles/` may read them, and * tools/axes.mjs fails the build if it does. */ /* the attribute name reused below by the writer, the guard's reader and both MutationObserver * filters (measured: 15 B x4 -> 28 B, 32 B saved) */ function stampDark(root, dark) { /* the literal stays spelled out HERE: tools/axes.mjs reads the attribute names out of * this function's SOURCE, so a hoisted const reads as no attribute at all and the gate * reports the pre-paint and the live applier as drifted. The 45 B a const would save are * not worth teaching a gate to resolve them. */ root.setAttribute('data-darkmode', dark ? 'true' : 'false'); root.setAttribute('data-theme', dark ? 'dark' : 'light'); root.setAttribute('data-bs-theme', dark ? 'dark' : 'light'); } const _mqDark = window.matchMedia('(prefers-color-scheme: dark)'); /* the one expression for "is this page dark right now", so the applier, the OS listener and the * guard below cannot disagree about it */ function intendedDark() { const m = currentMode(); return m === 'dark' || (m === 'auto' && _mqDark.matches); } function applyMode(val) { const root = document.documentElement; /* 'auto' is stored explicitly, so it overrides a router default of dark/light — otherwise a * router defaulted to dark could never be set back to "follow the OS" */ if (val === 'auto') lsSet('fs-darkmode', 'auto'); else lsSet('fs-darkmode', val === 'dark' ? 'true' : 'false'); /* after the store, so intendedDark() reads the choice just made and no second copy of the * condition is needed in terms of `val` */ stampDark(root, intendedDark()); } /* ---- the three dialects are published, so third parties write them too ---- * * Announcing dark mode in a vocabulary apps understand is what makes them follow the page, and it * is why an app reaches for the same attribute: `luci-app-openclash` stamps `data-darkmode="true"` * onto :root from seven of its templates, gated on an isDarkBackground() that consults * `matchMedia('(prefers-color-scheme: dark)')` before it looks at the real background. So a user * who chose LIGHT here, on an OS set to dark, has the theme flipped by opening an OpenClash page — * and one of those templates removes the attribute head.ut writes as 'false'. * * No cascade trick answers a DOM write, so watch the attributes we own and restate the truth. * Nothing else is guarded: the other axes are private to this theme, no app has a reason to know * them, and a survey of ten shipping packages found none that writes one. The published trio is * the surface precisely because it is published. * * This corrects a wrong premise rather than fighting the app's intent: when the page really is * dark, the app's write agrees with ours and the guard never fires. It cannot ping-pong either — * our write produces a mutation, the callback re-runs, the values match, it returns. */ function guardDarkStamp() { const root = document.documentElement; const check = () => { const dark = intendedDark(); if (root.getAttribute('data-darkmode') === (dark ? 'true' : 'false') && root.getAttribute('data-theme') === (dark ? 'dark' : 'light') && root.getAttribute('data-bs-theme') === (dark ? 'dark' : 'light')) return; stampDark(root, dark); }; /* an app's inline