1// store.js: window.ermiteStore, a small client for what the pages remember in
2// the browser (localStorage): the preferences. Loaded before ermite.js, which
3// builds on it (ermiteKeys, ermiteLayout).
4//
5// It never throws. Without storage (disabled, private mode, sandboxed frame)
6// or when the browser refuses a write (quota), the value is held in memory:
7// the choice lasts for this page, and the page works as if it were saved.
8// Values are strings, as in localStorage; null says "nothing stored".
9//
10// get(key, fallback) the value, or fallback (null) when there is none
11// set(key, value) stores it; null or undefined removes the key
12// remove(key) forgets the key
13// watch(key, fn) fn(value or null) when another tab changes the key
14// (the "storage" event); answers a function that stops
15// pref(key, opts) a preference among a closed list:
16// pref(key, { values, fallback }) answers
17// { get, set, watch }, where get is always one of
18// values (a stored value that is not falls back) and
19// set ignores what is not
20//
21// Self-contained: jstest/store.test.mjs evaluates this file alone.
22//
23// It also sets, before the first paint, what the stored preferences change on
24// <html>: the wide mode and the theme (window.ermiteTheme, below).
25window.ermiteStore = (() => {
26 // What storage could not keep, by key. A key mapped to null is removed for
27 // this page although storage would not say so.
28 const held = new Map();
29
30 const area = () => {
31 try { return localStorage; } catch { return null; }
32 };
33
34 function get(key, fallback = null) {
35 if (held.has(key)) return held.get(key) ?? fallback;
36 try { return area().getItem(key) ?? fallback; } catch { return fallback; }
37 }
38
39 function set(key, value) {
40 if (value === null || value === undefined) return remove(key);
41 value = String(value);
42 try {
43 area().setItem(key, value);
44 held.delete(key);
45 } catch { held.set(key, value); }
46 }
47
48 function remove(key) {
49 try {
50 area().removeItem(key);
51 held.delete(key);
52 } catch { held.set(key, null); }
53 }
54
55 function watch(key, fn) {
56 const on = (e) => {
57 // key is null when another tab clears the whole storage.
58 if (e.key !== null && e.key !== key) return;
59 if (e.storageArea && e.storageArea !== area()) return;
60 held.delete(key); // the other tab's word is newer than ours
61 fn(e.key === null ? null : (e.newValue ?? null));
62 };
63 addEventListener('storage', on);
64 return () => removeEventListener('storage', on);
65 }
66
67 function pref(key, { values, fallback }) {
68 const valid = (v) => (values.includes(v) ? v : fallback);
69 return {
70 get: () => valid(get(key)),
71 set(v) {
72 if (values.includes(v)) set(key, v);
73 },
74 watch: (fn) => watch(key, (v) => fn(valid(v))),
75 };
76 }
77
78 return { get, set, remove, watch, pref };
79})();
80
81// The wide mode (ermiteWide in ermite.js) is set here, before the first paint:
82// shell.templ loads this file without defer, so a wide page never draws narrow
83// first.
84if (window.ermiteStore.get('wide') === '1') document?.documentElement?.classList?.add('wide');
85
86// window.ermiteTheme: the colour theme, "auto" (the system's, the default),
87// "light" or "dark", kept by ermiteStore ("theme"). A forced theme is
88// html[data-theme], which sets color-scheme (00-tokens.css); the
89// color-scheme and theme-color metas follow so the browser's own UI matches.
90// Applied here, before the first paint: the page never draws in the wrong
91// theme first. The footer button (ermiteThemeSwitch in ermite.js) cycles it.
92//
93// get() the mode
94// next(mode) the mode after it: auto → light → dark → auto
95// set(mode) stores and applies it
96// apply() shows the stored mode on the page
97// watch(fn) fn(mode) when another tab changes it
98//
99// Tested in Node by jstest/theme.test.mjs.
100window.ermiteTheme = (() => {
101 const values = ['auto', 'light', 'dark'];
102 const pref = window.ermiteStore.pref('theme', { values, fallback: 'auto' });
103 const next = (mode) => values[(values.indexOf(mode) + 1) % values.length];
104
105 function apply() {
106 const mode = pref.get();
107 const doc = globalThis.document;
108 const root = doc?.documentElement;
109 if (!root) return mode;
110 if (mode === 'auto') root.removeAttribute('data-theme');
111 else root.setAttribute('data-theme', mode);
112 doc.querySelector('meta[name="color-scheme"]')
113 ?.setAttribute('content', mode === 'auto' ? 'light dark' : mode);
114 // Each theme-color meta keeps its rendered colour in data-auto. Forced,
115 // both metas take the colour of the meta of that scheme (data-scheme),
116 // whatever the system scheme their media matches.
117 const metas = [...doc.querySelectorAll('meta[name="theme-color"]')];
118 for (const m of metas) if (!m.hasAttribute('data-auto')) m.setAttribute('data-auto', m.getAttribute('content') ?? '');
119 const forced = metas.find((m) => m.getAttribute('data-scheme') === mode);
120 for (const m of metas) {
121 m.setAttribute('content', mode === 'auto' || !forced ? m.getAttribute('data-auto') : forced.getAttribute('data-auto'));
122 }
123 return mode;
124 }
125
126 function set(mode) {
127 pref.set(mode);
128 apply();
129 }
130
131 apply();
132 return { values, get: pref.get, next, set, apply, watch: pref.watch };
133})();