detached bb7efc8

feat(web): footer theme switch for auto, light and dark modes

commit
bb7efc81285aee2b61f392621f5d6de458adc7ef
author
brice · · 2026-10-22 09:45 UTC
parent
7c1886f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
42 files · +374 -8
M internal/adapters/web/ASSETS.md +2 -2
@@ -8,7 +8,7 @@ content-hashed names. Nothing is fetched at runtime.
88 | `static/js/htmx.min.js` | htmx 4.0.0 (BSD Zero Clause, `static/js/htmx-LICENSE`) | `mockups/assets/htmx/htmx.min.js` of the design | `e484d9171a9db30a39c8f16e3d709d4137f3211c659f8e6125816635033d593f` |
99 | `static/js/hx-live.min.js` | htmx 4 `hx-live` extension | `mockups/assets/htmx/ext/hx-live.min.js` | `adf9b90949d57baf20dc2eaf1d1fd46c81347ac82c090a5e2912ce6b37726734` |
1010 | `static/js/hx-sse.min.js` | htmx 4 `hx-sse` extension | `mockups/assets/htmx/ext/hx-sse.min.js` | `8a834680c4000a9034d79228872372a92e140c810a075cb6d4a76690dfc13085` |
11-| `static/js/store.js` | the localStorage client of the scripts: preferences such as the home layout and the single-key shortcuts (`window.ermiteStore`, tested in Node by `jstest/store.test.mjs`) | written here | |
11+| `static/js/store.js` | the localStorage client of the scripts: preferences such as the home layout and the single-key shortcuts (`window.ermiteStore`, tested in Node by `jstest/store.test.mjs`); applies the wide mode and the theme (`window.ermiteTheme`: auto, light or dark, key `theme`; tested by `jstest/theme.test.mjs`) before the first paint | written here | |
1212 | `static/js/ermite.js` | key sequences to `k-*` events, the functions the keys and inputs call (`window.ermite`), the live fragments (`ermiteLive`), the diff table of contents observer | `mockups/d/js/ermite.js`, extended here | |
1313 | `static/js/log.js` | the live log of a running job (`window.ermiteLog`, tested in Node by `jstest/log.test.mjs`) | written here | |
1414 | `static/js/files.js` | the Files view: a tree whose directories open in place and walk by keyboard (`window.ermiteTree`, tested in Node by `jstest/tree.test.mjs`), the fuzzy file finder and the palette's file hits (`window.ermiteFinder`; its core is tested in Node by `jstest/files.test.mjs`) | written here | |
@@ -19,7 +19,7 @@ content-hashed names. Nothing is fetched at runtime.
1919 | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | |
2020 | `static/fonts/atkinson-next.woff2`, `atkinson-next-italic.woff2` | Atkinson Hyperlegible Next, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-next.txt`) | Google Fonts | `18b2a1a39a2fa298b0ba5390aca68462669826c90925656f1c1f6796e0e1bbaf`, `4a5037bfaf6680f40147407407ec09fa42925774bde809a579283d27f9f08106` |
2121 | `static/fonts/atkinson-mono.woff2` | Atkinson Hyperlegible Mono, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-mono.txt`) | Google Fonts | `2706b1ee4f452e744ea91f7e4908cbde9c5d35521bf5ffffc71a382a2de89613` |
22-| `static/icons/*.svg` | interface icons, used as CSS masks; `brand.svg` is a grey picture shown as is. Only the icons in use are copied, one file each: `branch`, `search`, `tag`, `file`, `chevron`, `user`, `key`, `log-in`, `log-out`, `cards`, `split`, `compact` are Lucide 1.8 (`git-branch`, `search`, `tag`, `file-text`, `chevron-down`, `user-round`, `key-round`, `log-in`, `log-out`, `rows-2`, `columns-2`, `list`; ISC, https://lucide.dev/license) | Lucide; `brand.svg` written here | |
22+| `static/icons/*.svg` | interface icons, used as CSS masks; `brand.svg` is a grey picture shown as is. Only the icons in use are copied, one file each: `branch`, `search`, `tag`, `file`, `chevron`, `user`, `key`, `log-in`, `log-out`, `cards`, `split`, `compact`, `sun-moon`, `sun`, `moon` are Lucide 1.8 (`git-branch`, `search`, `tag`, `file-text`, `chevron-down`, `user-round`, `key-round`, `log-in`, `log-out`, `rows-2`, `columns-2`, `list`, `sun-moon`, `sun`, `moon`; ISC, https://lucide.dev/license) | Lucide; `brand.svg` written here | |
2323 | `static/app/*.png` | icons of the installed web app (manifest, `apple-touch-icon`), rendered from `brand.svg` with resvg; the maskable and Apple icons have the picture in the safe zone on `#181818` | generated here | |
2424 | `static/sw.js` | the service worker of the installed app: a network-only passthrough (no cache), served at `/-/sw.js` with `Service-Worker-Allowed: /` and revalidated on each check (not a hashed asset) | written here | |
2525 | `static/rules.json` | speculation rules: prefetch the light same-origin pages on hover (`Speculation-Rules` header); the heavy ones (tree, blob, commit, commits, compare, refs, raw, MR "since") are excluded, each costs several Git reads on the server | written here | |
@@ -8,7 +8,7 @@ content-hashed names. Nothing is fetched at runtime.
8 | `static/js/htmx.min.js` | htmx 4.0.0 (BSD Zero Clause, `static/js/htmx-LICENSE`) | `mockups/assets/htmx/htmx.min.js` of the design | `e484d9171a9db30a39c8f16e3d709d4137f3211c659f8e6125816635033d593f` |8 | `static/js/htmx.min.js` | htmx 4.0.0 (BSD Zero Clause, `static/js/htmx-LICENSE`) | `mockups/assets/htmx/htmx.min.js` of the design | `e484d9171a9db30a39c8f16e3d709d4137f3211c659f8e6125816635033d593f` |
9 | `static/js/hx-live.min.js` | htmx 4 `hx-live` extension | `mockups/assets/htmx/ext/hx-live.min.js` | `adf9b90949d57baf20dc2eaf1d1fd46c81347ac82c090a5e2912ce6b37726734` |9 | `static/js/hx-live.min.js` | htmx 4 `hx-live` extension | `mockups/assets/htmx/ext/hx-live.min.js` | `adf9b90949d57baf20dc2eaf1d1fd46c81347ac82c090a5e2912ce6b37726734` |
10 | `static/js/hx-sse.min.js` | htmx 4 `hx-sse` extension | `mockups/assets/htmx/ext/hx-sse.min.js` | `8a834680c4000a9034d79228872372a92e140c810a075cb6d4a76690dfc13085` |10 | `static/js/hx-sse.min.js` | htmx 4 `hx-sse` extension | `mockups/assets/htmx/ext/hx-sse.min.js` | `8a834680c4000a9034d79228872372a92e140c810a075cb6d4a76690dfc13085` |
11-| `static/js/store.js` | the localStorage client of the scripts: preferences such as the home layout and the single-key shortcuts (`window.ermiteStore`, tested in Node by `jstest/store.test.mjs`) | written here | |11+| `static/js/store.js` | the localStorage client of the scripts: preferences such as the home layout and the single-key shortcuts (`window.ermiteStore`, tested in Node by `jstest/store.test.mjs`); applies the wide mode and the theme (`window.ermiteTheme`: auto, light or dark, key `theme`; tested by `jstest/theme.test.mjs`) before the first paint | written here | |
12 | `static/js/ermite.js` | key sequences to `k-*` events, the functions the keys and inputs call (`window.ermite`), the live fragments (`ermiteLive`), the diff table of contents observer | `mockups/d/js/ermite.js`, extended here | |12 | `static/js/ermite.js` | key sequences to `k-*` events, the functions the keys and inputs call (`window.ermite`), the live fragments (`ermiteLive`), the diff table of contents observer | `mockups/d/js/ermite.js`, extended here | |
13 | `static/js/log.js` | the live log of a running job (`window.ermiteLog`, tested in Node by `jstest/log.test.mjs`) | written here | |13 | `static/js/log.js` | the live log of a running job (`window.ermiteLog`, tested in Node by `jstest/log.test.mjs`) | written here | |
14 | `static/js/files.js` | the Files view: a tree whose directories open in place and walk by keyboard (`window.ermiteTree`, tested in Node by `jstest/tree.test.mjs`), the fuzzy file finder and the palette's file hits (`window.ermiteFinder`; its core is tested in Node by `jstest/files.test.mjs`) | written here | |14 | `static/js/files.js` | the Files view: a tree whose directories open in place and walk by keyboard (`window.ermiteTree`, tested in Node by `jstest/tree.test.mjs`), the fuzzy file finder and the palette's file hits (`window.ermiteFinder`; its core is tested in Node by `jstest/files.test.mjs`) | written here | |
@@ -19,7 +19,7 @@ content-hashed names. Nothing is fetched at runtime.
19 | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | |19 | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | |
20 | `static/fonts/atkinson-next.woff2`, `atkinson-next-italic.woff2` | Atkinson Hyperlegible Next, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-next.txt`) | Google Fonts | `18b2a1a39a2fa298b0ba5390aca68462669826c90925656f1c1f6796e0e1bbaf`, `4a5037bfaf6680f40147407407ec09fa42925774bde809a579283d27f9f08106` |20 | `static/fonts/atkinson-next.woff2`, `atkinson-next-italic.woff2` | Atkinson Hyperlegible Next, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-next.txt`) | Google Fonts | `18b2a1a39a2fa298b0ba5390aca68462669826c90925656f1c1f6796e0e1bbaf`, `4a5037bfaf6680f40147407407ec09fa42925774bde809a579283d27f9f08106` |
21 | `static/fonts/atkinson-mono.woff2` | Atkinson Hyperlegible Mono, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-mono.txt`) | Google Fonts | `2706b1ee4f452e744ea91f7e4908cbde9c5d35521bf5ffffc71a382a2de89613` |21 | `static/fonts/atkinson-mono.woff2` | Atkinson Hyperlegible Mono, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-mono.txt`) | Google Fonts | `2706b1ee4f452e744ea91f7e4908cbde9c5d35521bf5ffffc71a382a2de89613` |
22-| `static/icons/*.svg` | interface icons, used as CSS masks; `brand.svg` is a grey picture shown as is. Only the icons in use are copied, one file each: `branch`, `search`, `tag`, `file`, `chevron`, `user`, `key`, `log-in`, `log-out`, `cards`, `split`, `compact` are Lucide 1.8 (`git-branch`, `search`, `tag`, `file-text`, `chevron-down`, `user-round`, `key-round`, `log-in`, `log-out`, `rows-2`, `columns-2`, `list`; ISC, https://lucide.dev/license) | Lucide; `brand.svg` written here | |22+| `static/icons/*.svg` | interface icons, used as CSS masks; `brand.svg` is a grey picture shown as is. Only the icons in use are copied, one file each: `branch`, `search`, `tag`, `file`, `chevron`, `user`, `key`, `log-in`, `log-out`, `cards`, `split`, `compact`, `sun-moon`, `sun`, `moon` are Lucide 1.8 (`git-branch`, `search`, `tag`, `file-text`, `chevron-down`, `user-round`, `key-round`, `log-in`, `log-out`, `rows-2`, `columns-2`, `list`, `sun-moon`, `sun`, `moon`; ISC, https://lucide.dev/license) | Lucide; `brand.svg` written here | |
23 | `static/app/*.png` | icons of the installed web app (manifest, `apple-touch-icon`), rendered from `brand.svg` with resvg; the maskable and Apple icons have the picture in the safe zone on `#181818` | generated here | |23 | `static/app/*.png` | icons of the installed web app (manifest, `apple-touch-icon`), rendered from `brand.svg` with resvg; the maskable and Apple icons have the picture in the safe zone on `#181818` | generated here | |
24 | `static/sw.js` | the service worker of the installed app: a network-only passthrough (no cache), served at `/-/sw.js` with `Service-Worker-Allowed: /` and revalidated on each check (not a hashed asset) | written here | |24 | `static/sw.js` | the service worker of the installed app: a network-only passthrough (no cache), served at `/-/sw.js` with `Service-Worker-Allowed: /` and revalidated on each check (not a hashed asset) | written here | |
25 | `static/rules.json` | speculation rules: prefetch the light same-origin pages on hover (`Speculation-Rules` header); the heavy ones (tree, blob, commit, commits, compare, refs, raw, MR "since") are excluded, each costs several Git reads on the server | written here | |25 | `static/rules.json` | speculation rules: prefetch the light same-origin pages on hover (`Speculation-Rules` header); the heavy ones (tree, blob, commit, commits, compare, refs, raw, MR "since") are excluded, each costs several Git reads on the server | written here | |
A internal/adapters/web/jstest/theme.test.mjs +139 -0
@@ -0,0 +1,139 @@
1+// Tests of the theme: window.ermiteTheme of static/js/store.js (applied before
2+// the first paint) and the footer switch of static/js/ermite.js
3+// (window.ermiteThemeSwitch), on the small DOM of dom.mjs. Only the
4+// ermiteThemeSwitch block of ermite.js is evaluated: the rest needs a page.
5+// Run with
6+// node --test 'internal/adapters/web/jstest/*.test.mjs'
7+import assert from 'node:assert/strict';
8+import { readFileSync } from 'node:fs';
9+import { beforeEach, test } from 'node:test';
10+import { el, installDom } from './dom.mjs';
11+
12+const read = (f) => readFileSync(new URL('../static/js/' + f, import.meta.url), 'utf8');
13+const ermite = read('ermite.js');
14+const start = ermite.indexOf(' window.ermiteThemeSwitch = ');
15+const block = ermite.slice(start, ermite.indexOf(' })();', start) + ' })();'.length);
16+const storeSrc = read('store.js');
17+
18+const doc = installDom();
19+let listeners;
20+let storage;
21+let root;
22+let scheme;
23+let colors;
24+let button;
25+let icon;
26+
27+// A page as shell.templ renders it: the metas and the footer button in auto.
28+// Then store.js runs (in <head>) and, unless withSwitch is false, the block of
29+// ermite.js (deferred).
30+function load({ stored, withSwitch = true } = {}) {
31+ listeners = {};
32+ storage = new Map(stored ? Object.entries(stored) : []);
33+ globalThis.localStorage = {
34+ getItem: (k) => (storage.has(k) ? storage.get(k) : null),
35+ setItem: (k, v) => { storage.set(k, String(v)); },
36+ removeItem: (k) => { storage.delete(k); },
37+ };
38+ globalThis.addEventListener = (type, f) => (listeners[type] ||= []).push(f);
39+ globalThis.removeEventListener = (type, f) => { listeners[type] = (listeners[type] || []).filter((g) => g !== f); };
40+ root = el('html');
41+ doc.documentElement = root;
42+ scheme = el('meta', { attrs: { name: 'color-scheme', content: 'light dark' } });
43+ colors = [
44+ el('meta', { attrs: { name: 'theme-color', media: '(prefers-color-scheme: light)', content: '#ffffff' } }),
45+ el('meta', { attrs: { name: 'theme-color', media: '(prefers-color-scheme: dark)', content: '#0c0c0c' } }),
46+ ];
47+ icon = el('span', { cls: 'ico i-sun-moon', attrs: { 'aria-hidden': 'true' } });
48+ button = el('button', { cls: 'foot-theme', attrs: { type: 'button', 'data-js': '', 'data-theme-switch': '' } }, icon);
49+ doc.body.replaceChildren(scheme, ...colors, el('footer', { cls: 'foot' }, button));
50+ new Function(storeSrc)();
51+ if (withSwitch) new Function(block)();
52+ return window.ermiteTheme;
53+}
54+
55+const state = () => ({
56+ attr: root.getAttribute('data-theme'),
57+ scheme: scheme.getAttribute('content'),
58+ colors: colors.map((m) => m.getAttribute('content')),
59+});
60+const click = (target) => listeners.click.forEach((f) => f({ target }));
61+const storageEvent = (newValue) => listeners.storage.forEach((f) => f({ key: 'theme', newValue, storageArea: localStorage }));
62+
63+beforeEach(() => { delete globalThis.localStorage; });
64+
65+test('nothing stored: auto, the page is left to the system', () => {
66+ const T = load({ withSwitch: false });
67+ assert.equal(T.get(), 'auto');
68+ assert.deepEqual(state(), { attr: null, scheme: 'light dark', colors: ['#ffffff', '#0c0c0c'] });
69+});
70+
71+test('store.js applies a stored theme before ermite.js runs', () => {
72+ load({ stored: { theme: 'dark' }, withSwitch: false });
73+ assert.deepEqual(state(), { attr: 'dark', scheme: 'dark', colors: ['#0c0c0c', '#0c0c0c'] });
74+ load({ stored: { theme: 'light' }, withSwitch: false });
75+ assert.deepEqual(state(), { attr: 'light', scheme: 'light', colors: ['#ffffff', '#ffffff'] });
76+});
77+
78+test('a stored value that is not a theme falls back to auto', () => {
79+ const T = load({ stored: { theme: 'sepia' } });
80+ assert.equal(T.get(), 'auto');
81+ assert.equal(root.getAttribute('data-theme'), null);
82+});
83+
84+test('next cycles auto, light, dark', () => {
85+ const T = load({ withSwitch: false });
86+ assert.deepEqual(T.values.map(T.next), ['light', 'dark', 'auto']);
87+});
88+
89+test('a click on the button (or its icon) cycles, stores and shows the theme', () => {
90+ load();
91+ assert.equal(button.getAttribute('aria-label'), 'theme: auto (click for light)');
92+ click(icon);
93+ assert.equal(storage.get('theme'), 'light');
94+ assert.deepEqual(state(), { attr: 'light', scheme: 'light', colors: ['#ffffff', '#ffffff'] });
95+ assert.equal(icon.className, 'ico i-sun');
96+ assert.equal(button.getAttribute('aria-label'), 'theme: light (click for dark)');
97+ assert.equal(button.getAttribute('title'), 'theme: light (click for dark)');
98+ click(button);
99+ assert.equal(storage.get('theme'), 'dark');
100+ assert.deepEqual(state(), { attr: 'dark', scheme: 'dark', colors: ['#0c0c0c', '#0c0c0c'] });
101+ assert.equal(icon.className, 'ico i-moon');
102+ click(button);
103+ assert.equal(storage.get('theme'), 'auto');
104+ assert.deepEqual(state(), { attr: null, scheme: 'light dark', colors: ['#ffffff', '#0c0c0c'] });
105+ assert.equal(icon.className, 'ico i-sun-moon');
106+ assert.equal(button.getAttribute('aria-label'), 'theme: auto (click for light)');
107+});
108+
109+test('other clicks do nothing', () => {
110+ const T = load();
111+ click(doc.body);
112+ click({ closest: undefined });
113+ assert.equal(T.get(), 'auto');
114+});
115+
116+test("another tab's change applies live", () => {
117+ const T = load();
118+ storage.set('theme', 'dark');
119+ storageEvent('dark');
120+ assert.equal(T.get(), 'dark');
121+ assert.equal(root.getAttribute('data-theme'), 'dark');
122+ assert.equal(icon.className, 'ico i-moon');
123+ storage.delete('theme');
124+ storageEvent(null);
125+ assert.equal(root.getAttribute('data-theme'), null, 'removed: back to auto');
126+ assert.equal(button.getAttribute('aria-label'), 'theme: auto (click for light)');
127+});
128+
129+test('without usable storage the choice holds for the page', () => {
130+ load();
131+ globalThis.localStorage = {
132+ getItem: () => { throw new Error('denied'); },
133+ setItem: () => { throw new Error('quota'); },
134+ removeItem: () => { throw new Error('denied'); },
135+ };
136+ click(button);
137+ assert.equal(window.ermiteTheme.get(), 'light');
138+ assert.equal(root.getAttribute('data-theme'), 'light');
139+});
@@ -0,0 +1,139 @@
1+// Tests of the theme: window.ermiteTheme of static/js/store.js (applied before
2+// the first paint) and the footer switch of static/js/ermite.js
3+// (window.ermiteThemeSwitch), on the small DOM of dom.mjs. Only the
4+// ermiteThemeSwitch block of ermite.js is evaluated: the rest needs a page.
5+// Run with
6+// node --test 'internal/adapters/web/jstest/*.test.mjs'
7+import assert from 'node:assert/strict';
8+import { readFileSync } from 'node:fs';
9+import { beforeEach, test } from 'node:test';
10+import { el, installDom } from './dom.mjs';
11+
12+const read = (f) => readFileSync(new URL('../static/js/' + f, import.meta.url), 'utf8');
13+const ermite = read('ermite.js');
14+const start = ermite.indexOf(' window.ermiteThemeSwitch = ');
15+const block = ermite.slice(start, ermite.indexOf(' })();', start) + ' })();'.length);
16+const storeSrc = read('store.js');
17+
18+const doc = installDom();
19+let listeners;
20+let storage;
21+let root;
22+let scheme;
23+let colors;
24+let button;
25+let icon;
26+
27+// A page as shell.templ renders it: the metas and the footer button in auto.
28+// Then store.js runs (in <head>) and, unless withSwitch is false, the block of
29+// ermite.js (deferred).
30+function load({ stored, withSwitch = true } = {}) {
31+ listeners = {};
32+ storage = new Map(stored ? Object.entries(stored) : []);
33+ globalThis.localStorage = {
34+ getItem: (k) => (storage.has(k) ? storage.get(k) : null),
35+ setItem: (k, v) => { storage.set(k, String(v)); },
36+ removeItem: (k) => { storage.delete(k); },
37+ };
38+ globalThis.addEventListener = (type, f) => (listeners[type] ||= []).push(f);
39+ globalThis.removeEventListener = (type, f) => { listeners[type] = (listeners[type] || []).filter((g) => g !== f); };
40+ root = el('html');
41+ doc.documentElement = root;
42+ scheme = el('meta', { attrs: { name: 'color-scheme', content: 'light dark' } });
43+ colors = [
44+ el('meta', { attrs: { name: 'theme-color', media: '(prefers-color-scheme: light)', content: '#ffffff' } }),
45+ el('meta', { attrs: { name: 'theme-color', media: '(prefers-color-scheme: dark)', content: '#0c0c0c' } }),
46+ ];
47+ icon = el('span', { cls: 'ico i-sun-moon', attrs: { 'aria-hidden': 'true' } });
48+ button = el('button', { cls: 'foot-theme', attrs: { type: 'button', 'data-js': '', 'data-theme-switch': '' } }, icon);
49+ doc.body.replaceChildren(scheme, ...colors, el('footer', { cls: 'foot' }, button));
50+ new Function(storeSrc)();
51+ if (withSwitch) new Function(block)();
52+ return window.ermiteTheme;
53+}
54+
55+const state = () => ({
56+ attr: root.getAttribute('data-theme'),
57+ scheme: scheme.getAttribute('content'),
58+ colors: colors.map((m) => m.getAttribute('content')),
59+});
60+const click = (target) => listeners.click.forEach((f) => f({ target }));
61+const storageEvent = (newValue) => listeners.storage.forEach((f) => f({ key: 'theme', newValue, storageArea: localStorage }));
62+
63+beforeEach(() => { delete globalThis.localStorage; });
64+
65+test('nothing stored: auto, the page is left to the system', () => {
66+ const T = load({ withSwitch: false });
67+ assert.equal(T.get(), 'auto');
68+ assert.deepEqual(state(), { attr: null, scheme: 'light dark', colors: ['#ffffff', '#0c0c0c'] });
69+});
70+
71+test('store.js applies a stored theme before ermite.js runs', () => {
72+ load({ stored: { theme: 'dark' }, withSwitch: false });
73+ assert.deepEqual(state(), { attr: 'dark', scheme: 'dark', colors: ['#0c0c0c', '#0c0c0c'] });
74+ load({ stored: { theme: 'light' }, withSwitch: false });
75+ assert.deepEqual(state(), { attr: 'light', scheme: 'light', colors: ['#ffffff', '#ffffff'] });
76+});
77+
78+test('a stored value that is not a theme falls back to auto', () => {
79+ const T = load({ stored: { theme: 'sepia' } });
80+ assert.equal(T.get(), 'auto');
81+ assert.equal(root.getAttribute('data-theme'), null);
82+});
83+
84+test('next cycles auto, light, dark', () => {
85+ const T = load({ withSwitch: false });
86+ assert.deepEqual(T.values.map(T.next), ['light', 'dark', 'auto']);
87+});
88+
89+test('a click on the button (or its icon) cycles, stores and shows the theme', () => {
90+ load();
91+ assert.equal(button.getAttribute('aria-label'), 'theme: auto (click for light)');
92+ click(icon);
93+ assert.equal(storage.get('theme'), 'light');
94+ assert.deepEqual(state(), { attr: 'light', scheme: 'light', colors: ['#ffffff', '#ffffff'] });
95+ assert.equal(icon.className, 'ico i-sun');
96+ assert.equal(button.getAttribute('aria-label'), 'theme: light (click for dark)');
97+ assert.equal(button.getAttribute('title'), 'theme: light (click for dark)');
98+ click(button);
99+ assert.equal(storage.get('theme'), 'dark');
100+ assert.deepEqual(state(), { attr: 'dark', scheme: 'dark', colors: ['#0c0c0c', '#0c0c0c'] });
101+ assert.equal(icon.className, 'ico i-moon');
102+ click(button);
103+ assert.equal(storage.get('theme'), 'auto');
104+ assert.deepEqual(state(), { attr: null, scheme: 'light dark', colors: ['#ffffff', '#0c0c0c'] });
105+ assert.equal(icon.className, 'ico i-sun-moon');
106+ assert.equal(button.getAttribute('aria-label'), 'theme: auto (click for light)');
107+});
108+
109+test('other clicks do nothing', () => {
110+ const T = load();
111+ click(doc.body);
112+ click({ closest: undefined });
113+ assert.equal(T.get(), 'auto');
114+});
115+
116+test("another tab's change applies live", () => {
117+ const T = load();
118+ storage.set('theme', 'dark');
119+ storageEvent('dark');
120+ assert.equal(T.get(), 'dark');
121+ assert.equal(root.getAttribute('data-theme'), 'dark');
122+ assert.equal(icon.className, 'ico i-moon');
123+ storage.delete('theme');
124+ storageEvent(null);
125+ assert.equal(root.getAttribute('data-theme'), null, 'removed: back to auto');
126+ assert.equal(button.getAttribute('aria-label'), 'theme: auto (click for light)');
127+});
128+
129+test('without usable storage the choice holds for the page', () => {
130+ load();
131+ globalThis.localStorage = {
132+ getItem: () => { throw new Error('denied'); },
133+ setItem: () => { throw new Error('quota'); },
134+ removeItem: () => { throw new Error('denied'); },
135+ };
136+ click(button);
137+ assert.equal(window.ermiteTheme.get(), 'light');
138+ assert.equal(root.getAttribute('data-theme'), 'light');
139+});
M internal/adapters/web/shell.templ +4 -1
@@ -192,12 +192,15 @@ templ Header(l Layout) {
192192 }
193193
194194 // Footer is the version and the link to the source of the running version,
195-// which the AGPL §13 asks every user of the service to be offered.
195+// which the AGPL §13 asks every user of the service to be offered, then the
196+// keyboard shortcuts and the theme switch (auto, light, dark; ermite.js shows
197+// the stored mode).
196198 templ Footer(l Layout) {
197199 <footer class="foot">
198200 <span>ermite { l.Version }</span>
199201 <a href={ templ.SafeURL(l.SourceURL) } rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
200202 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?"><kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
203+ <button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)"><span class="ico i-sun-moon" aria-hidden="true"></span></button>
201204 </footer>
202205 }
203206
@@ -192,12 +192,15 @@ templ Header(l Layout) {
192 }192 }
193 193
194 // Footer is the version and the link to the source of the running version,194 // Footer is the version and the link to the source of the running version,
195-// which the AGPL §13 asks every user of the service to be offered.195+// which the AGPL §13 asks every user of the service to be offered, then the
196+// keyboard shortcuts and the theme switch (auto, light, dark; ermite.js shows
197+// the stored mode).
196 templ Footer(l Layout) {198 templ Footer(l Layout) {
197 <footer class="foot">199 <footer class="foot">
198 <span>ermite { l.Version }</span>200 <span>ermite { l.Version }</span>
199 <a href={ templ.SafeURL(l.SourceURL) } rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>201 <a href={ templ.SafeURL(l.SourceURL) } rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
200 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?"><kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>202 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?"><kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
203+ <button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)"><span class="ico i-sun-moon" aria-hidden="true"></span></button>
201 </footer>204 </footer>
202 }205 }
203 206
M internal/adapters/web/shell_templ.go +6 -4 generated file: collapsed
M internal/adapters/web/static/css/00-tokens.css +4 -0
@@ -27,7 +27,11 @@
2727 cursor, links in prose, open folders. */
2828 @layer tokens {
2929 :root {
30+ /* the system's scheme, unless the theme switch forces one
31+ (html[data-theme], set by store.js) */
3032 color-scheme: light dark;
33+ &[data-theme="light"] { color-scheme: light; }
34+ &[data-theme="dark"] { color-scheme: dark; }
3135
3236 /* surfaces, bottom to top */
3337 --bg-canvas: light-dark(#f3f3f3, #050505); /* the page */
@@ -27,7 +27,11 @@
27 cursor, links in prose, open folders. */27 cursor, links in prose, open folders. */
28 @layer tokens {28 @layer tokens {
29 :root {29 :root {
30+ /* the system's scheme, unless the theme switch forces one
31+ (html[data-theme], set by store.js) */
30 color-scheme: light dark;32 color-scheme: light dark;
33+ &[data-theme="light"] { color-scheme: light; }
34+ &[data-theme="dark"] { color-scheme: dark; }
31 35
32 /* surfaces, bottom to top */36 /* surfaces, bottom to top */
33 --bg-canvas: light-dark(#f3f3f3, #050505); /* the page */37 --bg-canvas: light-dark(#f3f3f3, #050505); /* the page */
M internal/adapters/web/static/css/20-shell.css +9 -1
@@ -30,6 +30,9 @@
3030 .i-split { --i: url(icons/split.svg); }
3131 .i-compact { --i: url(icons/compact.svg); }
3232 .i-mailbox { --i: url(icons/mailbox.svg); }
33+ .i-sun-moon { --i: url(icons/sun-moon.svg); }
34+ .i-sun { --i: url(icons/sun.svg); }
35+ .i-moon { --i: url(icons/moon.svg); }
3336
3437 .pill {
3538 display: inline-flex; align-items: center; height: 20px; padding: 0 var(--s2); margin-left: var(--s1);
@@ -185,7 +188,7 @@
185188 & .l4, & .l5, & .l6 { padding-left: var(--s6); }
186189 }
187190
188- /* footer: version and source (AGPL §13) */
191+ /* footer: version and source (AGPL §13), then shortcuts and theme */
189192 .foot {
190193 display: flex; flex-wrap: wrap; gap: var(--s4); align-items: center;
191194 width: 100%; max-width: var(--page); margin: 0 auto; padding: var(--s4) var(--pad) var(--s5);
@@ -194,6 +197,11 @@
194197 & a { color: var(--ink-dim); }
195198 & .foot-keys { margin-left: auto; color: var(--ink-dim); }
196199 & .foot-keys:hover { color: var(--ink-hi); }
200+ & .foot-theme {
201+ display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px;
202+ margin: -6px 0; color: var(--ink-dim); border-radius: var(--r-s);
203+ }
204+ & .foot-theme:hover { color: var(--ink-hi); background: var(--bg-raised); }
197205 }
198206
199207 /* hint: bottom right, only while it has something to say */
@@ -30,6 +30,9 @@
30 .i-split { --i: url(icons/split.svg); }30 .i-split { --i: url(icons/split.svg); }
31 .i-compact { --i: url(icons/compact.svg); }31 .i-compact { --i: url(icons/compact.svg); }
32 .i-mailbox { --i: url(icons/mailbox.svg); }32 .i-mailbox { --i: url(icons/mailbox.svg); }
33+ .i-sun-moon { --i: url(icons/sun-moon.svg); }
34+ .i-sun { --i: url(icons/sun.svg); }
35+ .i-moon { --i: url(icons/moon.svg); }
33 36
34 .pill {37 .pill {
35 display: inline-flex; align-items: center; height: 20px; padding: 0 var(--s2); margin-left: var(--s1);38 display: inline-flex; align-items: center; height: 20px; padding: 0 var(--s2); margin-left: var(--s1);
@@ -185,7 +188,7 @@
185 & .l4, & .l5, & .l6 { padding-left: var(--s6); }188 & .l4, & .l5, & .l6 { padding-left: var(--s6); }
186 }189 }
187 190
188- /* footer: version and source (AGPL §13) */191+ /* footer: version and source (AGPL §13), then shortcuts and theme */
189 .foot {192 .foot {
190 display: flex; flex-wrap: wrap; gap: var(--s4); align-items: center;193 display: flex; flex-wrap: wrap; gap: var(--s4); align-items: center;
191 width: 100%; max-width: var(--page); margin: 0 auto; padding: var(--s4) var(--pad) var(--s5);194 width: 100%; max-width: var(--page); margin: 0 auto; padding: var(--s4) var(--pad) var(--s5);
@@ -194,6 +197,11 @@
194 & a { color: var(--ink-dim); }197 & a { color: var(--ink-dim); }
195 & .foot-keys { margin-left: auto; color: var(--ink-dim); }198 & .foot-keys { margin-left: auto; color: var(--ink-dim); }
196 & .foot-keys:hover { color: var(--ink-hi); }199 & .foot-keys:hover { color: var(--ink-hi); }
200+ & .foot-theme {
201+ display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px;
202+ margin: -6px 0; color: var(--ink-dim); border-radius: var(--r-s);
203+ }
204+ & .foot-theme:hover { color: var(--ink-hi); background: var(--bg-raised); }
197 }205 }
198 206
199 /* hint: bottom right, only while it has something to say */207 /* hint: bottom right, only while it has something to say */
A internal/adapters/web/static/icons/moon.svg +1 -0
@@ -0,0 +1,1 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"/></svg>
@@ -0,0 +1,1 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"/></svg>
A internal/adapters/web/static/icons/sun-moon.svg +1 -0
@@ -0,0 +1,1 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v2"/><path d="M14.837 16.385a6 6 0 1 1-7.223-7.222c.624-.147.97.66.715 1.248a4 4 0 0 0 5.26 5.259c.589-.255 1.396.09 1.248.715"/><path d="M16 12a4 4 0 0 0-4-4"/><path d="m19 5-1.256 1.256"/><path d="M20 12h2"/></svg>
@@ -0,0 +1,1 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v2"/><path d="M14.837 16.385a6 6 0 1 1-7.223-7.222c.624-.147.97.66.715 1.248a4 4 0 0 0 5.26 5.259c.589-.255 1.396.09 1.248.715"/><path d="M16 12a4 4 0 0 0-4-4"/><path d="m19 5-1.256 1.256"/><path d="M20 12h2"/></svg>
A internal/adapters/web/static/icons/sun.svg +1 -0
@@ -0,0 +1,1 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/></svg>
@@ -0,0 +1,1 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/></svg>
M internal/adapters/web/static/js/ermite.js +31 -0
@@ -15,6 +15,7 @@
1515 // 8. Chip lists kept on one line, with a … chip (ermiteFit).
1616 // 9. The layout of the home page, remembered in the browser (ermiteLayout).
1717 // 10. The wide mode: every page takes the whole width (ermiteWide).
18+// 11. The theme switch of the footer (ermiteThemeSwitch).
1819 //
1920 // Preferences are kept by ermiteStore (store.js, loaded first).
2021 (() => {
@@ -600,6 +601,36 @@
600601 return { toggle, apply };
601602 })();
602603
604+ // The theme switch: the footer button [data-theme-switch] cycles the theme
605+ // of ermiteTheme (store.js, which applies it before the first paint):
606+ // auto → light → dark → auto. Its icon, label and title say the current
607+ // mode and what a click does. Another tab's change applies here at once.
608+ // Self-contained with store.js: jstest/theme.test.mjs evaluates this block.
609+ window.ermiteThemeSwitch = (() => {
610+ const T = window.ermiteTheme;
611+ const icons = { auto: 'i-sun-moon', light: 'i-sun', dark: 'i-moon' };
612+ function apply() {
613+ const mode = T.apply();
614+ const label = `theme: ${mode} (click for ${T.next(mode)})`;
615+ for (const b of document.querySelectorAll('[data-theme-switch]')) {
616+ b.setAttribute('aria-label', label);
617+ b.setAttribute('title', label);
618+ const ico = b.querySelector('.ico');
619+ if (ico) ico.className = 'ico ' + icons[mode];
620+ }
621+ }
622+ function cycle() {
623+ T.set(T.next(T.get()));
624+ apply();
625+ }
626+ addEventListener('click', (e) => {
627+ if (e.target.closest?.('[data-theme-switch]')) cycle();
628+ });
629+ T.watch(apply);
630+ apply();
631+ return { cycle, apply };
632+ })();
633+
603634 // The menus of the bars (<details class="menu">: the diff view, the merge
604635 // menu): closing one hands the focus back to its button when it was inside,
605636 // so that a keyboard user keeps their place.
@@ -15,6 +15,7 @@
15 // 8. Chip lists kept on one line, with a … chip (ermiteFit).15 // 8. Chip lists kept on one line, with a … chip (ermiteFit).
16 // 9. The layout of the home page, remembered in the browser (ermiteLayout).16 // 9. The layout of the home page, remembered in the browser (ermiteLayout).
17 // 10. The wide mode: every page takes the whole width (ermiteWide).17 // 10. The wide mode: every page takes the whole width (ermiteWide).
18+// 11. The theme switch of the footer (ermiteThemeSwitch).
18 //19 //
19 // Preferences are kept by ermiteStore (store.js, loaded first).20 // Preferences are kept by ermiteStore (store.js, loaded first).
20 (() => {21 (() => {
@@ -600,6 +601,36 @@
600 return { toggle, apply };601 return { toggle, apply };
601 })();602 })();
602 603
604+ // The theme switch: the footer button [data-theme-switch] cycles the theme
605+ // of ermiteTheme (store.js, which applies it before the first paint):
606+ // auto → light → dark → auto. Its icon, label and title say the current
607+ // mode and what a click does. Another tab's change applies here at once.
608+ // Self-contained with store.js: jstest/theme.test.mjs evaluates this block.
609+ window.ermiteThemeSwitch = (() => {
610+ const T = window.ermiteTheme;
611+ const icons = { auto: 'i-sun-moon', light: 'i-sun', dark: 'i-moon' };
612+ function apply() {
613+ const mode = T.apply();
614+ const label = `theme: ${mode} (click for ${T.next(mode)})`;
615+ for (const b of document.querySelectorAll('[data-theme-switch]')) {
616+ b.setAttribute('aria-label', label);
617+ b.setAttribute('title', label);
618+ const ico = b.querySelector('.ico');
619+ if (ico) ico.className = 'ico ' + icons[mode];
620+ }
621+ }
622+ function cycle() {
623+ T.set(T.next(T.get()));
624+ apply();
625+ }
626+ addEventListener('click', (e) => {
627+ if (e.target.closest?.('[data-theme-switch]')) cycle();
628+ });
629+ T.watch(apply);
630+ apply();
631+ return { cycle, apply };
632+ })();
633+
603 // The menus of the bars (<details class="menu">: the diff view, the merge634 // The menus of the bars (<details class="menu">: the diff view, the merge
604 // menu): closing one hands the focus back to its button when it was inside,635 // menu): closing one hands the focus back to its button when it was inside,
605 // so that a keyboard user keeps their place.636 // so that a keyboard user keeps their place.
M internal/adapters/web/static/js/store.js +52 -0
@@ -19,6 +19,9 @@
1919 // set ignores what is not
2020 //
2121 // 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).
2225 window.ermiteStore = (() => {
2326 // What storage could not keep, by key. A key mapped to null is removed for
2427 // this page although storage would not say so.
@@ -79,3 +82,52 @@ window.ermiteStore = (() => {
7982 // shell.templ loads this file without defer, so a wide page never draws narrow
8083 // first.
8184 if (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.
100+window.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, whatever the
116+ // 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('media') ?? '').includes('prefers-color-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+})();
@@ -19,6 +19,9 @@
19 // set ignores what is not19 // set ignores what is not
20 //20 //
21 // Self-contained: jstest/store.test.mjs evaluates this file alone.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).
22 window.ermiteStore = (() => {25 window.ermiteStore = (() => {
23 // What storage could not keep, by key. A key mapped to null is removed for26 // What storage could not keep, by key. A key mapped to null is removed for
24 // this page although storage would not say so.27 // this page although storage would not say so.
@@ -79,3 +82,52 @@ window.ermiteStore = (() => {
79 // shell.templ loads this file without defer, so a wide page never draws narrow82 // shell.templ loads this file without defer, so a wide page never draws narrow
80 // first.83 // first.
81 if (window.ermiteStore.get('wide') === '1') document?.documentElement?.classList?.add('wide');84 if (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.
100+window.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, whatever the
116+ // 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('media') ?? '').includes('prefers-color-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+})();
M internal/app/testdata/web/blob-binary.html +4 -0
@@ -140,6 +140,10 @@
140140 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
141141 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
142142 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
143+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
144+<span class="ico i-sun-moon" aria-hidden="true">
145+</span>
146+</button>
143147 </footer>
144148 <div class="hint">
145149 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
@@ -140,6 +140,10 @@
140 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>140 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
141 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">141 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
142 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>142 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
143+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
144+<span class="ico i-sun-moon" aria-hidden="true">
145+</span>
146+</button>
143 </footer>147 </footer>
144 <div class="hint">148 <div class="hint">
145 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">149 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
M internal/app/testdata/web/blob-code.html +4 -0
@@ -210,6 +210,10 @@
210210 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
211211 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
212212 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
213+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
214+<span class="ico i-sun-moon" aria-hidden="true">
215+</span>
216+</button>
213217 </footer>
214218 <div class="hint">
215219 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
@@ -210,6 +210,10 @@
210 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>210 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
211 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">211 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
212 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>212 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
213+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
214+<span class="ico i-sun-moon" aria-hidden="true">
215+</span>
216+</button>
213 </footer>217 </footer>
214 <div class="hint">218 <div class="hint">
215 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">219 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
M internal/app/testdata/web/blob-empty.html +4 -0
@@ -140,6 +140,10 @@
140140 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
141141 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
142142 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
143+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
144+<span class="ico i-sun-moon" aria-hidden="true">
145+</span>
146+</button>
143147 </footer>
144148 <div class="hint">
145149 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
@@ -140,6 +140,10 @@
140 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>140 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
141 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">141 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
142 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>142 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
143+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
144+<span class="ico i-sun-moon" aria-hidden="true">
145+</span>
146+</button>
143 </footer>147 </footer>
144 <div class="hint">148 <div class="hint">
145 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">149 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
M internal/app/testdata/web/blob-image.html +4 -0
@@ -142,6 +142,10 @@
142142 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
143143 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
144144 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
145+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
146+<span class="ico i-sun-moon" aria-hidden="true">
147+</span>
148+</button>
145149 </footer>
146150 <div class="hint">
147151 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
@@ -142,6 +142,10 @@
142 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>142 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
143 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">143 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
144 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>144 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
145+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
146+<span class="ico i-sun-moon" aria-hidden="true">
147+</span>
148+</button>
145 </footer>149 </footer>
146 <div class="hint">150 <div class="hint">
147 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">151 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
M internal/app/testdata/web/blob-markdown.html +4 -0
@@ -207,6 +207,10 @@
207207 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
208208 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
209209 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
210+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
211+<span class="ico i-sun-moon" aria-hidden="true">
212+</span>
213+</button>
210214 </footer>
211215 <div class="hint">
212216 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
@@ -207,6 +207,10 @@
207 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>207 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
208 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">208 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
209 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>209 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
210+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
211+<span class="ico i-sun-moon" aria-hidden="true">
212+</span>
213+</button>
210 </footer>214 </footer>
211 <div class="hint">215 <div class="hint">
212 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">216 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
M internal/app/testdata/web/blob-svg.html +4 -0
@@ -165,6 +165,10 @@
165165 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
166166 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
167167 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
168+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
169+<span class="ico i-sun-moon" aria-hidden="true">
170+</span>
171+</button>
168172 </footer>
169173 <div class="hint">
170174 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
@@ -165,6 +165,10 @@
165 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>165 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
166 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">166 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
167 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>167 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
168+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
169+<span class="ico i-sun-moon" aria-hidden="true">
170+</span>
171+</button>
168 </footer>172 </footer>
169 <div class="hint">173 <div class="hint">
170 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">174 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
M internal/app/testdata/web/blob-text.html +4 -0
@@ -160,6 +160,10 @@
160160 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
161161 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
162162 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
163+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
164+<span class="ico i-sun-moon" aria-hidden="true">
165+</span>
166+</button>
163167 </footer>
164168 <div class="hint">
165169 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
@@ -160,6 +160,10 @@
160 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>160 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
161 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">161 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
162 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>162 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
163+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
164+<span class="ico i-sun-moon" aria-hidden="true">
165+</span>
166+</button>
163 </footer>167 </footer>
164 <div class="hint">168 <div class="hint">
165 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">169 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
M internal/app/testdata/web/blob-too-large.html +4 -0
@@ -140,6 +140,10 @@
140140 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
141141 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
142142 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
143+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
144+<span class="ico i-sun-moon" aria-hidden="true">
145+</span>
146+</button>
143147 </footer>
144148 <div class="hint">
145149 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
@@ -140,6 +140,10 @@
140 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>140 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
141 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">141 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
142 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>142 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
143+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
144+<span class="ico i-sun-moon" aria-hidden="true">
145+</span>
146+</button>
143 </footer>147 </footer>
144 <div class="hint">148 <div class="hint">
145 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">149 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
M internal/app/testdata/web/blob-trojan.html +4 -0
@@ -207,6 +207,10 @@
207207 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
208208 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
209209 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
210+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
211+<span class="ico i-sun-moon" aria-hidden="true">
212+</span>
213+</button>
210214 </footer>
211215 <div class="hint">
212216 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
@@ -207,6 +207,10 @@
207 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>207 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
208 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">208 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
209 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>209 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
210+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
211+<span class="ico i-sun-moon" aria-hidden="true">
212+</span>
213+</button>
210 </footer>214 </footer>
211 <div class="hint">215 <div class="hint">
212 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">216 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
M internal/app/testdata/web/commit-big.html +4 -0
@@ -192,6 +192,10 @@
192192 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
193193 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
194194 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
195+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
196+<span class="ico i-sun-moon" aria-hidden="true">
197+</span>
198+</button>
195199 </footer>
196200 <div class="hint">
197201 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
@@ -192,6 +192,10 @@
192 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>192 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
193 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">193 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
194 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>194 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
195+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
196+<span class="ico i-sun-moon" aria-hidden="true">
197+</span>
198+</button>
195 </footer>199 </footer>
196 <div class="hint">200 <div class="hint">
197 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">201 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
M internal/app/testdata/web/commit-binary.html +4 -0
@@ -183,6 +183,10 @@
183183 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
184184 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
185185 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
186+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
187+<span class="ico i-sun-moon" aria-hidden="true">
188+</span>
189+</button>
186190 </footer>
187191 <div class="hint">
188192 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
@@ -183,6 +183,10 @@
183 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>183 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
184 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">184 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
185 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>185 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
186+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
187+<span class="ico i-sun-moon" aria-hidden="true">
188+</span>
189+</button>
186 </footer>190 </footer>
187 <div class="hint">191 <div class="hint">
188 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">192 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
M internal/app/testdata/web/commit-merge.html +4 -0
@@ -225,6 +225,10 @@
225225 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
226226 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
227227 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
228+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
229+<span class="ico i-sun-moon" aria-hidden="true">
230+</span>
231+</button>
228232 </footer>
229233 <div class="hint">
230234 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
@@ -225,6 +225,10 @@
225 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>225 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
226 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">226 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
227 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>227 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
228+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
229+<span class="ico i-sun-moon" aria-hidden="true">
230+</span>
231+</button>
228 </footer>232 </footer>
229 <div class="hint">233 <div class="hint">
230 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">234 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
M internal/app/testdata/web/commit-modify-split.html +4 -0
@@ -348,6 +348,10 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
348348 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
349349 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
350350 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
351+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
352+<span class="ico i-sun-moon" aria-hidden="true">
353+</span>
354+</button>
351355 </footer>
352356 <div class="hint">
353357 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
@@ -348,6 +348,10 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
348 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>348 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
349 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">349 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
350 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>350 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
351+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
352+<span class="ico i-sun-moon" aria-hidden="true">
353+</span>
354+</button>
351 </footer>355 </footer>
352 <div class="hint">356 <div class="hint">
353 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">357 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
M internal/app/testdata/web/commit-modify.html +4 -0
@@ -348,6 +348,10 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
348348 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
349349 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
350350 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
351+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
352+<span class="ico i-sun-moon" aria-hidden="true">
353+</span>
354+</button>
351355 </footer>
352356 <div class="hint">
353357 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
@@ -348,6 +348,10 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
348 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>348 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
349 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">349 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
350 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>350 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
351+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
352+<span class="ico i-sun-moon" aria-hidden="true">
353+</span>
354+</button>
351 </footer>355 </footer>
352 <div class="hint">356 <div class="hint">
353 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">357 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
M internal/app/testdata/web/commit-rename.html +4 -0
@@ -252,6 +252,10 @@
252252 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
253253 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
254254 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
255+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
256+<span class="ico i-sun-moon" aria-hidden="true">
257+</span>
258+</button>
255259 </footer>
256260 <div class="hint">
257261 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
@@ -252,6 +252,10 @@
252 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>252 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
253 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">253 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
254 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>254 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
255+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
256+<span class="ico i-sun-moon" aria-hidden="true">
257+</span>
258+</button>
255 </footer>259 </footer>
256 <div class="hint">260 <div class="hint">
257 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">261 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
M internal/app/testdata/web/commit-root.html +4 -0
@@ -571,6 +571,10 @@
571571 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
572572 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
573573 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
574+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
575+<span class="ico i-sun-moon" aria-hidden="true">
576+</span>
577+</button>
574578 </footer>
575579 <div class="hint">
576580 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
@@ -571,6 +571,10 @@
571 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>571 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
572 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">572 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
573 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>573 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
574+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
575+<span class="ico i-sun-moon" aria-hidden="true">
576+</span>
577+</button>
574 </footer>578 </footer>
575 <div class="hint">579 <div class="hint">
576 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">580 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
M internal/app/testdata/web/commit-trojan.html +4 -0
@@ -400,6 +400,10 @@
400400 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
401401 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
402402 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
403+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
404+<span class="ico i-sun-moon" aria-hidden="true">
405+</span>
406+</button>
403407 </footer>
404408 <div class="hint">
405409 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
@@ -400,6 +400,10 @@
400 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>400 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
401 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">401 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
402 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>402 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
403+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
404+<span class="ico i-sun-moon" aria-hidden="true">
405+</span>
406+</button>
403 </footer>407 </footer>
404 <div class="hint">408 <div class="hint">
405 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">409 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
M internal/app/testdata/web/commits-page2.html +4 -0
@@ -167,6 +167,10 @@
167167 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
168168 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
169169 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
170+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
171+<span class="ico i-sun-moon" aria-hidden="true">
172+</span>
173+</button>
170174 </footer>
171175 <div class="hint">
172176 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
@@ -167,6 +167,10 @@
167 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>167 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
168 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">168 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
169 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>169 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
170+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
171+<span class="ico i-sun-moon" aria-hidden="true">
172+</span>
173+</button>
170 </footer>174 </footer>
171 <div class="hint">175 <div class="hint">
172 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">176 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
M internal/app/testdata/web/commits-path.html +4 -0
@@ -182,6 +182,10 @@
182182 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
183183 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
184184 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
185+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
186+<span class="ico i-sun-moon" aria-hidden="true">
187+</span>
188+</button>
185189 </footer>
186190 <div class="hint">
187191 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
@@ -182,6 +182,10 @@
182 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>182 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
183 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">183 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
184 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>184 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
185+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
186+<span class="ico i-sun-moon" aria-hidden="true">
187+</span>
188+</button>
185 </footer>189 </footer>
186 <div class="hint">190 <div class="hint">
187 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">191 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
M internal/app/testdata/web/commits-root.html +4 -0
@@ -964,6 +964,10 @@
964964 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
965965 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
966966 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
967+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
968+<span class="ico i-sun-moon" aria-hidden="true">
969+</span>
970+</button>
967971 </footer>
968972 <div class="hint">
969973 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
@@ -964,6 +964,10 @@
964 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>964 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
965 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">965 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
966 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>966 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
967+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
968+<span class="ico i-sun-moon" aria-hidden="true">
969+</span>
970+</button>
967 </footer>971 </footer>
968 <div class="hint">972 <div class="hint">
969 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">973 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
M internal/app/testdata/web/commits-small.html +4 -0
@@ -309,6 +309,10 @@
309309 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
310310 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
311311 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
312+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
313+<span class="ico i-sun-moon" aria-hidden="true">
314+</span>
315+</button>
312316 </footer>
313317 <div class="hint">
314318 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
@@ -309,6 +309,10 @@
309 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>309 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
310 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">310 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
311 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>311 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
312+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
313+<span class="ico i-sun-moon" aria-hidden="true">
314+</span>
315+</button>
312 </footer>316 </footer>
313 <div class="hint">317 <div class="hint">
314 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">318 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
M internal/app/testdata/web/empty.html +4 -0
@@ -85,6 +85,10 @@
8585 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
8686 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
8787 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
88+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
89+<span class="ico i-sun-moon" aria-hidden="true">
90+</span>
91+</button>
8892 </footer>
8993 <div class="hint">
9094 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
@@ -85,6 +85,10 @@
85 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>85 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
86 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">86 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
87 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>87 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
88+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
89+<span class="ico i-sun-moon" aria-hidden="true">
90+</span>
91+</button>
88 </footer>92 </footer>
89 <div class="hint">93 <div class="hint">
90 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">94 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
M internal/app/testdata/web/home.html +4 -0
@@ -181,6 +181,10 @@
181181 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
182182 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
183183 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
184+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
185+<span class="ico i-sun-moon" aria-hidden="true">
186+</span>
187+</button>
184188 </footer>
185189 <div class="hint">
186190 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
@@ -181,6 +181,10 @@
181 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>181 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
182 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">182 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
183 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>183 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
184+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
185+<span class="ico i-sun-moon" aria-hidden="true">
186+</span>
187+</button>
184 </footer>188 </footer>
185 <div class="hint">189 <div class="hint">
186 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">190 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
M internal/app/testdata/web/not-found-page.html +4 -0
@@ -74,6 +74,10 @@
7474 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
7575 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
7676 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
77+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
78+<span class="ico i-sun-moon" aria-hidden="true">
79+</span>
80+</button>
7781 </footer>
7882 <div class="hint">
7983 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
@@ -74,6 +74,10 @@
74 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>74 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
75 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">75 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
76 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>76 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
77+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
78+<span class="ico i-sun-moon" aria-hidden="true">
79+</span>
80+</button>
77 </footer>81 </footer>
78 <div class="hint">82 <div class="hint">
79 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">83 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
M internal/app/testdata/web/not-found.html +4 -0
@@ -74,6 +74,10 @@
7474 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
7575 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
7676 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
77+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
78+<span class="ico i-sun-moon" aria-hidden="true">
79+</span>
80+</button>
7781 </footer>
7882 <div class="hint">
7983 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
@@ -74,6 +74,10 @@
74 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>74 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
75 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">75 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
76 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>76 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
77+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
78+<span class="ico i-sun-moon" aria-hidden="true">
79+</span>
80+</button>
77 </footer>81 </footer>
78 <div class="hint">82 <div class="hint">
79 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">83 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
M internal/app/testdata/web/refs.html +4 -0
@@ -191,6 +191,10 @@
191191 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
192192 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
193193 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
194+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
195+<span class="ico i-sun-moon" aria-hidden="true">
196+</span>
197+</button>
194198 </footer>
195199 <div class="hint">
196200 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
@@ -191,6 +191,10 @@
191 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>191 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
192 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">192 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
193 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>193 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
194+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
195+<span class="ico i-sun-moon" aria-hidden="true">
196+</span>
197+</button>
194 </footer>198 </footer>
195 <div class="hint">199 <div class="hint">
196 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">200 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
M internal/app/testdata/web/summary.html +4 -0
@@ -217,6 +217,10 @@
217217 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
218218 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
219219 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
220+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
221+<span class="ico i-sun-moon" aria-hidden="true">
222+</span>
223+</button>
220224 </footer>
221225 <div class="hint">
222226 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
@@ -217,6 +217,10 @@
217 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>217 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
218 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">218 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
219 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>219 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
220+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
221+<span class="ico i-sun-moon" aria-hidden="true">
222+</span>
223+</button>
220 </footer>224 </footer>
221 <div class="hint">225 <div class="hint">
222 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">226 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
M internal/app/testdata/web/tree-nested.html +4 -0
@@ -155,6 +155,10 @@
155155 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
156156 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
157157 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
158+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
159+<span class="ico i-sun-moon" aria-hidden="true">
160+</span>
161+</button>
158162 </footer>
159163 <div class="hint">
160164 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
@@ -155,6 +155,10 @@
155 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>155 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
156 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">156 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
157 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>157 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
158+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
159+<span class="ico i-sun-moon" aria-hidden="true">
160+</span>
161+</button>
158 </footer>162 </footer>
159 <div class="hint">163 <div class="hint">
160 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">164 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
M internal/app/testdata/web/tree-root.html +4 -0
@@ -376,6 +376,10 @@
376376 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
377377 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
378378 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
379+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
380+<span class="ico i-sun-moon" aria-hidden="true">
381+</span>
382+</button>
379383 </footer>
380384 <div class="hint">
381385 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
@@ -376,6 +376,10 @@
376 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>376 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
377 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">377 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
378 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>378 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
379+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
380+<span class="ico i-sun-moon" aria-hidden="true">
381+</span>
382+</button>
379 </footer>383 </footer>
380 <div class="hint">384 <div class="hint">
381 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">385 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
M internal/app/testdata/web/tree-slash-ref.html +4 -0
@@ -152,6 +152,10 @@
152152 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
153153 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
154154 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
155+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
156+<span class="ico i-sun-moon" aria-hidden="true">
157+</span>
158+</button>
155159 </footer>
156160 <div class="hint">
157161 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
@@ -152,6 +152,10 @@
152 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>152 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
153 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">153 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
154 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>154 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
155+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
156+<span class="ico i-sun-moon" aria-hidden="true">
157+</span>
158+</button>
155 </footer>159 </footer>
156 <div class="hint">160 <div class="hint">
157 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">161 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
M internal/app/testdata/web/tree-subdir.html +4 -0
@@ -168,6 +168,10 @@
168168 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
169169 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
170170 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
171+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
172+<span class="ico i-sun-moon" aria-hidden="true">
173+</span>
174+</button>
171175 </footer>
172176 <div class="hint">
173177 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">
@@ -168,6 +168,10 @@
168 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>168 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
169 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">169 <button type="button" class="foot-keys" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?">
170 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>170 <kbd aria-hidden="true">?</kbd> keyboard shortcuts</button>
171+<button type="button" class="foot-theme" data-js data-theme-switch aria-label="theme: auto (click for light)" title="theme: auto (click for light)">
172+<span class="ico i-sun-moon" aria-hidden="true">
173+</span>
174+</button>
171 </footer>175 </footer>
172 <div class="hint">176 <div class="hint">
173 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">177 <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''">

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 41

↑↓ moveenter openesc close