detached f347d8a

feat(web): split and compact home layouts (#71)

commit
f347d8a69e70ff6b308c481c06d53962c1e83692
author
brice · · 2026-10-18 17:39 UTC
committer
bnema · · 2026-10-18 17:39 UTC
parent
cdeae08
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
50 files · +926 -250
M internal/adapters/web/ASSETS.md +2 -1
@@ -8,6 +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 | |
1112 | `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 | |
1213 | `static/js/log.js` | the live log of a running job (`window.ermiteLog`, tested in Node by `jstest/log.test.mjs`) | written here | |
1314 | `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 | |
@@ -16,7 +17,7 @@ content-hashed names. Nothing is fetched at runtime.
1617 | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | |
1718 | `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` |
1819 | `static/fonts/atkinson-mono.woff2` | Atkinson Hyperlegible Mono, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-mono.txt`) | Google Fonts | `2706b1ee4f452e744ea91f7e4908cbde9c5d35521bf5ffffc71a382a2de89613` |
19-| `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` are Lucide 1.8 (`git-branch`, `search`, `tag`, `file-text`, `chevron-down`, `user-round`, `key-round`, `log-in`, `log-out`; ISC, https://lucide.dev/license) | Lucide; `brand.svg` written here | |
20+| `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 | |
2021 | `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 | |
2122 | `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 | |
2223 | `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,6 +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/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 | |
12 | `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 | |
13 | `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 | |
@@ -16,7 +17,7 @@ content-hashed names. Nothing is fetched at runtime.
16 | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | |17 | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | |
17 | `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` |18 | `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` |
18 | `static/fonts/atkinson-mono.woff2` | Atkinson Hyperlegible Mono, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-mono.txt`) | Google Fonts | `2706b1ee4f452e744ea91f7e4908cbde9c5d35521bf5ffffc71a382a2de89613` |19 | `static/fonts/atkinson-mono.woff2` | Atkinson Hyperlegible Mono, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-mono.txt`) | Google Fonts | `2706b1ee4f452e744ea91f7e4908cbde9c5d35521bf5ffffc71a382a2de89613` |
19-| `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` are Lucide 1.8 (`git-branch`, `search`, `tag`, `file-text`, `chevron-down`, `user-round`, `key-round`, `log-in`, `log-out`; ISC, https://lucide.dev/license) | Lucide; `brand.svg` written here | |20+| `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 | |
20 | `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 | |21 | `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 | |
21 | `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 | |22 | `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 | |
22 | `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 | |23 | `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 | |
M internal/adapters/web/assets.go +2 -1
@@ -24,7 +24,7 @@ const StaticPrefix = "/-/static/"
2424 // Assets are the URLs of the assets a page loads. Their names carry a hash of
2525 // the content, so they can be cached for ever.
2626 type Assets struct {
27- CSS, HTMX, Live, SSE, App, Files, Passkey, Log, MR, Icon string
27+ CSS, HTMX, Live, SSE, Store, App, Files, Passkey, Log, MR, Icon string
2828 // Manifest is the web app manifest (install on a phone's home screen),
2929 // AppleIcon the home screen icon iOS reads from a link instead.
3030 Manifest, AppleIcon string
@@ -59,6 +59,7 @@ var scripts = []struct {
5959 {"htmx.min.js", func(a *Assets, u string) { a.HTMX = u }},
6060 {"hx-live.min.js", func(a *Assets, u string) { a.Live = u }},
6161 {"hx-sse.min.js", func(a *Assets, u string) { a.SSE = u }},
62+ {"store.js", func(a *Assets, u string) { a.Store = u }},
6263 {"ermite.js", func(a *Assets, u string) { a.App = u }},
6364 {"files.js", func(a *Assets, u string) { a.Files = u }},
6465 {"passkey.js", func(a *Assets, u string) { a.Passkey = u }},
@@ -24,7 +24,7 @@ const StaticPrefix = "/-/static/"
24 // Assets are the URLs of the assets a page loads. Their names carry a hash of24 // Assets are the URLs of the assets a page loads. Their names carry a hash of
25 // the content, so they can be cached for ever.25 // the content, so they can be cached for ever.
26 type Assets struct {26 type Assets struct {
27- CSS, HTMX, Live, SSE, App, Files, Passkey, Log, MR, Icon string27+ CSS, HTMX, Live, SSE, Store, App, Files, Passkey, Log, MR, Icon string
28 // Manifest is the web app manifest (install on a phone's home screen),28 // Manifest is the web app manifest (install on a phone's home screen),
29 // AppleIcon the home screen icon iOS reads from a link instead.29 // AppleIcon the home screen icon iOS reads from a link instead.
30 Manifest, AppleIcon string30 Manifest, AppleIcon string
@@ -59,6 +59,7 @@ var scripts = []struct {
59 {"htmx.min.js", func(a *Assets, u string) { a.HTMX = u }},59 {"htmx.min.js", func(a *Assets, u string) { a.HTMX = u }},
60 {"hx-live.min.js", func(a *Assets, u string) { a.Live = u }},60 {"hx-live.min.js", func(a *Assets, u string) { a.Live = u }},
61 {"hx-sse.min.js", func(a *Assets, u string) { a.SSE = u }},61 {"hx-sse.min.js", func(a *Assets, u string) { a.SSE = u }},
62+ {"store.js", func(a *Assets, u string) { a.Store = u }},
62 {"ermite.js", func(a *Assets, u string) { a.App = u }},63 {"ermite.js", func(a *Assets, u string) { a.App = u }},
63 {"files.js", func(a *Assets, u string) { a.Files = u }},64 {"files.js", func(a *Assets, u string) { a.Files = u }},
64 {"passkey.js", func(a *Assets, u string) { a.Passkey = u }},65 {"passkey.js", func(a *Assets, u string) { a.Passkey = u }},
M internal/adapters/web/components.templ +1 -1
@@ -74,7 +74,7 @@ templ HomeRowView(r HomeRow) {
7474 <div><dt>commits</dt><dd>{ s.Commits }</dd></div>
7575 <div><dt>last 30 days</dt><dd>{ s.Recent }</dd></div>
7676 }
77- <div>
77+ <div class="rs-active">
7878 <dt>last activity</dt>
7979 if r.ActiveAt != "" {
8080 <dd><time datetime={ r.ActiveAt } title={ r.ActiveAt }>{ r.Active }</time></dd>
@@ -74,7 +74,7 @@ templ HomeRowView(r HomeRow) {
74 <div><dt>commits</dt><dd>{ s.Commits }</dd></div>74 <div><dt>commits</dt><dd>{ s.Commits }</dd></div>
75 <div><dt>last 30 days</dt><dd>{ s.Recent }</dd></div>75 <div><dt>last 30 days</dt><dd>{ s.Recent }</dd></div>
76 }76 }
77- <div>77+ <div class="rs-active">
78 <dt>last activity</dt>78 <dt>last activity</dt>
79 if r.ActiveAt != "" {79 if r.ActiveAt != "" {
80 <dd><time datetime={ r.ActiveAt } title={ r.ActiveAt }>{ r.Active }</time></dd>80 <dd><time datetime={ r.ActiveAt } title={ r.ActiveAt }>{ r.Active }</time></dd>
M internal/adapters/web/components_templ.go +1 -1 generated file: collapsed
M internal/adapters/web/home_test.go +24 -0
@@ -320,3 +320,27 @@ func TestHomeRowsAreBoundedAndOrdered(t *testing.T) {
320320 assert.LessOrEqual(t, int(peak.Load()), homeWorkers)
321321 assert.Greater(t, int(peak.Load()), 1, "the reads overlap")
322322 }
323+
324+// The page starts in the cards layout (a script applies the stored choice) and
325+// offers the three layouts, "cards" pressed; the store loads before the app.
326+func TestHomeOffersTheLayouts(t *testing.T) {
327+ t.Parallel()
328+ browse := homeBrowse(t)
329+ browse.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.HistoryPage{Commits: homeCommits(t, 2)}, nil)
330+ srv := newTestServer(t, Config{Repos: reposServing(t, pushedRepo(t, "proj")), Browse: browse})
331+
332+ rec := httptest.NewRecorder()
333+ srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
334+ require.Equal(t, http.StatusOK, rec.Code)
335+ body := rec.Body.String()
336+ assert.Contains(t, body, `<div class="home" data-layout="cards">`)
337+ assert.Contains(t, body, `<div class="layout-switch" role="group" aria-label="Layout" data-js>`)
338+ for layout, pressed := range map[string]string{"cards": "true", "split": "false", "compact": "false"} {
339+ assert.Contains(t, body, `data-set-layout="`+layout+`" aria-pressed="`+pressed+`"`)
340+ assert.Contains(t, body, `class="ico i-`+layout+`"`)
341+ }
342+ store := strings.Index(body, "/js/store.")
343+ app := strings.Index(body, "/js/ermite.")
344+ require.NotEqual(t, -1, store)
345+ assert.Less(t, store, app, "the store loads before the app script")
346+}
@@ -320,3 +320,27 @@ func TestHomeRowsAreBoundedAndOrdered(t *testing.T) {
320 assert.LessOrEqual(t, int(peak.Load()), homeWorkers)320 assert.LessOrEqual(t, int(peak.Load()), homeWorkers)
321 assert.Greater(t, int(peak.Load()), 1, "the reads overlap")321 assert.Greater(t, int(peak.Load()), 1, "the reads overlap")
322 }322 }
323+
324+// The page starts in the cards layout (a script applies the stored choice) and
325+// offers the three layouts, "cards" pressed; the store loads before the app.
326+func TestHomeOffersTheLayouts(t *testing.T) {
327+ t.Parallel()
328+ browse := homeBrowse(t)
329+ browse.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.HistoryPage{Commits: homeCommits(t, 2)}, nil)
330+ srv := newTestServer(t, Config{Repos: reposServing(t, pushedRepo(t, "proj")), Browse: browse})
331+
332+ rec := httptest.NewRecorder()
333+ srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
334+ require.Equal(t, http.StatusOK, rec.Code)
335+ body := rec.Body.String()
336+ assert.Contains(t, body, `<div class="home" data-layout="cards">`)
337+ assert.Contains(t, body, `<div class="layout-switch" role="group" aria-label="Layout" data-js>`)
338+ for layout, pressed := range map[string]string{"cards": "true", "split": "false", "compact": "false"} {
339+ assert.Contains(t, body, `data-set-layout="`+layout+`" aria-pressed="`+pressed+`"`)
340+ assert.Contains(t, body, `class="ico i-`+layout+`"`)
341+ }
342+ store := strings.Index(body, "/js/store.")
343+ app := strings.Index(body, "/js/ermite.")
344+ require.NotEqual(t, -1, store)
345+ assert.Less(t, store, app, "the store loads before the app script")
346+}
M internal/adapters/web/jstest/a11y.test.mjs +17 -1
@@ -49,13 +49,15 @@ doc.querySelectorAll = () => []; // the page-wide queries of the top of ermite.j
4949 doc.querySelector = () => null;
5050
5151 const store = new Map();
52-globalThis.localStorage = { getItem: (k) => store.get(k) ?? null, setItem: (k, v) => store.set(k, String(v)) };
52+globalThis.localStorage = { getItem: (k) => store.get(k) ?? null, setItem: (k, v) => store.set(k, String(v)), removeItem: (k) => store.delete(k) };
5353 const listeners = {};
5454 globalThis.addEventListener = (type, f) => (listeners[type] ||= []).push(f);
55+globalThis.removeEventListener = (type, f) => { listeners[type] = (listeners[type] || []).filter((g) => g !== f); };
5556 globalThis.scrollTo = () => {};
5657 globalThis.fetch = async () => ({ ok: true, json: async () => ({ paths: ['cmd/main.go', 'README.md'] }) });
5758
5859 await import('../static/js/files.js');
60+await import('../static/js/store.js'); // loaded before ermite.js, as in the page
5961 await import('../static/js/ermite.js');
6062 doc.querySelectorAll = query.all;
6163 doc.querySelector = query.one;
@@ -304,6 +306,20 @@ test('shortcuts off: the checkbox, the note and the palette action show the stat
304306 doc.querySelector = find;
305307 });
306308
309+test('shortcuts off: another tab\'s change applies here', () => {
310+ // The browser has written the value before the event comes.
311+ const storage = (newValue) => {
312+ if (newValue === null) store.delete('keys-off'); else store.set('keys-off', newValue);
313+ listeners.storage.forEach((f) => f({ key: 'keys-off', newValue }));
314+ };
315+ storage('1');
316+ assert.equal(K.off(), true);
317+ storage('');
318+ assert.equal(K.off(), false);
319+ storage(null);
320+ assert.equal(K.off(), false, 'removed: on');
321+});
322+
307323 test('the preference is read from storage when the page loads', async () => {
308324 store.set('keys-off', '1');
309325 listeners.keydown = [];
@@ -49,13 +49,15 @@ doc.querySelectorAll = () => []; // the page-wide queries of the top of ermite.j
49 doc.querySelector = () => null;49 doc.querySelector = () => null;
50 50
51 const store = new Map();51 const store = new Map();
52-globalThis.localStorage = { getItem: (k) => store.get(k) ?? null, setItem: (k, v) => store.set(k, String(v)) };52+globalThis.localStorage = { getItem: (k) => store.get(k) ?? null, setItem: (k, v) => store.set(k, String(v)), removeItem: (k) => store.delete(k) };
53 const listeners = {};53 const listeners = {};
54 globalThis.addEventListener = (type, f) => (listeners[type] ||= []).push(f);54 globalThis.addEventListener = (type, f) => (listeners[type] ||= []).push(f);
55+globalThis.removeEventListener = (type, f) => { listeners[type] = (listeners[type] || []).filter((g) => g !== f); };
55 globalThis.scrollTo = () => {};56 globalThis.scrollTo = () => {};
56 globalThis.fetch = async () => ({ ok: true, json: async () => ({ paths: ['cmd/main.go', 'README.md'] }) });57 globalThis.fetch = async () => ({ ok: true, json: async () => ({ paths: ['cmd/main.go', 'README.md'] }) });
57 58
58 await import('../static/js/files.js');59 await import('../static/js/files.js');
60+await import('../static/js/store.js'); // loaded before ermite.js, as in the page
59 await import('../static/js/ermite.js');61 await import('../static/js/ermite.js');
60 doc.querySelectorAll = query.all;62 doc.querySelectorAll = query.all;
61 doc.querySelector = query.one;63 doc.querySelector = query.one;
@@ -304,6 +306,20 @@ test('shortcuts off: the checkbox, the note and the palette action show the stat
304 doc.querySelector = find;306 doc.querySelector = find;
305 });307 });
306 308
309+test('shortcuts off: another tab\'s change applies here', () => {
310+ // The browser has written the value before the event comes.
311+ const storage = (newValue) => {
312+ if (newValue === null) store.delete('keys-off'); else store.set('keys-off', newValue);
313+ listeners.storage.forEach((f) => f({ key: 'keys-off', newValue }));
314+ };
315+ storage('1');
316+ assert.equal(K.off(), true);
317+ storage('');
318+ assert.equal(K.off(), false);
319+ storage(null);
320+ assert.equal(K.off(), false, 'removed: on');
321+});
322+
307 test('the preference is read from storage when the page loads', async () => {323 test('the preference is read from storage when the page loads', async () => {
308 store.set('keys-off', '1');324 store.set('keys-off', '1');
309 listeners.keydown = [];325 listeners.keydown = [];
A internal/adapters/web/jstest/layout.test.mjs +139 -0
@@ -0,0 +1,139 @@
1+// Tests of the home page layout of static/js/ermite.js (window.ermiteLayout)
2+// with store.js, on the small DOM of dom.mjs. Only the ermiteLayout block is
3+// evaluated: the rest needs a page. Run with
4+// node --test 'internal/adapters/web/jstest/*.test.mjs'
5+import assert from 'node:assert/strict';
6+import { readFileSync } from 'node:fs';
7+import { beforeEach, test } from 'node:test';
8+import { el, installDom } from './dom.mjs';
9+
10+const read = (f) => readFileSync(new URL('../static/js/' + f, import.meta.url), 'utf8');
11+const ermite = read('ermite.js');
12+const start = ermite.indexOf(' window.ermiteLayout = ');
13+const block = ermite.slice(start, ermite.indexOf(' })();', start) + ' })();'.length);
14+const storeSrc = read('store.js');
15+
16+const doc = installDom();
17+let listeners;
18+let storage;
19+let fits;
20+let home;
21+let buttons;
22+
23+// A page: .home and the switch, with "cards" pressed as the server renders it.
24+// Then store.js and the block are evaluated, as a page load does.
25+function load({ stored, storageObj } = {}) {
26+ listeners = {};
27+ fits = 0;
28+ storage = storageObj ?? new Map(stored ? Object.entries(stored) : []);
29+ globalThis.localStorage = storageObj ?? {
30+ getItem: (k) => (storage.has(k) ? storage.get(k) : null),
31+ setItem: (k, v) => { storage.set(k, String(v)); },
32+ removeItem: (k) => { storage.delete(k); },
33+ };
34+ globalThis.addEventListener = (type, f) => (listeners[type] ||= []).push(f);
35+ globalThis.removeEventListener = (type, f) => { listeners[type] = (listeners[type] || []).filter((g) => g !== f); };
36+ home = el('div', { cls: 'home', attrs: { 'data-layout': 'cards' } });
37+ buttons = ['cards', 'split', 'compact'].map((v) => el('button', {
38+ cls: 'btn icon', attrs: { type: 'button', 'data-set-layout': v, 'aria-pressed': String(v === 'cards') },
39+ }));
40+ const sw = el('div', { cls: 'layout-switch', attrs: { role: 'group', 'data-js': '' } }, ...buttons);
41+ doc.body.replaceChildren(sw, home);
42+ window.ermiteFit = { all: () => { fits++; } };
43+ new Function(storeSrc)();
44+ new Function(block)();
45+ return window.ermiteLayout;
46+}
47+
48+const pressed = () => buttons.map((b) => b.getAttribute('aria-pressed'));
49+const click = (target) => listeners.click.forEach((f) => f({ target }));
50+
51+beforeEach(() => { delete globalThis.localStorage; });
52+
53+test('with nothing stored the layout is cards, and the page is as rendered', () => {
54+ const L = load();
55+ assert.equal(L.get(), 'cards');
56+ assert.deepEqual(L.values, ['cards', 'split', 'compact']);
57+ assert.equal(home.getAttribute('data-layout'), 'cards');
58+ assert.deepEqual(pressed(), ['true', 'false', 'false']);
59+});
60+
61+test('the stored layout is applied when the page loads', () => {
62+ const L = load({ stored: { 'home-layout': 'compact' } });
63+ assert.equal(L.get(), 'compact');
64+ assert.equal(home.getAttribute('data-layout'), 'compact');
65+ assert.deepEqual(pressed(), ['false', 'false', 'true']);
66+ assert.ok(fits > 0, 'the chips are fitted again');
67+});
68+
69+test('a stored value that is not a layout falls back to cards', () => {
70+ const L = load({ stored: { 'home-layout': 'grid' } });
71+ assert.equal(L.get(), 'cards');
72+ assert.equal(home.getAttribute('data-layout'), 'cards');
73+ assert.deepEqual(pressed(), ['true', 'false', 'false']);
74+});
75+
76+test('set stores the layout, shows it and fits the chips again', () => {
77+ const L = load();
78+ fits = 0;
79+ L.set('split');
80+ assert.equal(storage.get('home-layout'), 'split');
81+ assert.equal(home.getAttribute('data-layout'), 'split');
82+ assert.deepEqual(pressed(), ['false', 'true', 'false']);
83+ assert.equal(fits, 1);
84+ L.set('nope');
85+ assert.equal(L.get(), 'split', 'an unknown layout is ignored');
86+ assert.equal(storage.get('home-layout'), 'split');
87+});
88+
89+test('pressing a layout button sets it; other clicks do nothing', () => {
90+ const L = load();
91+ click(buttons[2]);
92+ assert.equal(L.get(), 'compact');
93+ assert.equal(home.getAttribute('data-layout'), 'compact');
94+ assert.deepEqual(pressed(), ['false', 'false', 'true']);
95+ click(home);
96+ click({ closest: undefined });
97+ assert.equal(L.get(), 'compact');
98+ // a child of the button (an icon) counts as the button
99+ const icon = el('svg');
100+ buttons[1].append(icon);
101+ click(icon);
102+ assert.equal(L.get(), 'split');
103+});
104+
105+test("another tab's change applies live", () => {
106+ const L = load();
107+ storage.set('home-layout', 'split');
108+ listeners.storage.forEach((f) => f({ key: 'home-layout', newValue: 'split', storageArea: localStorage }));
109+ assert.equal(L.get(), 'split');
110+ assert.equal(home.getAttribute('data-layout'), 'split');
111+ assert.deepEqual(pressed(), ['false', 'true', 'false']);
112+ storage.delete('home-layout');
113+ listeners.storage.forEach((f) => f({ key: 'home-layout', newValue: null, storageArea: localStorage }));
114+ assert.equal(home.getAttribute('data-layout'), 'cards', 'removed: back to the default');
115+ listeners.storage.forEach((f) => f({ key: 'keys-off', newValue: '1', storageArea: localStorage }));
116+ assert.equal(home.getAttribute('data-layout'), 'cards');
117+});
118+
119+test('a page without .home or without ermiteFit still works', () => {
120+ const L = load();
121+ doc.body.replaceChildren();
122+ delete window.ermiteFit;
123+ L.set('split');
124+ assert.equal(L.get(), 'split');
125+ L.apply();
126+});
127+
128+test('without usable storage the choice holds for the page', () => {
129+ const refusing = {
130+ getItem: () => { throw new Error('denied'); },
131+ setItem: () => { throw new Error('quota'); },
132+ removeItem: () => { throw new Error('denied'); },
133+ };
134+ const L = load({ storageObj: refusing });
135+ assert.equal(L.get(), 'cards');
136+ click(buttons[1]);
137+ assert.equal(L.get(), 'split');
138+ assert.equal(home.getAttribute('data-layout'), 'split');
139+});
@@ -0,0 +1,139 @@
1+// Tests of the home page layout of static/js/ermite.js (window.ermiteLayout)
2+// with store.js, on the small DOM of dom.mjs. Only the ermiteLayout block is
3+// evaluated: the rest needs a page. Run with
4+// node --test 'internal/adapters/web/jstest/*.test.mjs'
5+import assert from 'node:assert/strict';
6+import { readFileSync } from 'node:fs';
7+import { beforeEach, test } from 'node:test';
8+import { el, installDom } from './dom.mjs';
9+
10+const read = (f) => readFileSync(new URL('../static/js/' + f, import.meta.url), 'utf8');
11+const ermite = read('ermite.js');
12+const start = ermite.indexOf(' window.ermiteLayout = ');
13+const block = ermite.slice(start, ermite.indexOf(' })();', start) + ' })();'.length);
14+const storeSrc = read('store.js');
15+
16+const doc = installDom();
17+let listeners;
18+let storage;
19+let fits;
20+let home;
21+let buttons;
22+
23+// A page: .home and the switch, with "cards" pressed as the server renders it.
24+// Then store.js and the block are evaluated, as a page load does.
25+function load({ stored, storageObj } = {}) {
26+ listeners = {};
27+ fits = 0;
28+ storage = storageObj ?? new Map(stored ? Object.entries(stored) : []);
29+ globalThis.localStorage = storageObj ?? {
30+ getItem: (k) => (storage.has(k) ? storage.get(k) : null),
31+ setItem: (k, v) => { storage.set(k, String(v)); },
32+ removeItem: (k) => { storage.delete(k); },
33+ };
34+ globalThis.addEventListener = (type, f) => (listeners[type] ||= []).push(f);
35+ globalThis.removeEventListener = (type, f) => { listeners[type] = (listeners[type] || []).filter((g) => g !== f); };
36+ home = el('div', { cls: 'home', attrs: { 'data-layout': 'cards' } });
37+ buttons = ['cards', 'split', 'compact'].map((v) => el('button', {
38+ cls: 'btn icon', attrs: { type: 'button', 'data-set-layout': v, 'aria-pressed': String(v === 'cards') },
39+ }));
40+ const sw = el('div', { cls: 'layout-switch', attrs: { role: 'group', 'data-js': '' } }, ...buttons);
41+ doc.body.replaceChildren(sw, home);
42+ window.ermiteFit = { all: () => { fits++; } };
43+ new Function(storeSrc)();
44+ new Function(block)();
45+ return window.ermiteLayout;
46+}
47+
48+const pressed = () => buttons.map((b) => b.getAttribute('aria-pressed'));
49+const click = (target) => listeners.click.forEach((f) => f({ target }));
50+
51+beforeEach(() => { delete globalThis.localStorage; });
52+
53+test('with nothing stored the layout is cards, and the page is as rendered', () => {
54+ const L = load();
55+ assert.equal(L.get(), 'cards');
56+ assert.deepEqual(L.values, ['cards', 'split', 'compact']);
57+ assert.equal(home.getAttribute('data-layout'), 'cards');
58+ assert.deepEqual(pressed(), ['true', 'false', 'false']);
59+});
60+
61+test('the stored layout is applied when the page loads', () => {
62+ const L = load({ stored: { 'home-layout': 'compact' } });
63+ assert.equal(L.get(), 'compact');
64+ assert.equal(home.getAttribute('data-layout'), 'compact');
65+ assert.deepEqual(pressed(), ['false', 'false', 'true']);
66+ assert.ok(fits > 0, 'the chips are fitted again');
67+});
68+
69+test('a stored value that is not a layout falls back to cards', () => {
70+ const L = load({ stored: { 'home-layout': 'grid' } });
71+ assert.equal(L.get(), 'cards');
72+ assert.equal(home.getAttribute('data-layout'), 'cards');
73+ assert.deepEqual(pressed(), ['true', 'false', 'false']);
74+});
75+
76+test('set stores the layout, shows it and fits the chips again', () => {
77+ const L = load();
78+ fits = 0;
79+ L.set('split');
80+ assert.equal(storage.get('home-layout'), 'split');
81+ assert.equal(home.getAttribute('data-layout'), 'split');
82+ assert.deepEqual(pressed(), ['false', 'true', 'false']);
83+ assert.equal(fits, 1);
84+ L.set('nope');
85+ assert.equal(L.get(), 'split', 'an unknown layout is ignored');
86+ assert.equal(storage.get('home-layout'), 'split');
87+});
88+
89+test('pressing a layout button sets it; other clicks do nothing', () => {
90+ const L = load();
91+ click(buttons[2]);
92+ assert.equal(L.get(), 'compact');
93+ assert.equal(home.getAttribute('data-layout'), 'compact');
94+ assert.deepEqual(pressed(), ['false', 'false', 'true']);
95+ click(home);
96+ click({ closest: undefined });
97+ assert.equal(L.get(), 'compact');
98+ // a child of the button (an icon) counts as the button
99+ const icon = el('svg');
100+ buttons[1].append(icon);
101+ click(icon);
102+ assert.equal(L.get(), 'split');
103+});
104+
105+test("another tab's change applies live", () => {
106+ const L = load();
107+ storage.set('home-layout', 'split');
108+ listeners.storage.forEach((f) => f({ key: 'home-layout', newValue: 'split', storageArea: localStorage }));
109+ assert.equal(L.get(), 'split');
110+ assert.equal(home.getAttribute('data-layout'), 'split');
111+ assert.deepEqual(pressed(), ['false', 'true', 'false']);
112+ storage.delete('home-layout');
113+ listeners.storage.forEach((f) => f({ key: 'home-layout', newValue: null, storageArea: localStorage }));
114+ assert.equal(home.getAttribute('data-layout'), 'cards', 'removed: back to the default');
115+ listeners.storage.forEach((f) => f({ key: 'keys-off', newValue: '1', storageArea: localStorage }));
116+ assert.equal(home.getAttribute('data-layout'), 'cards');
117+});
118+
119+test('a page without .home or without ermiteFit still works', () => {
120+ const L = load();
121+ doc.body.replaceChildren();
122+ delete window.ermiteFit;
123+ L.set('split');
124+ assert.equal(L.get(), 'split');
125+ L.apply();
126+});
127+
128+test('without usable storage the choice holds for the page', () => {
129+ const refusing = {
130+ getItem: () => { throw new Error('denied'); },
131+ setItem: () => { throw new Error('quota'); },
132+ removeItem: () => { throw new Error('denied'); },
133+ };
134+ const L = load({ storageObj: refusing });
135+ assert.equal(L.get(), 'cards');
136+ click(buttons[1]);
137+ assert.equal(L.get(), 'split');
138+ assert.equal(home.getAttribute('data-layout'), 'split');
139+});
A internal/adapters/web/jstest/store.test.mjs +183 -0
@@ -0,0 +1,183 @@
1+// Tests of static/js/store.js (window.ermiteStore): the localStorage client,
2+// evaluated alone against a fake storage. Run with
3+// node --test 'internal/adapters/web/jstest/*.test.mjs'
4+import assert from 'node:assert/strict';
5+import { readFileSync } from 'node:fs';
6+import { test } from 'node:test';
7+
8+const src = readFileSync(new URL('../static/js/store.js', import.meta.url), 'utf8');
9+
10+// load evaluates the file on a page whose storage is the given object (a
11+// Map-backed one by default) and answers the store and the "storage" listeners.
12+function load(storage = fakeStorage()) {
13+ const listeners = new Set();
14+ const g = globalThis;
15+ g.window = g;
16+ if (storage === 'missing') delete g.localStorage;
17+ else g.localStorage = storage;
18+ g.addEventListener = (type, f) => { if (type === 'storage') listeners.add(f); };
19+ g.removeEventListener = (type, f) => { if (type === 'storage') listeners.delete(f); };
20+ new Function(src)();
21+ const emit = (e) => [...listeners].forEach((f) => f({ storageArea: g.localStorage, ...e }));
22+ return { store: g.ermiteStore, listeners, emit };
23+}
24+
25+function fakeStorage() {
26+ const m = new Map();
27+ return {
28+ m,
29+ getItem: (k) => (m.has(k) ? m.get(k) : null),
30+ setItem: (k, v) => { m.set(k, String(v)); },
31+ removeItem: (k) => { m.delete(k); },
32+ };
33+}
34+
35+const refusing = () => ({
36+ getItem: () => { throw new Error('denied'); },
37+ setItem: () => { throw new Error('quota'); },
38+ removeItem: () => { throw new Error('denied'); },
39+});
40+
41+test('get answers the stored value, else the fallback (null by default)', () => {
42+ const st = fakeStorage();
43+ const { store } = load(st);
44+ assert.equal(store.get('k'), null);
45+ assert.equal(store.get('k', 'x'), 'x');
46+ st.m.set('k', 'v');
47+ assert.equal(store.get('k', 'x'), 'v');
48+ st.m.set('k', '');
49+ assert.equal(store.get('k', 'x'), '', 'an empty string is a value');
50+});
51+
52+test('set stores strings; null and undefined remove; remove forgets', () => {
53+ const st = fakeStorage();
54+ const { store } = load(st);
55+ store.set('k', 'v');
56+ assert.equal(st.m.get('k'), 'v');
57+ store.set('n', 3);
58+ assert.equal(st.m.get('n'), '3');
59+ store.set('k', null);
60+ assert.equal(st.m.has('k'), false);
61+ store.set('k', 'v');
62+ store.set('k', undefined);
63+ assert.equal(st.m.has('k'), false);
64+ store.remove('n');
65+ assert.equal(store.get('n'), null);
66+ store.remove('nothing'); // no error
67+});
68+
69+test('without storage nothing throws and the value holds for the page', () => {
70+ const { store } = load('missing');
71+ assert.equal(store.get('k', 'x'), 'x');
72+ store.set('k', 'v');
73+ assert.equal(store.get('k', 'x'), 'v');
74+ store.remove('k');
75+ assert.equal(store.get('k', 'x'), 'x');
76+});
77+
78+test('a storage that refuses reads and writes does not throw either', () => {
79+ const { store } = load(refusing());
80+ assert.equal(store.get('k', 'x'), 'x');
81+ store.set('k', 'v');
82+ assert.equal(store.get('k', 'x'), 'v', 'held in memory');
83+ store.remove('k');
84+ assert.equal(store.get('k', 'x'), 'x', 'removed for the page');
85+});
86+
87+test('a quota error on write keeps the value for the page; a later write that works wins', () => {
88+ const st = fakeStorage();
89+ const { store } = load(st);
90+ let full = true;
91+ st.setItem = (k, v) => { if (full) throw new Error('quota'); st.m.set(k, v); };
92+ store.set('k', 'a');
93+ assert.equal(store.get('k'), 'a');
94+ assert.equal(st.m.has('k'), false);
95+ full = false;
96+ store.set('k', 'b');
97+ assert.equal(st.m.get('k'), 'b');
98+ st.m.set('k', 'c'); // changed elsewhere: the held value is gone
99+ assert.equal(store.get('k'), 'c');
100+});
101+
102+test('a getter of localStorage that throws (sandboxed frame) is survived', () => {
103+ const { store } = load('missing');
104+ Object.defineProperty(globalThis, 'localStorage', { get() { throw new Error('SecurityError'); }, configurable: true });
105+ try {
106+ assert.equal(store.get('k', 'x'), 'x');
107+ store.set('k', 'v');
108+ assert.equal(store.get('k'), 'v');
109+ } finally {
110+ delete globalThis.localStorage;
111+ }
112+});
113+
114+test('watch calls back with the new value of its key only, and stops when told', () => {
115+ const { store, emit, listeners } = load();
116+ const seen = [];
117+ const stop = store.watch('k', (v) => seen.push(v));
118+ emit({ key: 'other', newValue: 'z' });
119+ emit({ key: 'k', newValue: 'v' });
120+ emit({ key: 'k', newValue: null });
121+ emit({ key: null, newValue: null }); // the other tab cleared everything
122+ assert.deepEqual(seen, ['v', null, null]);
123+ stop();
124+ assert.equal(listeners.size, 0);
125+ emit({ key: 'k', newValue: 'w' });
126+ assert.deepEqual(seen, ['v', null, null]);
127+});
128+
129+test('watch ignores the events of another storage area (sessionStorage)', () => {
130+ const { store, emit } = load();
131+ const seen = [];
132+ store.watch('k', (v) => seen.push(v));
133+ emit({ key: 'k', newValue: 'v', storageArea: {} });
134+ assert.deepEqual(seen, []);
135+});
136+
137+test('an event from another tab replaces a value held for this page', () => {
138+ const { store, emit } = load(refusing());
139+ store.set('k', 'mine');
140+ store.watch('k', () => {});
141+ emit({ key: 'k', newValue: 'theirs', storageArea: undefined });
142+ assert.equal(store.get('k'), null, 'the held value is dropped; storage is the truth');
143+});
144+
145+test('pref answers only values of its list, else the fallback', () => {
146+ const st = fakeStorage();
147+ const { store } = load(st);
148+ const p = store.pref('layout', { values: ['a', 'b'], fallback: 'a' });
149+ assert.equal(p.get(), 'a', 'nothing stored');
150+ p.set('b');
151+ assert.equal(st.m.get('layout'), 'b');
152+ assert.equal(p.get(), 'b');
153+ p.set('zzz'); // not allowed: ignored
154+ assert.equal(p.get(), 'b');
155+ st.m.set('layout', 'old-value');
156+ assert.equal(p.get(), 'a', 'an invalid stored value falls back');
157+});
158+
159+test('pref works with the empty string as a value (keys-off)', () => {
160+ const st = fakeStorage();
161+ const { store } = load(st);
162+ const p = store.pref('keys-off', { values: ['1', ''], fallback: '' });
163+ assert.equal(p.get(), '');
164+ p.set('1');
165+ assert.equal(st.m.get('keys-off'), '1');
166+ p.set('');
167+ assert.equal(st.m.get('keys-off'), '');
168+ assert.equal(p.get(), '');
169+});
170+
171+test('pref.watch gives validated values', () => {
172+ const { store, emit } = load();
173+ const p = store.pref('layout', { values: ['a', 'b'], fallback: 'a' });
174+ const seen = [];
175+ const stop = p.watch((v) => seen.push(v));
176+ emit({ key: 'layout', newValue: 'b' });
177+ emit({ key: 'layout', newValue: 'junk' });
178+ emit({ key: 'layout', newValue: null });
179+ assert.deepEqual(seen, ['b', 'a', 'a']);
180+ stop();
181+ emit({ key: 'layout', newValue: 'b' });
182+ assert.equal(seen.length, 3);
183+});
@@ -0,0 +1,183 @@
1+// Tests of static/js/store.js (window.ermiteStore): the localStorage client,
2+// evaluated alone against a fake storage. Run with
3+// node --test 'internal/adapters/web/jstest/*.test.mjs'
4+import assert from 'node:assert/strict';
5+import { readFileSync } from 'node:fs';
6+import { test } from 'node:test';
7+
8+const src = readFileSync(new URL('../static/js/store.js', import.meta.url), 'utf8');
9+
10+// load evaluates the file on a page whose storage is the given object (a
11+// Map-backed one by default) and answers the store and the "storage" listeners.
12+function load(storage = fakeStorage()) {
13+ const listeners = new Set();
14+ const g = globalThis;
15+ g.window = g;
16+ if (storage === 'missing') delete g.localStorage;
17+ else g.localStorage = storage;
18+ g.addEventListener = (type, f) => { if (type === 'storage') listeners.add(f); };
19+ g.removeEventListener = (type, f) => { if (type === 'storage') listeners.delete(f); };
20+ new Function(src)();
21+ const emit = (e) => [...listeners].forEach((f) => f({ storageArea: g.localStorage, ...e }));
22+ return { store: g.ermiteStore, listeners, emit };
23+}
24+
25+function fakeStorage() {
26+ const m = new Map();
27+ return {
28+ m,
29+ getItem: (k) => (m.has(k) ? m.get(k) : null),
30+ setItem: (k, v) => { m.set(k, String(v)); },
31+ removeItem: (k) => { m.delete(k); },
32+ };
33+}
34+
35+const refusing = () => ({
36+ getItem: () => { throw new Error('denied'); },
37+ setItem: () => { throw new Error('quota'); },
38+ removeItem: () => { throw new Error('denied'); },
39+});
40+
41+test('get answers the stored value, else the fallback (null by default)', () => {
42+ const st = fakeStorage();
43+ const { store } = load(st);
44+ assert.equal(store.get('k'), null);
45+ assert.equal(store.get('k', 'x'), 'x');
46+ st.m.set('k', 'v');
47+ assert.equal(store.get('k', 'x'), 'v');
48+ st.m.set('k', '');
49+ assert.equal(store.get('k', 'x'), '', 'an empty string is a value');
50+});
51+
52+test('set stores strings; null and undefined remove; remove forgets', () => {
53+ const st = fakeStorage();
54+ const { store } = load(st);
55+ store.set('k', 'v');
56+ assert.equal(st.m.get('k'), 'v');
57+ store.set('n', 3);
58+ assert.equal(st.m.get('n'), '3');
59+ store.set('k', null);
60+ assert.equal(st.m.has('k'), false);
61+ store.set('k', 'v');
62+ store.set('k', undefined);
63+ assert.equal(st.m.has('k'), false);
64+ store.remove('n');
65+ assert.equal(store.get('n'), null);
66+ store.remove('nothing'); // no error
67+});
68+
69+test('without storage nothing throws and the value holds for the page', () => {
70+ const { store } = load('missing');
71+ assert.equal(store.get('k', 'x'), 'x');
72+ store.set('k', 'v');
73+ assert.equal(store.get('k', 'x'), 'v');
74+ store.remove('k');
75+ assert.equal(store.get('k', 'x'), 'x');
76+});
77+
78+test('a storage that refuses reads and writes does not throw either', () => {
79+ const { store } = load(refusing());
80+ assert.equal(store.get('k', 'x'), 'x');
81+ store.set('k', 'v');
82+ assert.equal(store.get('k', 'x'), 'v', 'held in memory');
83+ store.remove('k');
84+ assert.equal(store.get('k', 'x'), 'x', 'removed for the page');
85+});
86+
87+test('a quota error on write keeps the value for the page; a later write that works wins', () => {
88+ const st = fakeStorage();
89+ const { store } = load(st);
90+ let full = true;
91+ st.setItem = (k, v) => { if (full) throw new Error('quota'); st.m.set(k, v); };
92+ store.set('k', 'a');
93+ assert.equal(store.get('k'), 'a');
94+ assert.equal(st.m.has('k'), false);
95+ full = false;
96+ store.set('k', 'b');
97+ assert.equal(st.m.get('k'), 'b');
98+ st.m.set('k', 'c'); // changed elsewhere: the held value is gone
99+ assert.equal(store.get('k'), 'c');
100+});
101+
102+test('a getter of localStorage that throws (sandboxed frame) is survived', () => {
103+ const { store } = load('missing');
104+ Object.defineProperty(globalThis, 'localStorage', { get() { throw new Error('SecurityError'); }, configurable: true });
105+ try {
106+ assert.equal(store.get('k', 'x'), 'x');
107+ store.set('k', 'v');
108+ assert.equal(store.get('k'), 'v');
109+ } finally {
110+ delete globalThis.localStorage;
111+ }
112+});
113+
114+test('watch calls back with the new value of its key only, and stops when told', () => {
115+ const { store, emit, listeners } = load();
116+ const seen = [];
117+ const stop = store.watch('k', (v) => seen.push(v));
118+ emit({ key: 'other', newValue: 'z' });
119+ emit({ key: 'k', newValue: 'v' });
120+ emit({ key: 'k', newValue: null });
121+ emit({ key: null, newValue: null }); // the other tab cleared everything
122+ assert.deepEqual(seen, ['v', null, null]);
123+ stop();
124+ assert.equal(listeners.size, 0);
125+ emit({ key: 'k', newValue: 'w' });
126+ assert.deepEqual(seen, ['v', null, null]);
127+});
128+
129+test('watch ignores the events of another storage area (sessionStorage)', () => {
130+ const { store, emit } = load();
131+ const seen = [];
132+ store.watch('k', (v) => seen.push(v));
133+ emit({ key: 'k', newValue: 'v', storageArea: {} });
134+ assert.deepEqual(seen, []);
135+});
136+
137+test('an event from another tab replaces a value held for this page', () => {
138+ const { store, emit } = load(refusing());
139+ store.set('k', 'mine');
140+ store.watch('k', () => {});
141+ emit({ key: 'k', newValue: 'theirs', storageArea: undefined });
142+ assert.equal(store.get('k'), null, 'the held value is dropped; storage is the truth');
143+});
144+
145+test('pref answers only values of its list, else the fallback', () => {
146+ const st = fakeStorage();
147+ const { store } = load(st);
148+ const p = store.pref('layout', { values: ['a', 'b'], fallback: 'a' });
149+ assert.equal(p.get(), 'a', 'nothing stored');
150+ p.set('b');
151+ assert.equal(st.m.get('layout'), 'b');
152+ assert.equal(p.get(), 'b');
153+ p.set('zzz'); // not allowed: ignored
154+ assert.equal(p.get(), 'b');
155+ st.m.set('layout', 'old-value');
156+ assert.equal(p.get(), 'a', 'an invalid stored value falls back');
157+});
158+
159+test('pref works with the empty string as a value (keys-off)', () => {
160+ const st = fakeStorage();
161+ const { store } = load(st);
162+ const p = store.pref('keys-off', { values: ['1', ''], fallback: '' });
163+ assert.equal(p.get(), '');
164+ p.set('1');
165+ assert.equal(st.m.get('keys-off'), '1');
166+ p.set('');
167+ assert.equal(st.m.get('keys-off'), '');
168+ assert.equal(p.get(), '');
169+});
170+
171+test('pref.watch gives validated values', () => {
172+ const { store, emit } = load();
173+ const p = store.pref('layout', { values: ['a', 'b'], fallback: 'a' });
174+ const seen = [];
175+ const stop = p.watch((v) => seen.push(v));
176+ emit({ key: 'layout', newValue: 'b' });
177+ emit({ key: 'layout', newValue: 'junk' });
178+ emit({ key: 'layout', newValue: null });
179+ assert.deepEqual(seen, ['b', 'a', 'a']);
180+ stop();
181+ emit({ key: 'layout', newValue: 'b' });
182+ assert.equal(seen.length, 3);
183+});
M internal/adapters/web/pages.templ +12 -1
@@ -6,7 +6,7 @@ package web
66 templ Home(l Layout, p HomePage) {
77 @Shell(l, nil) {
88 <h1 class="sr">Repositories</h1>
9- <div class="home">
9+ <div class="home" data-layout="cards">
1010 <section class="home-repos" aria-label="Repositories">
1111 <div class="home-bar">
1212 <div class="filter">
@@ -37,6 +37,17 @@ templ Home(l Layout, p HomePage) {
3737 </a>
3838 }
3939 </nav>
40+ <div class="layout-switch" role="group" aria-label="Layout" data-js>
41+ <button type="button" class="btn icon" data-set-layout="cards" aria-pressed="true" title="Cards" aria-label="Cards">
42+ <span class="ico i-cards" aria-hidden="true"></span>
43+ </button>
44+ <button type="button" class="btn icon" data-set-layout="split" aria-pressed="false" title="Two columns" aria-label="Two columns">
45+ <span class="ico i-split" aria-hidden="true"></span>
46+ </button>
47+ <button type="button" class="btn icon" data-set-layout="compact" aria-pressed="false" title="Compact list" aria-label="Compact list">
48+ <span class="ico i-compact" aria-hidden="true"></span>
49+ </button>
50+ </div>
4051 </div>
4152 <ul class="repos" id="repo-rows" data-rows>
4253 for _, r := range p.Rows {
@@ -6,7 +6,7 @@ package web
6 templ Home(l Layout, p HomePage) {6 templ Home(l Layout, p HomePage) {
7 @Shell(l, nil) {7 @Shell(l, nil) {
8 <h1 class="sr">Repositories</h1>8 <h1 class="sr">Repositories</h1>
9- <div class="home">9+ <div class="home" data-layout="cards">
10 <section class="home-repos" aria-label="Repositories">10 <section class="home-repos" aria-label="Repositories">
11 <div class="home-bar">11 <div class="home-bar">
12 <div class="filter">12 <div class="filter">
@@ -37,6 +37,17 @@ templ Home(l Layout, p HomePage) {
37 </a>37 </a>
38 }38 }
39 </nav>39 </nav>
40+ <div class="layout-switch" role="group" aria-label="Layout" data-js>
41+ <button type="button" class="btn icon" data-set-layout="cards" aria-pressed="true" title="Cards" aria-label="Cards">
42+ <span class="ico i-cards" aria-hidden="true"></span>
43+ </button>
44+ <button type="button" class="btn icon" data-set-layout="split" aria-pressed="false" title="Two columns" aria-label="Two columns">
45+ <span class="ico i-split" aria-hidden="true"></span>
46+ </button>
47+ <button type="button" class="btn icon" data-set-layout="compact" aria-pressed="false" title="Compact list" aria-label="Compact list">
48+ <span class="ico i-compact" aria-hidden="true"></span>
49+ </button>
50+ </div>
40 </div>51 </div>
41 <ul class="repos" id="repo-rows" data-rows>52 <ul class="repos" id="repo-rows" data-rows>
42 for _, r := range p.Rows {53 for _, r := range p.Rows {
M internal/adapters/web/pages_templ.go +50 -50 generated file: collapsed
M internal/adapters/web/shell.templ +1 -0
@@ -25,6 +25,7 @@ templ Shell(l Layout, rail templ.Component) {
2525 <script src={ l.Assets.HTMX } defer></script>
2626 <script src={ l.Assets.Live } defer></script>
2727 <script src={ l.Assets.SSE } defer></script>
28+ <script src={ l.Assets.Store } defer></script>
2829 <script src={ l.Assets.App } defer></script>
2930 <script src={ l.Assets.Files } defer></script>
3031 <script src={ l.Assets.Passkey } defer></script>
@@ -25,6 +25,7 @@ templ Shell(l Layout, rail templ.Component) {
25 <script src={ l.Assets.HTMX } defer></script>25 <script src={ l.Assets.HTMX } defer></script>
26 <script src={ l.Assets.Live } defer></script>26 <script src={ l.Assets.Live } defer></script>
27 <script src={ l.Assets.SSE } defer></script>27 <script src={ l.Assets.SSE } defer></script>
28+ <script src={ l.Assets.Store } defer></script>
28 <script src={ l.Assets.App } defer></script>29 <script src={ l.Assets.App } defer></script>
29 <script src={ l.Assets.Files } defer></script>30 <script src={ l.Assets.Files } defer></script>
30 <script src={ l.Assets.Passkey } defer></script>31 <script src={ l.Assets.Passkey } defer></script>
M internal/adapters/web/shell_templ.go +189 -176
@@ -156,9 +156,9 @@ func Shell(l Layout, rail templ.Component) templ.Component {
156156 return templ_7745c5c3_Err
157157 }
158158 var templ_7745c5c3_Var11 string
159- templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.Assets.App)
159+ templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.Assets.Store)
160160 if templ_7745c5c3_Err != nil {
161- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 28, Col: 29}
161+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 28, Col: 31}
162162 }
163163 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
164164 if templ_7745c5c3_Err != nil {
@@ -169,9 +169,9 @@ func Shell(l Layout, rail templ.Component) templ.Component {
169169 return templ_7745c5c3_Err
170170 }
171171 var templ_7745c5c3_Var12 string
172- templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.Assets.Files)
172+ templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.Assets.App)
173173 if templ_7745c5c3_Err != nil {
174- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 29, Col: 31}
174+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 29, Col: 29}
175175 }
176176 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
177177 if templ_7745c5c3_Err != nil {
@@ -182,9 +182,9 @@ func Shell(l Layout, rail templ.Component) templ.Component {
182182 return templ_7745c5c3_Err
183183 }
184184 var templ_7745c5c3_Var13 string
185- templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.Assets.Passkey)
185+ templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.Assets.Files)
186186 if templ_7745c5c3_Err != nil {
187- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 30, Col: 33}
187+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 30, Col: 31}
188188 }
189189 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
190190 if templ_7745c5c3_Err != nil {
@@ -195,100 +195,113 @@ func Shell(l Layout, rail templ.Component) templ.Component {
195195 return templ_7745c5c3_Err
196196 }
197197 var templ_7745c5c3_Var14 string
198- templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.Assets.Log)
198+ templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.Assets.Passkey)
199199 if templ_7745c5c3_Err != nil {
200- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 31, Col: 29}
200+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 31, Col: 33}
201201 }
202202 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
203203 if templ_7745c5c3_Err != nil {
204204 return templ_7745c5c3_Err
205205 }
206- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" defer></script>")
206+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" defer></script><script src=\"")
207+ if templ_7745c5c3_Err != nil {
208+ return templ_7745c5c3_Err
209+ }
210+ var templ_7745c5c3_Var15 string
211+ templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.Assets.Log)
212+ if templ_7745c5c3_Err != nil {
213+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 32, Col: 29}
214+ }
215+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
216+ if templ_7745c5c3_Err != nil {
217+ return templ_7745c5c3_Err
218+ }
219+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" defer></script>")
207220 if templ_7745c5c3_Err != nil {
208221 return templ_7745c5c3_Err
209222 }
210223 if l.MR {
211- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<script src=\"")
224+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<script src=\"")
212225 if templ_7745c5c3_Err != nil {
213226 return templ_7745c5c3_Err
214227 }
215- var templ_7745c5c3_Var15 string
216- templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.Assets.MR)
228+ var templ_7745c5c3_Var16 string
229+ templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.Assets.MR)
217230 if templ_7745c5c3_Err != nil {
218- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 33, Col: 29}
231+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 34, Col: 29}
219232 }
220- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
233+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
221234 if templ_7745c5c3_Err != nil {
222235 return templ_7745c5c3_Err
223236 }
224- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" defer></script>")
237+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" defer></script>")
225238 if templ_7745c5c3_Err != nil {
226239 return templ_7745c5c3_Err
227240 }
228241 }
229- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</head><body data-ref=\"")
242+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</head><body data-ref=\"")
230243 if templ_7745c5c3_Err != nil {
231244 return templ_7745c5c3_Err
232245 }
233- var templ_7745c5c3_Var16 string
234- templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.RefPath)
246+ var templ_7745c5c3_Var17 string
247+ templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.RefPath)
235248 if templ_7745c5c3_Err != nil {
236- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 37, Col: 23}
249+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 38, Col: 23}
237250 }
238- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
251+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
239252 if templ_7745c5c3_Err != nil {
240253 return templ_7745c5c3_Err
241254 }
242- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" data-sha=\"")
255+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" data-sha=\"")
243256 if templ_7745c5c3_Err != nil {
244257 return templ_7745c5c3_Err
245258 }
246- var templ_7745c5c3_Var17 string
247- templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.SHA)
259+ var templ_7745c5c3_Var18 string
260+ templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.SHA)
248261 if templ_7745c5c3_Err != nil {
249- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 38, Col: 19}
262+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 39, Col: 19}
250263 }
251- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
264+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
252265 if templ_7745c5c3_Err != nil {
253266 return templ_7745c5c3_Err
254267 }
255- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" data-sel=\"\" data-nav=\"\"")
268+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\" data-sel=\"\" data-nav=\"\"")
256269 if templ_7745c5c3_Err != nil {
257270 return templ_7745c5c3_Err
258271 }
259272 if l.PathsURL != "" {
260- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, " data-paths=\"")
273+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, " data-paths=\"")
261274 if templ_7745c5c3_Err != nil {
262275 return templ_7745c5c3_Err
263276 }
264- var templ_7745c5c3_Var18 string
265- templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.PathsURL)
277+ var templ_7745c5c3_Var19 string
278+ templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.PathsURL)
266279 if templ_7745c5c3_Err != nil {
267- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 42, Col: 27}
280+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 43, Col: 27}
268281 }
269- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
282+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
270283 if templ_7745c5c3_Err != nil {
271284 return templ_7745c5c3_Err
272285 }
273- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" data-blob=\"")
286+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" data-blob=\"")
274287 if templ_7745c5c3_Err != nil {
275288 return templ_7745c5c3_Err
276289 }
277- var templ_7745c5c3_Var19 string
278- templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.BlobBase)
290+ var templ_7745c5c3_Var20 string
291+ templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.BlobBase)
279292 if templ_7745c5c3_Err != nil {
280- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 43, Col: 26}
293+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 44, Col: 26}
281294 }
282- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
295+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
283296 if templ_7745c5c3_Err != nil {
284297 return templ_7745c5c3_Err
285298 }
286- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\"")
299+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\"")
287300 if templ_7745c5c3_Err != nil {
288301 return templ_7745c5c3_Err
289302 }
290303 }
291- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "><a class=\"skip\" href=\"#main\">skip to content</a>")
304+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "><a class=\"skip\" href=\"#main\">skip to content</a>")
292305 if templ_7745c5c3_Err != nil {
293306 return templ_7745c5c3_Err
294307 }
@@ -296,48 +309,48 @@ func Shell(l Layout, rail templ.Component) templ.Component {
296309 if templ_7745c5c3_Err != nil {
297310 return templ_7745c5c3_Err
298311 }
299- var templ_7745c5c3_Var20 = []any{"work", templ.KV("has-rail", l.HasRail && rail != nil)}
300- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var20...)
312+ var templ_7745c5c3_Var21 = []any{"work", templ.KV("has-rail", l.HasRail && rail != nil)}
313+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var21...)
301314 if templ_7745c5c3_Err != nil {
302315 return templ_7745c5c3_Err
303316 }
304- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<div class=\"")
317+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<div class=\"")
305318 if templ_7745c5c3_Err != nil {
306319 return templ_7745c5c3_Err
307320 }
308- var templ_7745c5c3_Var21 string
309- templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var20).String())
321+ var templ_7745c5c3_Var22 string
322+ templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var21).String())
310323 if templ_7745c5c3_Err != nil {
311324 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 1, Col: 0}
312325 }
313- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
326+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
314327 if templ_7745c5c3_Err != nil {
315328 return templ_7745c5c3_Err
316329 }
317- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\">")
330+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\">")
318331 if templ_7745c5c3_Err != nil {
319332 return templ_7745c5c3_Err
320333 }
321334 if l.Lede != "" {
322- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<section class=\"work-lede\" aria-label=\"Description\"><p class=\"lede\">")
335+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<section class=\"work-lede\" aria-label=\"Description\"><p class=\"lede\">")
323336 if templ_7745c5c3_Err != nil {
324337 return templ_7745c5c3_Err
325338 }
326- var templ_7745c5c3_Var22 string
327- templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(l.Lede)
339+ var templ_7745c5c3_Var23 string
340+ templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(l.Lede)
328341 if templ_7745c5c3_Err != nil {
329- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 50, Col: 81}
342+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 51, Col: 81}
330343 }
331- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
344+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
332345 if templ_7745c5c3_Err != nil {
333346 return templ_7745c5c3_Err
334347 }
335- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</p></section>")
348+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</p></section>")
336349 if templ_7745c5c3_Err != nil {
337350 return templ_7745c5c3_Err
338351 }
339352 }
340- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<main id=\"main\" tabindex=\"-1\">")
353+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<main id=\"main\" tabindex=\"-1\">")
341354 if templ_7745c5c3_Err != nil {
342355 return templ_7745c5c3_Err
343356 }
@@ -345,12 +358,12 @@ func Shell(l Layout, rail templ.Component) templ.Component {
345358 if templ_7745c5c3_Err != nil {
346359 return templ_7745c5c3_Err
347360 }
348- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</main>")
361+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</main>")
349362 if templ_7745c5c3_Err != nil {
350363 return templ_7745c5c3_Err
351364 }
352365 if l.HasRail && rail != nil {
353- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<aside class=\"rail\" aria-label=\"Context\">")
366+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<aside class=\"rail\" aria-label=\"Context\">")
354367 if templ_7745c5c3_Err != nil {
355368 return templ_7745c5c3_Err
356369 }
@@ -358,12 +371,12 @@ func Shell(l Layout, rail templ.Component) templ.Component {
358371 if templ_7745c5c3_Err != nil {
359372 return templ_7745c5c3_Err
360373 }
361- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</aside>")
374+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</aside>")
362375 if templ_7745c5c3_Err != nil {
363376 return templ_7745c5c3_Err
364377 }
365378 }
366- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</div>")
379+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</div>")
367380 if templ_7745c5c3_Err != nil {
368381 return templ_7745c5c3_Err
369382 }
@@ -391,7 +404,7 @@ func Shell(l Layout, rail templ.Component) templ.Component {
391404 if templ_7745c5c3_Err != nil {
392405 return templ_7745c5c3_Err
393406 }
394- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</body></html>")
407+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</body></html>")
395408 if templ_7745c5c3_Err != nil {
396409 return templ_7745c5c3_Err
397410 }
@@ -417,324 +430,324 @@ func Header(l Layout) templ.Component {
417430 }()
418431 }
419432 ctx = templ.InitializeContext(ctx)
420- templ_7745c5c3_Var23 := templ.GetChildren(ctx)
421- if templ_7745c5c3_Var23 == nil {
422- templ_7745c5c3_Var23 = templ.NopComponent
433+ templ_7745c5c3_Var24 := templ.GetChildren(ctx)
434+ if templ_7745c5c3_Var24 == nil {
435+ templ_7745c5c3_Var24 = templ.NopComponent
423436 }
424437 ctx = templ.ClearChildren(ctx)
425- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<header class=\"hdr\"><div class=\"hdr-top\"><a class=\"brand\" href=\"/\" data-g=\"h\" aria-label=\"home\" title=\"home (g h)\"><span class=\"brand-mark\" aria-hidden=\"true\"></span></a> ")
438+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<header class=\"hdr\"><div class=\"hdr-top\"><a class=\"brand\" href=\"/\" data-g=\"h\" aria-label=\"home\" title=\"home (g h)\"><span class=\"brand-mark\" aria-hidden=\"true\"></span></a> ")
426439 if templ_7745c5c3_Err != nil {
427440 return templ_7745c5c3_Err
428441 }
429442 if len(l.Crumbs) > 0 {
430- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<nav class=\"crumbs\" aria-label=\"Path\"><ol>")
443+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<nav class=\"crumbs\" aria-label=\"Path\"><ol>")
431444 if templ_7745c5c3_Err != nil {
432445 return templ_7745c5c3_Err
433446 }
434447 for _, c := range l.Crumbs {
435- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<li>")
448+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<li>")
436449 if templ_7745c5c3_Err != nil {
437450 return templ_7745c5c3_Err
438451 }
439452 if c.Href != "" {
440- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<a href=\"")
453+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<a href=\"")
441454 if templ_7745c5c3_Err != nil {
442455 return templ_7745c5c3_Err
443456 }
444- var templ_7745c5c3_Var24 templ.SafeURL
445- templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))
457+ var templ_7745c5c3_Var25 templ.SafeURL
458+ templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))
446459 if templ_7745c5c3_Err != nil {
447- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 86, Col: 38}
460+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 87, Col: 38}
448461 }
449- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
462+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
450463 if templ_7745c5c3_Err != nil {
451464 return templ_7745c5c3_Err
452465 }
453- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\"")
466+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\"")
454467 if templ_7745c5c3_Err != nil {
455468 return templ_7745c5c3_Err
456469 }
457470 if c.Current {
458- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, " aria-current=\"page\"")
471+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " aria-current=\"page\"")
459472 if templ_7745c5c3_Err != nil {
460473 return templ_7745c5c3_Err
461474 }
462475 }
463- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, ">")
476+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, ">")
464477 if templ_7745c5c3_Err != nil {
465478 return templ_7745c5c3_Err
466479 }
467- var templ_7745c5c3_Var25 string
468- templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
480+ var templ_7745c5c3_Var26 string
481+ templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
469482 if templ_7745c5c3_Err != nil {
470- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 90, Col: 19}
483+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 91, Col: 19}
471484 }
472- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
485+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
473486 if templ_7745c5c3_Err != nil {
474487 return templ_7745c5c3_Err
475488 }
476- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</a>")
489+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</a>")
477490 if templ_7745c5c3_Err != nil {
478491 return templ_7745c5c3_Err
479492 }
480493 } else {
481- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<span")
494+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<span")
482495 if templ_7745c5c3_Err != nil {
483496 return templ_7745c5c3_Err
484497 }
485498 if c.Current {
486- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, " aria-current=\"page\"")
499+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, " aria-current=\"page\"")
487500 if templ_7745c5c3_Err != nil {
488501 return templ_7745c5c3_Err
489502 }
490503 }
491- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, ">")
504+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, ">")
492505 if templ_7745c5c3_Err != nil {
493506 return templ_7745c5c3_Err
494507 }
495- var templ_7745c5c3_Var26 string
496- templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
508+ var templ_7745c5c3_Var27 string
509+ templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
497510 if templ_7745c5c3_Err != nil {
498- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 96, Col: 19}
511+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 97, Col: 19}
499512 }
500- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
513+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
501514 if templ_7745c5c3_Err != nil {
502515 return templ_7745c5c3_Err
503516 }
504- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</span>")
517+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</span>")
505518 if templ_7745c5c3_Err != nil {
506519 return templ_7745c5c3_Err
507520 }
508521 }
509- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</li>")
522+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</li>")
510523 if templ_7745c5c3_Err != nil {
511524 return templ_7745c5c3_Err
512525 }
513526 }
514- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</ol></nav>")
527+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</ol></nav>")
515528 if templ_7745c5c3_Err != nil {
516529 return templ_7745c5c3_Err
517530 }
518531 }
519- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<div class=\"hdr-acts\">")
532+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<div class=\"hdr-acts\">")
520533 if templ_7745c5c3_Err != nil {
521534 return templ_7745c5c3_Err
522535 }
523536 if l.Ref != "" {
524- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<a class=\"btn ref\" href=\"")
537+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<a class=\"btn ref\" href=\"")
525538 if templ_7745c5c3_Err != nil {
526539 return templ_7745c5c3_Err
527540 }
528- var templ_7745c5c3_Var27 templ.SafeURL
529- templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(repoHref(l.Repo, "refs")))
541+ var templ_7745c5c3_Var28 templ.SafeURL
542+ templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(repoHref(l.Repo, "refs")))
530543 if templ_7745c5c3_Err != nil {
531- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 107, Col: 52}
544+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 108, Col: 52}
532545 }
533- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
546+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
534547 if templ_7745c5c3_Err != nil {
535548 return templ_7745c5c3_Err
536549 }
537- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\" data-fire=\"refs\" aria-keyshortcuts=\"b\" title=\"switch branch or tag (b)\"><span class=\"ico i-branch\" aria-hidden=\"true\"></span> <span class=\"ref-name\">")
550+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\" data-fire=\"refs\" aria-keyshortcuts=\"b\" title=\"switch branch or tag (b)\"><span class=\"ico i-branch\" aria-hidden=\"true\"></span> <span class=\"ref-name\">")
538551 if templ_7745c5c3_Err != nil {
539552 return templ_7745c5c3_Err
540553 }
541- var templ_7745c5c3_Var28 string
542- templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(l.Ref)
554+ var templ_7745c5c3_Var29 string
555+ templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(l.Ref)
543556 if templ_7745c5c3_Err != nil {
544- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 113, Col: 36}
557+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 114, Col: 36}
545558 }
546- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
559+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
547560 if templ_7745c5c3_Err != nil {
548561 return templ_7745c5c3_Err
549562 }
550- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</span> <span class=\"sha\">")
563+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</span> <span class=\"sha\">")
551564 if templ_7745c5c3_Err != nil {
552565 return templ_7745c5c3_Err
553566 }
554- var templ_7745c5c3_Var29 string
555- templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(l.Short)
567+ var templ_7745c5c3_Var30 string
568+ templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(l.Short)
556569 if templ_7745c5c3_Err != nil {
557- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 114, Col: 33}
570+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 115, Col: 33}
558571 }
559- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
572+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
560573 if templ_7745c5c3_Err != nil {
561574 return templ_7745c5c3_Err
562575 }
563- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</span></a> ")
576+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</span></a> ")
564577 if templ_7745c5c3_Err != nil {
565578 return templ_7745c5c3_Err
566579 }
567580 }
568- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<button type=\"button\" class=\"btn search\" data-js aria-haspopup=\"dialog\" aria-controls=\"panel-cmd\" aria-keyshortcuts=\"Control+K :\" title=\"go to a section, ref, file or repo (ctrl k or :)\" data-fire=\"cmd\"><span class=\"ico i-search\" aria-hidden=\"true\"></span> <span class=\"search-label\">Go to…</span> <kbd aria-hidden=\"true\">ctrl k</kbd></button> ")
581+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<button type=\"button\" class=\"btn search\" data-js aria-haspopup=\"dialog\" aria-controls=\"panel-cmd\" aria-keyshortcuts=\"Control+K :\" title=\"go to a section, ref, file or repo (ctrl k or :)\" data-fire=\"cmd\"><span class=\"ico i-search\" aria-hidden=\"true\"></span> <span class=\"search-label\">Go to…</span> <kbd aria-hidden=\"true\">ctrl k</kbd></button> ")
569582 if templ_7745c5c3_Err != nil {
570583 return templ_7745c5c3_Err
571584 }
572585 if SignedIn(ctx) {
573- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<form class=\"owner\" method=\"post\" action=\"/-/logout\"><span class=\"owner-badge\" title=\"signed in as the owner\"><span class=\"ico i-user\" aria-hidden=\"true\"></span>")
586+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<form class=\"owner\" method=\"post\" action=\"/-/logout\"><span class=\"owner-badge\" title=\"signed in as the owner\"><span class=\"ico i-user\" aria-hidden=\"true\"></span>")
574587 if templ_7745c5c3_Err != nil {
575588 return templ_7745c5c3_Err
576589 }
577- var templ_7745c5c3_Var30 string
578- templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(l.Owner)
590+ var templ_7745c5c3_Var31 string
591+ templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(l.Owner)
579592 if templ_7745c5c3_Err != nil {
580- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 133, Col: 123}
593+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 134, Col: 123}
581594 }
582- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
595+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
583596 if templ_7745c5c3_Err != nil {
584597 return templ_7745c5c3_Err
585598 }
586- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</span> ")
599+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</span> ")
587600 if templ_7745c5c3_Err != nil {
588601 return templ_7745c5c3_Err
589602 }
590603 if l.Passkeys {
591- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<a class=\"btn icon\" href=\"/-/passkeys\" aria-label=\"Passkeys\" title=\"passkeys: add or remove the passkeys that sign you in\"><span class=\"ico i-key\" aria-hidden=\"true\"></span></a> ")
604+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<a class=\"btn icon\" href=\"/-/passkeys\" aria-label=\"Passkeys\" title=\"passkeys: add or remove the passkeys that sign you in\"><span class=\"ico i-key\" aria-hidden=\"true\"></span></a> ")
592605 if templ_7745c5c3_Err != nil {
593606 return templ_7745c5c3_Err
594607 }
595608 }
596- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<button type=\"submit\" class=\"btn icon\" aria-label=\"Sign out\" title=\"sign out of this browser\"><span class=\"ico i-log-out\" aria-hidden=\"true\"></span></button></form>")
609+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<button type=\"submit\" class=\"btn icon\" aria-label=\"Sign out\" title=\"sign out of this browser\"><span class=\"ico i-log-out\" aria-hidden=\"true\"></span></button></form>")
597610 if templ_7745c5c3_Err != nil {
598611 return templ_7745c5c3_Err
599612 }
600613 } else if l.Passkeys {
601- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<a class=\"btn icon\" href=\"/-/login\" aria-label=\"Sign in\" title=\"sign in with a passkey\"><span class=\"ico i-log-in\" aria-hidden=\"true\"></span></a>")
614+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<a class=\"btn icon\" href=\"/-/login\" aria-label=\"Sign in\" title=\"sign in with a passkey\"><span class=\"ico i-log-in\" aria-hidden=\"true\"></span></a>")
602615 if templ_7745c5c3_Err != nil {
603616 return templ_7745c5c3_Err
604617 }
605618 }
606- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</div></div>")
619+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</div></div>")
607620 if templ_7745c5c3_Err != nil {
608621 return templ_7745c5c3_Err
609622 }
610623 if len(l.Sections) > 0 {
611- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<nav class=\"tabs\" aria-label=\"Sections\"><ul>")
624+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<nav class=\"tabs\" aria-label=\"Sections\"><ul>")
612625 if templ_7745c5c3_Err != nil {
613626 return templ_7745c5c3_Err
614627 }
615628 for _, s := range l.Sections {
616- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<li><a href=\"")
629+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<li><a href=\"")
617630 if templ_7745c5c3_Err != nil {
618631 return templ_7745c5c3_Err
619632 }
620- var templ_7745c5c3_Var31 templ.SafeURL
621- templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.Href))
633+ var templ_7745c5c3_Var32 templ.SafeURL
634+ templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.Href))
622635 if templ_7745c5c3_Err != nil {
623- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 150, Col: 36}
636+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 151, Col: 36}
624637 }
625- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
638+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
626639 if templ_7745c5c3_Err != nil {
627640 return templ_7745c5c3_Err
628641 }
629- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "\" data-key=\"")
642+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\" data-key=\"")
630643 if templ_7745c5c3_Err != nil {
631644 return templ_7745c5c3_Err
632645 }
633- var templ_7745c5c3_Var32 string
634- templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)
646+ var templ_7745c5c3_Var33 string
647+ templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)
635648 if templ_7745c5c3_Err != nil {
636- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 151, Col: 24}
649+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 152, Col: 24}
637650 }
638- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32)
651+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
639652 if templ_7745c5c3_Err != nil {
640653 return templ_7745c5c3_Err
641654 }
642- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\" aria-keyshortcuts=\"")
655+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "\" aria-keyshortcuts=\"")
643656 if templ_7745c5c3_Err != nil {
644657 return templ_7745c5c3_Err
645658 }
646- var templ_7745c5c3_Var33 string
647- templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)
659+ var templ_7745c5c3_Var34 string
660+ templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)
648661 if templ_7745c5c3_Err != nil {
649- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 152, Col: 33}
662+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 153, Col: 33}
650663 }
651- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
664+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
652665 if templ_7745c5c3_Err != nil {
653666 return templ_7745c5c3_Err
654667 }
655- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "\"")
668+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\"")
656669 if templ_7745c5c3_Err != nil {
657670 return templ_7745c5c3_Err
658671 }
659672 if s.Current {
660- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, " aria-current=\"page\"")
673+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, " aria-current=\"page\"")
661674 if templ_7745c5c3_Err != nil {
662675 return templ_7745c5c3_Err
663676 }
664677 }
665- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, ">")
678+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, ">")
666679 if templ_7745c5c3_Err != nil {
667680 return templ_7745c5c3_Err
668681 }
669- var templ_7745c5c3_Var34 string
670- templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(s.Label)
682+ var templ_7745c5c3_Var35 string
683+ templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(s.Label)
671684 if templ_7745c5c3_Err != nil {
672- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 157, Col: 17}
685+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 158, Col: 17}
673686 }
674- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
687+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
675688 if templ_7745c5c3_Err != nil {
676689 return templ_7745c5c3_Err
677690 }
678- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, " ")
691+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, " ")
679692 if templ_7745c5c3_Err != nil {
680693 return templ_7745c5c3_Err
681694 }
682695 if s.Count != "" {
683- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<span class=\"count\">")
696+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<span class=\"count\">")
684697 if templ_7745c5c3_Err != nil {
685698 return templ_7745c5c3_Err
686699 }
687- var templ_7745c5c3_Var35 string
688- templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(s.Count)
700+ var templ_7745c5c3_Var36 string
701+ templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(s.Count)
689702 if templ_7745c5c3_Err != nil {
690- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 159, Col: 38}
703+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 160, Col: 38}
691704 }
692- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
705+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
693706 if templ_7745c5c3_Err != nil {
694707 return templ_7745c5c3_Err
695708 }
696- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<span class=\"sr\">")
709+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<span class=\"sr\">")
697710 if templ_7745c5c3_Err != nil {
698711 return templ_7745c5c3_Err
699712 }
700- var templ_7745c5c3_Var36 string
701- templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(" open")
713+ var templ_7745c5c3_Var37 string
714+ templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(" open")
702715 if templ_7745c5c3_Err != nil {
703- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 159, Col: 66}
716+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 160, Col: 66}
704717 }
705- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
718+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
706719 if templ_7745c5c3_Err != nil {
707720 return templ_7745c5c3_Err
708721 }
709- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</span></span> ")
722+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</span></span> ")
710723 if templ_7745c5c3_Err != nil {
711724 return templ_7745c5c3_Err
712725 }
713726 }
714- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<kbd aria-hidden=\"true\">")
727+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "<kbd aria-hidden=\"true\">")
715728 if templ_7745c5c3_Err != nil {
716729 return templ_7745c5c3_Err
717730 }
718- var templ_7745c5c3_Var37 string
719- templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(s.Key)
731+ var templ_7745c5c3_Var38 string
732+ templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(s.Key)
720733 if templ_7745c5c3_Err != nil {
721- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 161, Col: 39}
734+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 162, Col: 39}
722735 }
723- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
736+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
724737 if templ_7745c5c3_Err != nil {
725738 return templ_7745c5c3_Err
726739 }
727- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</kbd></a></li>")
740+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</kbd></a></li>")
728741 if templ_7745c5c3_Err != nil {
729742 return templ_7745c5c3_Err
730743 }
731744 }
732- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</ul></nav>")
745+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</ul></nav>")
733746 if templ_7745c5c3_Err != nil {
734747 return templ_7745c5c3_Err
735748 }
736749 }
737- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</header>")
750+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</header>")
738751 if templ_7745c5c3_Err != nil {
739752 return templ_7745c5c3_Err
740753 }
@@ -760,38 +773,38 @@ func Footer(l Layout) templ.Component {
760773 }()
761774 }
762775 ctx = templ.InitializeContext(ctx)
763- templ_7745c5c3_Var38 := templ.GetChildren(ctx)
764- if templ_7745c5c3_Var38 == nil {
765- templ_7745c5c3_Var38 = templ.NopComponent
776+ templ_7745c5c3_Var39 := templ.GetChildren(ctx)
777+ if templ_7745c5c3_Var39 == nil {
778+ templ_7745c5c3_Var39 = templ.NopComponent
766779 }
767780 ctx = templ.ClearChildren(ctx)
768- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "<footer class=\"foot\"><span>ermite ")
781+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<footer class=\"foot\"><span>ermite ")
769782 if templ_7745c5c3_Err != nil {
770783 return templ_7745c5c3_Err
771784 }
772- var templ_7745c5c3_Var39 string
773- templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(l.Version)
785+ var templ_7745c5c3_Var40 string
786+ templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(l.Version)
774787 if templ_7745c5c3_Err != nil {
775- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 175, Col: 26}
788+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 176, Col: 26}
776789 }
777- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
790+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
778791 if templ_7745c5c3_Err != nil {
779792 return templ_7745c5c3_Err
780793 }
781- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</span> <a href=\"")
794+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</span> <a href=\"")
782795 if templ_7745c5c3_Err != nil {
783796 return templ_7745c5c3_Err
784797 }
785- var templ_7745c5c3_Var40 templ.SafeURL
786- templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.SourceURL))
798+ var templ_7745c5c3_Var41 templ.SafeURL
799+ templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.SourceURL))
787800 if templ_7745c5c3_Err != nil {
788- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 176, Col: 38}
801+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 177, Col: 38}
789802 }
790- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
803+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
791804 if templ_7745c5c3_Err != nil {
792805 return templ_7745c5c3_Err
793806 }
794- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "\" rel=\"noopener\" title=\"source of this version (AGPL-3.0-or-later)\">source</a> <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></footer>")
807+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "\" rel=\"noopener\" title=\"source of this version (AGPL-3.0-or-later)\">source</a> <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></footer>")
795808 if templ_7745c5c3_Err != nil {
796809 return templ_7745c5c3_Err
797810 }
@@ -819,12 +832,12 @@ func Hint(l Layout) templ.Component {
819832 }()
820833 }
821834 ctx = templ.InitializeContext(ctx)
822- templ_7745c5c3_Var41 := templ.GetChildren(ctx)
823- if templ_7745c5c3_Var41 == nil {
824- templ_7745c5c3_Var41 = templ.NopComponent
835+ templ_7745c5c3_Var42 := templ.GetChildren(ctx)
836+ if templ_7745c5c3_Var42 == nil {
837+ templ_7745c5c3_Var42 = templ.NopComponent
825838 }
826839 ctx = templ.ClearChildren(ctx)
827- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "<div class=\"hint\" role=\"status\" aria-live=\"polite\"><span class=\"hint-nav\" :text=\"document.body.dataset.nav || ''\"></span> <span class=\"hint-sel\" :text=\"document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''\"></span> <span class=\"hint-flash\" :text=\"document.body.dataset.flash || ''\"></span>")
840+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<div class=\"hint\" role=\"status\" aria-live=\"polite\"><span class=\"hint-nav\" :text=\"document.body.dataset.nav || ''\"></span> <span class=\"hint-sel\" :text=\"document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''\"></span> <span class=\"hint-flash\" :text=\"document.body.dataset.flash || ''\"></span>")
828841 if templ_7745c5c3_Err != nil {
829842 return templ_7745c5c3_Err
830843 }
@@ -832,7 +845,7 @@ func Hint(l Layout) templ.Component {
832845 if templ_7745c5c3_Err != nil {
833846 return templ_7745c5c3_Err
834847 }
835- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</div>")
848+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</div>")
836849 if templ_7745c5c3_Err != nil {
837850 return templ_7745c5c3_Err
838851 }
@@ -156,9 +156,9 @@ func Shell(l Layout, rail templ.Component) templ.Component {
156 return templ_7745c5c3_Err156 return templ_7745c5c3_Err
157 }157 }
158 var templ_7745c5c3_Var11 string158 var templ_7745c5c3_Var11 string
159- templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.Assets.App)159+ templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.Assets.Store)
160 if templ_7745c5c3_Err != nil {160 if templ_7745c5c3_Err != nil {
161- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 28, Col: 29}161+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 28, Col: 31}
162 }162 }
163 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)163 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
164 if templ_7745c5c3_Err != nil {164 if templ_7745c5c3_Err != nil {
@@ -169,9 +169,9 @@ func Shell(l Layout, rail templ.Component) templ.Component {
169 return templ_7745c5c3_Err169 return templ_7745c5c3_Err
170 }170 }
171 var templ_7745c5c3_Var12 string171 var templ_7745c5c3_Var12 string
172- templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.Assets.Files)172+ templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.Assets.App)
173 if templ_7745c5c3_Err != nil {173 if templ_7745c5c3_Err != nil {
174- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 29, Col: 31}174+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 29, Col: 29}
175 }175 }
176 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)176 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
177 if templ_7745c5c3_Err != nil {177 if templ_7745c5c3_Err != nil {
@@ -182,9 +182,9 @@ func Shell(l Layout, rail templ.Component) templ.Component {
182 return templ_7745c5c3_Err182 return templ_7745c5c3_Err
183 }183 }
184 var templ_7745c5c3_Var13 string184 var templ_7745c5c3_Var13 string
185- templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.Assets.Passkey)185+ templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.Assets.Files)
186 if templ_7745c5c3_Err != nil {186 if templ_7745c5c3_Err != nil {
187- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 30, Col: 33}187+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 30, Col: 31}
188 }188 }
189 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)189 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
190 if templ_7745c5c3_Err != nil {190 if templ_7745c5c3_Err != nil {
@@ -195,100 +195,113 @@ func Shell(l Layout, rail templ.Component) templ.Component {
195 return templ_7745c5c3_Err195 return templ_7745c5c3_Err
196 }196 }
197 var templ_7745c5c3_Var14 string197 var templ_7745c5c3_Var14 string
198- templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.Assets.Log)198+ templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.Assets.Passkey)
199 if templ_7745c5c3_Err != nil {199 if templ_7745c5c3_Err != nil {
200- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 31, Col: 29}200+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 31, Col: 33}
201 }201 }
202 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)202 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
203 if templ_7745c5c3_Err != nil {203 if templ_7745c5c3_Err != nil {
204 return templ_7745c5c3_Err204 return templ_7745c5c3_Err
205 }205 }
206- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" defer></script>")206+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" defer></script><script src=\"")
207+ if templ_7745c5c3_Err != nil {
208+ return templ_7745c5c3_Err
209+ }
210+ var templ_7745c5c3_Var15 string
211+ templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.Assets.Log)
212+ if templ_7745c5c3_Err != nil {
213+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 32, Col: 29}
214+ }
215+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
216+ if templ_7745c5c3_Err != nil {
217+ return templ_7745c5c3_Err
218+ }
219+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" defer></script>")
207 if templ_7745c5c3_Err != nil {220 if templ_7745c5c3_Err != nil {
208 return templ_7745c5c3_Err221 return templ_7745c5c3_Err
209 }222 }
210 if l.MR {223 if l.MR {
211- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<script src=\"")224+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<script src=\"")
212 if templ_7745c5c3_Err != nil {225 if templ_7745c5c3_Err != nil {
213 return templ_7745c5c3_Err226 return templ_7745c5c3_Err
214 }227 }
215- var templ_7745c5c3_Var15 string228+ var templ_7745c5c3_Var16 string
216- templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.Assets.MR)229+ templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.Assets.MR)
217 if templ_7745c5c3_Err != nil {230 if templ_7745c5c3_Err != nil {
218- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 33, Col: 29}231+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 34, Col: 29}
219 }232 }
220- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)233+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
221 if templ_7745c5c3_Err != nil {234 if templ_7745c5c3_Err != nil {
222 return templ_7745c5c3_Err235 return templ_7745c5c3_Err
223 }236 }
224- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" defer></script>")237+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" defer></script>")
225 if templ_7745c5c3_Err != nil {238 if templ_7745c5c3_Err != nil {
226 return templ_7745c5c3_Err239 return templ_7745c5c3_Err
227 }240 }
228 }241 }
229- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</head><body data-ref=\"")242+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</head><body data-ref=\"")
230 if templ_7745c5c3_Err != nil {243 if templ_7745c5c3_Err != nil {
231 return templ_7745c5c3_Err244 return templ_7745c5c3_Err
232 }245 }
233- var templ_7745c5c3_Var16 string246+ var templ_7745c5c3_Var17 string
234- templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.RefPath)247+ templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.RefPath)
235 if templ_7745c5c3_Err != nil {248 if templ_7745c5c3_Err != nil {
236- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 37, Col: 23}249+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 38, Col: 23}
237 }250 }
238- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)251+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
239 if templ_7745c5c3_Err != nil {252 if templ_7745c5c3_Err != nil {
240 return templ_7745c5c3_Err253 return templ_7745c5c3_Err
241 }254 }
242- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" data-sha=\"")255+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" data-sha=\"")
243 if templ_7745c5c3_Err != nil {256 if templ_7745c5c3_Err != nil {
244 return templ_7745c5c3_Err257 return templ_7745c5c3_Err
245 }258 }
246- var templ_7745c5c3_Var17 string259+ var templ_7745c5c3_Var18 string
247- templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.SHA)260+ templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.SHA)
248 if templ_7745c5c3_Err != nil {261 if templ_7745c5c3_Err != nil {
249- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 38, Col: 19}262+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 39, Col: 19}
250 }263 }
251- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)264+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
252 if templ_7745c5c3_Err != nil {265 if templ_7745c5c3_Err != nil {
253 return templ_7745c5c3_Err266 return templ_7745c5c3_Err
254 }267 }
255- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" data-sel=\"\" data-nav=\"\"")268+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\" data-sel=\"\" data-nav=\"\"")
256 if templ_7745c5c3_Err != nil {269 if templ_7745c5c3_Err != nil {
257 return templ_7745c5c3_Err270 return templ_7745c5c3_Err
258 }271 }
259 if l.PathsURL != "" {272 if l.PathsURL != "" {
260- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, " data-paths=\"")273+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, " data-paths=\"")
261 if templ_7745c5c3_Err != nil {274 if templ_7745c5c3_Err != nil {
262 return templ_7745c5c3_Err275 return templ_7745c5c3_Err
263 }276 }
264- var templ_7745c5c3_Var18 string277+ var templ_7745c5c3_Var19 string
265- templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.PathsURL)278+ templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.PathsURL)
266 if templ_7745c5c3_Err != nil {279 if templ_7745c5c3_Err != nil {
267- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 42, Col: 27}280+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 43, Col: 27}
268 }281 }
269- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)282+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
270 if templ_7745c5c3_Err != nil {283 if templ_7745c5c3_Err != nil {
271 return templ_7745c5c3_Err284 return templ_7745c5c3_Err
272 }285 }
273- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" data-blob=\"")286+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" data-blob=\"")
274 if templ_7745c5c3_Err != nil {287 if templ_7745c5c3_Err != nil {
275 return templ_7745c5c3_Err288 return templ_7745c5c3_Err
276 }289 }
277- var templ_7745c5c3_Var19 string290+ var templ_7745c5c3_Var20 string
278- templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.BlobBase)291+ templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.BlobBase)
279 if templ_7745c5c3_Err != nil {292 if templ_7745c5c3_Err != nil {
280- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 43, Col: 26}293+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 44, Col: 26}
281 }294 }
282- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)295+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
283 if templ_7745c5c3_Err != nil {296 if templ_7745c5c3_Err != nil {
284 return templ_7745c5c3_Err297 return templ_7745c5c3_Err
285 }298 }
286- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\"")299+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\"")
287 if templ_7745c5c3_Err != nil {300 if templ_7745c5c3_Err != nil {
288 return templ_7745c5c3_Err301 return templ_7745c5c3_Err
289 }302 }
290 }303 }
291- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "><a class=\"skip\" href=\"#main\">skip to content</a>")304+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "><a class=\"skip\" href=\"#main\">skip to content</a>")
292 if templ_7745c5c3_Err != nil {305 if templ_7745c5c3_Err != nil {
293 return templ_7745c5c3_Err306 return templ_7745c5c3_Err
294 }307 }
@@ -296,48 +309,48 @@ func Shell(l Layout, rail templ.Component) templ.Component {
296 if templ_7745c5c3_Err != nil {309 if templ_7745c5c3_Err != nil {
297 return templ_7745c5c3_Err310 return templ_7745c5c3_Err
298 }311 }
299- var templ_7745c5c3_Var20 = []any{"work", templ.KV("has-rail", l.HasRail && rail != nil)}312+ var templ_7745c5c3_Var21 = []any{"work", templ.KV("has-rail", l.HasRail && rail != nil)}
300- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var20...)313+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var21...)
301 if templ_7745c5c3_Err != nil {314 if templ_7745c5c3_Err != nil {
302 return templ_7745c5c3_Err315 return templ_7745c5c3_Err
303 }316 }
304- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<div class=\"")317+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<div class=\"")
305 if templ_7745c5c3_Err != nil {318 if templ_7745c5c3_Err != nil {
306 return templ_7745c5c3_Err319 return templ_7745c5c3_Err
307 }320 }
308- var templ_7745c5c3_Var21 string321+ var templ_7745c5c3_Var22 string
309- templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var20).String())322+ templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var21).String())
310 if templ_7745c5c3_Err != nil {323 if templ_7745c5c3_Err != nil {
311 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 1, Col: 0}324 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 1, Col: 0}
312 }325 }
313- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)326+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
314 if templ_7745c5c3_Err != nil {327 if templ_7745c5c3_Err != nil {
315 return templ_7745c5c3_Err328 return templ_7745c5c3_Err
316 }329 }
317- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\">")330+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\">")
318 if templ_7745c5c3_Err != nil {331 if templ_7745c5c3_Err != nil {
319 return templ_7745c5c3_Err332 return templ_7745c5c3_Err
320 }333 }
321 if l.Lede != "" {334 if l.Lede != "" {
322- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<section class=\"work-lede\" aria-label=\"Description\"><p class=\"lede\">")335+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<section class=\"work-lede\" aria-label=\"Description\"><p class=\"lede\">")
323 if templ_7745c5c3_Err != nil {336 if templ_7745c5c3_Err != nil {
324 return templ_7745c5c3_Err337 return templ_7745c5c3_Err
325 }338 }
326- var templ_7745c5c3_Var22 string339+ var templ_7745c5c3_Var23 string
327- templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(l.Lede)340+ templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(l.Lede)
328 if templ_7745c5c3_Err != nil {341 if templ_7745c5c3_Err != nil {
329- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 50, Col: 81}342+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 51, Col: 81}
330 }343 }
331- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))344+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
332 if templ_7745c5c3_Err != nil {345 if templ_7745c5c3_Err != nil {
333 return templ_7745c5c3_Err346 return templ_7745c5c3_Err
334 }347 }
335- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</p></section>")348+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</p></section>")
336 if templ_7745c5c3_Err != nil {349 if templ_7745c5c3_Err != nil {
337 return templ_7745c5c3_Err350 return templ_7745c5c3_Err
338 }351 }
339 }352 }
340- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<main id=\"main\" tabindex=\"-1\">")353+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<main id=\"main\" tabindex=\"-1\">")
341 if templ_7745c5c3_Err != nil {354 if templ_7745c5c3_Err != nil {
342 return templ_7745c5c3_Err355 return templ_7745c5c3_Err
343 }356 }
@@ -345,12 +358,12 @@ func Shell(l Layout, rail templ.Component) templ.Component {
345 if templ_7745c5c3_Err != nil {358 if templ_7745c5c3_Err != nil {
346 return templ_7745c5c3_Err359 return templ_7745c5c3_Err
347 }360 }
348- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</main>")361+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</main>")
349 if templ_7745c5c3_Err != nil {362 if templ_7745c5c3_Err != nil {
350 return templ_7745c5c3_Err363 return templ_7745c5c3_Err
351 }364 }
352 if l.HasRail && rail != nil {365 if l.HasRail && rail != nil {
353- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<aside class=\"rail\" aria-label=\"Context\">")366+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<aside class=\"rail\" aria-label=\"Context\">")
354 if templ_7745c5c3_Err != nil {367 if templ_7745c5c3_Err != nil {
355 return templ_7745c5c3_Err368 return templ_7745c5c3_Err
356 }369 }
@@ -358,12 +371,12 @@ func Shell(l Layout, rail templ.Component) templ.Component {
358 if templ_7745c5c3_Err != nil {371 if templ_7745c5c3_Err != nil {
359 return templ_7745c5c3_Err372 return templ_7745c5c3_Err
360 }373 }
361- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</aside>")374+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</aside>")
362 if templ_7745c5c3_Err != nil {375 if templ_7745c5c3_Err != nil {
363 return templ_7745c5c3_Err376 return templ_7745c5c3_Err
364 }377 }
365 }378 }
366- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</div>")379+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</div>")
367 if templ_7745c5c3_Err != nil {380 if templ_7745c5c3_Err != nil {
368 return templ_7745c5c3_Err381 return templ_7745c5c3_Err
369 }382 }
@@ -391,7 +404,7 @@ func Shell(l Layout, rail templ.Component) templ.Component {
391 if templ_7745c5c3_Err != nil {404 if templ_7745c5c3_Err != nil {
392 return templ_7745c5c3_Err405 return templ_7745c5c3_Err
393 }406 }
394- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</body></html>")407+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</body></html>")
395 if templ_7745c5c3_Err != nil {408 if templ_7745c5c3_Err != nil {
396 return templ_7745c5c3_Err409 return templ_7745c5c3_Err
397 }410 }
@@ -417,324 +430,324 @@ func Header(l Layout) templ.Component {
417 }()430 }()
418 }431 }
419 ctx = templ.InitializeContext(ctx)432 ctx = templ.InitializeContext(ctx)
420- templ_7745c5c3_Var23 := templ.GetChildren(ctx)433+ templ_7745c5c3_Var24 := templ.GetChildren(ctx)
421- if templ_7745c5c3_Var23 == nil {434+ if templ_7745c5c3_Var24 == nil {
422- templ_7745c5c3_Var23 = templ.NopComponent435+ templ_7745c5c3_Var24 = templ.NopComponent
423 }436 }
424 ctx = templ.ClearChildren(ctx)437 ctx = templ.ClearChildren(ctx)
425- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<header class=\"hdr\"><div class=\"hdr-top\"><a class=\"brand\" href=\"/\" data-g=\"h\" aria-label=\"home\" title=\"home (g h)\"><span class=\"brand-mark\" aria-hidden=\"true\"></span></a> ")438+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<header class=\"hdr\"><div class=\"hdr-top\"><a class=\"brand\" href=\"/\" data-g=\"h\" aria-label=\"home\" title=\"home (g h)\"><span class=\"brand-mark\" aria-hidden=\"true\"></span></a> ")
426 if templ_7745c5c3_Err != nil {439 if templ_7745c5c3_Err != nil {
427 return templ_7745c5c3_Err440 return templ_7745c5c3_Err
428 }441 }
429 if len(l.Crumbs) > 0 {442 if len(l.Crumbs) > 0 {
430- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<nav class=\"crumbs\" aria-label=\"Path\"><ol>")443+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<nav class=\"crumbs\" aria-label=\"Path\"><ol>")
431 if templ_7745c5c3_Err != nil {444 if templ_7745c5c3_Err != nil {
432 return templ_7745c5c3_Err445 return templ_7745c5c3_Err
433 }446 }
434 for _, c := range l.Crumbs {447 for _, c := range l.Crumbs {
435- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<li>")448+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<li>")
436 if templ_7745c5c3_Err != nil {449 if templ_7745c5c3_Err != nil {
437 return templ_7745c5c3_Err450 return templ_7745c5c3_Err
438 }451 }
439 if c.Href != "" {452 if c.Href != "" {
440- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<a href=\"")453+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<a href=\"")
441 if templ_7745c5c3_Err != nil {454 if templ_7745c5c3_Err != nil {
442 return templ_7745c5c3_Err455 return templ_7745c5c3_Err
443 }456 }
444- var templ_7745c5c3_Var24 templ.SafeURL457+ var templ_7745c5c3_Var25 templ.SafeURL
445- templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))458+ templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))
446 if templ_7745c5c3_Err != nil {459 if templ_7745c5c3_Err != nil {
447- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 86, Col: 38}460+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 87, Col: 38}
448 }461 }
449- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))462+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
450 if templ_7745c5c3_Err != nil {463 if templ_7745c5c3_Err != nil {
451 return templ_7745c5c3_Err464 return templ_7745c5c3_Err
452 }465 }
453- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\"")466+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\"")
454 if templ_7745c5c3_Err != nil {467 if templ_7745c5c3_Err != nil {
455 return templ_7745c5c3_Err468 return templ_7745c5c3_Err
456 }469 }
457 if c.Current {470 if c.Current {
458- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, " aria-current=\"page\"")471+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " aria-current=\"page\"")
459 if templ_7745c5c3_Err != nil {472 if templ_7745c5c3_Err != nil {
460 return templ_7745c5c3_Err473 return templ_7745c5c3_Err
461 }474 }
462 }475 }
463- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, ">")476+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, ">")
464 if templ_7745c5c3_Err != nil {477 if templ_7745c5c3_Err != nil {
465 return templ_7745c5c3_Err478 return templ_7745c5c3_Err
466 }479 }
467- var templ_7745c5c3_Var25 string480+ var templ_7745c5c3_Var26 string
468- templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)481+ templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
469 if templ_7745c5c3_Err != nil {482 if templ_7745c5c3_Err != nil {
470- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 90, Col: 19}483+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 91, Col: 19}
471 }484 }
472- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))485+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
473 if templ_7745c5c3_Err != nil {486 if templ_7745c5c3_Err != nil {
474 return templ_7745c5c3_Err487 return templ_7745c5c3_Err
475 }488 }
476- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</a>")489+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</a>")
477 if templ_7745c5c3_Err != nil {490 if templ_7745c5c3_Err != nil {
478 return templ_7745c5c3_Err491 return templ_7745c5c3_Err
479 }492 }
480 } else {493 } else {
481- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<span")494+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<span")
482 if templ_7745c5c3_Err != nil {495 if templ_7745c5c3_Err != nil {
483 return templ_7745c5c3_Err496 return templ_7745c5c3_Err
484 }497 }
485 if c.Current {498 if c.Current {
486- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, " aria-current=\"page\"")499+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, " aria-current=\"page\"")
487 if templ_7745c5c3_Err != nil {500 if templ_7745c5c3_Err != nil {
488 return templ_7745c5c3_Err501 return templ_7745c5c3_Err
489 }502 }
490 }503 }
491- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, ">")504+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, ">")
492 if templ_7745c5c3_Err != nil {505 if templ_7745c5c3_Err != nil {
493 return templ_7745c5c3_Err506 return templ_7745c5c3_Err
494 }507 }
495- var templ_7745c5c3_Var26 string508+ var templ_7745c5c3_Var27 string
496- templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)509+ templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
497 if templ_7745c5c3_Err != nil {510 if templ_7745c5c3_Err != nil {
498- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 96, Col: 19}511+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 97, Col: 19}
499 }512 }
500- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))513+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
501 if templ_7745c5c3_Err != nil {514 if templ_7745c5c3_Err != nil {
502 return templ_7745c5c3_Err515 return templ_7745c5c3_Err
503 }516 }
504- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</span>")517+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</span>")
505 if templ_7745c5c3_Err != nil {518 if templ_7745c5c3_Err != nil {
506 return templ_7745c5c3_Err519 return templ_7745c5c3_Err
507 }520 }
508 }521 }
509- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</li>")522+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</li>")
510 if templ_7745c5c3_Err != nil {523 if templ_7745c5c3_Err != nil {
511 return templ_7745c5c3_Err524 return templ_7745c5c3_Err
512 }525 }
513 }526 }
514- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</ol></nav>")527+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</ol></nav>")
515 if templ_7745c5c3_Err != nil {528 if templ_7745c5c3_Err != nil {
516 return templ_7745c5c3_Err529 return templ_7745c5c3_Err
517 }530 }
518 }531 }
519- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<div class=\"hdr-acts\">")532+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<div class=\"hdr-acts\">")
520 if templ_7745c5c3_Err != nil {533 if templ_7745c5c3_Err != nil {
521 return templ_7745c5c3_Err534 return templ_7745c5c3_Err
522 }535 }
523 if l.Ref != "" {536 if l.Ref != "" {
524- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<a class=\"btn ref\" href=\"")537+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<a class=\"btn ref\" href=\"")
525 if templ_7745c5c3_Err != nil {538 if templ_7745c5c3_Err != nil {
526 return templ_7745c5c3_Err539 return templ_7745c5c3_Err
527 }540 }
528- var templ_7745c5c3_Var27 templ.SafeURL541+ var templ_7745c5c3_Var28 templ.SafeURL
529- templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(repoHref(l.Repo, "refs")))542+ templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(repoHref(l.Repo, "refs")))
530 if templ_7745c5c3_Err != nil {543 if templ_7745c5c3_Err != nil {
531- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 107, Col: 52}544+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 108, Col: 52}
532 }545 }
533- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))546+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
534 if templ_7745c5c3_Err != nil {547 if templ_7745c5c3_Err != nil {
535 return templ_7745c5c3_Err548 return templ_7745c5c3_Err
536 }549 }
537- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\" data-fire=\"refs\" aria-keyshortcuts=\"b\" title=\"switch branch or tag (b)\"><span class=\"ico i-branch\" aria-hidden=\"true\"></span> <span class=\"ref-name\">")550+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\" data-fire=\"refs\" aria-keyshortcuts=\"b\" title=\"switch branch or tag (b)\"><span class=\"ico i-branch\" aria-hidden=\"true\"></span> <span class=\"ref-name\">")
538 if templ_7745c5c3_Err != nil {551 if templ_7745c5c3_Err != nil {
539 return templ_7745c5c3_Err552 return templ_7745c5c3_Err
540 }553 }
541- var templ_7745c5c3_Var28 string554+ var templ_7745c5c3_Var29 string
542- templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(l.Ref)555+ templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(l.Ref)
543 if templ_7745c5c3_Err != nil {556 if templ_7745c5c3_Err != nil {
544- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 113, Col: 36}557+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 114, Col: 36}
545 }558 }
546- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))559+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
547 if templ_7745c5c3_Err != nil {560 if templ_7745c5c3_Err != nil {
548 return templ_7745c5c3_Err561 return templ_7745c5c3_Err
549 }562 }
550- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</span> <span class=\"sha\">")563+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</span> <span class=\"sha\">")
551 if templ_7745c5c3_Err != nil {564 if templ_7745c5c3_Err != nil {
552 return templ_7745c5c3_Err565 return templ_7745c5c3_Err
553 }566 }
554- var templ_7745c5c3_Var29 string567+ var templ_7745c5c3_Var30 string
555- templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(l.Short)568+ templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(l.Short)
556 if templ_7745c5c3_Err != nil {569 if templ_7745c5c3_Err != nil {
557- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 114, Col: 33}570+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 115, Col: 33}
558 }571 }
559- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))572+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
560 if templ_7745c5c3_Err != nil {573 if templ_7745c5c3_Err != nil {
561 return templ_7745c5c3_Err574 return templ_7745c5c3_Err
562 }575 }
563- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</span></a> ")576+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</span></a> ")
564 if templ_7745c5c3_Err != nil {577 if templ_7745c5c3_Err != nil {
565 return templ_7745c5c3_Err578 return templ_7745c5c3_Err
566 }579 }
567 }580 }
568- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<button type=\"button\" class=\"btn search\" data-js aria-haspopup=\"dialog\" aria-controls=\"panel-cmd\" aria-keyshortcuts=\"Control+K :\" title=\"go to a section, ref, file or repo (ctrl k or :)\" data-fire=\"cmd\"><span class=\"ico i-search\" aria-hidden=\"true\"></span> <span class=\"search-label\">Go to…</span> <kbd aria-hidden=\"true\">ctrl k</kbd></button> ")581+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<button type=\"button\" class=\"btn search\" data-js aria-haspopup=\"dialog\" aria-controls=\"panel-cmd\" aria-keyshortcuts=\"Control+K :\" title=\"go to a section, ref, file or repo (ctrl k or :)\" data-fire=\"cmd\"><span class=\"ico i-search\" aria-hidden=\"true\"></span> <span class=\"search-label\">Go to…</span> <kbd aria-hidden=\"true\">ctrl k</kbd></button> ")
569 if templ_7745c5c3_Err != nil {582 if templ_7745c5c3_Err != nil {
570 return templ_7745c5c3_Err583 return templ_7745c5c3_Err
571 }584 }
572 if SignedIn(ctx) {585 if SignedIn(ctx) {
573- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<form class=\"owner\" method=\"post\" action=\"/-/logout\"><span class=\"owner-badge\" title=\"signed in as the owner\"><span class=\"ico i-user\" aria-hidden=\"true\"></span>")586+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<form class=\"owner\" method=\"post\" action=\"/-/logout\"><span class=\"owner-badge\" title=\"signed in as the owner\"><span class=\"ico i-user\" aria-hidden=\"true\"></span>")
574 if templ_7745c5c3_Err != nil {587 if templ_7745c5c3_Err != nil {
575 return templ_7745c5c3_Err588 return templ_7745c5c3_Err
576 }589 }
577- var templ_7745c5c3_Var30 string590+ var templ_7745c5c3_Var31 string
578- templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(l.Owner)591+ templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(l.Owner)
579 if templ_7745c5c3_Err != nil {592 if templ_7745c5c3_Err != nil {
580- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 133, Col: 123}593+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 134, Col: 123}
581 }594 }
582- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))595+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
583 if templ_7745c5c3_Err != nil {596 if templ_7745c5c3_Err != nil {
584 return templ_7745c5c3_Err597 return templ_7745c5c3_Err
585 }598 }
586- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</span> ")599+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</span> ")
587 if templ_7745c5c3_Err != nil {600 if templ_7745c5c3_Err != nil {
588 return templ_7745c5c3_Err601 return templ_7745c5c3_Err
589 }602 }
590 if l.Passkeys {603 if l.Passkeys {
591- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<a class=\"btn icon\" href=\"/-/passkeys\" aria-label=\"Passkeys\" title=\"passkeys: add or remove the passkeys that sign you in\"><span class=\"ico i-key\" aria-hidden=\"true\"></span></a> ")604+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<a class=\"btn icon\" href=\"/-/passkeys\" aria-label=\"Passkeys\" title=\"passkeys: add or remove the passkeys that sign you in\"><span class=\"ico i-key\" aria-hidden=\"true\"></span></a> ")
592 if templ_7745c5c3_Err != nil {605 if templ_7745c5c3_Err != nil {
593 return templ_7745c5c3_Err606 return templ_7745c5c3_Err
594 }607 }
595 }608 }
596- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<button type=\"submit\" class=\"btn icon\" aria-label=\"Sign out\" title=\"sign out of this browser\"><span class=\"ico i-log-out\" aria-hidden=\"true\"></span></button></form>")609+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<button type=\"submit\" class=\"btn icon\" aria-label=\"Sign out\" title=\"sign out of this browser\"><span class=\"ico i-log-out\" aria-hidden=\"true\"></span></button></form>")
597 if templ_7745c5c3_Err != nil {610 if templ_7745c5c3_Err != nil {
598 return templ_7745c5c3_Err611 return templ_7745c5c3_Err
599 }612 }
600 } else if l.Passkeys {613 } else if l.Passkeys {
601- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<a class=\"btn icon\" href=\"/-/login\" aria-label=\"Sign in\" title=\"sign in with a passkey\"><span class=\"ico i-log-in\" aria-hidden=\"true\"></span></a>")614+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<a class=\"btn icon\" href=\"/-/login\" aria-label=\"Sign in\" title=\"sign in with a passkey\"><span class=\"ico i-log-in\" aria-hidden=\"true\"></span></a>")
602 if templ_7745c5c3_Err != nil {615 if templ_7745c5c3_Err != nil {
603 return templ_7745c5c3_Err616 return templ_7745c5c3_Err
604 }617 }
605 }618 }
606- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</div></div>")619+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</div></div>")
607 if templ_7745c5c3_Err != nil {620 if templ_7745c5c3_Err != nil {
608 return templ_7745c5c3_Err621 return templ_7745c5c3_Err
609 }622 }
610 if len(l.Sections) > 0 {623 if len(l.Sections) > 0 {
611- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<nav class=\"tabs\" aria-label=\"Sections\"><ul>")624+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<nav class=\"tabs\" aria-label=\"Sections\"><ul>")
612 if templ_7745c5c3_Err != nil {625 if templ_7745c5c3_Err != nil {
613 return templ_7745c5c3_Err626 return templ_7745c5c3_Err
614 }627 }
615 for _, s := range l.Sections {628 for _, s := range l.Sections {
616- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<li><a href=\"")629+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<li><a href=\"")
617 if templ_7745c5c3_Err != nil {630 if templ_7745c5c3_Err != nil {
618 return templ_7745c5c3_Err631 return templ_7745c5c3_Err
619 }632 }
620- var templ_7745c5c3_Var31 templ.SafeURL633+ var templ_7745c5c3_Var32 templ.SafeURL
621- templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.Href))634+ templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.Href))
622 if templ_7745c5c3_Err != nil {635 if templ_7745c5c3_Err != nil {
623- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 150, Col: 36}636+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 151, Col: 36}
624 }637 }
625- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))638+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
626 if templ_7745c5c3_Err != nil {639 if templ_7745c5c3_Err != nil {
627 return templ_7745c5c3_Err640 return templ_7745c5c3_Err
628 }641 }
629- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "\" data-key=\"")642+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\" data-key=\"")
630 if templ_7745c5c3_Err != nil {643 if templ_7745c5c3_Err != nil {
631 return templ_7745c5c3_Err644 return templ_7745c5c3_Err
632 }645 }
633- var templ_7745c5c3_Var32 string646+ var templ_7745c5c3_Var33 string
634- templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)647+ templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)
635 if templ_7745c5c3_Err != nil {648 if templ_7745c5c3_Err != nil {
636- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 151, Col: 24}649+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 152, Col: 24}
637 }650 }
638- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32)651+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
639 if templ_7745c5c3_Err != nil {652 if templ_7745c5c3_Err != nil {
640 return templ_7745c5c3_Err653 return templ_7745c5c3_Err
641 }654 }
642- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\" aria-keyshortcuts=\"")655+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "\" aria-keyshortcuts=\"")
643 if templ_7745c5c3_Err != nil {656 if templ_7745c5c3_Err != nil {
644 return templ_7745c5c3_Err657 return templ_7745c5c3_Err
645 }658 }
646- var templ_7745c5c3_Var33 string659+ var templ_7745c5c3_Var34 string
647- templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)660+ templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key)
648 if templ_7745c5c3_Err != nil {661 if templ_7745c5c3_Err != nil {
649- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 152, Col: 33}662+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 153, Col: 33}
650 }663 }
651- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)664+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
652 if templ_7745c5c3_Err != nil {665 if templ_7745c5c3_Err != nil {
653 return templ_7745c5c3_Err666 return templ_7745c5c3_Err
654 }667 }
655- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "\"")668+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\"")
656 if templ_7745c5c3_Err != nil {669 if templ_7745c5c3_Err != nil {
657 return templ_7745c5c3_Err670 return templ_7745c5c3_Err
658 }671 }
659 if s.Current {672 if s.Current {
660- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, " aria-current=\"page\"")673+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, " aria-current=\"page\"")
661 if templ_7745c5c3_Err != nil {674 if templ_7745c5c3_Err != nil {
662 return templ_7745c5c3_Err675 return templ_7745c5c3_Err
663 }676 }
664 }677 }
665- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, ">")678+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, ">")
666 if templ_7745c5c3_Err != nil {679 if templ_7745c5c3_Err != nil {
667 return templ_7745c5c3_Err680 return templ_7745c5c3_Err
668 }681 }
669- var templ_7745c5c3_Var34 string682+ var templ_7745c5c3_Var35 string
670- templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(s.Label)683+ templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(s.Label)
671 if templ_7745c5c3_Err != nil {684 if templ_7745c5c3_Err != nil {
672- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 157, Col: 17}685+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 158, Col: 17}
673 }686 }
674- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))687+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
675 if templ_7745c5c3_Err != nil {688 if templ_7745c5c3_Err != nil {
676 return templ_7745c5c3_Err689 return templ_7745c5c3_Err
677 }690 }
678- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, " ")691+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, " ")
679 if templ_7745c5c3_Err != nil {692 if templ_7745c5c3_Err != nil {
680 return templ_7745c5c3_Err693 return templ_7745c5c3_Err
681 }694 }
682 if s.Count != "" {695 if s.Count != "" {
683- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<span class=\"count\">")696+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<span class=\"count\">")
684 if templ_7745c5c3_Err != nil {697 if templ_7745c5c3_Err != nil {
685 return templ_7745c5c3_Err698 return templ_7745c5c3_Err
686 }699 }
687- var templ_7745c5c3_Var35 string700+ var templ_7745c5c3_Var36 string
688- templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(s.Count)701+ templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(s.Count)
689 if templ_7745c5c3_Err != nil {702 if templ_7745c5c3_Err != nil {
690- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 159, Col: 38}703+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 160, Col: 38}
691 }704 }
692- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))705+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
693 if templ_7745c5c3_Err != nil {706 if templ_7745c5c3_Err != nil {
694 return templ_7745c5c3_Err707 return templ_7745c5c3_Err
695 }708 }
696- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<span class=\"sr\">")709+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<span class=\"sr\">")
697 if templ_7745c5c3_Err != nil {710 if templ_7745c5c3_Err != nil {
698 return templ_7745c5c3_Err711 return templ_7745c5c3_Err
699 }712 }
700- var templ_7745c5c3_Var36 string713+ var templ_7745c5c3_Var37 string
701- templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(" open")714+ templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(" open")
702 if templ_7745c5c3_Err != nil {715 if templ_7745c5c3_Err != nil {
703- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 159, Col: 66}716+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 160, Col: 66}
704 }717 }
705- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))718+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
706 if templ_7745c5c3_Err != nil {719 if templ_7745c5c3_Err != nil {
707 return templ_7745c5c3_Err720 return templ_7745c5c3_Err
708 }721 }
709- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</span></span> ")722+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</span></span> ")
710 if templ_7745c5c3_Err != nil {723 if templ_7745c5c3_Err != nil {
711 return templ_7745c5c3_Err724 return templ_7745c5c3_Err
712 }725 }
713 }726 }
714- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<kbd aria-hidden=\"true\">")727+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "<kbd aria-hidden=\"true\">")
715 if templ_7745c5c3_Err != nil {728 if templ_7745c5c3_Err != nil {
716 return templ_7745c5c3_Err729 return templ_7745c5c3_Err
717 }730 }
718- var templ_7745c5c3_Var37 string731+ var templ_7745c5c3_Var38 string
719- templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(s.Key)732+ templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(s.Key)
720 if templ_7745c5c3_Err != nil {733 if templ_7745c5c3_Err != nil {
721- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 161, Col: 39}734+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 162, Col: 39}
722 }735 }
723- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))736+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
724 if templ_7745c5c3_Err != nil {737 if templ_7745c5c3_Err != nil {
725 return templ_7745c5c3_Err738 return templ_7745c5c3_Err
726 }739 }
727- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</kbd></a></li>")740+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</kbd></a></li>")
728 if templ_7745c5c3_Err != nil {741 if templ_7745c5c3_Err != nil {
729 return templ_7745c5c3_Err742 return templ_7745c5c3_Err
730 }743 }
731 }744 }
732- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</ul></nav>")745+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</ul></nav>")
733 if templ_7745c5c3_Err != nil {746 if templ_7745c5c3_Err != nil {
734 return templ_7745c5c3_Err747 return templ_7745c5c3_Err
735 }748 }
736 }749 }
737- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</header>")750+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</header>")
738 if templ_7745c5c3_Err != nil {751 if templ_7745c5c3_Err != nil {
739 return templ_7745c5c3_Err752 return templ_7745c5c3_Err
740 }753 }
@@ -760,38 +773,38 @@ func Footer(l Layout) templ.Component {
760 }()773 }()
761 }774 }
762 ctx = templ.InitializeContext(ctx)775 ctx = templ.InitializeContext(ctx)
763- templ_7745c5c3_Var38 := templ.GetChildren(ctx)776+ templ_7745c5c3_Var39 := templ.GetChildren(ctx)
764- if templ_7745c5c3_Var38 == nil {777+ if templ_7745c5c3_Var39 == nil {
765- templ_7745c5c3_Var38 = templ.NopComponent778+ templ_7745c5c3_Var39 = templ.NopComponent
766 }779 }
767 ctx = templ.ClearChildren(ctx)780 ctx = templ.ClearChildren(ctx)
768- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "<footer class=\"foot\"><span>ermite ")781+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<footer class=\"foot\"><span>ermite ")
769 if templ_7745c5c3_Err != nil {782 if templ_7745c5c3_Err != nil {
770 return templ_7745c5c3_Err783 return templ_7745c5c3_Err
771 }784 }
772- var templ_7745c5c3_Var39 string785+ var templ_7745c5c3_Var40 string
773- templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(l.Version)786+ templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(l.Version)
774 if templ_7745c5c3_Err != nil {787 if templ_7745c5c3_Err != nil {
775- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 175, Col: 26}788+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 176, Col: 26}
776 }789 }
777- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))790+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
778 if templ_7745c5c3_Err != nil {791 if templ_7745c5c3_Err != nil {
779 return templ_7745c5c3_Err792 return templ_7745c5c3_Err
780 }793 }
781- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</span> <a href=\"")794+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</span> <a href=\"")
782 if templ_7745c5c3_Err != nil {795 if templ_7745c5c3_Err != nil {
783 return templ_7745c5c3_Err796 return templ_7745c5c3_Err
784 }797 }
785- var templ_7745c5c3_Var40 templ.SafeURL798+ var templ_7745c5c3_Var41 templ.SafeURL
786- templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.SourceURL))799+ templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.SourceURL))
787 if templ_7745c5c3_Err != nil {800 if templ_7745c5c3_Err != nil {
788- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 176, Col: 38}801+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 177, Col: 38}
789 }802 }
790- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))803+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
791 if templ_7745c5c3_Err != nil {804 if templ_7745c5c3_Err != nil {
792 return templ_7745c5c3_Err805 return templ_7745c5c3_Err
793 }806 }
794- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "\" rel=\"noopener\" title=\"source of this version (AGPL-3.0-or-later)\">source</a> <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></footer>")807+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "\" rel=\"noopener\" title=\"source of this version (AGPL-3.0-or-later)\">source</a> <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></footer>")
795 if templ_7745c5c3_Err != nil {808 if templ_7745c5c3_Err != nil {
796 return templ_7745c5c3_Err809 return templ_7745c5c3_Err
797 }810 }
@@ -819,12 +832,12 @@ func Hint(l Layout) templ.Component {
819 }()832 }()
820 }833 }
821 ctx = templ.InitializeContext(ctx)834 ctx = templ.InitializeContext(ctx)
822- templ_7745c5c3_Var41 := templ.GetChildren(ctx)835+ templ_7745c5c3_Var42 := templ.GetChildren(ctx)
823- if templ_7745c5c3_Var41 == nil {836+ if templ_7745c5c3_Var42 == nil {
824- templ_7745c5c3_Var41 = templ.NopComponent837+ templ_7745c5c3_Var42 = templ.NopComponent
825 }838 }
826 ctx = templ.ClearChildren(ctx)839 ctx = templ.ClearChildren(ctx)
827- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "<div class=\"hint\" role=\"status\" aria-live=\"polite\"><span class=\"hint-nav\" :text=\"document.body.dataset.nav || ''\"></span> <span class=\"hint-sel\" :text=\"document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''\"></span> <span class=\"hint-flash\" :text=\"document.body.dataset.flash || ''\"></span>")840+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<div class=\"hint\" role=\"status\" aria-live=\"polite\"><span class=\"hint-nav\" :text=\"document.body.dataset.nav || ''\"></span> <span class=\"hint-sel\" :text=\"document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''\"></span> <span class=\"hint-flash\" :text=\"document.body.dataset.flash || ''\"></span>")
828 if templ_7745c5c3_Err != nil {841 if templ_7745c5c3_Err != nil {
829 return templ_7745c5c3_Err842 return templ_7745c5c3_Err
830 }843 }
@@ -832,7 +845,7 @@ func Hint(l Layout) templ.Component {
832 if templ_7745c5c3_Err != nil {845 if templ_7745c5c3_Err != nil {
833 return templ_7745c5c3_Err846 return templ_7745c5c3_Err
834 }847 }
835- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</div>")848+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</div>")
836 if templ_7745c5c3_Err != nil {849 if templ_7745c5c3_Err != nil {
837 return templ_7745c5c3_Err850 return templ_7745c5c3_Err
838 }851 }
M internal/adapters/web/static/css/20-shell.css +3 -0
@@ -26,6 +26,9 @@
2626 .i-key { --i: url(icons/key.svg); }
2727 .i-log-in { --i: url(icons/log-in.svg); }
2828 .i-log-out { --i: url(icons/log-out.svg); }
29+ .i-cards { --i: url(icons/cards.svg); }
30+ .i-split { --i: url(icons/split.svg); }
31+ .i-compact { --i: url(icons/compact.svg); }
2932
3033 .pill {
3134 display: inline-flex; align-items: center; height: 20px; padding: 0 var(--s2); margin-left: var(--s1);
@@ -26,6 +26,9 @@
26 .i-key { --i: url(icons/key.svg); }26 .i-key { --i: url(icons/key.svg); }
27 .i-log-in { --i: url(icons/log-in.svg); }27 .i-log-in { --i: url(icons/log-in.svg); }
28 .i-log-out { --i: url(icons/log-out.svg); }28 .i-log-out { --i: url(icons/log-out.svg); }
29+ .i-cards { --i: url(icons/cards.svg); }
30+ .i-split { --i: url(icons/split.svg); }
31+ .i-compact { --i: url(icons/compact.svg); }
29 32
30 .pill {33 .pill {
31 display: inline-flex; align-items: center; height: 20px; padding: 0 var(--s2); margin-left: var(--s1);34 display: inline-flex; align-items: center; height: 20px; padding: 0 var(--s2); margin-left: var(--s1);
M internal/adapters/web/static/css/30-list.css +98 -0
@@ -135,6 +135,104 @@
135135 & .repo-commits { min-height: 0; }
136136 }
137137 }
138+ /* Home layouts. The server renders "cards" (the rules above); a script
139+ sets .home[data-layout] from the stored choice. The rows carry no markup
140+ of their own for a layout, so a row swapped in by a live update fits. */
141+ .home .layout-switch { display: flex; align-items: center; gap: 2px; }
142+ .home .layout-switch .btn {
143+ width: 28px; height: 28px; color: var(--ink-dim); background: none; border-color: transparent; border-radius: var(--r-s);
144+ &:hover { color: var(--ink-hi); background: var(--bg-raised); }
145+ &[aria-pressed="true"] { color: var(--ink-hi); background: var(--bg-raised); border-color: var(--border-strong); }
146+ }
147+ .home .home-bar .sort { margin-left: auto; }
148+
149+ /* split: two denser cards side by side. Name and CI state on top, the
150+ description, the pulse as a band from edge to edge, the figures in one
151+ row and the two newest commits. One column on a narrow screen. */
152+ .home[data-layout="split"] {
153+ & .repos { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--s4); }
154+ & .repo {
155+ grid-template-columns: minmax(0, 1fr) auto; column-gap: var(--s3); row-gap: 0;
156+ grid-template-areas: "name ci" "desc desc" "topics topics" "pulse pulse" "stats stats" "recent recent";
157+ grid-template-rows: auto 1fr auto auto auto auto;
158+ overflow: hidden;
159+ }
160+ & .repo-main, & .repo-head { display: contents; }
161+ & .repo-name { grid-area: name; align-self: center; font-size: 16px; }
162+ & .repo-ci { grid-area: ci; align-self: center; padding-top: 0; }
163+ & .repo-desc { grid-area: desc; margin-top: var(--s2); }
164+ & .repo-topics { grid-area: topics; margin-top: var(--s2); }
165+ & .repo-topics:empty { display: none; }
166+ & .repo-pulse { grid-area: pulse; margin: var(--s3) calc(-1 * var(--s4)) 0; padding: 0; border-block: 1px solid var(--border); }
167+ /* one row while it fits; a narrow card wraps the figures instead of
168+ letting them overlap */
169+ & .repo-stats { grid-area: stats; flex-wrap: wrap; justify-content: space-between; gap: var(--s2) var(--s3); margin-top: var(--s3); }
170+ & .repo-stats div { flex: none; }
171+ & .repo-stats dd { font-size: var(--fs); }
172+ & .repo-stats .rs-tag dd { max-width: 12ch; }
173+ & .repo-commits { grid-area: recent; min-height: calc(2 * (var(--lh) + 4px)); margin-top: var(--s2); }
174+ & .repo-commits li:nth-child(n+3) { display: none; }
175+ & .repo-commits[data-full] li:last-child { opacity: 1; }
176+ & .repo-commits[data-full] li:nth-child(2) { opacity: .65; }
177+ }
178+ @media (max-width: 900px) {
179+ .home[data-layout="split"] .repos { grid-template-columns: minmax(0, 1fr); }
180+ }
181+
182+ /* compact: a line per repository. Name, description, figures, CI state and
183+ a small pulse sit in one grid row; the commits and topics are left out. */
184+ .home[data-layout="compact"] {
185+ & .repos { gap: var(--s2); }
186+ & .repo {
187+ grid-template-columns: minmax(0, 2fr) minmax(0, 3fr) minmax(0, 20rem) 5.5rem 140px;
188+ grid-template-areas: "name desc stats ci pulse";
189+ grid-template-rows: none; column-gap: var(--s4); row-gap: 0; align-items: center;
190+ padding: var(--s2) var(--s4);
191+ }
192+ & .repo-main, & .repo-head { display: contents; }
193+ & .repo-topics, & .repo-commits { display: none; }
194+ & .repo-name { grid-area: name; flex-wrap: nowrap; gap: var(--s2); font-size: var(--fs); line-height: 24px; overflow-wrap: normal; }
195+ & .repo-name a { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
196+ & .repo-name .pill { flex: none; }
197+ & .repo-desc {
198+ grid-area: desc; display: block; min-height: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
199+ font-size: var(--fs-s); color: var(--ink-mute);
200+ }
201+ /* figures inline, the value before its label; the label of the date and
202+ of the tag is left to screen readers */
203+ & .repo-stats { grid-area: stats; flex-wrap: nowrap; gap: var(--s3); overflow: hidden; }
204+ & .repo-stats div { flex-direction: row; align-items: baseline; gap: 4px; flex: none; }
205+ & .repo-stats dt { order: 1; font-size: var(--fs-xs); line-height: 16px; letter-spacing: 0; text-transform: none; }
206+ & .repo-stats dd { font-size: var(--fs-xs); line-height: 16px; color: var(--ink-dim); }
207+ & .repo-stats .rs-active dt, & .repo-stats .rs-tag dt {
208+ position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%);
209+ }
210+ & .repo-stats .rs-tag dd { max-width: 10ch; }
211+ & .repo-stats .rs-tag dd::before {
212+ content: ""; display: inline-block; width: 12px; height: 12px; margin-right: 3px; vertical-align: -2px;
213+ background: currentColor; mask: url(icons/tag.svg) center / contain no-repeat;
214+ }
215+ & .repo-ci { grid-area: ci; justify-self: start; padding-top: 0; font-size: var(--fs-xs); }
216+ /* the pulse: the same curve, small, with a bigger dot to stay seen */
217+ & .repo-pulse { grid-area: pulse; width: 100%; height: 100%; min-height: 18px; margin: 0; padding: 0; }
218+ & div.repo-pulse { aspect-ratio: auto; box-sizing: border-box; }
219+ & .repo-pulse svg { position: relative; top: 50%; transform: translateY(-50%); }
220+ & .pulse-grid { display: none; }
221+ & .pulse-dot { r: 9px; stroke: none; }
222+ }
223+ @media (max-width: 1100px) {
224+ .home[data-layout="compact"] .repo { grid-template-columns: minmax(0, 2fr) minmax(0, 2fr) minmax(0, 14rem) 5.5rem 96px; column-gap: var(--s3); }
225+ .home[data-layout="compact"] .repo-stats .rs-tag { display: none; }
226+ }
227+ /* on a phone: name and CI state on a line, the description and the figures
228+ under it; the pulse is dropped */
229+ @media (max-width: 720px) {
230+ .home[data-layout="compact"] .repo {
231+ grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "name ci" "desc desc" "stats stats"; row-gap: 2px;
232+ }
233+ .home[data-layout="compact"] .repo-pulse { display: none; }
234+ .home[data-layout="compact"] .repo-stats .rs-tag { display: flex; }
235+ }
138236 .list.passkeys {
139237 & .c-name { width: auto; font-weight: 600; }
140238 & .c-when { width: 120px; font-size: var(--fs-s); }
@@ -135,6 +135,104 @@
135 & .repo-commits { min-height: 0; }135 & .repo-commits { min-height: 0; }
136 }136 }
137 }137 }
138+ /* Home layouts. The server renders "cards" (the rules above); a script
139+ sets .home[data-layout] from the stored choice. The rows carry no markup
140+ of their own for a layout, so a row swapped in by a live update fits. */
141+ .home .layout-switch { display: flex; align-items: center; gap: 2px; }
142+ .home .layout-switch .btn {
143+ width: 28px; height: 28px; color: var(--ink-dim); background: none; border-color: transparent; border-radius: var(--r-s);
144+ &:hover { color: var(--ink-hi); background: var(--bg-raised); }
145+ &[aria-pressed="true"] { color: var(--ink-hi); background: var(--bg-raised); border-color: var(--border-strong); }
146+ }
147+ .home .home-bar .sort { margin-left: auto; }
148+
149+ /* split: two denser cards side by side. Name and CI state on top, the
150+ description, the pulse as a band from edge to edge, the figures in one
151+ row and the two newest commits. One column on a narrow screen. */
152+ .home[data-layout="split"] {
153+ & .repos { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--s4); }
154+ & .repo {
155+ grid-template-columns: minmax(0, 1fr) auto; column-gap: var(--s3); row-gap: 0;
156+ grid-template-areas: "name ci" "desc desc" "topics topics" "pulse pulse" "stats stats" "recent recent";
157+ grid-template-rows: auto 1fr auto auto auto auto;
158+ overflow: hidden;
159+ }
160+ & .repo-main, & .repo-head { display: contents; }
161+ & .repo-name { grid-area: name; align-self: center; font-size: 16px; }
162+ & .repo-ci { grid-area: ci; align-self: center; padding-top: 0; }
163+ & .repo-desc { grid-area: desc; margin-top: var(--s2); }
164+ & .repo-topics { grid-area: topics; margin-top: var(--s2); }
165+ & .repo-topics:empty { display: none; }
166+ & .repo-pulse { grid-area: pulse; margin: var(--s3) calc(-1 * var(--s4)) 0; padding: 0; border-block: 1px solid var(--border); }
167+ /* one row while it fits; a narrow card wraps the figures instead of
168+ letting them overlap */
169+ & .repo-stats { grid-area: stats; flex-wrap: wrap; justify-content: space-between; gap: var(--s2) var(--s3); margin-top: var(--s3); }
170+ & .repo-stats div { flex: none; }
171+ & .repo-stats dd { font-size: var(--fs); }
172+ & .repo-stats .rs-tag dd { max-width: 12ch; }
173+ & .repo-commits { grid-area: recent; min-height: calc(2 * (var(--lh) + 4px)); margin-top: var(--s2); }
174+ & .repo-commits li:nth-child(n+3) { display: none; }
175+ & .repo-commits[data-full] li:last-child { opacity: 1; }
176+ & .repo-commits[data-full] li:nth-child(2) { opacity: .65; }
177+ }
178+ @media (max-width: 900px) {
179+ .home[data-layout="split"] .repos { grid-template-columns: minmax(0, 1fr); }
180+ }
181+
182+ /* compact: a line per repository. Name, description, figures, CI state and
183+ a small pulse sit in one grid row; the commits and topics are left out. */
184+ .home[data-layout="compact"] {
185+ & .repos { gap: var(--s2); }
186+ & .repo {
187+ grid-template-columns: minmax(0, 2fr) minmax(0, 3fr) minmax(0, 20rem) 5.5rem 140px;
188+ grid-template-areas: "name desc stats ci pulse";
189+ grid-template-rows: none; column-gap: var(--s4); row-gap: 0; align-items: center;
190+ padding: var(--s2) var(--s4);
191+ }
192+ & .repo-main, & .repo-head { display: contents; }
193+ & .repo-topics, & .repo-commits { display: none; }
194+ & .repo-name { grid-area: name; flex-wrap: nowrap; gap: var(--s2); font-size: var(--fs); line-height: 24px; overflow-wrap: normal; }
195+ & .repo-name a { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
196+ & .repo-name .pill { flex: none; }
197+ & .repo-desc {
198+ grid-area: desc; display: block; min-height: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
199+ font-size: var(--fs-s); color: var(--ink-mute);
200+ }
201+ /* figures inline, the value before its label; the label of the date and
202+ of the tag is left to screen readers */
203+ & .repo-stats { grid-area: stats; flex-wrap: nowrap; gap: var(--s3); overflow: hidden; }
204+ & .repo-stats div { flex-direction: row; align-items: baseline; gap: 4px; flex: none; }
205+ & .repo-stats dt { order: 1; font-size: var(--fs-xs); line-height: 16px; letter-spacing: 0; text-transform: none; }
206+ & .repo-stats dd { font-size: var(--fs-xs); line-height: 16px; color: var(--ink-dim); }
207+ & .repo-stats .rs-active dt, & .repo-stats .rs-tag dt {
208+ position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%);
209+ }
210+ & .repo-stats .rs-tag dd { max-width: 10ch; }
211+ & .repo-stats .rs-tag dd::before {
212+ content: ""; display: inline-block; width: 12px; height: 12px; margin-right: 3px; vertical-align: -2px;
213+ background: currentColor; mask: url(icons/tag.svg) center / contain no-repeat;
214+ }
215+ & .repo-ci { grid-area: ci; justify-self: start; padding-top: 0; font-size: var(--fs-xs); }
216+ /* the pulse: the same curve, small, with a bigger dot to stay seen */
217+ & .repo-pulse { grid-area: pulse; width: 100%; height: 100%; min-height: 18px; margin: 0; padding: 0; }
218+ & div.repo-pulse { aspect-ratio: auto; box-sizing: border-box; }
219+ & .repo-pulse svg { position: relative; top: 50%; transform: translateY(-50%); }
220+ & .pulse-grid { display: none; }
221+ & .pulse-dot { r: 9px; stroke: none; }
222+ }
223+ @media (max-width: 1100px) {
224+ .home[data-layout="compact"] .repo { grid-template-columns: minmax(0, 2fr) minmax(0, 2fr) minmax(0, 14rem) 5.5rem 96px; column-gap: var(--s3); }
225+ .home[data-layout="compact"] .repo-stats .rs-tag { display: none; }
226+ }
227+ /* on a phone: name and CI state on a line, the description and the figures
228+ under it; the pulse is dropped */
229+ @media (max-width: 720px) {
230+ .home[data-layout="compact"] .repo {
231+ grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "name ci" "desc desc" "stats stats"; row-gap: 2px;
232+ }
233+ .home[data-layout="compact"] .repo-pulse { display: none; }
234+ .home[data-layout="compact"] .repo-stats .rs-tag { display: flex; }
235+ }
138 .list.passkeys {236 .list.passkeys {
139 & .c-name { width: auto; font-weight: 600; }237 & .c-name { width: auto; font-weight: 600; }
140 & .c-when { width: 120px; font-size: var(--fs-s); }238 & .c-when { width: 120px; font-size: var(--fs-s); }
A internal/adapters/web/static/icons/cards.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"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 12h18"/></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"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 12h18"/></svg>
A internal/adapters/web/static/icons/compact.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="M3 5h.01"/><path d="M3 12h.01"/><path d="M3 19h.01"/><path d="M8 5h13"/><path d="M8 12h13"/><path d="M8 19h13"/></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="M3 5h.01"/><path d="M3 12h.01"/><path d="M3 19h.01"/><path d="M8 5h13"/><path d="M8 12h13"/><path d="M8 19h13"/></svg>
A internal/adapters/web/static/icons/split.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"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M12 3v18"/></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"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M12 3v18"/></svg>
M internal/adapters/web/static/js/ermite.js +46 -15
@@ -13,6 +13,9 @@
1313 // 6. The lines a diff hides load in place (ermiteGaps; e and E).
1414 // 7. The service worker (ermiteInstall).
1515 // 8. Chip lists kept on one line, with a … chip (ermiteFit).
16+// 9. The layout of the home page, remembered in the browser (ermiteLayout).
17+//
18+// Preferences are kept by ermiteStore (store.js, loaded first).
1619 (() => {
1720 // Marks the page as scripted: what does nothing without scripts (the filters,
1821 // the "go to" and help buttons) is hidden until this class is set (see the
@@ -481,16 +484,16 @@
481484 // The preference "single-character shortcuts" (WCAG 2.1.4): off, the keys
482485 // that are one printable character (j, k, s, ?, :, G, 1-9 in the merge menu,
483486 // …) do nothing. Shortcuts with ctrl or cmd, escape, enter and the arrows
484- // keep working. The choice lives in localStorage ("keys-off") and is made in
485- // the help dialog (a checkbox) or the palette (an action). Self-contained:
486- // jstest/keys.test.mjs evaluates this block alone.
487+ // keep working. The choice is kept by ermiteStore ("keys-off": '1' when off)
488+ // and is made in the help dialog (a checkbox) or the palette (an action).
489+ // Self-contained with store.js: jstest/a11y.test.mjs evaluates this block.
487490 window.ermiteKeys = (() => {
488- const KEY = 'keys-off';
489- let off = false;
490- try { off = localStorage.getItem(KEY) === '1'; } catch { /* no storage: on, and the choice holds for this page */ }
491+ const pref = window.ermiteStore.pref('keys-off', { values: ['1', ''], fallback: '' });
492+ const isOff = () => pref.get() === '1';
491493 // apply shows the state: the checkbox and the note of the help dialog,
492494 // and the label of the palette action.
493495 function apply() {
496+ const off = isOff();
494497 const box = document.getElementById('keys-off');
495498 if (box) box.checked = off;
496499 const note = document.getElementById('keys-note');
@@ -503,25 +506,53 @@
503506 if (act) act.textContent = off ? 'turn single-key shortcuts on' : 'turn single-key shortcuts off';
504507 }
505508 function set(v) {
506- off = !!v;
507- try { localStorage.setItem(KEY, off ? '1' : ''); } catch { /* storage refused: holds for this page */ }
509+ pref.set(v ? '1' : '');
508510 apply();
509511 }
510- addEventListener('storage', (e) => {
511- if (e.key !== KEY) return;
512- off = e.newValue === '1';
513- apply();
514- });
512+ pref.watch(apply);
515513 apply();
516514 return {
517- off: () => off,
515+ off: isOff,
518516 set,
519517 apply,
520518 // blocks says whether the preference turns this key off.
521- blocks: (e) => off && e.key.length === 1,
519+ blocks: (e) => isOff() && e.key.length === 1,
522520 };
523521 })();
524522
523+ // The layout of the home page: "cards" (the default), "split" (two
524+ // repositories per row) or "compact" (one line each). The choice is kept by
525+ // ermiteStore ("home-layout"), made with the buttons [data-set-layout] of
526+ // the layout switch, and shown as .home[data-layout], which the styles
527+ // follow, and as aria-pressed on the buttons. Another tab's change applies
528+ // here at once. The topic chips are fitted again: their room changes with the
529+ // layout. Self-contained with store.js: jstest/layout.test.mjs evaluates
530+ // this block.
531+ window.ermiteLayout = (() => {
532+ const values = ['cards', 'split', 'compact'];
533+ const pref = window.ermiteStore.pref('home-layout', { values, fallback: 'cards' });
534+ function apply() {
535+ const layout = pref.get();
536+ const home = document.querySelector('.home');
537+ if (home) home.setAttribute('data-layout', layout);
538+ for (const b of document.querySelectorAll('[data-set-layout]')) {
539+ b.setAttribute('aria-pressed', String(b.getAttribute('data-set-layout') === layout));
540+ }
541+ window.ermiteFit?.all();
542+ }
543+ function set(v) {
544+ pref.set(v);
545+ apply();
546+ }
547+ addEventListener('click', (e) => {
548+ const btn = e.target.closest?.('[data-set-layout]');
549+ if (btn) set(btn.getAttribute('data-set-layout'));
550+ });
551+ pref.watch(apply);
552+ apply();
553+ return { get: pref.get, set, apply, values };
554+ })();
555+
525556 // 1. Keys.
526557 const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink',
527558 s: 's', t: 't', c: 'c', r: 'r', m: 'm', n: 'next', h: 'left', l: 'right', d: 'split', u: 'unified', x: 'collapse', e: 'expand', E: 'expandAll', Enter: 'open', Escape: 'esc',
@@ -13,6 +13,9 @@
13 // 6. The lines a diff hides load in place (ermiteGaps; e and E).13 // 6. The lines a diff hides load in place (ermiteGaps; e and E).
14 // 7. The service worker (ermiteInstall).14 // 7. The service worker (ermiteInstall).
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).
17+//
18+// Preferences are kept by ermiteStore (store.js, loaded first).
16 (() => {19 (() => {
17 // Marks the page as scripted: what does nothing without scripts (the filters,20 // Marks the page as scripted: what does nothing without scripts (the filters,
18 // the "go to" and help buttons) is hidden until this class is set (see the21 // the "go to" and help buttons) is hidden until this class is set (see the
@@ -481,16 +484,16 @@
481 // The preference "single-character shortcuts" (WCAG 2.1.4): off, the keys484 // The preference "single-character shortcuts" (WCAG 2.1.4): off, the keys
482 // that are one printable character (j, k, s, ?, :, G, 1-9 in the merge menu,485 // that are one printable character (j, k, s, ?, :, G, 1-9 in the merge menu,
483 // …) do nothing. Shortcuts with ctrl or cmd, escape, enter and the arrows486 // …) do nothing. Shortcuts with ctrl or cmd, escape, enter and the arrows
484- // keep working. The choice lives in localStorage ("keys-off") and is made in487+ // keep working. The choice is kept by ermiteStore ("keys-off": '1' when off)
485- // the help dialog (a checkbox) or the palette (an action). Self-contained:488+ // and is made in the help dialog (a checkbox) or the palette (an action).
486- // jstest/keys.test.mjs evaluates this block alone.489+ // Self-contained with store.js: jstest/a11y.test.mjs evaluates this block.
487 window.ermiteKeys = (() => {490 window.ermiteKeys = (() => {
488- const KEY = 'keys-off';491+ const pref = window.ermiteStore.pref('keys-off', { values: ['1', ''], fallback: '' });
489- let off = false;492+ const isOff = () => pref.get() === '1';
490- try { off = localStorage.getItem(KEY) === '1'; } catch { /* no storage: on, and the choice holds for this page */ }
491 // apply shows the state: the checkbox and the note of the help dialog,493 // apply shows the state: the checkbox and the note of the help dialog,
492 // and the label of the palette action.494 // and the label of the palette action.
493 function apply() {495 function apply() {
496+ const off = isOff();
494 const box = document.getElementById('keys-off');497 const box = document.getElementById('keys-off');
495 if (box) box.checked = off;498 if (box) box.checked = off;
496 const note = document.getElementById('keys-note');499 const note = document.getElementById('keys-note');
@@ -503,25 +506,53 @@
503 if (act) act.textContent = off ? 'turn single-key shortcuts on' : 'turn single-key shortcuts off';506 if (act) act.textContent = off ? 'turn single-key shortcuts on' : 'turn single-key shortcuts off';
504 }507 }
505 function set(v) {508 function set(v) {
506- off = !!v;509+ pref.set(v ? '1' : '');
507- try { localStorage.setItem(KEY, off ? '1' : ''); } catch { /* storage refused: holds for this page */ }
508 apply();510 apply();
509 }511 }
510- addEventListener('storage', (e) => {512+ pref.watch(apply);
511- if (e.key !== KEY) return;
512- off = e.newValue === '1';
513- apply();
514- });
515 apply();513 apply();
516 return {514 return {
517- off: () => off,515+ off: isOff,
518 set,516 set,
519 apply,517 apply,
520 // blocks says whether the preference turns this key off.518 // blocks says whether the preference turns this key off.
521- blocks: (e) => off && e.key.length === 1,519+ blocks: (e) => isOff() && e.key.length === 1,
522 };520 };
523 })();521 })();
524 522
523+ // The layout of the home page: "cards" (the default), "split" (two
524+ // repositories per row) or "compact" (one line each). The choice is kept by
525+ // ermiteStore ("home-layout"), made with the buttons [data-set-layout] of
526+ // the layout switch, and shown as .home[data-layout], which the styles
527+ // follow, and as aria-pressed on the buttons. Another tab's change applies
528+ // here at once. The topic chips are fitted again: their room changes with the
529+ // layout. Self-contained with store.js: jstest/layout.test.mjs evaluates
530+ // this block.
531+ window.ermiteLayout = (() => {
532+ const values = ['cards', 'split', 'compact'];
533+ const pref = window.ermiteStore.pref('home-layout', { values, fallback: 'cards' });
534+ function apply() {
535+ const layout = pref.get();
536+ const home = document.querySelector('.home');
537+ if (home) home.setAttribute('data-layout', layout);
538+ for (const b of document.querySelectorAll('[data-set-layout]')) {
539+ b.setAttribute('aria-pressed', String(b.getAttribute('data-set-layout') === layout));
540+ }
541+ window.ermiteFit?.all();
542+ }
543+ function set(v) {
544+ pref.set(v);
545+ apply();
546+ }
547+ addEventListener('click', (e) => {
548+ const btn = e.target.closest?.('[data-set-layout]');
549+ if (btn) set(btn.getAttribute('data-set-layout'));
550+ });
551+ pref.watch(apply);
552+ apply();
553+ return { get: pref.get, set, apply, values };
554+ })();
555+
525 // 1. Keys.556 // 1. Keys.
526 const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink',557 const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink',
527 s: 's', t: 't', c: 'c', r: 'r', m: 'm', n: 'next', h: 'left', l: 'right', d: 'split', u: 'unified', x: 'collapse', e: 'expand', E: 'expandAll', Enter: 'open', Escape: 'esc',558 s: 's', t: 't', c: 'c', r: 'r', m: 'm', n: 'next', h: 'left', l: 'right', d: 'split', u: 'unified', x: 'collapse', e: 'expand', E: 'expandAll', Enter: 'open', Escape: 'esc',
A internal/adapters/web/static/js/store.js +76 -0
@@ -0,0 +1,76 @@
1+// store.js: window.ermiteStore, a small client for what the pages remember in
2+// the browser (localStorage): the preferences. Loaded before ermite.js, which
3+// builds on it (ermiteKeys, ermiteLayout).
4+//
5+// It never throws. Without storage (disabled, private mode, sandboxed frame)
6+// or when the browser refuses a write (quota), the value is held in memory:
7+// the choice lasts for this page, and the page works as if it were saved.
8+// Values are strings, as in localStorage; null says "nothing stored".
9+//
10+// get(key, fallback) the value, or fallback (null) when there is none
11+// set(key, value) stores it; null or undefined removes the key
12+// remove(key) forgets the key
13+// watch(key, fn) fn(value or null) when another tab changes the key
14+// (the "storage" event); answers a function that stops
15+// pref(key, opts) a preference among a closed list:
16+// pref(key, { values, fallback }) answers
17+// { get, set, watch }, where get is always one of
18+// values (a stored value that is not falls back) and
19+// set ignores what is not
20+//
21+// Self-contained: jstest/store.test.mjs evaluates this file alone.
22+window.ermiteStore = (() => {
23+ // What storage could not keep, by key. A key mapped to null is removed for
24+ // this page although storage would not say so.
25+ const held = new Map();
26+
27+ const area = () => {
28+ try { return localStorage; } catch { return null; }
29+ };
30+
31+ function get(key, fallback = null) {
32+ if (held.has(key)) return held.get(key) ?? fallback;
33+ try { return area().getItem(key) ?? fallback; } catch { return fallback; }
34+ }
35+
36+ function set(key, value) {
37+ if (value === null || value === undefined) return remove(key);
38+ value = String(value);
39+ try {
40+ area().setItem(key, value);
41+ held.delete(key);
42+ } catch { held.set(key, value); }
43+ }
44+
45+ function remove(key) {
46+ try {
47+ area().removeItem(key);
48+ held.delete(key);
49+ } catch { held.set(key, null); }
50+ }
51+
52+ function watch(key, fn) {
53+ const on = (e) => {
54+ // key is null when another tab clears the whole storage.
55+ if (e.key !== null && e.key !== key) return;
56+ if (e.storageArea && e.storageArea !== area()) return;
57+ held.delete(key); // the other tab's word is newer than ours
58+ fn(e.key === null ? null : (e.newValue ?? null));
59+ };
60+ addEventListener('storage', on);
61+ return () => removeEventListener('storage', on);
62+ }
63+
64+ function pref(key, { values, fallback }) {
65+ const valid = (v) => (values.includes(v) ? v : fallback);
66+ return {
67+ get: () => valid(get(key)),
68+ set(v) {
69+ if (values.includes(v)) set(key, v);
70+ },
71+ watch: (fn) => watch(key, (v) => fn(valid(v))),
72+ };
73+ }
74+
75+ return { get, set, remove, watch, pref };
76+})();
@@ -0,0 +1,76 @@
1+// store.js: window.ermiteStore, a small client for what the pages remember in
2+// the browser (localStorage): the preferences. Loaded before ermite.js, which
3+// builds on it (ermiteKeys, ermiteLayout).
4+//
5+// It never throws. Without storage (disabled, private mode, sandboxed frame)
6+// or when the browser refuses a write (quota), the value is held in memory:
7+// the choice lasts for this page, and the page works as if it were saved.
8+// Values are strings, as in localStorage; null says "nothing stored".
9+//
10+// get(key, fallback) the value, or fallback (null) when there is none
11+// set(key, value) stores it; null or undefined removes the key
12+// remove(key) forgets the key
13+// watch(key, fn) fn(value or null) when another tab changes the key
14+// (the "storage" event); answers a function that stops
15+// pref(key, opts) a preference among a closed list:
16+// pref(key, { values, fallback }) answers
17+// { get, set, watch }, where get is always one of
18+// values (a stored value that is not falls back) and
19+// set ignores what is not
20+//
21+// Self-contained: jstest/store.test.mjs evaluates this file alone.
22+window.ermiteStore = (() => {
23+ // What storage could not keep, by key. A key mapped to null is removed for
24+ // this page although storage would not say so.
25+ const held = new Map();
26+
27+ const area = () => {
28+ try { return localStorage; } catch { return null; }
29+ };
30+
31+ function get(key, fallback = null) {
32+ if (held.has(key)) return held.get(key) ?? fallback;
33+ try { return area().getItem(key) ?? fallback; } catch { return fallback; }
34+ }
35+
36+ function set(key, value) {
37+ if (value === null || value === undefined) return remove(key);
38+ value = String(value);
39+ try {
40+ area().setItem(key, value);
41+ held.delete(key);
42+ } catch { held.set(key, value); }
43+ }
44+
45+ function remove(key) {
46+ try {
47+ area().removeItem(key);
48+ held.delete(key);
49+ } catch { held.set(key, null); }
50+ }
51+
52+ function watch(key, fn) {
53+ const on = (e) => {
54+ // key is null when another tab clears the whole storage.
55+ if (e.key !== null && e.key !== key) return;
56+ if (e.storageArea && e.storageArea !== area()) return;
57+ held.delete(key); // the other tab's word is newer than ours
58+ fn(e.key === null ? null : (e.newValue ?? null));
59+ };
60+ addEventListener('storage', on);
61+ return () => removeEventListener('storage', on);
62+ }
63+
64+ function pref(key, { values, fallback }) {
65+ const valid = (v) => (values.includes(v) ? v : fallback);
66+ return {
67+ get: () => valid(get(key)),
68+ set(v) {
69+ if (values.includes(v)) set(key, v);
70+ },
71+ watch: (fn) => watch(key, (v) => fn(valid(v))),
72+ };
73+ }
74+
75+ return { get, set, remove, watch, pref };
76+})();
M internal/app/testdata/web/blob-binary.html +2 -0
@@ -20,6 +20,8 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
2325 <script src="/-/static/js/ermite.HASH.js" defer>
2426 </script>
2527 <script src="/-/static/js/files.HASH.js" defer>
@@ -20,6 +20,8 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
23 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
24 </script>26 </script>
25 <script src="/-/static/js/files.HASH.js" defer>27 <script src="/-/static/js/files.HASH.js" defer>
M internal/app/testdata/web/blob-code.html +2 -0
@@ -20,6 +20,8 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
2325 <script src="/-/static/js/ermite.HASH.js" defer>
2426 </script>
2527 <script src="/-/static/js/files.HASH.js" defer>
@@ -20,6 +20,8 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
23 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
24 </script>26 </script>
25 <script src="/-/static/js/files.HASH.js" defer>27 <script src="/-/static/js/files.HASH.js" defer>
M internal/app/testdata/web/blob-empty.html +2 -0
@@ -20,6 +20,8 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
2325 <script src="/-/static/js/ermite.HASH.js" defer>
2426 </script>
2527 <script src="/-/static/js/files.HASH.js" defer>
@@ -20,6 +20,8 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
23 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
24 </script>26 </script>
25 <script src="/-/static/js/files.HASH.js" defer>27 <script src="/-/static/js/files.HASH.js" defer>
M internal/app/testdata/web/blob-image.html +2 -0
@@ -20,6 +20,8 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
2325 <script src="/-/static/js/ermite.HASH.js" defer>
2426 </script>
2527 <script src="/-/static/js/files.HASH.js" defer>
@@ -20,6 +20,8 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
23 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
24 </script>26 </script>
25 <script src="/-/static/js/files.HASH.js" defer>27 <script src="/-/static/js/files.HASH.js" defer>
M internal/app/testdata/web/blob-markdown.html +2 -0
@@ -20,6 +20,8 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
2325 <script src="/-/static/js/ermite.HASH.js" defer>
2426 </script>
2527 <script src="/-/static/js/files.HASH.js" defer>
@@ -20,6 +20,8 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
23 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
24 </script>26 </script>
25 <script src="/-/static/js/files.HASH.js" defer>27 <script src="/-/static/js/files.HASH.js" defer>
M internal/app/testdata/web/blob-svg.html +2 -0
@@ -20,6 +20,8 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
2325 <script src="/-/static/js/ermite.HASH.js" defer>
2426 </script>
2527 <script src="/-/static/js/files.HASH.js" defer>
@@ -20,6 +20,8 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
23 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
24 </script>26 </script>
25 <script src="/-/static/js/files.HASH.js" defer>27 <script src="/-/static/js/files.HASH.js" defer>
M internal/app/testdata/web/blob-text.html +2 -0
@@ -20,6 +20,8 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
2325 <script src="/-/static/js/ermite.HASH.js" defer>
2426 </script>
2527 <script src="/-/static/js/files.HASH.js" defer>
@@ -20,6 +20,8 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
23 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
24 </script>26 </script>
25 <script src="/-/static/js/files.HASH.js" defer>27 <script src="/-/static/js/files.HASH.js" defer>
M internal/app/testdata/web/blob-too-large.html +2 -0
@@ -20,6 +20,8 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
2325 <script src="/-/static/js/ermite.HASH.js" defer>
2426 </script>
2527 <script src="/-/static/js/files.HASH.js" defer>
@@ -20,6 +20,8 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
23 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
24 </script>26 </script>
25 <script src="/-/static/js/files.HASH.js" defer>27 <script src="/-/static/js/files.HASH.js" defer>
M internal/app/testdata/web/blob-trojan.html +2 -0
@@ -20,6 +20,8 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
2325 <script src="/-/static/js/ermite.HASH.js" defer>
2426 </script>
2527 <script src="/-/static/js/files.HASH.js" defer>
@@ -20,6 +20,8 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
23 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
24 </script>26 </script>
25 <script src="/-/static/js/files.HASH.js" defer>27 <script src="/-/static/js/files.HASH.js" defer>
M internal/app/testdata/web/commit-big.html +2 -0
@@ -20,6 +20,8 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
2325 <script src="/-/static/js/ermite.HASH.js" defer>
2426 </script>
2527 <script src="/-/static/js/files.HASH.js" defer>
@@ -20,6 +20,8 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
23 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
24 </script>26 </script>
25 <script src="/-/static/js/files.HASH.js" defer>27 <script src="/-/static/js/files.HASH.js" defer>
M internal/app/testdata/web/commit-binary.html +2 -0
@@ -20,6 +20,8 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
2325 <script src="/-/static/js/ermite.HASH.js" defer>
2426 </script>
2527 <script src="/-/static/js/files.HASH.js" defer>
@@ -20,6 +20,8 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
23 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
24 </script>26 </script>
25 <script src="/-/static/js/files.HASH.js" defer>27 <script src="/-/static/js/files.HASH.js" defer>
M internal/app/testdata/web/commit-merge.html +2 -0
@@ -20,6 +20,8 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
2325 <script src="/-/static/js/ermite.HASH.js" defer>
2426 </script>
2527 <script src="/-/static/js/files.HASH.js" defer>
@@ -20,6 +20,8 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
23 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
24 </script>26 </script>
25 <script src="/-/static/js/files.HASH.js" defer>27 <script src="/-/static/js/files.HASH.js" defer>
M internal/app/testdata/web/commit-modify-split.html +2 -0
@@ -20,6 +20,8 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
2325 <script src="/-/static/js/ermite.HASH.js" defer>
2426 </script>
2527 <script src="/-/static/js/files.HASH.js" defer>
@@ -20,6 +20,8 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
23 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
24 </script>26 </script>
25 <script src="/-/static/js/files.HASH.js" defer>27 <script src="/-/static/js/files.HASH.js" defer>
M internal/app/testdata/web/commit-modify.html +2 -0
@@ -20,6 +20,8 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
2325 <script src="/-/static/js/ermite.HASH.js" defer>
2426 </script>
2527 <script src="/-/static/js/files.HASH.js" defer>
@@ -20,6 +20,8 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
23 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
24 </script>26 </script>
25 <script src="/-/static/js/files.HASH.js" defer>27 <script src="/-/static/js/files.HASH.js" defer>
M internal/app/testdata/web/commit-rename.html +2 -0
@@ -20,6 +20,8 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
2325 <script src="/-/static/js/ermite.HASH.js" defer>
2426 </script>
2527 <script src="/-/static/js/files.HASH.js" defer>
@@ -20,6 +20,8 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
23 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
24 </script>26 </script>
25 <script src="/-/static/js/files.HASH.js" defer>27 <script src="/-/static/js/files.HASH.js" defer>
M internal/app/testdata/web/commit-root.html +2 -0
@@ -20,6 +20,8 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
2325 <script src="/-/static/js/ermite.HASH.js" defer>
2426 </script>
2527 <script src="/-/static/js/files.HASH.js" defer>
@@ -20,6 +20,8 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
23 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
24 </script>26 </script>
25 <script src="/-/static/js/files.HASH.js" defer>27 <script src="/-/static/js/files.HASH.js" defer>
M internal/app/testdata/web/commit-trojan.html +2 -0
@@ -20,6 +20,8 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
2325 <script src="/-/static/js/ermite.HASH.js" defer>
2426 </script>
2527 <script src="/-/static/js/files.HASH.js" defer>
@@ -20,6 +20,8 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
23 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
24 </script>26 </script>
25 <script src="/-/static/js/files.HASH.js" defer>27 <script src="/-/static/js/files.HASH.js" defer>
M internal/app/testdata/web/commits-page2.html +2 -0
@@ -20,6 +20,8 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
2325 <script src="/-/static/js/ermite.HASH.js" defer>
2426 </script>
2527 <script src="/-/static/js/files.HASH.js" defer>
@@ -20,6 +20,8 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
23 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
24 </script>26 </script>
25 <script src="/-/static/js/files.HASH.js" defer>27 <script src="/-/static/js/files.HASH.js" defer>
M internal/app/testdata/web/commits-path.html +2 -0
@@ -20,6 +20,8 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
2325 <script src="/-/static/js/ermite.HASH.js" defer>
2426 </script>
2527 <script src="/-/static/js/files.HASH.js" defer>
@@ -20,6 +20,8 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
23 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
24 </script>26 </script>
25 <script src="/-/static/js/files.HASH.js" defer>27 <script src="/-/static/js/files.HASH.js" defer>
M internal/app/testdata/web/commits-root.html +2 -0
@@ -20,6 +20,8 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
2325 <script src="/-/static/js/ermite.HASH.js" defer>
2426 </script>
2527 <script src="/-/static/js/files.HASH.js" defer>
@@ -20,6 +20,8 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
23 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
24 </script>26 </script>
25 <script src="/-/static/js/files.HASH.js" defer>27 <script src="/-/static/js/files.HASH.js" defer>
M internal/app/testdata/web/commits-small.html +2 -0
@@ -20,6 +20,8 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
2325 <script src="/-/static/js/ermite.HASH.js" defer>
2426 </script>
2527 <script src="/-/static/js/files.HASH.js" defer>
@@ -20,6 +20,8 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
23 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
24 </script>26 </script>
25 <script src="/-/static/js/files.HASH.js" defer>27 <script src="/-/static/js/files.HASH.js" defer>
M internal/app/testdata/web/empty.html +2 -0
@@ -20,6 +20,8 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
2325 <script src="/-/static/js/ermite.HASH.js" defer>
2426 </script>
2527 <script src="/-/static/js/files.HASH.js" defer>
@@ -20,6 +20,8 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
23 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
24 </script>26 </script>
25 <script src="/-/static/js/files.HASH.js" defer>27 <script src="/-/static/js/files.HASH.js" defer>
M internal/app/testdata/web/home.html +19 -3
@@ -20,6 +20,8 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
2325 <script src="/-/static/js/ermite.HASH.js" defer>
2426 </script>
2527 <script src="/-/static/js/files.HASH.js" defer>
@@ -50,7 +52,7 @@
5052 <div class="work">
5153 <main id="main" tabindex="-1">
5254 <h1 class="sr">Repositories</h1>
53-<div class="home">
55+<div class="home" data-layout="cards">
5456 <section class="home-repos" aria-label="Repositories">
5557 <div class="home-bar">
5658 <div class="filter">
@@ -64,6 +66,20 @@
6466 </a>
6567 <a href="/?sort=name&amp;dir=asc">name </a>
6668 </nav>
69+<div class="layout-switch" role="group" aria-label="Layout" data-js>
70+<button type="button" class="btn icon" data-set-layout="cards" aria-pressed="true" title="Cards" aria-label="Cards">
71+<span class="ico i-cards" aria-hidden="true">
72+</span>
73+</button>
74+<button type="button" class="btn icon" data-set-layout="split" aria-pressed="false" title="Two columns" aria-label="Two columns">
75+<span class="ico i-split" aria-hidden="true">
76+</span>
77+</button>
78+<button type="button" class="btn icon" data-set-layout="compact" aria-pressed="false" title="Compact list" aria-label="Compact list">
79+<span class="ico i-compact" aria-hidden="true">
80+</span>
81+</button>
82+</div>
6783 </div>
6884 <ul class="repos" id="repo-rows" data-rows>
6985 <li id="repo-demo" class="repo" data-cursor>
@@ -105,7 +121,7 @@
105121 <dt>last 30 days</dt>
106122 <dd>0</dd>
107123 </div>
108-<div>
124+<div class="rs-active">
109125 <dt>last activity</dt>
110126 <dd>
111127 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -145,7 +161,7 @@
145161 </div>
146162 <div class="repo-head">
147163 <dl class="repo-stats" aria-label="Figures of quiet">
148-<div>
164+<div class="rs-active">
149165 <dt>last activity</dt>
150166 <dd class="mute">never</dd>
151167 </div>
@@ -20,6 +20,8 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
23 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
24 </script>26 </script>
25 <script src="/-/static/js/files.HASH.js" defer>27 <script src="/-/static/js/files.HASH.js" defer>
@@ -50,7 +52,7 @@
50 <div class="work">52 <div class="work">
51 <main id="main" tabindex="-1">53 <main id="main" tabindex="-1">
52 <h1 class="sr">Repositories</h1>54 <h1 class="sr">Repositories</h1>
53-<div class="home">55+<div class="home" data-layout="cards">
54 <section class="home-repos" aria-label="Repositories">56 <section class="home-repos" aria-label="Repositories">
55 <div class="home-bar">57 <div class="home-bar">
56 <div class="filter">58 <div class="filter">
@@ -64,6 +66,20 @@
64 </a>66 </a>
65 <a href="/?sort=name&amp;dir=asc">name </a>67 <a href="/?sort=name&amp;dir=asc">name </a>
66 </nav>68 </nav>
69+<div class="layout-switch" role="group" aria-label="Layout" data-js>
70+<button type="button" class="btn icon" data-set-layout="cards" aria-pressed="true" title="Cards" aria-label="Cards">
71+<span class="ico i-cards" aria-hidden="true">
72+</span>
73+</button>
74+<button type="button" class="btn icon" data-set-layout="split" aria-pressed="false" title="Two columns" aria-label="Two columns">
75+<span class="ico i-split" aria-hidden="true">
76+</span>
77+</button>
78+<button type="button" class="btn icon" data-set-layout="compact" aria-pressed="false" title="Compact list" aria-label="Compact list">
79+<span class="ico i-compact" aria-hidden="true">
80+</span>
81+</button>
82+</div>
67 </div>83 </div>
68 <ul class="repos" id="repo-rows" data-rows>84 <ul class="repos" id="repo-rows" data-rows>
69 <li id="repo-demo" class="repo" data-cursor>85 <li id="repo-demo" class="repo" data-cursor>
@@ -105,7 +121,7 @@
105 <dt>last 30 days</dt>121 <dt>last 30 days</dt>
106 <dd>0</dd>122 <dd>0</dd>
107 </div>123 </div>
108-<div>124+<div class="rs-active">
109 <dt>last activity</dt>125 <dt>last activity</dt>
110 <dd>126 <dd>
111 <time datetime="STAMP" title="STAMP">AGO</time>127 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -145,7 +161,7 @@
145 </div>161 </div>
146 <div class="repo-head">162 <div class="repo-head">
147 <dl class="repo-stats" aria-label="Figures of quiet">163 <dl class="repo-stats" aria-label="Figures of quiet">
148-<div>164+<div class="rs-active">
149 <dt>last activity</dt>165 <dt>last activity</dt>
150 <dd class="mute">never</dd>166 <dd class="mute">never</dd>
151 </div>167 </div>
M internal/app/testdata/web/not-found-page.html +2 -0
@@ -20,6 +20,8 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
2325 <script src="/-/static/js/ermite.HASH.js" defer>
2426 </script>
2527 <script src="/-/static/js/files.HASH.js" defer>
@@ -20,6 +20,8 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
23 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
24 </script>26 </script>
25 <script src="/-/static/js/files.HASH.js" defer>27 <script src="/-/static/js/files.HASH.js" defer>
M internal/app/testdata/web/not-found.html +2 -0
@@ -20,6 +20,8 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
2325 <script src="/-/static/js/ermite.HASH.js" defer>
2426 </script>
2527 <script src="/-/static/js/files.HASH.js" defer>
@@ -20,6 +20,8 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
23 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
24 </script>26 </script>
25 <script src="/-/static/js/files.HASH.js" defer>27 <script src="/-/static/js/files.HASH.js" defer>
M internal/app/testdata/web/refs.html +2 -0
@@ -20,6 +20,8 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
2325 <script src="/-/static/js/ermite.HASH.js" defer>
2426 </script>
2527 <script src="/-/static/js/files.HASH.js" defer>
@@ -20,6 +20,8 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
23 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
24 </script>26 </script>
25 <script src="/-/static/js/files.HASH.js" defer>27 <script src="/-/static/js/files.HASH.js" defer>
M internal/app/testdata/web/summary.html +2 -0
@@ -20,6 +20,8 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
2325 <script src="/-/static/js/ermite.HASH.js" defer>
2426 </script>
2527 <script src="/-/static/js/files.HASH.js" defer>
@@ -20,6 +20,8 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
23 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
24 </script>26 </script>
25 <script src="/-/static/js/files.HASH.js" defer>27 <script src="/-/static/js/files.HASH.js" defer>
M internal/app/testdata/web/tree-nested.html +2 -0
@@ -20,6 +20,8 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
2325 <script src="/-/static/js/ermite.HASH.js" defer>
2426 </script>
2527 <script src="/-/static/js/files.HASH.js" defer>
@@ -20,6 +20,8 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
23 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
24 </script>26 </script>
25 <script src="/-/static/js/files.HASH.js" defer>27 <script src="/-/static/js/files.HASH.js" defer>
M internal/app/testdata/web/tree-root.html +2 -0
@@ -20,6 +20,8 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
2325 <script src="/-/static/js/ermite.HASH.js" defer>
2426 </script>
2527 <script src="/-/static/js/files.HASH.js" defer>
@@ -20,6 +20,8 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
23 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
24 </script>26 </script>
25 <script src="/-/static/js/files.HASH.js" defer>27 <script src="/-/static/js/files.HASH.js" defer>
M internal/app/testdata/web/tree-slash-ref.html +2 -0
@@ -20,6 +20,8 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
2325 <script src="/-/static/js/ermite.HASH.js" defer>
2426 </script>
2527 <script src="/-/static/js/files.HASH.js" defer>
@@ -20,6 +20,8 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
23 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
24 </script>26 </script>
25 <script src="/-/static/js/files.HASH.js" defer>27 <script src="/-/static/js/files.HASH.js" defer>
M internal/app/testdata/web/tree-subdir.html +2 -0
@@ -20,6 +20,8 @@
2020 </script>
2121 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
2222 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
2325 <script src="/-/static/js/ermite.HASH.js" defer>
2426 </script>
2527 <script src="/-/static/js/files.HASH.js" defer>
@@ -20,6 +20,8 @@
20 </script>20 </script>
21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>21 <script src="/-/static/js/hx-sse.min.HASH.js" defer>
22 </script>22 </script>
23+<script src="/-/static/js/store.HASH.js" defer>
24+</script>
23 <script src="/-/static/js/ermite.HASH.js" defer>25 <script src="/-/static/js/ermite.HASH.js" defer>
24 </script>26 </script>
25 <script src="/-/static/js/files.HASH.js" defer>27 <script src="/-/static/js/files.HASH.js" defer>

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 45

↑↓ moveenter openesc close