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
@@ -44,7 +44,7 @@ func Home(l Layout, p HomePage) templ.Component {
4444 }()
4545 }
4646 ctx = templ.InitializeContext(ctx)
47- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<h1 class=\"sr\">Repositories</h1><div class=\"home\"><section class=\"home-repos\" aria-label=\"Repositories\"><div class=\"home-bar\"><div class=\"filter\"><label for=\"home-filter\" class=\"sr\">filter</label> <input id=\"home-filter\" type=\"search\" data-filter=\"#repo-rows > li\" placeholder=\"Filter repositories…\" autocomplete=\"off\" hx-on:input=\"ermite.filter(this)\"></div><nav class=\"sort\" aria-label=\"Sort repositories\"><span class=\"mute\">sort</span> ")
47+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<h1 class=\"sr\">Repositories</h1><div class=\"home\" data-layout=\"cards\"><section class=\"home-repos\" aria-label=\"Repositories\"><div class=\"home-bar\"><div class=\"filter\"><label for=\"home-filter\" class=\"sr\">filter</label> <input id=\"home-filter\" type=\"search\" data-filter=\"#repo-rows > li\" placeholder=\"Filter repositories…\" autocomplete=\"off\" hx-on:input=\"ermite.filter(this)\"></div><nav class=\"sort\" aria-label=\"Sort repositories\"><span class=\"mute\">sort</span> ")
4848 if templ_7745c5c3_Err != nil {
4949 return templ_7745c5c3_Err
5050 }
@@ -126,7 +126,7 @@ func Home(l Layout, p HomePage) templ.Component {
126126 return templ_7745c5c3_Err
127127 }
128128 }
129- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</nav></div><ul class=\"repos\" id=\"repo-rows\" data-rows>")
129+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</nav><div class=\"layout-switch\" role=\"group\" aria-label=\"Layout\" data-js><button type=\"button\" class=\"btn icon\" data-set-layout=\"cards\" aria-pressed=\"true\" title=\"Cards\" aria-label=\"Cards\"><span class=\"ico i-cards\" aria-hidden=\"true\"></span></button> <button type=\"button\" class=\"btn icon\" data-set-layout=\"split\" aria-pressed=\"false\" title=\"Two columns\" aria-label=\"Two columns\"><span class=\"ico i-split\" aria-hidden=\"true\"></span></button> <button type=\"button\" class=\"btn icon\" data-set-layout=\"compact\" aria-pressed=\"false\" title=\"Compact list\" aria-label=\"Compact list\"><span class=\"ico i-compact\" aria-hidden=\"true\"></span></button></div></div><ul class=\"repos\" id=\"repo-rows\" data-rows>")
130130 if templ_7745c5c3_Err != nil {
131131 return templ_7745c5c3_Err
132132 }
@@ -247,7 +247,7 @@ func latestTagFact(t LatestTag) templ.Component {
247247 var templ_7745c5c3_Var10 templ.SafeURL
248248 templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(t.Href))
249249 if templ_7745c5c3_Err != nil {
250- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 71, Col: 51}
250+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 82, Col: 51}
251251 }
252252 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
253253 if templ_7745c5c3_Err != nil {
@@ -260,7 +260,7 @@ func latestTagFact(t LatestTag) templ.Component {
260260 var templ_7745c5c3_Var11 string
261261 templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(t.Subject)
262262 if templ_7745c5c3_Err != nil {
263- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 71, Col: 71}
263+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 82, Col: 71}
264264 }
265265 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
266266 if templ_7745c5c3_Err != nil {
@@ -273,7 +273,7 @@ func latestTagFact(t LatestTag) templ.Component {
273273 var templ_7745c5c3_Var12 string
274274 templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(t.Name)
275275 if templ_7745c5c3_Err != nil {
276- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 71, Col: 82}
276+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 82, Col: 82}
277277 }
278278 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
279279 if templ_7745c5c3_Err != nil {
@@ -287,7 +287,7 @@ func latestTagFact(t LatestTag) templ.Component {
287287 var templ_7745c5c3_Var13 string
288288 templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
289289 if templ_7745c5c3_Err != nil {
290- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 73, Col: 12}
290+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 84, Col: 12}
291291 }
292292 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
293293 if templ_7745c5c3_Err != nil {
@@ -300,7 +300,7 @@ func latestTagFact(t LatestTag) templ.Component {
300300 var templ_7745c5c3_Var14 string
301301 templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(t.WhenAt)
302302 if templ_7745c5c3_Err != nil {
303- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 73, Col: 39}
303+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 84, Col: 39}
304304 }
305305 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
306306 if templ_7745c5c3_Err != nil {
@@ -313,7 +313,7 @@ func latestTagFact(t LatestTag) templ.Component {
313313 var templ_7745c5c3_Var15 string
314314 templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(t.WhenAt)
315315 if templ_7745c5c3_Err != nil {
316- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 73, Col: 58}
316+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 84, Col: 58}
317317 }
318318 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
319319 if templ_7745c5c3_Err != nil {
@@ -326,7 +326,7 @@ func latestTagFact(t LatestTag) templ.Component {
326326 var templ_7745c5c3_Var16 string
327327 templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(t.When)
328328 if templ_7745c5c3_Err != nil {
329- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 73, Col: 69}
329+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 84, Col: 69}
330330 }
331331 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
332332 if templ_7745c5c3_Err != nil {
@@ -344,7 +344,7 @@ func latestTagFact(t LatestTag) templ.Component {
344344 var templ_7745c5c3_Var17 string
345345 templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
346346 if templ_7745c5c3_Err != nil {
347- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 75, Col: 11}
347+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 86, Col: 11}
348348 }
349349 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
350350 if templ_7745c5c3_Err != nil {
@@ -362,7 +362,7 @@ func latestTagFact(t LatestTag) templ.Component {
362362 var templ_7745c5c3_Var18 templ.SafeURL
363363 templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(t.RefsHref))
364364 if templ_7745c5c3_Err != nil {
365- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 77, Col: 54}
365+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 88, Col: 54}
366366 }
367367 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
368368 if templ_7745c5c3_Err != nil {
@@ -375,7 +375,7 @@ func latestTagFact(t LatestTag) templ.Component {
375375 var templ_7745c5c3_Var19 string
376376 templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(plural(t.Count, "tag", "tags"))
377377 if templ_7745c5c3_Err != nil {
378- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 77, Col: 89}
378+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 88, Col: 89}
379379 }
380380 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
381381 if templ_7745c5c3_Err != nil {
@@ -430,7 +430,7 @@ func summaryRail(p SummaryPage) templ.Component {
430430 var templ_7745c5c3_Var21 string
431431 templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(f.Key)
432432 if templ_7745c5c3_Err != nil {
433- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 88, Col: 15}
433+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 99, Col: 15}
434434 }
435435 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
436436 if templ_7745c5c3_Err != nil {
@@ -463,7 +463,7 @@ func summaryRail(p SummaryPage) templ.Component {
463463 var templ_7745c5c3_Var22 templ.SafeURL
464464 templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.AllHref))
465465 if templ_7745c5c3_Err != nil {
466- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 97, Col: 105}
466+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 108, Col: 105}
467467 }
468468 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
469469 if templ_7745c5c3_Err != nil {
@@ -560,7 +560,7 @@ func Refs(l Layout, p RefsPage) templ.Component {
560560 var templ_7745c5c3_Var25 string
561561 templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note)
562562 if templ_7745c5c3_Err != nil {
563- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 130, Col: 27}
563+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 141, Col: 27}
564564 }
565565 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
566566 if templ_7745c5c3_Err != nil {
@@ -619,7 +619,7 @@ func refRow(kind string, r RefRow) templ.Component {
619619 var templ_7745c5c3_Var27 string
620620 templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(kind)
621621 if templ_7745c5c3_Err != nil {
622- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 137, Col: 32}
622+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 148, Col: 32}
623623 }
624624 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
625625 if templ_7745c5c3_Err != nil {
@@ -632,7 +632,7 @@ func refRow(kind string, r RefRow) templ.Component {
632632 var templ_7745c5c3_Var28 templ.SafeURL
633633 templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))
634634 if templ_7745c5c3_Err != nil {
635- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 139, Col: 34}
635+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 150, Col: 34}
636636 }
637637 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
638638 if templ_7745c5c3_Err != nil {
@@ -645,7 +645,7 @@ func refRow(kind string, r RefRow) templ.Component {
645645 var templ_7745c5c3_Var29 string
646646 templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
647647 if templ_7745c5c3_Err != nil {
648- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 139, Col: 45}
648+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 150, Col: 45}
649649 }
650650 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
651651 if templ_7745c5c3_Err != nil {
@@ -673,7 +673,7 @@ func refRow(kind string, r RefRow) templ.Component {
673673 var templ_7745c5c3_Var30 templ.SafeURL
674674 templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.CHref))
675675 if templ_7745c5c3_Err != nil {
676- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 146, Col: 36}
676+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 157, Col: 36}
677677 }
678678 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
679679 if templ_7745c5c3_Err != nil {
@@ -686,7 +686,7 @@ func refRow(kind string, r RefRow) templ.Component {
686686 var templ_7745c5c3_Var31 string
687687 templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(r.Short)
688688 if templ_7745c5c3_Err != nil {
689- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 146, Col: 48}
689+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 157, Col: 48}
690690 }
691691 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
692692 if templ_7745c5c3_Err != nil {
@@ -700,7 +700,7 @@ func refRow(kind string, r RefRow) templ.Component {
700700 var templ_7745c5c3_Var32 string
701701 templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(r.Short)
702702 if templ_7745c5c3_Err != nil {
703- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 148, Col: 13}
703+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 159, Col: 13}
704704 }
705705 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
706706 if templ_7745c5c3_Err != nil {
@@ -718,7 +718,7 @@ func refRow(kind string, r RefRow) templ.Component {
718718 var templ_7745c5c3_Var33 string
719719 templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(r.Subject)
720720 if templ_7745c5c3_Err != nil {
721- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 152, Col: 31}
721+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 163, Col: 31}
722722 }
723723 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
724724 if templ_7745c5c3_Err != nil {
@@ -736,7 +736,7 @@ func refRow(kind string, r RefRow) templ.Component {
736736 var templ_7745c5c3_Var34 string
737737 templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)
738738 if templ_7745c5c3_Err != nil {
739- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 155, Col: 29}
739+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 166, Col: 29}
740740 }
741741 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
742742 if templ_7745c5c3_Err != nil {
@@ -749,7 +749,7 @@ func refRow(kind string, r RefRow) templ.Component {
749749 var templ_7745c5c3_Var35 string
750750 templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)
751751 if templ_7745c5c3_Err != nil {
752- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 155, Col: 48}
752+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 166, Col: 48}
753753 }
754754 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
755755 if templ_7745c5c3_Err != nil {
@@ -762,7 +762,7 @@ func refRow(kind string, r RefRow) templ.Component {
762762 var templ_7745c5c3_Var36 string
763763 templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(r.When)
764764 if templ_7745c5c3_Err != nil {
765- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 155, Col: 59}
765+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 166, Col: 59}
766766 }
767767 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
768768 if templ_7745c5c3_Err != nil {
@@ -859,7 +859,7 @@ func State(l Layout, p StatePage) templ.Component {
859859 var templ_7745c5c3_Var40 string
860860 templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(p.Code)
861861 if templ_7745c5c3_Err != nil {
862- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 172, Col: 30}
862+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 183, Col: 30}
863863 }
864864 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
865865 if templ_7745c5c3_Err != nil {
@@ -872,7 +872,7 @@ func State(l Layout, p StatePage) templ.Component {
872872 var templ_7745c5c3_Var41 string
873873 templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)
874874 if templ_7745c5c3_Err != nil {
875- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 173, Col: 33}
875+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 184, Col: 33}
876876 }
877877 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
878878 if templ_7745c5c3_Err != nil {
@@ -890,7 +890,7 @@ func State(l Layout, p StatePage) templ.Component {
890890 var templ_7745c5c3_Var42 string
891891 templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(line)
892892 if templ_7745c5c3_Err != nil {
893- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 175, Col: 25}
893+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 186, Col: 25}
894894 }
895895 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
896896 if templ_7745c5c3_Err != nil {
@@ -919,7 +919,7 @@ func State(l Layout, p StatePage) templ.Component {
919919 var templ_7745c5c3_Var43 templ.SafeURL
920920 templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(b.Href))
921921 if templ_7745c5c3_Err != nil {
922- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 182, Col: 36}
922+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 193, Col: 36}
923923 }
924924 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
925925 if templ_7745c5c3_Err != nil {
@@ -932,7 +932,7 @@ func State(l Layout, p StatePage) templ.Component {
932932 var templ_7745c5c3_Var44 string
933933 templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(b.Label)
934934 if templ_7745c5c3_Err != nil {
935- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 182, Col: 48}
935+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 193, Col: 48}
936936 }
937937 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
938938 if templ_7745c5c3_Err != nil {
@@ -1002,7 +1002,7 @@ func Login(l Layout, p LoginPage) templ.Component {
10021002 var templ_7745c5c3_Var47 string
10031003 templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)
10041004 if templ_7745c5c3_Err != nil {
1005- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 197, Col: 33}
1005+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 208, Col: 33}
10061006 }
10071007 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))
10081008 if templ_7745c5c3_Err != nil {
@@ -1020,7 +1020,7 @@ func Login(l Layout, p LoginPage) templ.Component {
10201020 var templ_7745c5c3_Var48 string
10211021 templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(line)
10221022 if templ_7745c5c3_Err != nil {
1023- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 199, Col: 25}
1023+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 210, Col: 25}
10241024 }
10251025 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
10261026 if templ_7745c5c3_Err != nil {
@@ -1038,7 +1038,7 @@ func Login(l Layout, p LoginPage) templ.Component {
10381038 var templ_7745c5c3_Var49 templ.SafeURL
10391039 templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Action))
10401040 if templ_7745c5c3_Err != nil {
1041- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 201, Col: 73}
1041+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 212, Col: 73}
10421042 }
10431043 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
10441044 if templ_7745c5c3_Err != nil {
@@ -1051,7 +1051,7 @@ func Login(l Layout, p LoginPage) templ.Component {
10511051 var templ_7745c5c3_Var50 string
10521052 templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(p.Button)
10531053 if templ_7745c5c3_Err != nil {
1054- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 202, Col: 56}
1054+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 213, Col: 56}
10551055 }
10561056 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
10571057 if templ_7745c5c3_Err != nil {
@@ -1113,7 +1113,7 @@ func PasskeyLogin(l Layout, p PasskeyLoginPage) templ.Component {
11131113 var templ_7745c5c3_Var53 string
11141114 templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)
11151115 if templ_7745c5c3_Err != nil {
1116- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 214, Col: 33}
1116+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 225, Col: 33}
11171117 }
11181118 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53))
11191119 if templ_7745c5c3_Err != nil {
@@ -1126,7 +1126,7 @@ func PasskeyLogin(l Layout, p PasskeyLoginPage) templ.Component {
11261126 var templ_7745c5c3_Var54 string
11271127 templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Begin)
11281128 if templ_7745c5c3_Err != nil {
1129- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 216, Col: 87}
1129+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 227, Col: 87}
11301130 }
11311131 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var54)
11321132 if templ_7745c5c3_Err != nil {
@@ -1139,7 +1139,7 @@ func PasskeyLogin(l Layout, p PasskeyLoginPage) templ.Component {
11391139 var templ_7745c5c3_Var55 string
11401140 templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Finish)
11411141 if templ_7745c5c3_Err != nil {
1142- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 216, Col: 112}
1142+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 227, Col: 112}
11431143 }
11441144 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var55)
11451145 if templ_7745c5c3_Err != nil {
@@ -1152,7 +1152,7 @@ func PasskeyLogin(l Layout, p PasskeyLoginPage) templ.Component {
11521152 var templ_7745c5c3_Var56 string
11531153 templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(p.Button)
11541154 if templ_7745c5c3_Err != nil {
1155- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 216, Col: 125}
1155+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 227, Col: 125}
11561156 }
11571157 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))
11581158 if templ_7745c5c3_Err != nil {
@@ -1170,7 +1170,7 @@ func PasskeyLogin(l Layout, p PasskeyLoginPage) templ.Component {
11701170 var templ_7745c5c3_Var57 string
11711171 templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(line)
11721172 if templ_7745c5c3_Err != nil {
1173- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 220, Col: 25}
1173+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 231, Col: 25}
11741174 }
11751175 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57))
11761176 if templ_7745c5c3_Err != nil {
@@ -1236,7 +1236,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
12361236 var templ_7745c5c3_Var60 string
12371237 templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Rows)))
12381238 if templ_7745c5c3_Err != nil {
1239- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 230, Col: 81}
1239+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 241, Col: 81}
12401240 }
12411241 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60))
12421242 if templ_7745c5c3_Err != nil {
@@ -1259,7 +1259,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
12591259 var templ_7745c5c3_Var61 string
12601260 templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
12611261 if templ_7745c5c3_Err != nil {
1262- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 246, Col: 34}
1262+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 257, Col: 34}
12631263 }
12641264 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61))
12651265 if templ_7745c5c3_Err != nil {
@@ -1277,7 +1277,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
12771277 var templ_7745c5c3_Var62 string
12781278 templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.CreatedAt)
12791279 if templ_7745c5c3_Err != nil {
1280- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 250, Col: 32}
1280+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 261, Col: 32}
12811281 }
12821282 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var62)
12831283 if templ_7745c5c3_Err != nil {
@@ -1295,7 +1295,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
12951295 var templ_7745c5c3_Var63 string
12961296 templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(r.Created)
12971297 if templ_7745c5c3_Err != nil {
1298- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 252, Col: 20}
1298+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 263, Col: 20}
12991299 }
13001300 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63))
13011301 if templ_7745c5c3_Err != nil {
@@ -1313,7 +1313,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
13131313 var templ_7745c5c3_Var64 string
13141314 templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.LastUsedAt)
13151315 if templ_7745c5c3_Err != nil {
1316- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 257, Col: 33}
1316+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 268, Col: 33}
13171317 }
13181318 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var64)
13191319 if templ_7745c5c3_Err != nil {
@@ -1331,7 +1331,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
13311331 var templ_7745c5c3_Var65 string
13321332 templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs(r.LastUsed)
13331333 if templ_7745c5c3_Err != nil {
1334- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 259, Col: 21}
1334+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 270, Col: 21}
13351335 }
13361336 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65))
13371337 if templ_7745c5c3_Err != nil {
@@ -1359,7 +1359,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
13591359 var templ_7745c5c3_Var66 templ.SafeURL
13601360 templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Remove))
13611361 if templ_7745c5c3_Err != nil {
1362- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 269, Col: 60}
1362+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 280, Col: 60}
13631363 }
13641364 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var66))
13651365 if templ_7745c5c3_Err != nil {
@@ -1372,7 +1372,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
13721372 var templ_7745c5c3_Var67 string
13731373 templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.ResolveAttributeValue("Remove passkey " + r.Name)
13741374 if templ_7745c5c3_Err != nil {
1375- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 270, Col: 89}
1375+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 281, Col: 89}
13761376 }
13771377 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var67)
13781378 if templ_7745c5c3_Err != nil {
@@ -1405,7 +1405,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
14051405 var templ_7745c5c3_Var68 string
14061406 templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Max))
14071407 if templ_7745c5c3_Err != nil {
1408- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 281, Col: 31}
1408+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 292, Col: 31}
14091409 }
14101410 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68))
14111411 if templ_7745c5c3_Err != nil {
@@ -1423,7 +1423,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
14231423 var templ_7745c5c3_Var69 string
14241424 templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Begin)
14251425 if templ_7745c5c3_Err != nil {
1426- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 286, Col: 90}
1426+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 297, Col: 90}
14271427 }
14281428 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var69)
14291429 if templ_7745c5c3_Err != nil {
@@ -1436,7 +1436,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
14361436 var templ_7745c5c3_Var70 string
14371437 templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Finish)
14381438 if templ_7745c5c3_Err != nil {
1439- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 286, Col: 115}
1439+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 297, Col: 115}
14401440 }
14411441 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var70)
14421442 if templ_7745c5c3_Err != nil {
@@ -44,7 +44,7 @@ func Home(l Layout, p HomePage) templ.Component {
44 }()44 }()
45 }45 }
46 ctx = templ.InitializeContext(ctx)46 ctx = templ.InitializeContext(ctx)
47- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<h1 class=\"sr\">Repositories</h1><div class=\"home\"><section class=\"home-repos\" aria-label=\"Repositories\"><div class=\"home-bar\"><div class=\"filter\"><label for=\"home-filter\" class=\"sr\">filter</label> <input id=\"home-filter\" type=\"search\" data-filter=\"#repo-rows > li\" placeholder=\"Filter repositories…\" autocomplete=\"off\" hx-on:input=\"ermite.filter(this)\"></div><nav class=\"sort\" aria-label=\"Sort repositories\"><span class=\"mute\">sort</span> ")47+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<h1 class=\"sr\">Repositories</h1><div class=\"home\" data-layout=\"cards\"><section class=\"home-repos\" aria-label=\"Repositories\"><div class=\"home-bar\"><div class=\"filter\"><label for=\"home-filter\" class=\"sr\">filter</label> <input id=\"home-filter\" type=\"search\" data-filter=\"#repo-rows > li\" placeholder=\"Filter repositories…\" autocomplete=\"off\" hx-on:input=\"ermite.filter(this)\"></div><nav class=\"sort\" aria-label=\"Sort repositories\"><span class=\"mute\">sort</span> ")
48 if templ_7745c5c3_Err != nil {48 if templ_7745c5c3_Err != nil {
49 return templ_7745c5c3_Err49 return templ_7745c5c3_Err
50 }50 }
@@ -126,7 +126,7 @@ func Home(l Layout, p HomePage) templ.Component {
126 return templ_7745c5c3_Err126 return templ_7745c5c3_Err
127 }127 }
128 }128 }
129- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</nav></div><ul class=\"repos\" id=\"repo-rows\" data-rows>")129+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</nav><div class=\"layout-switch\" role=\"group\" aria-label=\"Layout\" data-js><button type=\"button\" class=\"btn icon\" data-set-layout=\"cards\" aria-pressed=\"true\" title=\"Cards\" aria-label=\"Cards\"><span class=\"ico i-cards\" aria-hidden=\"true\"></span></button> <button type=\"button\" class=\"btn icon\" data-set-layout=\"split\" aria-pressed=\"false\" title=\"Two columns\" aria-label=\"Two columns\"><span class=\"ico i-split\" aria-hidden=\"true\"></span></button> <button type=\"button\" class=\"btn icon\" data-set-layout=\"compact\" aria-pressed=\"false\" title=\"Compact list\" aria-label=\"Compact list\"><span class=\"ico i-compact\" aria-hidden=\"true\"></span></button></div></div><ul class=\"repos\" id=\"repo-rows\" data-rows>")
130 if templ_7745c5c3_Err != nil {130 if templ_7745c5c3_Err != nil {
131 return templ_7745c5c3_Err131 return templ_7745c5c3_Err
132 }132 }
@@ -247,7 +247,7 @@ func latestTagFact(t LatestTag) templ.Component {
247 var templ_7745c5c3_Var10 templ.SafeURL247 var templ_7745c5c3_Var10 templ.SafeURL
248 templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(t.Href))248 templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(t.Href))
249 if templ_7745c5c3_Err != nil {249 if templ_7745c5c3_Err != nil {
250- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 71, Col: 51}250+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 82, Col: 51}
251 }251 }
252 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))252 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
253 if templ_7745c5c3_Err != nil {253 if templ_7745c5c3_Err != nil {
@@ -260,7 +260,7 @@ func latestTagFact(t LatestTag) templ.Component {
260 var templ_7745c5c3_Var11 string260 var templ_7745c5c3_Var11 string
261 templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(t.Subject)261 templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(t.Subject)
262 if templ_7745c5c3_Err != nil {262 if templ_7745c5c3_Err != nil {
263- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 71, Col: 71}263+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 82, Col: 71}
264 }264 }
265 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)265 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
266 if templ_7745c5c3_Err != nil {266 if templ_7745c5c3_Err != nil {
@@ -273,7 +273,7 @@ func latestTagFact(t LatestTag) templ.Component {
273 var templ_7745c5c3_Var12 string273 var templ_7745c5c3_Var12 string
274 templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(t.Name)274 templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(t.Name)
275 if templ_7745c5c3_Err != nil {275 if templ_7745c5c3_Err != nil {
276- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 71, Col: 82}276+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 82, Col: 82}
277 }277 }
278 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))278 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
279 if templ_7745c5c3_Err != nil {279 if templ_7745c5c3_Err != nil {
@@ -287,7 +287,7 @@ func latestTagFact(t LatestTag) templ.Component {
287 var templ_7745c5c3_Var13 string287 var templ_7745c5c3_Var13 string
288 templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")288 templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
289 if templ_7745c5c3_Err != nil {289 if templ_7745c5c3_Err != nil {
290- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 73, Col: 12}290+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 84, Col: 12}
291 }291 }
292 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))292 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
293 if templ_7745c5c3_Err != nil {293 if templ_7745c5c3_Err != nil {
@@ -300,7 +300,7 @@ func latestTagFact(t LatestTag) templ.Component {
300 var templ_7745c5c3_Var14 string300 var templ_7745c5c3_Var14 string
301 templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(t.WhenAt)301 templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(t.WhenAt)
302 if templ_7745c5c3_Err != nil {302 if templ_7745c5c3_Err != nil {
303- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 73, Col: 39}303+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 84, Col: 39}
304 }304 }
305 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)305 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
306 if templ_7745c5c3_Err != nil {306 if templ_7745c5c3_Err != nil {
@@ -313,7 +313,7 @@ func latestTagFact(t LatestTag) templ.Component {
313 var templ_7745c5c3_Var15 string313 var templ_7745c5c3_Var15 string
314 templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(t.WhenAt)314 templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(t.WhenAt)
315 if templ_7745c5c3_Err != nil {315 if templ_7745c5c3_Err != nil {
316- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 73, Col: 58}316+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 84, Col: 58}
317 }317 }
318 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)318 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
319 if templ_7745c5c3_Err != nil {319 if templ_7745c5c3_Err != nil {
@@ -326,7 +326,7 @@ func latestTagFact(t LatestTag) templ.Component {
326 var templ_7745c5c3_Var16 string326 var templ_7745c5c3_Var16 string
327 templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(t.When)327 templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(t.When)
328 if templ_7745c5c3_Err != nil {328 if templ_7745c5c3_Err != nil {
329- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 73, Col: 69}329+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 84, Col: 69}
330 }330 }
331 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))331 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
332 if templ_7745c5c3_Err != nil {332 if templ_7745c5c3_Err != nil {
@@ -344,7 +344,7 @@ func latestTagFact(t LatestTag) templ.Component {
344 var templ_7745c5c3_Var17 string344 var templ_7745c5c3_Var17 string
345 templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")345 templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
346 if templ_7745c5c3_Err != nil {346 if templ_7745c5c3_Err != nil {
347- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 75, Col: 11}347+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 86, Col: 11}
348 }348 }
349 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))349 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
350 if templ_7745c5c3_Err != nil {350 if templ_7745c5c3_Err != nil {
@@ -362,7 +362,7 @@ func latestTagFact(t LatestTag) templ.Component {
362 var templ_7745c5c3_Var18 templ.SafeURL362 var templ_7745c5c3_Var18 templ.SafeURL
363 templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(t.RefsHref))363 templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(t.RefsHref))
364 if templ_7745c5c3_Err != nil {364 if templ_7745c5c3_Err != nil {
365- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 77, Col: 54}365+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 88, Col: 54}
366 }366 }
367 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))367 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
368 if templ_7745c5c3_Err != nil {368 if templ_7745c5c3_Err != nil {
@@ -375,7 +375,7 @@ func latestTagFact(t LatestTag) templ.Component {
375 var templ_7745c5c3_Var19 string375 var templ_7745c5c3_Var19 string
376 templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(plural(t.Count, "tag", "tags"))376 templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(plural(t.Count, "tag", "tags"))
377 if templ_7745c5c3_Err != nil {377 if templ_7745c5c3_Err != nil {
378- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 77, Col: 89}378+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 88, Col: 89}
379 }379 }
380 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))380 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
381 if templ_7745c5c3_Err != nil {381 if templ_7745c5c3_Err != nil {
@@ -430,7 +430,7 @@ func summaryRail(p SummaryPage) templ.Component {
430 var templ_7745c5c3_Var21 string430 var templ_7745c5c3_Var21 string
431 templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(f.Key)431 templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(f.Key)
432 if templ_7745c5c3_Err != nil {432 if templ_7745c5c3_Err != nil {
433- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 88, Col: 15}433+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 99, Col: 15}
434 }434 }
435 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))435 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
436 if templ_7745c5c3_Err != nil {436 if templ_7745c5c3_Err != nil {
@@ -463,7 +463,7 @@ func summaryRail(p SummaryPage) templ.Component {
463 var templ_7745c5c3_Var22 templ.SafeURL463 var templ_7745c5c3_Var22 templ.SafeURL
464 templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.AllHref))464 templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.AllHref))
465 if templ_7745c5c3_Err != nil {465 if templ_7745c5c3_Err != nil {
466- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 97, Col: 105}466+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 108, Col: 105}
467 }467 }
468 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))468 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
469 if templ_7745c5c3_Err != nil {469 if templ_7745c5c3_Err != nil {
@@ -560,7 +560,7 @@ func Refs(l Layout, p RefsPage) templ.Component {
560 var templ_7745c5c3_Var25 string560 var templ_7745c5c3_Var25 string
561 templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note)561 templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note)
562 if templ_7745c5c3_Err != nil {562 if templ_7745c5c3_Err != nil {
563- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 130, Col: 27}563+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 141, Col: 27}
564 }564 }
565 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))565 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
566 if templ_7745c5c3_Err != nil {566 if templ_7745c5c3_Err != nil {
@@ -619,7 +619,7 @@ func refRow(kind string, r RefRow) templ.Component {
619 var templ_7745c5c3_Var27 string619 var templ_7745c5c3_Var27 string
620 templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(kind)620 templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(kind)
621 if templ_7745c5c3_Err != nil {621 if templ_7745c5c3_Err != nil {
622- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 137, Col: 32}622+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 148, Col: 32}
623 }623 }
624 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))624 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
625 if templ_7745c5c3_Err != nil {625 if templ_7745c5c3_Err != nil {
@@ -632,7 +632,7 @@ func refRow(kind string, r RefRow) templ.Component {
632 var templ_7745c5c3_Var28 templ.SafeURL632 var templ_7745c5c3_Var28 templ.SafeURL
633 templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))633 templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))
634 if templ_7745c5c3_Err != nil {634 if templ_7745c5c3_Err != nil {
635- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 139, Col: 34}635+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 150, Col: 34}
636 }636 }
637 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))637 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
638 if templ_7745c5c3_Err != nil {638 if templ_7745c5c3_Err != nil {
@@ -645,7 +645,7 @@ func refRow(kind string, r RefRow) templ.Component {
645 var templ_7745c5c3_Var29 string645 var templ_7745c5c3_Var29 string
646 templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)646 templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
647 if templ_7745c5c3_Err != nil {647 if templ_7745c5c3_Err != nil {
648- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 139, Col: 45}648+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 150, Col: 45}
649 }649 }
650 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))650 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
651 if templ_7745c5c3_Err != nil {651 if templ_7745c5c3_Err != nil {
@@ -673,7 +673,7 @@ func refRow(kind string, r RefRow) templ.Component {
673 var templ_7745c5c3_Var30 templ.SafeURL673 var templ_7745c5c3_Var30 templ.SafeURL
674 templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.CHref))674 templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.CHref))
675 if templ_7745c5c3_Err != nil {675 if templ_7745c5c3_Err != nil {
676- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 146, Col: 36}676+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 157, Col: 36}
677 }677 }
678 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))678 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
679 if templ_7745c5c3_Err != nil {679 if templ_7745c5c3_Err != nil {
@@ -686,7 +686,7 @@ func refRow(kind string, r RefRow) templ.Component {
686 var templ_7745c5c3_Var31 string686 var templ_7745c5c3_Var31 string
687 templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(r.Short)687 templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(r.Short)
688 if templ_7745c5c3_Err != nil {688 if templ_7745c5c3_Err != nil {
689- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 146, Col: 48}689+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 157, Col: 48}
690 }690 }
691 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))691 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
692 if templ_7745c5c3_Err != nil {692 if templ_7745c5c3_Err != nil {
@@ -700,7 +700,7 @@ func refRow(kind string, r RefRow) templ.Component {
700 var templ_7745c5c3_Var32 string700 var templ_7745c5c3_Var32 string
701 templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(r.Short)701 templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(r.Short)
702 if templ_7745c5c3_Err != nil {702 if templ_7745c5c3_Err != nil {
703- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 148, Col: 13}703+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 159, Col: 13}
704 }704 }
705 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))705 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
706 if templ_7745c5c3_Err != nil {706 if templ_7745c5c3_Err != nil {
@@ -718,7 +718,7 @@ func refRow(kind string, r RefRow) templ.Component {
718 var templ_7745c5c3_Var33 string718 var templ_7745c5c3_Var33 string
719 templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(r.Subject)719 templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(r.Subject)
720 if templ_7745c5c3_Err != nil {720 if templ_7745c5c3_Err != nil {
721- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 152, Col: 31}721+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 163, Col: 31}
722 }722 }
723 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))723 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
724 if templ_7745c5c3_Err != nil {724 if templ_7745c5c3_Err != nil {
@@ -736,7 +736,7 @@ func refRow(kind string, r RefRow) templ.Component {
736 var templ_7745c5c3_Var34 string736 var templ_7745c5c3_Var34 string
737 templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)737 templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)
738 if templ_7745c5c3_Err != nil {738 if templ_7745c5c3_Err != nil {
739- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 155, Col: 29}739+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 166, Col: 29}
740 }740 }
741 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)741 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
742 if templ_7745c5c3_Err != nil {742 if templ_7745c5c3_Err != nil {
@@ -749,7 +749,7 @@ func refRow(kind string, r RefRow) templ.Component {
749 var templ_7745c5c3_Var35 string749 var templ_7745c5c3_Var35 string
750 templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)750 templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)
751 if templ_7745c5c3_Err != nil {751 if templ_7745c5c3_Err != nil {
752- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 155, Col: 48}752+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 166, Col: 48}
753 }753 }
754 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)754 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
755 if templ_7745c5c3_Err != nil {755 if templ_7745c5c3_Err != nil {
@@ -762,7 +762,7 @@ func refRow(kind string, r RefRow) templ.Component {
762 var templ_7745c5c3_Var36 string762 var templ_7745c5c3_Var36 string
763 templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(r.When)763 templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(r.When)
764 if templ_7745c5c3_Err != nil {764 if templ_7745c5c3_Err != nil {
765- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 155, Col: 59}765+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 166, Col: 59}
766 }766 }
767 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))767 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
768 if templ_7745c5c3_Err != nil {768 if templ_7745c5c3_Err != nil {
@@ -859,7 +859,7 @@ func State(l Layout, p StatePage) templ.Component {
859 var templ_7745c5c3_Var40 string859 var templ_7745c5c3_Var40 string
860 templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(p.Code)860 templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(p.Code)
861 if templ_7745c5c3_Err != nil {861 if templ_7745c5c3_Err != nil {
862- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 172, Col: 30}862+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 183, Col: 30}
863 }863 }
864 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))864 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
865 if templ_7745c5c3_Err != nil {865 if templ_7745c5c3_Err != nil {
@@ -872,7 +872,7 @@ func State(l Layout, p StatePage) templ.Component {
872 var templ_7745c5c3_Var41 string872 var templ_7745c5c3_Var41 string
873 templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)873 templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)
874 if templ_7745c5c3_Err != nil {874 if templ_7745c5c3_Err != nil {
875- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 173, Col: 33}875+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 184, Col: 33}
876 }876 }
877 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))877 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
878 if templ_7745c5c3_Err != nil {878 if templ_7745c5c3_Err != nil {
@@ -890,7 +890,7 @@ func State(l Layout, p StatePage) templ.Component {
890 var templ_7745c5c3_Var42 string890 var templ_7745c5c3_Var42 string
891 templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(line)891 templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(line)
892 if templ_7745c5c3_Err != nil {892 if templ_7745c5c3_Err != nil {
893- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 175, Col: 25}893+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 186, Col: 25}
894 }894 }
895 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))895 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
896 if templ_7745c5c3_Err != nil {896 if templ_7745c5c3_Err != nil {
@@ -919,7 +919,7 @@ func State(l Layout, p StatePage) templ.Component {
919 var templ_7745c5c3_Var43 templ.SafeURL919 var templ_7745c5c3_Var43 templ.SafeURL
920 templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(b.Href))920 templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(b.Href))
921 if templ_7745c5c3_Err != nil {921 if templ_7745c5c3_Err != nil {
922- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 182, Col: 36}922+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 193, Col: 36}
923 }923 }
924 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))924 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
925 if templ_7745c5c3_Err != nil {925 if templ_7745c5c3_Err != nil {
@@ -932,7 +932,7 @@ func State(l Layout, p StatePage) templ.Component {
932 var templ_7745c5c3_Var44 string932 var templ_7745c5c3_Var44 string
933 templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(b.Label)933 templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(b.Label)
934 if templ_7745c5c3_Err != nil {934 if templ_7745c5c3_Err != nil {
935- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 182, Col: 48}935+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 193, Col: 48}
936 }936 }
937 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))937 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
938 if templ_7745c5c3_Err != nil {938 if templ_7745c5c3_Err != nil {
@@ -1002,7 +1002,7 @@ func Login(l Layout, p LoginPage) templ.Component {
1002 var templ_7745c5c3_Var47 string1002 var templ_7745c5c3_Var47 string
1003 templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)1003 templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)
1004 if templ_7745c5c3_Err != nil {1004 if templ_7745c5c3_Err != nil {
1005- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 197, Col: 33}1005+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 208, Col: 33}
1006 }1006 }
1007 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))1007 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))
1008 if templ_7745c5c3_Err != nil {1008 if templ_7745c5c3_Err != nil {
@@ -1020,7 +1020,7 @@ func Login(l Layout, p LoginPage) templ.Component {
1020 var templ_7745c5c3_Var48 string1020 var templ_7745c5c3_Var48 string
1021 templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(line)1021 templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(line)
1022 if templ_7745c5c3_Err != nil {1022 if templ_7745c5c3_Err != nil {
1023- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 199, Col: 25}1023+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 210, Col: 25}
1024 }1024 }
1025 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))1025 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
1026 if templ_7745c5c3_Err != nil {1026 if templ_7745c5c3_Err != nil {
@@ -1038,7 +1038,7 @@ func Login(l Layout, p LoginPage) templ.Component {
1038 var templ_7745c5c3_Var49 templ.SafeURL1038 var templ_7745c5c3_Var49 templ.SafeURL
1039 templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Action))1039 templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Action))
1040 if templ_7745c5c3_Err != nil {1040 if templ_7745c5c3_Err != nil {
1041- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 201, Col: 73}1041+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 212, Col: 73}
1042 }1042 }
1043 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))1043 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
1044 if templ_7745c5c3_Err != nil {1044 if templ_7745c5c3_Err != nil {
@@ -1051,7 +1051,7 @@ func Login(l Layout, p LoginPage) templ.Component {
1051 var templ_7745c5c3_Var50 string1051 var templ_7745c5c3_Var50 string
1052 templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(p.Button)1052 templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(p.Button)
1053 if templ_7745c5c3_Err != nil {1053 if templ_7745c5c3_Err != nil {
1054- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 202, Col: 56}1054+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 213, Col: 56}
1055 }1055 }
1056 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))1056 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
1057 if templ_7745c5c3_Err != nil {1057 if templ_7745c5c3_Err != nil {
@@ -1113,7 +1113,7 @@ func PasskeyLogin(l Layout, p PasskeyLoginPage) templ.Component {
1113 var templ_7745c5c3_Var53 string1113 var templ_7745c5c3_Var53 string
1114 templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)1114 templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)
1115 if templ_7745c5c3_Err != nil {1115 if templ_7745c5c3_Err != nil {
1116- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 214, Col: 33}1116+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 225, Col: 33}
1117 }1117 }
1118 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53))1118 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53))
1119 if templ_7745c5c3_Err != nil {1119 if templ_7745c5c3_Err != nil {
@@ -1126,7 +1126,7 @@ func PasskeyLogin(l Layout, p PasskeyLoginPage) templ.Component {
1126 var templ_7745c5c3_Var54 string1126 var templ_7745c5c3_Var54 string
1127 templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Begin)1127 templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Begin)
1128 if templ_7745c5c3_Err != nil {1128 if templ_7745c5c3_Err != nil {
1129- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 216, Col: 87}1129+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 227, Col: 87}
1130 }1130 }
1131 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var54)1131 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var54)
1132 if templ_7745c5c3_Err != nil {1132 if templ_7745c5c3_Err != nil {
@@ -1139,7 +1139,7 @@ func PasskeyLogin(l Layout, p PasskeyLoginPage) templ.Component {
1139 var templ_7745c5c3_Var55 string1139 var templ_7745c5c3_Var55 string
1140 templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Finish)1140 templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Finish)
1141 if templ_7745c5c3_Err != nil {1141 if templ_7745c5c3_Err != nil {
1142- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 216, Col: 112}1142+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 227, Col: 112}
1143 }1143 }
1144 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var55)1144 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var55)
1145 if templ_7745c5c3_Err != nil {1145 if templ_7745c5c3_Err != nil {
@@ -1152,7 +1152,7 @@ func PasskeyLogin(l Layout, p PasskeyLoginPage) templ.Component {
1152 var templ_7745c5c3_Var56 string1152 var templ_7745c5c3_Var56 string
1153 templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(p.Button)1153 templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(p.Button)
1154 if templ_7745c5c3_Err != nil {1154 if templ_7745c5c3_Err != nil {
1155- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 216, Col: 125}1155+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 227, Col: 125}
1156 }1156 }
1157 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))1157 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))
1158 if templ_7745c5c3_Err != nil {1158 if templ_7745c5c3_Err != nil {
@@ -1170,7 +1170,7 @@ func PasskeyLogin(l Layout, p PasskeyLoginPage) templ.Component {
1170 var templ_7745c5c3_Var57 string1170 var templ_7745c5c3_Var57 string
1171 templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(line)1171 templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(line)
1172 if templ_7745c5c3_Err != nil {1172 if templ_7745c5c3_Err != nil {
1173- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 220, Col: 25}1173+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 231, Col: 25}
1174 }1174 }
1175 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57))1175 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57))
1176 if templ_7745c5c3_Err != nil {1176 if templ_7745c5c3_Err != nil {
@@ -1236,7 +1236,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
1236 var templ_7745c5c3_Var60 string1236 var templ_7745c5c3_Var60 string
1237 templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Rows)))1237 templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Rows)))
1238 if templ_7745c5c3_Err != nil {1238 if templ_7745c5c3_Err != nil {
1239- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 230, Col: 81}1239+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 241, Col: 81}
1240 }1240 }
1241 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60))1241 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60))
1242 if templ_7745c5c3_Err != nil {1242 if templ_7745c5c3_Err != nil {
@@ -1259,7 +1259,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
1259 var templ_7745c5c3_Var61 string1259 var templ_7745c5c3_Var61 string
1260 templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)1260 templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
1261 if templ_7745c5c3_Err != nil {1261 if templ_7745c5c3_Err != nil {
1262- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 246, Col: 34}1262+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 257, Col: 34}
1263 }1263 }
1264 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61))1264 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61))
1265 if templ_7745c5c3_Err != nil {1265 if templ_7745c5c3_Err != nil {
@@ -1277,7 +1277,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
1277 var templ_7745c5c3_Var62 string1277 var templ_7745c5c3_Var62 string
1278 templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.CreatedAt)1278 templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.CreatedAt)
1279 if templ_7745c5c3_Err != nil {1279 if templ_7745c5c3_Err != nil {
1280- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 250, Col: 32}1280+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 261, Col: 32}
1281 }1281 }
1282 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var62)1282 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var62)
1283 if templ_7745c5c3_Err != nil {1283 if templ_7745c5c3_Err != nil {
@@ -1295,7 +1295,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
1295 var templ_7745c5c3_Var63 string1295 var templ_7745c5c3_Var63 string
1296 templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(r.Created)1296 templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(r.Created)
1297 if templ_7745c5c3_Err != nil {1297 if templ_7745c5c3_Err != nil {
1298- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 252, Col: 20}1298+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 263, Col: 20}
1299 }1299 }
1300 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63))1300 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63))
1301 if templ_7745c5c3_Err != nil {1301 if templ_7745c5c3_Err != nil {
@@ -1313,7 +1313,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
1313 var templ_7745c5c3_Var64 string1313 var templ_7745c5c3_Var64 string
1314 templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.LastUsedAt)1314 templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.LastUsedAt)
1315 if templ_7745c5c3_Err != nil {1315 if templ_7745c5c3_Err != nil {
1316- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 257, Col: 33}1316+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 268, Col: 33}
1317 }1317 }
1318 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var64)1318 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var64)
1319 if templ_7745c5c3_Err != nil {1319 if templ_7745c5c3_Err != nil {
@@ -1331,7 +1331,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
1331 var templ_7745c5c3_Var65 string1331 var templ_7745c5c3_Var65 string
1332 templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs(r.LastUsed)1332 templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs(r.LastUsed)
1333 if templ_7745c5c3_Err != nil {1333 if templ_7745c5c3_Err != nil {
1334- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 259, Col: 21}1334+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 270, Col: 21}
1335 }1335 }
1336 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65))1336 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65))
1337 if templ_7745c5c3_Err != nil {1337 if templ_7745c5c3_Err != nil {
@@ -1359,7 +1359,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
1359 var templ_7745c5c3_Var66 templ.SafeURL1359 var templ_7745c5c3_Var66 templ.SafeURL
1360 templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Remove))1360 templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Remove))
1361 if templ_7745c5c3_Err != nil {1361 if templ_7745c5c3_Err != nil {
1362- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 269, Col: 60}1362+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 280, Col: 60}
1363 }1363 }
1364 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var66))1364 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var66))
1365 if templ_7745c5c3_Err != nil {1365 if templ_7745c5c3_Err != nil {
@@ -1372,7 +1372,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
1372 var templ_7745c5c3_Var67 string1372 var templ_7745c5c3_Var67 string
1373 templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.ResolveAttributeValue("Remove passkey " + r.Name)1373 templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.ResolveAttributeValue("Remove passkey " + r.Name)
1374 if templ_7745c5c3_Err != nil {1374 if templ_7745c5c3_Err != nil {
1375- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 270, Col: 89}1375+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 281, Col: 89}
1376 }1376 }
1377 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var67)1377 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var67)
1378 if templ_7745c5c3_Err != nil {1378 if templ_7745c5c3_Err != nil {
@@ -1405,7 +1405,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
1405 var templ_7745c5c3_Var68 string1405 var templ_7745c5c3_Var68 string
1406 templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Max))1406 templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Max))
1407 if templ_7745c5c3_Err != nil {1407 if templ_7745c5c3_Err != nil {
1408- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 281, Col: 31}1408+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 292, Col: 31}
1409 }1409 }
1410 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68))1410 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68))
1411 if templ_7745c5c3_Err != nil {1411 if templ_7745c5c3_Err != nil {
@@ -1423,7 +1423,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
1423 var templ_7745c5c3_Var69 string1423 var templ_7745c5c3_Var69 string
1424 templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Begin)1424 templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Begin)
1425 if templ_7745c5c3_Err != nil {1425 if templ_7745c5c3_Err != nil {
1426- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 286, Col: 90}1426+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 297, Col: 90}
1427 }1427 }
1428 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var69)1428 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var69)
1429 if templ_7745c5c3_Err != nil {1429 if templ_7745c5c3_Err != nil {
@@ -1436,7 +1436,7 @@ func Passkeys(l Layout, p PasskeysPage) templ.Component {
1436 var templ_7745c5c3_Var70 string1436 var templ_7745c5c3_Var70 string
1437 templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Finish)1437 templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Finish)
1438 if templ_7745c5c3_Err != nil {1438 if templ_7745c5c3_Err != nil {
1439- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 286, Col: 115}1439+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 297, Col: 115}
1440 }1440 }
1441 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var70)1441 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var70)
1442 if templ_7745c5c3_Err != nil {1442 if templ_7745c5c3_Err != nil {
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 generated file: collapsed
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