detached f347d8a

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

commit
f347d8a69e70ff6b308c481c06d53962c1e83692
author
brice · · 2026-10-18 17:39 UTC
committer
bnema · · 2026-10-18 17:39 UTC
parent
cdeae08
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
50 files · +926 -250
M internal/adapters/web/ASSETS.md +2 -1
@@ -8,6 +8,7 @@ content-hashed names. Nothing is fetched at runtime.
88 | `static/js/htmx.min.js` | htmx 4.0.0 (BSD Zero Clause, `static/js/htmx-LICENSE`) | `mockups/assets/htmx/htmx.min.js` of the design | `e484d9171a9db30a39c8f16e3d709d4137f3211c659f8e6125816635033d593f` |
99 | `static/js/hx-live.min.js` | htmx 4 `hx-live` extension | `mockups/assets/htmx/ext/hx-live.min.js` | `adf9b90949d57baf20dc2eaf1d1fd46c81347ac82c090a5e2912ce6b37726734` |
1010 | `static/js/hx-sse.min.js` | htmx 4 `hx-sse` extension | `mockups/assets/htmx/ext/hx-sse.min.js` | `8a834680c4000a9034d79228872372a92e140c810a075cb6d4a76690dfc13085` |
11+| `static/js/store.js` | the localStorage client of the scripts: preferences such as the home layout and the single-key shortcuts (`window.ermiteStore`, tested in Node by `jstest/store.test.mjs`) | written here | |
1112 | `static/js/ermite.js` | key sequences to `k-*` events, the functions the keys and inputs call (`window.ermite`), the live fragments (`ermiteLive`), the diff table of contents observer | `mockups/d/js/ermite.js`, extended here | |
1213 | `static/js/log.js` | the live log of a running job (`window.ermiteLog`, tested in Node by `jstest/log.test.mjs`) | written here | |
1314 | `static/js/files.js` | the Files view: a tree whose directories open in place and walk by keyboard (`window.ermiteTree`, tested in Node by `jstest/tree.test.mjs`), the fuzzy file finder and the palette's file hits (`window.ermiteFinder`; its core is tested in Node by `jstest/files.test.mjs`) | written here | |
@@ -16,7 +17,7 @@ content-hashed names. Nothing is fetched at runtime.
1617 | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | |
1718 | `static/fonts/atkinson-next.woff2`, `atkinson-next-italic.woff2` | Atkinson Hyperlegible Next, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-next.txt`) | Google Fonts | `18b2a1a39a2fa298b0ba5390aca68462669826c90925656f1c1f6796e0e1bbaf`, `4a5037bfaf6680f40147407407ec09fa42925774bde809a579283d27f9f08106` |
1819 | `static/fonts/atkinson-mono.woff2` | Atkinson Hyperlegible Mono, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-mono.txt`) | Google Fonts | `2706b1ee4f452e744ea91f7e4908cbde9c5d35521bf5ffffc71a382a2de89613` |
19-| `static/icons/*.svg` | interface icons, used as CSS masks; `brand.svg` is a grey picture shown as is. Only the icons in use are copied, one file each: `branch`, `search`, `tag`, `file`, `chevron`, `user`, `key`, `log-in`, `log-out` are Lucide 1.8 (`git-branch`, `search`, `tag`, `file-text`, `chevron-down`, `user-round`, `key-round`, `log-in`, `log-out`; ISC, https://lucide.dev/license) | Lucide; `brand.svg` written here | |
20+| `static/icons/*.svg` | interface icons, used as CSS masks; `brand.svg` is a grey picture shown as is. Only the icons in use are copied, one file each: `branch`, `search`, `tag`, `file`, `chevron`, `user`, `key`, `log-in`, `log-out`, `cards`, `split`, `compact` are Lucide 1.8 (`git-branch`, `search`, `tag`, `file-text`, `chevron-down`, `user-round`, `key-round`, `log-in`, `log-out`, `rows-2`, `columns-2`, `list`; ISC, https://lucide.dev/license) | Lucide; `brand.svg` written here | |
2021 | `static/app/*.png` | icons of the installed web app (manifest, `apple-touch-icon`), rendered from `brand.svg` with resvg; the maskable and Apple icons have the picture in the safe zone on `#181818` | generated here | |
2122 | `static/sw.js` | the service worker of the installed app: a network-only passthrough (no cache), served at `/-/sw.js` with `Service-Worker-Allowed: /` and revalidated on each check (not a hashed asset) | written here | |
2223 | `static/rules.json` | speculation rules: prefetch the light same-origin pages on hover (`Speculation-Rules` header); the heavy ones (tree, blob, commit, commits, compare, refs, raw, MR "since") are excluded, each costs several Git reads on the server | written here | |
@@ -8,6 +8,7 @@ content-hashed names. Nothing is fetched at runtime.
8 | `static/js/htmx.min.js` | htmx 4.0.0 (BSD Zero Clause, `static/js/htmx-LICENSE`) | `mockups/assets/htmx/htmx.min.js` of the design | `e484d9171a9db30a39c8f16e3d709d4137f3211c659f8e6125816635033d593f` |8 | `static/js/htmx.min.js` | htmx 4.0.0 (BSD Zero Clause, `static/js/htmx-LICENSE`) | `mockups/assets/htmx/htmx.min.js` of the design | `e484d9171a9db30a39c8f16e3d709d4137f3211c659f8e6125816635033d593f` |
9 | `static/js/hx-live.min.js` | htmx 4 `hx-live` extension | `mockups/assets/htmx/ext/hx-live.min.js` | `adf9b90949d57baf20dc2eaf1d1fd46c81347ac82c090a5e2912ce6b37726734` |9 | `static/js/hx-live.min.js` | htmx 4 `hx-live` extension | `mockups/assets/htmx/ext/hx-live.min.js` | `adf9b90949d57baf20dc2eaf1d1fd46c81347ac82c090a5e2912ce6b37726734` |
10 | `static/js/hx-sse.min.js` | htmx 4 `hx-sse` extension | `mockups/assets/htmx/ext/hx-sse.min.js` | `8a834680c4000a9034d79228872372a92e140c810a075cb6d4a76690dfc13085` |10 | `static/js/hx-sse.min.js` | htmx 4 `hx-sse` extension | `mockups/assets/htmx/ext/hx-sse.min.js` | `8a834680c4000a9034d79228872372a92e140c810a075cb6d4a76690dfc13085` |
11+| `static/js/store.js` | the localStorage client of the scripts: preferences such as the home layout and the single-key shortcuts (`window.ermiteStore`, tested in Node by `jstest/store.test.mjs`) | written here | |
11 | `static/js/ermite.js` | key sequences to `k-*` events, the functions the keys and inputs call (`window.ermite`), the live fragments (`ermiteLive`), the diff table of contents observer | `mockups/d/js/ermite.js`, extended here | |12 | `static/js/ermite.js` | key sequences to `k-*` events, the functions the keys and inputs call (`window.ermite`), the live fragments (`ermiteLive`), the diff table of contents observer | `mockups/d/js/ermite.js`, extended here | |
12 | `static/js/log.js` | the live log of a running job (`window.ermiteLog`, tested in Node by `jstest/log.test.mjs`) | written here | |13 | `static/js/log.js` | the live log of a running job (`window.ermiteLog`, tested in Node by `jstest/log.test.mjs`) | written here | |
13 | `static/js/files.js` | the Files view: a tree whose directories open in place and walk by keyboard (`window.ermiteTree`, tested in Node by `jstest/tree.test.mjs`), the fuzzy file finder and the palette's file hits (`window.ermiteFinder`; its core is tested in Node by `jstest/files.test.mjs`) | written here | |14 | `static/js/files.js` | the Files view: a tree whose directories open in place and walk by keyboard (`window.ermiteTree`, tested in Node by `jstest/tree.test.mjs`), the fuzzy file finder and the palette's file hits (`window.ermiteFinder`; its core is tested in Node by `jstest/files.test.mjs`) | written here | |
@@ -16,7 +17,7 @@ content-hashed names. Nothing is fetched at runtime.
16 | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | |17 | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | |
17 | `static/fonts/atkinson-next.woff2`, `atkinson-next-italic.woff2` | Atkinson Hyperlegible Next, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-next.txt`) | Google Fonts | `18b2a1a39a2fa298b0ba5390aca68462669826c90925656f1c1f6796e0e1bbaf`, `4a5037bfaf6680f40147407407ec09fa42925774bde809a579283d27f9f08106` |18 | `static/fonts/atkinson-next.woff2`, `atkinson-next-italic.woff2` | Atkinson Hyperlegible Next, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-next.txt`) | Google Fonts | `18b2a1a39a2fa298b0ba5390aca68462669826c90925656f1c1f6796e0e1bbaf`, `4a5037bfaf6680f40147407407ec09fa42925774bde809a579283d27f9f08106` |
18 | `static/fonts/atkinson-mono.woff2` | Atkinson Hyperlegible Mono, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-mono.txt`) | Google Fonts | `2706b1ee4f452e744ea91f7e4908cbde9c5d35521bf5ffffc71a382a2de89613` |19 | `static/fonts/atkinson-mono.woff2` | Atkinson Hyperlegible Mono, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-mono.txt`) | Google Fonts | `2706b1ee4f452e744ea91f7e4908cbde9c5d35521bf5ffffc71a382a2de89613` |
19-| `static/icons/*.svg` | interface icons, used as CSS masks; `brand.svg` is a grey picture shown as is. Only the icons in use are copied, one file each: `branch`, `search`, `tag`, `file`, `chevron`, `user`, `key`, `log-in`, `log-out` are Lucide 1.8 (`git-branch`, `search`, `tag`, `file-text`, `chevron-down`, `user-round`, `key-round`, `log-in`, `log-out`; ISC, https://lucide.dev/license) | Lucide; `brand.svg` written here | |20+| `static/icons/*.svg` | interface icons, used as CSS masks; `brand.svg` is a grey picture shown as is. Only the icons in use are copied, one file each: `branch`, `search`, `tag`, `file`, `chevron`, `user`, `key`, `log-in`, `log-out`, `cards`, `split`, `compact` are Lucide 1.8 (`git-branch`, `search`, `tag`, `file-text`, `chevron-down`, `user-round`, `key-round`, `log-in`, `log-out`, `rows-2`, `columns-2`, `list`; ISC, https://lucide.dev/license) | Lucide; `brand.svg` written here | |
20 | `static/app/*.png` | icons of the installed web app (manifest, `apple-touch-icon`), rendered from `brand.svg` with resvg; the maskable and Apple icons have the picture in the safe zone on `#181818` | generated here | |21 | `static/app/*.png` | icons of the installed web app (manifest, `apple-touch-icon`), rendered from `brand.svg` with resvg; the maskable and Apple icons have the picture in the safe zone on `#181818` | generated here | |
21 | `static/sw.js` | the service worker of the installed app: a network-only passthrough (no cache), served at `/-/sw.js` with `Service-Worker-Allowed: /` and revalidated on each check (not a hashed asset) | written here | |22 | `static/sw.js` | the service worker of the installed app: a network-only passthrough (no cache), served at `/-/sw.js` with `Service-Worker-Allowed: /` and revalidated on each check (not a hashed asset) | written here | |
22 | `static/rules.json` | speculation rules: prefetch the light same-origin pages on hover (`Speculation-Rules` header); the heavy ones (tree, blob, commit, commits, compare, refs, raw, MR "since") are excluded, each costs several Git reads on the server | written here | |23 | `static/rules.json` | speculation rules: prefetch the light same-origin pages on hover (`Speculation-Rules` header); the heavy ones (tree, blob, commit, commits, compare, refs, raw, MR "since") are excluded, each costs several Git reads on the server | written here | |
M internal/adapters/web/assets.go +2 -1
@@ -24,7 +24,7 @@ const StaticPrefix = "/-/static/"
2424 // Assets are the URLs of the assets a page loads. Their names carry a hash of
2525 // the content, so they can be cached for ever.
2626 type Assets struct {
27- CSS, HTMX, Live, SSE, App, Files, Passkey, Log, MR, Icon string
27+ CSS, HTMX, Live, SSE, Store, App, Files, Passkey, Log, MR, Icon string
2828 // Manifest is the web app manifest (install on a phone's home screen),
2929 // AppleIcon the home screen icon iOS reads from a link instead.
3030 Manifest, AppleIcon string
@@ -59,6 +59,7 @@ var scripts = []struct {
5959 {"htmx.min.js", func(a *Assets, u string) { a.HTMX = u }},
6060 {"hx-live.min.js", func(a *Assets, u string) { a.Live = u }},
6161 {"hx-sse.min.js", func(a *Assets, u string) { a.SSE = u }},
62+ {"store.js", func(a *Assets, u string) { a.Store = u }},
6263 {"ermite.js", func(a *Assets, u string) { a.App = u }},
6364 {"files.js", func(a *Assets, u string) { a.Files = u }},
6465 {"passkey.js", func(a *Assets, u string) { a.Passkey = u }},
@@ -24,7 +24,7 @@ const StaticPrefix = "/-/static/"
24 // Assets are the URLs of the assets a page loads. Their names carry a hash of24 // Assets are the URLs of the assets a page loads. Their names carry a hash of
25 // the content, so they can be cached for ever.25 // the content, so they can be cached for ever.
26 type Assets struct {26 type Assets struct {
27- CSS, HTMX, Live, SSE, App, Files, Passkey, Log, MR, Icon string27+ CSS, HTMX, Live, SSE, Store, App, Files, Passkey, Log, MR, Icon string
28 // Manifest is the web app manifest (install on a phone's home screen),28 // Manifest is the web app manifest (install on a phone's home screen),
29 // AppleIcon the home screen icon iOS reads from a link instead.29 // AppleIcon the home screen icon iOS reads from a link instead.
30 Manifest, AppleIcon string30 Manifest, AppleIcon string
@@ -59,6 +59,7 @@ var scripts = []struct {
59 {"htmx.min.js", func(a *Assets, u string) { a.HTMX = u }},59 {"htmx.min.js", func(a *Assets, u string) { a.HTMX = u }},
60 {"hx-live.min.js", func(a *Assets, u string) { a.Live = u }},60 {"hx-live.min.js", func(a *Assets, u string) { a.Live = u }},
61 {"hx-sse.min.js", func(a *Assets, u string) { a.SSE = u }},61 {"hx-sse.min.js", func(a *Assets, u string) { a.SSE = u }},
62+ {"store.js", func(a *Assets, u string) { a.Store = u }},
62 {"ermite.js", func(a *Assets, u string) { a.App = u }},63 {"ermite.js", func(a *Assets, u string) { a.App = u }},
63 {"files.js", func(a *Assets, u string) { a.Files = u }},64 {"files.js", func(a *Assets, u string) { a.Files = u }},
64 {"passkey.js", func(a *Assets, u string) { a.Passkey = u }},65 {"passkey.js", func(a *Assets, u string) { a.Passkey = u }},
M internal/adapters/web/components.templ +1 -1
@@ -74,7 +74,7 @@ templ HomeRowView(r HomeRow) {
7474 <div><dt>commits</dt><dd>{ s.Commits }</dd></div>
7575 <div><dt>last 30 days</dt><dd>{ s.Recent }</dd></div>
7676 }
77- <div>
77+ <div class="rs-active">
7878 <dt>last activity</dt>
7979 if r.ActiveAt != "" {
8080 <dd><time datetime={ r.ActiveAt } title={ r.ActiveAt }>{ r.Active }</time></dd>
@@ -74,7 +74,7 @@ templ HomeRowView(r HomeRow) {
74 <div><dt>commits</dt><dd>{ s.Commits }</dd></div>74 <div><dt>commits</dt><dd>{ s.Commits }</dd></div>
75 <div><dt>last 30 days</dt><dd>{ s.Recent }</dd></div>75 <div><dt>last 30 days</dt><dd>{ s.Recent }</dd></div>
76 }76 }
77- <div>77+ <div class="rs-active">
78 <dt>last activity</dt>78 <dt>last activity</dt>
79 if r.ActiveAt != "" {79 if r.ActiveAt != "" {
80 <dd><time datetime={ r.ActiveAt } title={ r.ActiveAt }>{ r.Active }</time></dd>80 <dd><time datetime={ r.ActiveAt } title={ r.ActiveAt }>{ r.Active }</time></dd>
M internal/adapters/web/components_templ.go +1 -1 generated file: collapsed
M internal/adapters/web/home_test.go +24 -0
@@ -320,3 +320,27 @@ func TestHomeRowsAreBoundedAndOrdered(t *testing.T) {
320320 assert.LessOrEqual(t, int(peak.Load()), homeWorkers)
321321 assert.Greater(t, int(peak.Load()), 1, "the reads overlap")
322322 }
323+
324+// The page starts in the cards layout (a script applies the stored choice) and
325+// offers the three layouts, "cards" pressed; the store loads before the app.
326+func TestHomeOffersTheLayouts(t *testing.T) {
327+ t.Parallel()
328+ browse := homeBrowse(t)
329+ browse.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.HistoryPage{Commits: homeCommits(t, 2)}, nil)
330+ srv := newTestServer(t, Config{Repos: reposServing(t, pushedRepo(t, "proj")), Browse: browse})
331+
332+ rec := httptest.NewRecorder()
333+ srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
334+ require.Equal(t, http.StatusOK, rec.Code)
335+ body := rec.Body.String()
336+ assert.Contains(t, body, `<div class="home" data-layout="cards">`)
337+ assert.Contains(t, body, `<div class="layout-switch" role="group" aria-label="Layout" data-js>`)
338+ for layout, pressed := range map[string]string{"cards": "true", "split": "false", "compact": "false"} {
339+ assert.Contains(t, body, `data-set-layout="`+layout+`" aria-pressed="`+pressed+`"`)
340+ assert.Contains(t, body, `class="ico i-`+layout+`"`)
341+ }
342+ store := strings.Index(body, "/js/store.")
343+ app := strings.Index(body, "/js/ermite.")
344+ require.NotEqual(t, -1, store)
345+ assert.Less(t, store, app, "the store loads before the app script")
346+}
@@ -320,3 +320,27 @@ func TestHomeRowsAreBoundedAndOrdered(t *testing.T) {
320 assert.LessOrEqual(t, int(peak.Load()), homeWorkers)320 assert.LessOrEqual(t, int(peak.Load()), homeWorkers)
321 assert.Greater(t, int(peak.Load()), 1, "the reads overlap")321 assert.Greater(t, int(peak.Load()), 1, "the reads overlap")
322 }322 }
323+
324+// The page starts in the cards layout (a script applies the stored choice) and
325+// offers the three layouts, "cards" pressed; the store loads before the app.
326+func TestHomeOffersTheLayouts(t *testing.T) {
327+ t.Parallel()
328+ browse := homeBrowse(t)
329+ browse.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.HistoryPage{Commits: homeCommits(t, 2)}, nil)
330+ srv := newTestServer(t, Config{Repos: reposServing(t, pushedRepo(t, "proj")), Browse: browse})
331+
332+ rec := httptest.NewRecorder()
333+ srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
334+ require.Equal(t, http.StatusOK, rec.Code)
335+ body := rec.Body.String()
336+ assert.Contains(t, body, `<div class="home" data-layout="cards">`)
337+ assert.Contains(t, body, `<div class="layout-switch" role="group" aria-label="Layout" data-js>`)
338+ for layout, pressed := range map[string]string{"cards": "true", "split": "false", "compact": "false"} {
339+ assert.Contains(t, body, `data-set-layout="`+layout+`" aria-pressed="`+pressed+`"`)
340+ assert.Contains(t, body, `class="ico i-`+layout+`"`)
341+ }
342+ store := strings.Index(body, "/js/store.")
343+ app := strings.Index(body, "/js/ermite.")
344+ require.NotEqual(t, -1, store)
345+ assert.Less(t, store, app, "the store loads before the app script")
346+}
M internal/adapters/web/jstest/a11y.test.mjs +17 -1
@@ -49,13 +49,15 @@ doc.querySelectorAll = () => []; // the page-wide queries of the top of ermite.j
4949 doc.querySelector = () => null;
5050
5151 const store = new Map();
52-globalThis.localStorage = { getItem: (k) => store.get(k) ?? null, setItem: (k, v) => store.set(k, String(v)) };
52+globalThis.localStorage = { getItem: (k) => store.get(k) ?? null, setItem: (k, v) => store.set(k, String(v)), removeItem: (k) => store.delete(k) };
5353 const listeners = {};
5454 globalThis.addEventListener = (type, f) => (listeners[type] ||= []).push(f);
55+globalThis.removeEventListener = (type, f) => { listeners[type] = (listeners[type] || []).filter((g) => g !== f); };
5556 globalThis.scrollTo = () => {};
5657 globalThis.fetch = async () => ({ ok: true, json: async () => ({ paths: ['cmd/main.go', 'README.md'] }) });
5758
5859 await import('../static/js/files.js');
60+await import('../static/js/store.js'); // loaded before ermite.js, as in the page
5961 await import('../static/js/ermite.js');
6062 doc.querySelectorAll = query.all;
6163 doc.querySelector = query.one;
@@ -304,6 +306,20 @@ test('shortcuts off: the checkbox, the note and the palette action show the stat
304306 doc.querySelector = find;
305307 });
306308
309+test('shortcuts off: another tab\'s change applies here', () => {
310+ // The browser has written the value before the event comes.
311+ const storage = (newValue) => {
312+ if (newValue === null) store.delete('keys-off'); else store.set('keys-off', newValue);
313+ listeners.storage.forEach((f) => f({ key: 'keys-off', newValue }));
314+ };
315+ storage('1');
316+ assert.equal(K.off(), true);
317+ storage('');
318+ assert.equal(K.off(), false);
319+ storage(null);
320+ assert.equal(K.off(), false, 'removed: on');
321+});
322+
307323 test('the preference is read from storage when the page loads', async () => {
308324 store.set('keys-off', '1');
309325 listeners.keydown = [];
@@ -49,13 +49,15 @@ doc.querySelectorAll = () => []; // the page-wide queries of the top of ermite.j
49 doc.querySelector = () => null;49 doc.querySelector = () => null;
50 50
51 const store = new Map();51 const store = new Map();
52-globalThis.localStorage = { getItem: (k) => store.get(k) ?? null, setItem: (k, v) => store.set(k, String(v)) };52+globalThis.localStorage = { getItem: (k) => store.get(k) ?? null, setItem: (k, v) => store.set(k, String(v)), removeItem: (k) => store.delete(k) };
53 const listeners = {};53 const listeners = {};
54 globalThis.addEventListener = (type, f) => (listeners[type] ||= []).push(f);54 globalThis.addEventListener = (type, f) => (listeners[type] ||= []).push(f);
55+globalThis.removeEventListener = (type, f) => { listeners[type] = (listeners[type] || []).filter((g) => g !== f); };
55 globalThis.scrollTo = () => {};56 globalThis.scrollTo = () => {};
56 globalThis.fetch = async () => ({ ok: true, json: async () => ({ paths: ['cmd/main.go', 'README.md'] }) });57 globalThis.fetch = async () => ({ ok: true, json: async () => ({ paths: ['cmd/main.go', 'README.md'] }) });
57 58
58 await import('../static/js/files.js');59 await import('../static/js/files.js');
60+await import('../static/js/store.js'); // loaded before ermite.js, as in the page
59 await import('../static/js/ermite.js');61 await import('../static/js/ermite.js');
60 doc.querySelectorAll = query.all;62 doc.querySelectorAll = query.all;
61 doc.querySelector = query.one;63 doc.querySelector = query.one;
@@ -304,6 +306,20 @@ test('shortcuts off: the checkbox, the note and the palette action show the stat
304 doc.querySelector = find;306 doc.querySelector = find;
305 });307 });
306 308
309+test('shortcuts off: another tab\'s change applies here', () => {
310+ // The browser has written the value before the event comes.
311+ const storage = (newValue) => {
312+ if (newValue === null) store.delete('keys-off'); else store.set('keys-off', newValue);
313+ listeners.storage.forEach((f) => f({ key: 'keys-off', newValue }));
314+ };
315+ storage('1');
316+ assert.equal(K.off(), true);
317+ storage('');
318+ assert.equal(K.off(), false);
319+ storage(null);
320+ assert.equal(K.off(), false, 'removed: on');
321+});
322+
307 test('the preference is read from storage when the page loads', async () => {323 test('the preference is read from storage when the page loads', async () => {
308 store.set('keys-off', '1');324 store.set('keys-off', '1');
309 listeners.keydown = [];325 listeners.keydown = [];
A internal/adapters/web/jstest/layout.test.mjs +139 -0
@@ -0,0 +1,139 @@
1+// Tests of the home page layout of static/js/ermite.js (window.ermiteLayout)
2+// with store.js, on the small DOM of dom.mjs. Only the ermiteLayout block is
3+// evaluated: the rest needs a page. Run with
4+// node --test 'internal/adapters/web/jstest/*.test.mjs'
5+import assert from 'node:assert/strict';
6+import { readFileSync } from 'node:fs';
7+import { beforeEach, test } from 'node:test';
8+import { el, installDom } from './dom.mjs';
9+
10+const read = (f) => readFileSync(new URL('../static/js/' + f, import.meta.url), 'utf8');
11+const ermite = read('ermite.js');
12+const start = ermite.indexOf(' window.ermiteLayout = ');
13+const block = ermite.slice(start, ermite.indexOf(' })();', start) + ' })();'.length);
14+const storeSrc = read('store.js');
15+
16+const doc = installDom();
17+let listeners;
18+let storage;
19+let fits;
20+let home;
21+let buttons;
22+
23+// A page: .home and the switch, with "cards" pressed as the server renders it.
24+// Then store.js and the block are evaluated, as a page load does.
25+function load({ stored, storageObj } = {}) {
26+ listeners = {};
27+ fits = 0;
28+ storage = storageObj ?? new Map(stored ? Object.entries(stored) : []);
29+ globalThis.localStorage = storageObj ?? {
30+ getItem: (k) => (storage.has(k) ? storage.get(k) : null),
31+ setItem: (k, v) => { storage.set(k, String(v)); },
32+ removeItem: (k) => { storage.delete(k); },
33+ };
34+ globalThis.addEventListener = (type, f) => (listeners[type] ||= []).push(f);
35+ globalThis.removeEventListener = (type, f) => { listeners[type] = (listeners[type] || []).filter((g) => g !== f); };
36+ home = el('div', { cls: 'home', attrs: { 'data-layout': 'cards' } });
37+ buttons = ['cards', 'split', 'compact'].map((v) => el('button', {
38+ cls: 'btn icon', attrs: { type: 'button', 'data-set-layout': v, 'aria-pressed': String(v === 'cards') },
39+ }));
40+ const sw = el('div', { cls: 'layout-switch', attrs: { role: 'group', 'data-js': '' } }, ...buttons);
41+ doc.body.replaceChildren(sw, home);
42+ window.ermiteFit = { all: () => { fits++; } };
43+ new Function(storeSrc)();
44+ new Function(block)();
45+ return window.ermiteLayout;
46+}
47+
48+const pressed = () => buttons.map((b) => b.getAttribute('aria-pressed'));
49+const click = (target) => listeners.click.forEach((f) => f({ target }));
50+
51+beforeEach(() => { delete globalThis.localStorage; });
52+
53+test('with nothing stored the layout is cards, and the page is as rendered', () => {
54+ const L = load();
55+ assert.equal(L.get(), 'cards');
56+ assert.deepEqual(L.values, ['cards', 'split', 'compact']);
57+ assert.equal(home.getAttribute('data-layout'), 'cards');
58+ assert.deepEqual(pressed(), ['true', 'false', 'false']);
59+});
60+
61+test('the stored layout is applied when the page loads', () => {
62+ const L = load({ stored: { 'home-layout': 'compact' } });
63+ assert.equal(L.get(), 'compact');
64+ assert.equal(home.getAttribute('data-layout'), 'compact');
65+ assert.deepEqual(pressed(), ['false', 'false', 'true']);
66+ assert.ok(fits > 0, 'the chips are fitted again');
67+});
68+
69+test('a stored value that is not a layout falls back to cards', () => {
70+ const L = load({ stored: { 'home-layout': 'grid' } });
71+ assert.equal(L.get(), 'cards');
72+ assert.equal(home.getAttribute('data-layout'), 'cards');
73+ assert.deepEqual(pressed(), ['true', 'false', 'false']);
74+});
75+
76+test('set stores the layout, shows it and fits the chips again', () => {
77+ const L = load();
78+ fits = 0;
79+ L.set('split');
80+ assert.equal(storage.get('home-layout'), 'split');
81+ assert.equal(home.getAttribute('data-layout'), 'split');
82+ assert.deepEqual(pressed(), ['false', 'true', 'false']);
83+ assert.equal(fits, 1);
84+ L.set('nope');
85+ assert.equal(L.get(), 'split', 'an unknown layout is ignored');
86+ assert.equal(storage.get('home-layout'), 'split');
87+});
88+
89+test('pressing a layout button sets it; other clicks do nothing', () => {
90+ const L = load();
91+ click(buttons[2]);
92+ assert.equal(L.get(), 'compact');
93+ assert.equal(home.getAttribute('data-layout'), 'compact');
94+ assert.deepEqual(pressed(), ['false', 'false', 'true']);
95+ click(home);
96+ click({ closest: undefined });
97+ assert.equal(L.get(), 'compact');
98+ // a child of the button (an icon) counts as the button
99+ const icon = el('svg');
100+ buttons[1].append(icon);
101+ click(icon);
102+ assert.equal(L.get(), 'split');
103+});
104+
105+test("another tab's change applies live", () => {
106+ const L = load();
107+ storage.set('home-layout', 'split');
108+ listeners.storage.forEach((f) => f({ key: 'home-layout', newValue: 'split', storageArea: localStorage }));
109+ assert.equal(L.get(), 'split');
110+ assert.equal(home.getAttribute('data-layout'), 'split');
111+ assert.deepEqual(pressed(), ['false', 'true', 'false']);
112+ storage.delete('home-layout');
113+ listeners.storage.forEach((f) => f({ key: 'home-layout', newValue: null, storageArea: localStorage }));
114+ assert.equal(home.getAttribute('data-layout'), 'cards', 'removed: back to the default');
115+ listeners.storage.forEach((f) => f({ key: 'keys-off', newValue: '1', storageArea: localStorage }));
116+ assert.equal(home.getAttribute('data-layout'), 'cards');
117+});
118+
119+test('a page without .home or without ermiteFit still works', () => {
120+ const L = load();
121+ doc.body.replaceChildren();
122+ delete window.ermiteFit;
123+ L.set('split');
124+ assert.equal(L.get(), 'split');
125+ L.apply();
126+});
127+
128+test('without usable storage the choice holds for the page', () => {
129+ const refusing = {
130+ getItem: () => { throw new Error('denied'); },
131+ setItem: () => { throw new Error('quota'); },
132+ removeItem: () => { throw new Error('denied'); },
133+ };
134+ const L = load({ storageObj: refusing });
135+ assert.equal(L.get(), 'cards');
136+ click(buttons[1]);
137+ assert.equal(L.get(), 'split');
138+ assert.equal(home.getAttribute('data-layout'), 'split');
139+});
@@ -0,0 +1,139 @@
1+// Tests of the home page layout of static/js/ermite.js (window.ermiteLayout)
2+// with store.js, on the small DOM of dom.mjs. Only the ermiteLayout block is
3+// evaluated: the rest needs a page. Run with
4+// node --test 'internal/adapters/web/jstest/*.test.mjs'
5+import assert from 'node:assert/strict';
6+import { readFileSync } from 'node:fs';
7+import { beforeEach, test } from 'node:test';
8+import { el, installDom } from './dom.mjs';
9+
10+const read = (f) => readFileSync(new URL('../static/js/' + f, import.meta.url), 'utf8');
11+const ermite = read('ermite.js');
12+const start = ermite.indexOf(' window.ermiteLayout = ');
13+const block = ermite.slice(start, ermite.indexOf(' })();', start) + ' })();'.length);
14+const storeSrc = read('store.js');
15+
16+const doc = installDom();
17+let listeners;
18+let storage;
19+let fits;
20+let home;
21+let buttons;
22+
23+// A page: .home and the switch, with "cards" pressed as the server renders it.
24+// Then store.js and the block are evaluated, as a page load does.
25+function load({ stored, storageObj } = {}) {
26+ listeners = {};
27+ fits = 0;
28+ storage = storageObj ?? new Map(stored ? Object.entries(stored) : []);
29+ globalThis.localStorage = storageObj ?? {
30+ getItem: (k) => (storage.has(k) ? storage.get(k) : null),
31+ setItem: (k, v) => { storage.set(k, String(v)); },
32+ removeItem: (k) => { storage.delete(k); },
33+ };
34+ globalThis.addEventListener = (type, f) => (listeners[type] ||= []).push(f);
35+ globalThis.removeEventListener = (type, f) => { listeners[type] = (listeners[type] || []).filter((g) => g !== f); };
36+ home = el('div', { cls: 'home', attrs: { 'data-layout': 'cards' } });
37+ buttons = ['cards', 'split', 'compact'].map((v) => el('button', {
38+ cls: 'btn icon', attrs: { type: 'button', 'data-set-layout': v, 'aria-pressed': String(v === 'cards') },
39+ }));
40+ const sw = el('div', { cls: 'layout-switch', attrs: { role: 'group', 'data-js': '' } }, ...buttons);
41+ doc.body.replaceChildren(sw, home);
42+ window.ermiteFit = { all: () => { fits++; } };
43+ new Function(storeSrc)();
44+ new Function(block)();
45+ return window.ermiteLayout;
46+}
47+
48+const pressed = () => buttons.map((b) => b.getAttribute('aria-pressed'));
49+const click = (target) => listeners.click.forEach((f) => f({ target }));
50+
51+beforeEach(() => { delete globalThis.localStorage; });
52+
53+test('with nothing stored the layout is cards, and the page is as rendered', () => {
54+ const L = load();
55+ assert.equal(L.get(), 'cards');
56+ assert.deepEqual(L.values, ['cards', 'split', 'compact']);
57+ assert.equal(home.getAttribute('data-layout'), 'cards');
58+ assert.deepEqual(pressed(), ['true', 'false', 'false']);
59+});
60+
61+test('the stored layout is applied when the page loads', () => {
62+ const L = load({ stored: { 'home-layout': 'compact' } });
63+ assert.equal(L.get(), 'compact');
64+ assert.equal(home.getAttribute('data-layout'), 'compact');
65+ assert.deepEqual(pressed(), ['false', 'false', 'true']);
66+ assert.ok(fits > 0, 'the chips are fitted again');
67+});
68+
69+test('a stored value that is not a layout falls back to cards', () => {
70+ const L = load({ stored: { 'home-layout': 'grid' } });
71+ assert.equal(L.get(), 'cards');
72+ assert.equal(home.getAttribute('data-layout'), 'cards');
73+ assert.deepEqual(pressed(), ['true', 'false', 'false']);
74+});
75+
76+test('set stores the layout, shows it and fits the chips again', () => {
77+ const L = load();
78+ fits = 0;
79+ L.set('split');
80+ assert.equal(storage.get('home-layout'), 'split');
81+ assert.equal(home.getAttribute('data-layout'), 'split');
82+ assert.deepEqual(pressed(), ['false', 'true', 'false']);
83+ assert.equal(fits, 1);
84+ L.set('nope');
85+ assert.equal(L.get(), 'split', 'an unknown layout is ignored');
86+ assert.equal(storage.get('home-layout'), 'split');
87+});
88+
89+test('pressing a layout button sets it; other clicks do nothing', () => {
90+ const L = load();
91+ click(buttons[2]);
92+ assert.equal(L.get(), 'compact');
93+ assert.equal(home.getAttribute('data-layout'), 'compact');
94+ assert.deepEqual(pressed(), ['false', 'false', 'true']);
95+ click(home);
96+ click({ closest: undefined });
97+ assert.equal(L.get(), 'compact');
98+ // a child of the button (an icon) counts as the button
99+ const icon = el('svg');
100+ buttons[1].append(icon);
101+ click(icon);
102+ assert.equal(L.get(), 'split');
103+});
104+
105+test("another tab's change applies live", () => {
106+ const L = load();
107+ storage.set('home-layout', 'split');
108+ listeners.storage.forEach((f) => f({ key: 'home-layout', newValue: 'split', storageArea: localStorage }));
109+ assert.equal(L.get(), 'split');
110+ assert.equal(home.getAttribute('data-layout'), 'split');
111+ assert.deepEqual(pressed(), ['false', 'true', 'false']);
112+ storage.delete('home-layout');
113+ listeners.storage.forEach((f) => f({ key: 'home-layout', newValue: null, storageArea: localStorage }));
114+ assert.equal(home.getAttribute('data-layout'), 'cards', 'removed: back to the default');
115+ listeners.storage.forEach((f) => f({ key: 'keys-off', newValue: '1', storageArea: localStorage }));
116+ assert.equal(home.getAttribute('data-layout'), 'cards');
117+});
118+
119+test('a page without .home or without ermiteFit still works', () => {
120+ const L = load();
121+ doc.body.replaceChildren();
122+ delete window.ermiteFit;
123+ L.set('split');
124+ assert.equal(L.get(), 'split');
125+ L.apply();
126+});
127+
128+test('without usable storage the choice holds for the page', () => {
129+ const refusing = {
130+ getItem: () => { throw new Error('denied'); },
131+ setItem: () => { throw new Error('quota'); },
132+ removeItem: () => { throw new Error('denied'); },
133+ };
134+ const L = load({ storageObj: refusing });
135+ assert.equal(L.get(), 'cards');
136+ click(buttons[1]);
137+ assert.equal(L.get(), 'split');
138+ assert.equal(home.getAttribute('data-layout'), 'split');
139+});
A internal/adapters/web/jstest/store.test.mjs +183 -0
@@ -0,0 +1,183 @@
1+// Tests of static/js/store.js (window.ermiteStore): the localStorage client,
2+// evaluated alone against a fake storage. Run with
3+// node --test 'internal/adapters/web/jstest/*.test.mjs'
4+import assert from 'node:assert/strict';
5+import { readFileSync } from 'node:fs';
6+import { test } from 'node:test';
7+
8+const src = readFileSync(new URL('../static/js/store.js', import.meta.url), 'utf8');
9+
10+// load evaluates the file on a page whose storage is the given object (a
11+// Map-backed one by default) and answers the store and the "storage" listeners.
12+function load(storage = fakeStorage()) {
13+ const listeners = new Set();
14+ const g = globalThis;
15+ g.window = g;
16+ if (storage === 'missing') delete g.localStorage;
17+ else g.localStorage = storage;
18+ g.addEventListener = (type, f) => { if (type === 'storage') listeners.add(f); };
19+ g.removeEventListener = (type, f) => { if (type === 'storage') listeners.delete(f); };
20+ new Function(src)();
21+ const emit = (e) => [...listeners].forEach((f) => f({ storageArea: g.localStorage, ...e }));
22+ return { store: g.ermiteStore, listeners, emit };
23+}
24+
25+function fakeStorage() {
26+ const m = new Map();
27+ return {
28+ m,
29+ getItem: (k) => (m.has(k) ? m.get(k) : null),
30+ setItem: (k, v) => { m.set(k, String(v)); },
31+ removeItem: (k) => { m.delete(k); },
32+ };
33+}
34+
35+const refusing = () => ({
36+ getItem: () => { throw new Error('denied'); },
37+ setItem: () => { throw new Error('quota'); },
38+ removeItem: () => { throw new Error('denied'); },
39+});
40+
41+test('get answers the stored value, else the fallback (null by default)', () => {
42+ const st = fakeStorage();
43+ const { store } = load(st);
44+ assert.equal(store.get('k'), null);
45+ assert.equal(store.get('k', 'x'), 'x');
46+ st.m.set('k', 'v');
47+ assert.equal(store.get('k', 'x'), 'v');
48+ st.m.set('k', '');
49+ assert.equal(store.get('k', 'x'), '', 'an empty string is a value');
50+});
51+
52+test('set stores strings; null and undefined remove; remove forgets', () => {
53+ const st = fakeStorage();
54+ const { store } = load(st);
55+ store.set('k', 'v');
56+ assert.equal(st.m.get('k'), 'v');
57+ store.set('n', 3);
58+ assert.equal(st.m.get('n'), '3');
59+ store.set('k', null);
60+ assert.equal(st.m.has('k'), false);
61+ store.set('k', 'v');
62+ store.set('k', undefined);
63+ assert.equal(st.m.has('k'), false);
64+ store.remove('n');
65+ assert.equal(store.get('n'), null);
66+ store.remove('nothing'); // no error
67+});
68+
69+test('without storage nothing throws and the value holds for the page', () => {
70+ const { store } = load('missing');
71+ assert.equal(store.get('k', 'x'), 'x');
72+ store.set('k', 'v');
73+ assert.equal(store.get('k', 'x'), 'v');
74+ store.remove('k');
75+ assert.equal(store.get('k', 'x'), 'x');
76+});
77+
78+test('a storage that refuses reads and writes does not throw either', () => {
79+ const { store } = load(refusing());
80+ assert.equal(store.get('k', 'x'), 'x');
81+ store.set('k', 'v');
82+ assert.equal(store.get('k', 'x'), 'v', 'held in memory');
83+ store.remove('k');
84+ assert.equal(store.get('k', 'x'), 'x', 'removed for the page');
85+});
86+
87+test('a quota error on write keeps the value for the page; a later write that works wins', () => {
88+ const st = fakeStorage();
89+ const { store } = load(st);
90+ let full = true;
91+ st.setItem = (k, v) => { if (full) throw new Error('quota'); st.m.set(k, v); };
92+ store.set('k', 'a');
93+ assert.equal(store.get('k'), 'a');
94+ assert.equal(st.m.has('k'), false);
95+ full = false;
96+ store.set('k', 'b');
97+ assert.equal(st.m.get('k'), 'b');
98+ st.m.set('k', 'c'); // changed elsewhere: the held value is gone
99+ assert.equal(store.get('k'), 'c');
100+});
101+
102+test('a getter of localStorage that throws (sandboxed frame) is survived', () => {
103+ const { store } = load('missing');
104+ Object.defineProperty(globalThis, 'localStorage', { get() { throw new Error('SecurityError'); }, configurable: true });
105+ try {
106+ assert.equal(store.get('k', 'x'), 'x');
107+ store.set('k', 'v');
108+ assert.equal(store.get('k'), 'v');
109+ } finally {
110+ delete globalThis.localStorage;
111+ }
112+});
113+
114+test('watch calls back with the new value of its key only, and stops when told', () => {
115+ const { store, emit, listeners } = load();
116+ const seen = [];
117+ const stop = store.watch('k', (v) => seen.push(v));
118+ emit({ key: 'other', newValue: 'z' });
119+ emit({ key: 'k', newValue: 'v' });
120+ emit({ key: 'k', newValue: null });
121+ emit({ key: null, newValue: null }); // the other tab cleared everything
122+ assert.deepEqual(seen, ['v', null, null]);
123+ stop();
124+ assert.equal(listeners.size, 0);
125+ emit({ key: 'k', newValue: 'w' });
126+ assert.deepEqual(seen, ['v', null, null]);
127+});
128+
129+test('watch ignores the events of another storage area (sessionStorage)', () => {
130+ const { store, emit } = load();
131+ const seen = [];
132+ store.watch('k', (v) => seen.push(v));
133+ emit({ key: 'k', newValue: 'v', storageArea: {} });
134+ assert.deepEqual(seen, []);
135+});
136+
137+test('an event from another tab replaces a value held for this page', () => {
138+ const { store, emit } = load(refusing());
139+ store.set('k', 'mine');
140+ store.watch('k', () => {});
141+ emit({ key: 'k', newValue: 'theirs', storageArea: undefined });
142+ assert.equal(store.get('k'), null, 'the held value is dropped; storage is the truth');
143+});
144+
145+test('pref answers only values of its list, else the fallback', () => {
146+ const st = fakeStorage();
147+ const { store } = load(st);
148+ const p = store.pref('layout', { values: ['a', 'b'], fallback: 'a' });
149+ assert.equal(p.get(), 'a', 'nothing stored');
150+ p.set('b');
151+ assert.equal(st.m.get('layout'), 'b');
152+ assert.equal(p.get(), 'b');
153+ p.set('zzz'); // not allowed: ignored
154+ assert.equal(p.get(), 'b');
155+ st.m.set('layout', 'old-value');
156+ assert.equal(p.get(), 'a', 'an invalid stored value falls back');
157+});
158+
159+test('pref works with the empty string as a value (keys-off)', () => {
160+ const st = fakeStorage();
161+ const { store } = load(st);
162+ const p = store.pref('keys-off', { values: ['1', ''], fallback: '' });
163+ assert.equal(p.get(), '');
164+ p.set('1');
165+ assert.equal(st.m.get('keys-off'), '1');
166+ p.set('');
167+ assert.equal(st.m.get('keys-off'), '');
168+ assert.equal(p.get(), '');
169+});
170+
171+test('pref.watch gives validated values', () => {
172+ const { store, emit } = load();
173+ const p = store.pref('layout', { values: ['a', 'b'], fallback: 'a' });
174+ const seen = [];
175+ const stop = p.watch((v) => seen.push(v));
176+ emit({ key: 'layout', newValue: 'b' });
177+ emit({ key: 'layout', newValue: 'junk' });
178+ emit({ key: 'layout', newValue: null });
179+ assert.deepEqual(seen, ['b', 'a', 'a']);
180+ stop();
181+ emit({ key: 'layout', newValue: 'b' });
182+ assert.equal(seen.length, 3);
183+});
@@ -0,0 +1,183 @@
1+// Tests of static/js/store.js (window.ermiteStore): the localStorage client,
2+// evaluated alone against a fake storage. Run with
3+// node --test 'internal/adapters/web/jstest/*.test.mjs'
4+import assert from 'node:assert/strict';
5+import { readFileSync } from 'node:fs';
6+import { test } from 'node:test';
7+
8+const src = readFileSync(new URL('../static/js/store.js', import.meta.url), 'utf8');
9+
10+// load evaluates the file on a page whose storage is the given object (a
11+// Map-backed one by default) and answers the store and the "storage" listeners.
12+function load(storage = fakeStorage()) {
13+ const listeners = new Set();
14+ const g = globalThis;
15+ g.window = g;
16+ if (storage === 'missing') delete g.localStorage;
17+ else g.localStorage = storage;
18+ g.addEventListener = (type, f) => { if (type === 'storage') listeners.add(f); };
19+ g.removeEventListener = (type, f) => { if (type === 'storage') listeners.delete(f); };
20+ new Function(src)();
21+ const emit = (e) => [...listeners].forEach((f) => f({ storageArea: g.localStorage, ...e }));
22+ return { store: g.ermiteStore, listeners, emit };
23+}
24+
25+function fakeStorage() {
26+ const m = new Map();
27+ return {
28+ m,
29+ getItem: (k) => (m.has(k) ? m.get(k) : null),
30+ setItem: (k, v) => { m.set(k, String(v)); },
31+ removeItem: (k) => { m.delete(k); },
32+ };
33+}
34+
35+const refusing = () => ({
36+ getItem: () => { throw new Error('denied'); },
37+ setItem: () => { throw new Error('quota'); },
38+ removeItem: () => { throw new Error('denied'); },
39+});
40+
41+test('get answers the stored value, else the fallback (null by default)', () => {
42+ const st = fakeStorage();
43+ const { store } = load(st);
44+ assert.equal(store.get('k'), null);
45+ assert.equal(store.get('k', 'x'), 'x');
46+ st.m.set('k', 'v');
47+ assert.equal(store.get('k', 'x'), 'v');
48+ st.m.set('k', '');
49+ assert.equal(store.get('k', 'x'), '', 'an empty string is a value');
50+});
51+
52+test('set stores strings; null and undefined remove; remove forgets', () => {
53+ const st = fakeStorage();
54+ const { store } = load(st);
55+ store.set('k', 'v');
56+ assert.equal(st.m.get('k'), 'v');
57+ store.set('n', 3);
58+ assert.equal(st.m.get('n'), '3');
59+ store.set('k', null);
60+ assert.equal(st.m.has('k'), false);
61+ store.set('k', 'v');
62+ store.set('k', undefined);
63+ assert.equal(st.m.has('k'), false);
64+ store.remove('n');
65+ assert.equal(store.get('n'), null);
66+ store.remove('nothing'); // no error
67+});
68+
69+test('without storage nothing throws and the value holds for the page', () => {
70+ const { store } = load('missing');
71+ assert.equal(store.get('k', 'x'), 'x');
72+ store.set('k', 'v');
73+ assert.equal(store.get('k', 'x'), 'v');
74+ store.remove('k');
75+ assert.equal(store.get('k', 'x'), 'x');
76+});
77+
78+test('a storage that refuses reads and writes does not throw either', () => {
79+ const { store } = load(refusing());
80+ assert.equal(store.get('k', 'x'), 'x');
81+ store.set('k', 'v');
82+ assert.equal(store.get('k', 'x'), 'v', 'held in memory');
83+ store.remove('k');
84+ assert.equal(store.get('k', 'x'), 'x', 'removed for the page');
85+});
86+
87+test('a quota error on write keeps the value for the page; a later write that works wins', () => {
88+ const st = fakeStorage();
89+ const { store } = load(st);
90+ let full = true;
91+ st.setItem = (k, v) => { if (full) throw new Error('quota'); st.m.set(k, v); };
92+ store.set('k', 'a');
93+ assert.equal(store.get('k'), 'a');
94+ assert.equal(st.m.has('k'), false);
95+ full = false;
96+ store.set('k', 'b');
97+ assert.equal(st.m.get('k'), 'b');
98+ st.m.set('k', 'c'); // changed elsewhere: the held value is gone
99+ assert.equal(store.get('k'), 'c');
100+});
101+
102+test('a getter of localStorage that throws (sandboxed frame) is survived', () => {
103+ const { store } = load('missing');
104+ Object.defineProperty(globalThis, 'localStorage', { get() { throw new Error('SecurityError'); }, configurable: true });
105+ try {
106+ assert.equal(store.get('k', 'x'), 'x');
107+ store.set('k', 'v');
108+ assert.equal(store.get('k'), 'v');
109+ } finally {
110+ delete globalThis.localStorage;
111+ }
112+});
113+
114+test('watch calls back with the new value of its key only, and stops when told', () => {
115+ const { store, emit, listeners } = load();
116+ const seen = [];
117+ const stop = store.watch('k', (v) => seen.push(v));
118+ emit({ key: 'other', newValue: 'z' });
119+ emit({ key: 'k', newValue: 'v' });
120+ emit({ key: 'k', newValue: null });
121+ emit({ key: null, newValue: null }); // the other tab cleared everything
122+ assert.deepEqual(seen, ['v', null, null]);
123+ stop();
124+ assert.equal(listeners.size, 0);
125+ emit({ key: 'k', newValue: 'w' });
126+ assert.deepEqual(seen, ['v', null, null]);
127+});
128+
129+test('watch ignores the events of another storage area (sessionStorage)', () => {
130+ const { store, emit } = load();
131+ const seen = [];
132+ store.watch('k', (v) => seen.push(v));
133+ emit({ key: 'k', newValue: 'v', storageArea: {} });
134+ assert.deepEqual(seen, []);
135+});
136+
137+test('an event from another tab replaces a value held for this page', () => {
138+ const { store, emit } = load(refusing());
139+ store.set('k', 'mine');
140+ store.watch('k', () => {});
141+ emit({ key: 'k', newValue: 'theirs', storageArea: undefined });
142+ assert.equal(store.get('k'), null, 'the held value is dropped; storage is the truth');
143+});
144+
145+test('pref answers only values of its list, else the fallback', () => {
146+ const st = fakeStorage();
147+ const { store } = load(st);
148+ const p = store.pref('layout', { values: ['a', 'b'], fallback: 'a' });
149+ assert.equal(p.get(), 'a', 'nothing stored');
150+ p.set('b');
151+ assert.equal(st.m.get('layout'), 'b');
152+ assert.equal(p.get(), 'b');
153+ p.set('zzz'); // not allowed: ignored
154+ assert.equal(p.get(), 'b');
155+ st.m.set('layout', 'old-value');
156+ assert.equal(p.get(), 'a', 'an invalid stored value falls back');
157+});
158+
159+test('pref works with the empty string as a value (keys-off)', () => {
160+ const st = fakeStorage();
161+ const { store } = load(st);
162+ const p = store.pref('keys-off', { values: ['1', ''], fallback: '' });
163+ assert.equal(p.get(), '');
164+ p.set('1');
165+ assert.equal(st.m.get('keys-off'), '1');
166+ p.set('');
167+ assert.equal(st.m.get('keys-off'), '');
168+ assert.equal(p.get(), '');
169+});
170+
171+test('pref.watch gives validated values', () => {
172+ const { store, emit } = load();
173+ const p = store.pref('layout', { values: ['a', 'b'], fallback: 'a' });
174+ const seen = [];
175+ const stop = p.watch((v) => seen.push(v));
176+ emit({ key: 'layout', newValue: 'b' });
177+ emit({ key: 'layout', newValue: 'junk' });
178+ emit({ key: 'layout', newValue: null });
179+ assert.deepEqual(seen, ['b', 'a', 'a']);
180+ stop();
181+ emit({ key: 'layout', newValue: 'b' });
182+ assert.equal(seen.length, 3);
183+});
M internal/adapters/web/pages.templ +12 -1
@@ -6,7 +6,7 @@ package web
66 templ Home(l Layout, p HomePage) {
77 @Shell(l, nil) {
88 <h1 class="sr">Repositories</h1>
9- <div class="home">
9+ <div class="home" data-layout="cards">
1010 <section class="home-repos" aria-label="Repositories">
1111 <div class="home-bar">
1212 <div class="filter">
@@ -37,6 +37,17 @@ templ Home(l Layout, p HomePage) {
3737 </a>
3838 }
3939 </nav>
40+ <div class="layout-switch" role="group" aria-label="Layout" data-js>
41+ <button type="button" class="btn icon" data-set-layout="cards" aria-pressed="true" title="Cards" aria-label="Cards">
42+ <span class="ico i-cards" aria-hidden="true"></span>
43+ </button>
44+ <button type="button" class="btn icon" data-set-layout="split" aria-pressed="false" title="Two columns" aria-label="Two columns">
45+ <span class="ico i-split" aria-hidden="true"></span>
46+ </button>
47+ <button type="button" class="btn icon" data-set-layout="compact" aria-pressed="false" title="Compact list" aria-label="Compact list">
48+ <span class="ico i-compact" aria-hidden="true"></span>
49+ </button>
50+ </div>
4051 </div>
4152 <ul class="repos" id="repo-rows" data-rows>
4253 for _, r := range p.Rows {
@@ -6,7 +6,7 @@ package web
6 templ Home(l Layout, p HomePage) {6 templ Home(l Layout, p HomePage) {
7 @Shell(l, nil) {7 @Shell(l, nil) {
8 <h1 class="sr">Repositories</h1>8 <h1 class="sr">Repositories</h1>
9- <div class="home">9+ <div class="home" data-layout="cards">
10 <section class="home-repos" aria-label="Repositories">10 <section class="home-repos" aria-label="Repositories">
11 <div class="home-bar">11 <div class="home-bar">
12 <div class="filter">12 <div class="filter">
@@ -37,6 +37,17 @@ templ Home(l Layout, p HomePage) {
37 </a>37 </a>
38 }38 }
39 </nav>39 </nav>
40+ <div class="layout-switch" role="group" aria-label="Layout" data-js>
41+ <button type="button" class="btn icon" data-set-layout="cards" aria-pressed="true" title="Cards" aria-label="Cards">
42+ <span class="ico i-cards" aria-hidden="true"></span>
43+ </button>
44+ <button type="button" class="btn icon" data-set-layout="split" aria-pressed="false" title="Two columns" aria-label="Two columns">
45+ <span class="ico i-split" aria-hidden="true"></span>
46+ </button>
47+ <button type="button" class="btn icon" data-set-layout="compact" aria-pressed="false" title="Compact list" aria-label="Compact list">
48+ <span class="ico i-compact" aria-hidden="true"></span>
49+ </button>
50+ </div>
40 </div>51 </div>
41 <ul class="repos" id="repo-rows" data-rows>52 <ul class="repos" id="repo-rows" data-rows>
42 for _, r := range p.Rows {53 for _, r := range p.Rows {
M internal/adapters/web/pages_templ.go +50 -50 generated file: collapsed
M internal/adapters/web/shell.templ +1 -0
@@ -25,6 +25,7 @@ templ Shell(l Layout, rail templ.Component) {
2525 <script src={ l.Assets.HTMX } defer></script>
2626 <script src={ l.Assets.Live } defer></script>
2727 <script src={ l.Assets.SSE } defer></script>
28+ <script src={ l.Assets.Store } defer></script>
2829 <script src={ l.Assets.App } defer></script>
2930 <script src={ l.Assets.Files } defer></script>
3031 <script src={ l.Assets.Passkey } defer></script>
@@ -25,6 +25,7 @@ templ Shell(l Layout, rail templ.Component) {
25 <script src={ l.Assets.HTMX } defer></script>25 <script src={ l.Assets.HTMX } defer></script>
26 <script src={ l.Assets.Live } defer></script>26 <script src={ l.Assets.Live } defer></script>
27 <script src={ l.Assets.SSE } defer></script>27 <script src={ l.Assets.SSE } defer></script>
28+ <script src={ l.Assets.Store } defer></script>
28 <script src={ l.Assets.App } defer></script>29 <script src={ l.Assets.App } defer></script>
29 <script src={ l.Assets.Files } defer></script>30 <script src={ l.Assets.Files } defer></script>
30 <script src={ l.Assets.Passkey } defer></script>31 <script src={ l.Assets.Passkey } defer></script>
M internal/adapters/web/shell_templ.go +189 -176 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