detached c48540b

feat(web): rework the interface with surfaces, a two-level header and a command palette

Replace the terminal-style bars with a header (instance, path, ref
switcher, go-to palette, owner) and repository tabs that show their key.
Pages use grey surface levels, cards for the README and the rail blocks,
an 8px spacing scale, and embedded Atkinson Hyperlegible Next and Mono
fonts. The palette, ref switcher and keys are centred modal dialogs
(ctrl k opens the palette). The bottom bar becomes a footer with the
version and source link, and a hint badge that shows the row position,
the line selection, flash messages and a lost event stream. The rail
moves under the content on narrow screens. Pages are prefetched on
hover through speculation rules and cross-fade between documents.
commit
c48540b27ef91bcc0838d09f16cdcefe39a494c2
author
brice · · 2026-10-11 10:28 UTC
parent
3027a40
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
98 files · +6883 -5112
M cmd/ermite/testdata/web/blob-binary.html +162 -114
@@ -6,6 +6,7 @@
66 <meta name="color-scheme" content="light dark">
77 <title>data.bin · files · example.test</title>
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9+<link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
910 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
1011 <script src="/-/static/js/htmx.min.HASH.js" defer>
1112 </script>
@@ -16,12 +17,15 @@
1617 <script src="/-/static/js/ermite.HASH.js" defer>
1718 </script>
1819 </head>
19-<body data-ref="main" data-sha="SHA40" data-sel="">
20+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
2021 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
23-<span class="fade" aria-hidden="true">
22+<header class="hdr">
23+<div class="hdr-top">
24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
2426 </span>
27+<span>example.test</span>
28+</a>
2529 <nav class="crumbs" aria-label="Path">
2630 <ol>
2731 <li>
@@ -38,45 +42,62 @@
3842 </li>
3943 </ol>
4044 </nav>
41-<nav class="secs" aria-label="Sections">
45+<div class="hdr-acts">
46+<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
47+<span class="ico i-branch" aria-hidden="true">
48+</span>
49+<span class="ref-name">main</span>
50+<span class="sha">SHA7</span>
51+</button>
52+<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
53+<span class="ico i-search" aria-hidden="true">
54+</span>
55+<span class="search-label">Go to…</span>
56+<kbd>ctrl k</kbd>
57+</button>
58+<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
59+</div>
60+</div>
61+<nav class="tabs" aria-label="Sections">
4262 <ul>
4363 <li>
44-<a href="/files" data-key="s" aria-keyshortcuts="g s">summary</a>
64+<a href="/files" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
65+</a>
4566 </li>
4667 <li>
47-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="g t" aria-current="page">tree</a>
68+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files<kbd aria-hidden="true">t</kbd>
69+</a>
4870 </li>
4971 <li>
50-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a>
72+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits<kbd aria-hidden="true">c</kbd>
73+</a>
5174 </li>
5275 <li>
53-<a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
76+<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
77+</a>
5478 </li>
5579 <li>
56-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
80+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
81+</a>
5782 </li>
5883 </ul>
5984 </nav>
60-<button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
61-</button>
6285 </header>
6386 <div class="work has-rail">
64-<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
87+<main id="main" tabindex="-1">
6588 <h1 class="file-h">
6689 <span>data.bin</span>
6790 <span class="acts">
68-<a href="/files/raw/main/data.bin">raw</a>
69-<a href="/files/commits/main/data.bin">history</a>
70-<a href="/files/tree/main">dir</a>
91+<a class="btn" href="/files/raw/main/data.bin">Raw</a>
92+<a class="btn" href="/files/commits/main/data.bin">History</a>
93+<a class="btn" href="/files/tree/main">Folder</a>
7194 </span>
7295 </h1>
7396 <p class="notice" role="note">binary file (application/octet-stream, 10 B): not shown. use raw.</p>
7497 </main>
7598 <aside class="rail" aria-label="Context">
76-<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
77-<summary>file · last change · selection</summary>
7899 <section aria-labelledby="r-file">
79-<h2 id="r-file">file</h2>
100+<h2 id="r-file">File</h2>
80101 <dl class="kv">
81102 <dt>size</dt>
82103 <dd>10 B</dd>
@@ -85,80 +106,80 @@
85106 </dl>
86107 </section>
87108 <section aria-labelledby="r-last">
88-<h2 id="r-last">last change <a href="/files/commits/main/data.bin">history</a>
109+<h2 id="r-last">Last change <a href="/files/commits/main/data.bin">History</a>
89110 </h2>
90111 <p>
91112 <a href="/files/commit/SHA40">SHA7</a> add data.bin</p>
92113 <p class="dim">T · <time datetime="STAMP" title="STAMP">AGO</time>
93114 </p>
94115 </section>
95-</details>
96116 </aside>
97117 </div>
98-<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
118+<footer class="foot">
119+<span>ermite devel</span>
120+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
121+<span class="foot-keys">
122+<kbd>?</kbd> keyboard shortcuts</span>
123+</footer>
124+<div class="hint" role="status" aria-live="polite">
125+<span class="hint-nav" :text="document.body.dataset.nav || ''">
126+</span>
127+<span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''">
128+</span>
129+<span class="hint-flash" :text="document.body.dataset.flash || ''">
130+</span>
131+</div>
132+<dialog id="panel-help" class="panel modal" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
99133 <div class="panel-head">
100-<span>keys</span>
101-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
134+<h2>Keyboard shortcuts</h2>
135+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
136+<kbd>esc</kbd> close</button>
102137 </div>
103138 <dl class="keys">
104139 <div>
105140 <dt>
106-<kbd>j</kbd>
107-<kbd>k</kbd>
141+<kbd>ctrl k</kbd>
142+<kbd>:</kbd>
108143 </dt>
109-<dd>move cursor down / up</dd>
144+<dd>go to a section, branch, tag, file or repository</dd>
110145 </div>
111146 <div>
112147 <dt>
113-<kbd>enter</kbd>
148+<kbd>?</kbd>
114149 </dt>
115-<dd>open row under cursor</dd>
150+<dd>this list</dd>
116151 </div>
117152 <div>
118153 <dt>
119-<kbd>g</kbd>
120-<kbd>g</kbd> / <kbd>G</kbd>
154+<kbd>j</kbd>
155+<kbd>k</kbd>
121156 </dt>
122-<dd>first / last row</dd>
157+<dd>move the row cursor down / up</dd>
123158 </div>
124159 <div>
125160 <dt>
126-<kbd>/</kbd>
161+<kbd>enter</kbd>
127162 </dt>
128-<dd>focus the filter</dd>
163+<dd>open the row under the cursor</dd>
129164 </div>
130165 <div>
131166 <dt>
132-<kbd>g h</kbd>
133-<kbd>g t</kbd>
167+<kbd>g g</kbd>
168+<kbd>G</kbd>
134169 </dt>
135-<dd>go home / tree</dd>
170+<dd>first / last row</dd>
136171 </div>
137172 <div>
138173 <dt>
139-<kbd>g c</kbd>
140-<kbd>g r</kbd>
174+<kbd>/</kbd>
141175 </dt>
142-<dd>go commits / refs</dd>
176+<dd>filter the list</dd>
143177 </div>
144178 <div>
145179 <dt>
146180 <kbd>b</kbd>
147181 </dt>
148-<dd>switch ref (branches, tags)</dd>
149-</div>
150-<div>
151-<dt>
152-<kbd>:</kbd>
153-<kbd>f</kbd>
154-</dt>
155-<dd>go to: section, ref, file, repo</dd>
156-</div>
157-<div>
158-<dt>
159-<kbd>y</kbd>
160-</dt>
161-<dd>permalink: ref becomes the full sha</dd>
182+<dd>switch branch or tag</dd>
162183 </div>
163184 <div>
164185 <dt>
@@ -166,15 +187,21 @@
166187 <kbd>t</kbd>
167188 <kbd>c</kbd>
168189 <kbd>r</kbd>
190+<kbd>m</kbd>
169191 </dt>
170-<dd>repo sections (when shown)</dd>
192+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
171193 </div>
172194 <div>
173195 <dt>
174-<kbd>m</kbd>
175-<kbd>g m</kbd>
196+<kbd>g h</kbd>
197+</dt>
198+<dd>home</dd>
199+</div>
200+<div>
201+<dt>
202+<kbd>y</kbd>
176203 </dt>
177-<dd>merge requests (when shown)</dd>
204+<dd>copy a permalink pinned to the commit</dd>
178205 </div>
179206 <div>
180207 <dt>
@@ -194,19 +221,21 @@
194221 <dt>
195222 <kbd>esc</kbd>
196223 </dt>
197-<dd>close panel</dd>
224+<dd>close a dialog, leave a field</dd>
198225 </div>
199226 </dl>
200227 </dialog>
201-<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
228+<dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
202229 <div class="panel-head">
203-<label for="refs-filter">ref&gt;</label>
204-<input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
205-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
230+<label for="refs-filter" class="sr">filter branches and tags</label>
231+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
232+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
233+<kbd>esc</kbd>
234+</button>
206235 </div>
207236 <div class="panel-cols" data-picklist>
208237 <section aria-label="Branches">
209-<h2>branches <span class="mute">2</span>
238+<h2>Branches <span class="count">2</span>
210239 </h2>
211240 <ul>
212241 <li>
@@ -220,7 +249,7 @@
220249 </ul>
221250 </section>
222251 <section aria-label="Tags">
223-<h2>tags <span class="mute">1</span>
252+<h2>Tags <span class="count">1</span>
224253 </h2>
225254 <ul>
226255 <li>
@@ -231,105 +260,124 @@
231260 </section>
232261 </div>
233262 </dialog>
234-<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
263+<dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
235264 <div class="panel-head">
236-<label for="cmd-q" class="hi">:</label>
237-<input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
265+<label for="cmd-q" class="sr">go to</label>
266+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
238267 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
239268 let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
240269 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
241270 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
242271 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
243272 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
244-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
273+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
274+<kbd>esc</kbd>
275+</button>
245276 </div>
246-<ul id="cmd-list" class="cmd-list">
277+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
247278 <li data-k="go home" data-cursor>
248279 <a href="/" tabindex="-1">
249-<span class="k">go</span>home</a>
280+<span class="lbl">home</span>
281+<span class="k">go</span>
282+</a>
250283 </li>
251284 <li data-k="section summary">
252285 <a href="/files" tabindex="-1">
253-<span class="k">section</span>summary</a>
286+<span class="lbl">summary</span>
287+<span class="k">section</span>
288+</a>
254289 </li>
255-<li data-k="section tree">
290+<li data-k="section files">
256291 <a href="/files/tree/main" tabindex="-1">
257-<span class="k">section</span>tree</a>
292+<span class="lbl">files</span>
293+<span class="k">section</span>
294+</a>
258295 </li>
259296 <li data-k="section commits">
260297 <a href="/files/commits/main" tabindex="-1">
261-<span class="k">section</span>commits</a>
298+<span class="lbl">commits</span>
299+<span class="k">section</span>
300+</a>
262301 </li>
263-<li data-k="section refs">
302+<li data-k="section branches &amp; tags">
264303 <a href="/files/refs" tabindex="-1">
265-<span class="k">section</span>refs</a>
304+<span class="lbl">branches &amp; tags</span>
305+<span class="k">section</span>
306+</a>
266307 </li>
267308 <li data-k="section merge requests">
268309 <a href="/files/merge-requests" tabindex="-1">
269-<span class="k">section</span>merge requests</a>
310+<span class="lbl">merge requests</span>
311+<span class="k">section</span>
312+</a>
270313 </li>
271314 <li data-k="branch feature/x">
272315 <a href="/files/tree/feature/x" tabindex="-1">
273-<span class="k">branch</span>feature/x</a>
316+<span class="lbl">feature/x</span>
317+<span class="k">branch</span>
318+</a>
274319 </li>
275320 <li data-k="branch main">
276321 <a href="/files/tree/main" tabindex="-1">
277-<span class="k">branch</span>main</a>
322+<span class="lbl">main</span>
323+<span class="k">branch</span>
324+</a>
278325 </li>
279326 <li data-k="tag v1.0">
280327 <a href="/files/tree/v1.0" tabindex="-1">
281-<span class="k">tag</span>v1.0</a>
328+<span class="lbl">v1.0</span>
329+<span class="k">tag</span>
330+</a>
282331 </li>
283332 </ul>
333+<p class="panel-foot">
334+<span>
335+<kbd>↑</kbd>
336+<kbd>↓</kbd> move</span>
337+<span>
338+<kbd>enter</kbd> open</span>
339+<span>
340+<kbd>esc</kbd> close</span>
341+</p>
284342 </dialog>
285-<footer class="bar status" aria-label="Status">
286-<span class="mode">blob</span>
287-<span class="st-ctx">data.bin</span>
288-<button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
289-<span class="hi">:</span> go to…</button>
290-<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
291-</span>
292-<span class="st-hints">click a line number · y permalink · ? help</span>
293-<span class="st-src">
294-<span class="st-ver">ermite devel · </span>
295-<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
296-</span>
297-<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
298-</footer>
299-<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
343+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
300344 if (q('dialog[open]').count) return;
301345 let rows = q('[data-rows] > :not([hidden])').arr();
302346 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
303347 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
304- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
305- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
348+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
349+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
350+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
351+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
306352 })(1);
307- k-k from:body -> ((d) => {
353+ k-k from:body -> (async (d) => {
308354 if (q('dialog[open]').count) return;
309355 let rows = q('[data-rows] > :not([hidden])').arr();
310356 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
311357 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
312- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
313- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
358+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
359+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
360+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
361+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
314362 })(-1);
315363 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
316364 k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
317365 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
318366 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
319- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
320- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus());
321- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
367+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
368+ k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
369+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
322370 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
323- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
324- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
325- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
326- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
327- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
328- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
329- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
330- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
331- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
332- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
371+ k-gh from:body -> document.querySelector('.brand')?.click();
372+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
373+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
374+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
375+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
376+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
377+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
378+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
379+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
380+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
333381 k-next from:body -> {
334382 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
335383 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/blob-code.html +163 -116
@@ -6,6 +6,7 @@
66 <meta name="color-scheme" content="light dark">
77 <title>main.go · files · example.test</title>
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9+<link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
910 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
1011 <script src="/-/static/js/htmx.min.HASH.js" defer>
1112 </script>
@@ -16,12 +17,15 @@
1617 <script src="/-/static/js/ermite.HASH.js" defer>
1718 </script>
1819 </head>
19-<body data-ref="main" data-sha="SHA40" data-sel="">
20+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
2021 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
23-<span class="fade" aria-hidden="true">
22+<header class="hdr">
23+<div class="hdr-top">
24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
2426 </span>
27+<span>example.test</span>
28+</a>
2529 <nav class="crumbs" aria-label="Path">
2630 <ol>
2731 <li>
@@ -38,36 +42,55 @@
3842 </li>
3943 </ol>
4044 </nav>
41-<nav class="secs" aria-label="Sections">
45+<div class="hdr-acts">
46+<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
47+<span class="ico i-branch" aria-hidden="true">
48+</span>
49+<span class="ref-name">main</span>
50+<span class="sha">SHA7</span>
51+</button>
52+<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
53+<span class="ico i-search" aria-hidden="true">
54+</span>
55+<span class="search-label">Go to…</span>
56+<kbd>ctrl k</kbd>
57+</button>
58+<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
59+</div>
60+</div>
61+<nav class="tabs" aria-label="Sections">
4262 <ul>
4363 <li>
44-<a href="/files" data-key="s" aria-keyshortcuts="g s">summary</a>
64+<a href="/files" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
65+</a>
4566 </li>
4667 <li>
47-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="g t" aria-current="page">tree</a>
68+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files<kbd aria-hidden="true">t</kbd>
69+</a>
4870 </li>
4971 <li>
50-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a>
72+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits<kbd aria-hidden="true">c</kbd>
73+</a>
5174 </li>
5275 <li>
53-<a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
76+<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
77+</a>
5478 </li>
5579 <li>
56-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
80+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
81+</a>
5782 </li>
5883 </ul>
5984 </nav>
60-<button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
61-</button>
6285 </header>
6386 <div class="work has-rail">
64-<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
87+<main id="main" tabindex="-1">
6588 <h1 class="file-h">
6689 <span>main.go</span>
6790 <span class="acts">
68-<a href="/files/raw/main/main.go">raw</a>
69-<a href="/files/commits/main/main.go">history</a>
70-<a href="/files/tree/main">dir</a>
91+<a class="btn" href="/files/raw/main/main.go">Raw</a>
92+<a class="btn" href="/files/commits/main/main.go">History</a>
93+<a class="btn" href="/files/tree/main">Folder</a>
7194 </span>
7295 </h1>
7396 <pre class="chroma code" data-anchor="" hx-on:click="let a = event.target.closest('a.ln'); if (!a) return; event.preventDefault();
@@ -136,10 +159,8 @@
136159 </pre>
137160 </main>
138161 <aside class="rail" aria-label="Context">
139-<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
140-<summary>file · last change · selection</summary>
141162 <section aria-labelledby="r-file">
142-<h2 id="r-file">file</h2>
163+<h2 id="r-file">File</h2>
143164 <dl class="kv">
144165 <dt>size</dt>
145166 <dd>87 B</dd>
@@ -150,7 +171,7 @@
150171 </dl>
151172 </section>
152173 <section aria-labelledby="r-last">
153-<h2 id="r-last">last change <a href="/files/commits/main/main.go">history</a>
174+<h2 id="r-last">Last change <a href="/files/commits/main/main.go">History</a>
154175 </h2>
155176 <p>
156177 <a href="/files/commit/SHA40">SHA7</a> add main.go</p>
@@ -158,77 +179,77 @@
158179 </p>
159180 </section>
160181 <section aria-labelledby="r-sel">
161-<h2 id="r-sel">selection</h2>
182+<h2 id="r-sel">Selection</h2>
162183 <p :text="document.body.dataset.sel || 'none · click a line number, shift-click extends'">none · click a line number, shift-click extends</p>
163184 <p class="dim">y copies a permalink pinned to the commit sha</p>
164185 </section>
165-</details>
166186 </aside>
167187 </div>
168-<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
188+<footer class="foot">
189+<span>ermite devel</span>
190+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
191+<span class="foot-keys">
192+<kbd>?</kbd> keyboard shortcuts</span>
193+</footer>
194+<div class="hint" role="status" aria-live="polite">
195+<span class="hint-nav" :text="document.body.dataset.nav || ''">
196+</span>
197+<span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''">
198+</span>
199+<span class="hint-flash" :text="document.body.dataset.flash || ''">
200+</span>
201+</div>
202+<dialog id="panel-help" class="panel modal" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
169203 <div class="panel-head">
170-<span>keys</span>
171-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
204+<h2>Keyboard shortcuts</h2>
205+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
206+<kbd>esc</kbd> close</button>
172207 </div>
173208 <dl class="keys">
174209 <div>
175210 <dt>
176-<kbd>j</kbd>
177-<kbd>k</kbd>
211+<kbd>ctrl k</kbd>
212+<kbd>:</kbd>
178213 </dt>
179-<dd>move cursor down / up</dd>
214+<dd>go to a section, branch, tag, file or repository</dd>
180215 </div>
181216 <div>
182217 <dt>
183-<kbd>enter</kbd>
218+<kbd>?</kbd>
184219 </dt>
185-<dd>open row under cursor</dd>
220+<dd>this list</dd>
186221 </div>
187222 <div>
188223 <dt>
189-<kbd>g</kbd>
190-<kbd>g</kbd> / <kbd>G</kbd>
224+<kbd>j</kbd>
225+<kbd>k</kbd>
191226 </dt>
192-<dd>first / last row</dd>
227+<dd>move the row cursor down / up</dd>
193228 </div>
194229 <div>
195230 <dt>
196-<kbd>/</kbd>
231+<kbd>enter</kbd>
197232 </dt>
198-<dd>focus the filter</dd>
233+<dd>open the row under the cursor</dd>
199234 </div>
200235 <div>
201236 <dt>
202-<kbd>g h</kbd>
203-<kbd>g t</kbd>
237+<kbd>g g</kbd>
238+<kbd>G</kbd>
204239 </dt>
205-<dd>go home / tree</dd>
240+<dd>first / last row</dd>
206241 </div>
207242 <div>
208243 <dt>
209-<kbd>g c</kbd>
210-<kbd>g r</kbd>
244+<kbd>/</kbd>
211245 </dt>
212-<dd>go commits / refs</dd>
246+<dd>filter the list</dd>
213247 </div>
214248 <div>
215249 <dt>
216250 <kbd>b</kbd>
217251 </dt>
218-<dd>switch ref (branches, tags)</dd>
219-</div>
220-<div>
221-<dt>
222-<kbd>:</kbd>
223-<kbd>f</kbd>
224-</dt>
225-<dd>go to: section, ref, file, repo</dd>
226-</div>
227-<div>
228-<dt>
229-<kbd>y</kbd>
230-</dt>
231-<dd>permalink: ref becomes the full sha</dd>
252+<dd>switch branch or tag</dd>
232253 </div>
233254 <div>
234255 <dt>
@@ -236,15 +257,21 @@
236257 <kbd>t</kbd>
237258 <kbd>c</kbd>
238259 <kbd>r</kbd>
260+<kbd>m</kbd>
239261 </dt>
240-<dd>repo sections (when shown)</dd>
262+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
241263 </div>
242264 <div>
243265 <dt>
244-<kbd>m</kbd>
245-<kbd>g m</kbd>
266+<kbd>g h</kbd>
267+</dt>
268+<dd>home</dd>
269+</div>
270+<div>
271+<dt>
272+<kbd>y</kbd>
246273 </dt>
247-<dd>merge requests (when shown)</dd>
274+<dd>copy a permalink pinned to the commit</dd>
248275 </div>
249276 <div>
250277 <dt>
@@ -264,19 +291,21 @@
264291 <dt>
265292 <kbd>esc</kbd>
266293 </dt>
267-<dd>close panel</dd>
294+<dd>close a dialog, leave a field</dd>
268295 </div>
269296 </dl>
270297 </dialog>
271-<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
298+<dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
272299 <div class="panel-head">
273-<label for="refs-filter">ref&gt;</label>
274-<input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
275-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
300+<label for="refs-filter" class="sr">filter branches and tags</label>
301+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
302+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
303+<kbd>esc</kbd>
304+</button>
276305 </div>
277306 <div class="panel-cols" data-picklist>
278307 <section aria-label="Branches">
279-<h2>branches <span class="mute">2</span>
308+<h2>Branches <span class="count">2</span>
280309 </h2>
281310 <ul>
282311 <li>
@@ -290,7 +319,7 @@
290319 </ul>
291320 </section>
292321 <section aria-label="Tags">
293-<h2>tags <span class="mute">1</span>
322+<h2>Tags <span class="count">1</span>
294323 </h2>
295324 <ul>
296325 <li>
@@ -301,106 +330,124 @@
301330 </section>
302331 </div>
303332 </dialog>
304-<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
333+<dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
305334 <div class="panel-head">
306-<label for="cmd-q" class="hi">:</label>
307-<input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
335+<label for="cmd-q" class="sr">go to</label>
336+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
308337 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
309338 let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
310339 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
311340 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
312341 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
313342 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
314-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
343+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
344+<kbd>esc</kbd>
345+</button>
315346 </div>
316-<ul id="cmd-list" class="cmd-list">
347+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
317348 <li data-k="go home" data-cursor>
318349 <a href="/" tabindex="-1">
319-<span class="k">go</span>home</a>
350+<span class="lbl">home</span>
351+<span class="k">go</span>
352+</a>
320353 </li>
321354 <li data-k="section summary">
322355 <a href="/files" tabindex="-1">
323-<span class="k">section</span>summary</a>
356+<span class="lbl">summary</span>
357+<span class="k">section</span>
358+</a>
324359 </li>
325-<li data-k="section tree">
360+<li data-k="section files">
326361 <a href="/files/tree/main" tabindex="-1">
327-<span class="k">section</span>tree</a>
362+<span class="lbl">files</span>
363+<span class="k">section</span>
364+</a>
328365 </li>
329366 <li data-k="section commits">
330367 <a href="/files/commits/main" tabindex="-1">
331-<span class="k">section</span>commits</a>
368+<span class="lbl">commits</span>
369+<span class="k">section</span>
370+</a>
332371 </li>
333-<li data-k="section refs">
372+<li data-k="section branches &amp; tags">
334373 <a href="/files/refs" tabindex="-1">
335-<span class="k">section</span>refs</a>
374+<span class="lbl">branches &amp; tags</span>
375+<span class="k">section</span>
376+</a>
336377 </li>
337378 <li data-k="section merge requests">
338379 <a href="/files/merge-requests" tabindex="-1">
339-<span class="k">section</span>merge requests</a>
380+<span class="lbl">merge requests</span>
381+<span class="k">section</span>
382+</a>
340383 </li>
341384 <li data-k="branch feature/x">
342385 <a href="/files/tree/feature/x" tabindex="-1">
343-<span class="k">branch</span>feature/x</a>
386+<span class="lbl">feature/x</span>
387+<span class="k">branch</span>
388+</a>
344389 </li>
345390 <li data-k="branch main">
346391 <a href="/files/tree/main" tabindex="-1">
347-<span class="k">branch</span>main</a>
392+<span class="lbl">main</span>
393+<span class="k">branch</span>
394+</a>
348395 </li>
349396 <li data-k="tag v1.0">
350397 <a href="/files/tree/v1.0" tabindex="-1">
351-<span class="k">tag</span>v1.0</a>
398+<span class="lbl">v1.0</span>
399+<span class="k">tag</span>
400+</a>
352401 </li>
353402 </ul>
403+<p class="panel-foot">
404+<span>
405+<kbd>↑</kbd>
406+<kbd>↓</kbd> move</span>
407+<span>
408+<kbd>enter</kbd> open</span>
409+<span>
410+<kbd>esc</kbd> close</span>
411+</p>
354412 </dialog>
355-<footer class="bar status" aria-label="Status">
356-<span class="mode">blob</span>
357-<span class="st-ctx">main.go</span>
358-<button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
359-<span class="hi">:</span> go to…</button>
360-<span class="st-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : 'click a line number · shift-click extends'">click a line number · shift-click extends</span>
361-<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
362-</span>
363-<span class="st-hints">click a line number · y permalink · ? help</span>
364-<span class="st-src">
365-<span class="st-ver">ermite devel · </span>
366-<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
367-</span>
368-<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
369-</footer>
370-<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
413+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
371414 if (q('dialog[open]').count) return;
372415 let rows = q('[data-rows] > :not([hidden])').arr();
373416 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
374417 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
375- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
376- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
418+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
419+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
420+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
421+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
377422 })(1);
378- k-k from:body -> ((d) => {
423+ k-k from:body -> (async (d) => {
379424 if (q('dialog[open]').count) return;
380425 let rows = q('[data-rows] > :not([hidden])').arr();
381426 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
382427 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
383- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
384- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
428+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
429+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
430+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
431+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
385432 })(-1);
386433 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
387434 k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
388435 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
389436 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
390- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
391- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus());
392- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
437+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
438+ k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
439+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
393440 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
394- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
395- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
396- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
397- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
398- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
399- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
400- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
401- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
402- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
403- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
441+ k-gh from:body -> document.querySelector('.brand')?.click();
442+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
443+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
444+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
445+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
446+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
447+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
448+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
449+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
450+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
404451 k-next from:body -> {
405452 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
406453 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/blob-empty.html +162 -114
@@ -6,6 +6,7 @@
66 <meta name="color-scheme" content="light dark">
77 <title>empty.txt · files · example.test</title>
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9+<link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
910 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
1011 <script src="/-/static/js/htmx.min.HASH.js" defer>
1112 </script>
@@ -16,12 +17,15 @@
1617 <script src="/-/static/js/ermite.HASH.js" defer>
1718 </script>
1819 </head>
19-<body data-ref="main" data-sha="SHA40" data-sel="">
20+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
2021 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
23-<span class="fade" aria-hidden="true">
22+<header class="hdr">
23+<div class="hdr-top">
24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
2426 </span>
27+<span>example.test</span>
28+</a>
2529 <nav class="crumbs" aria-label="Path">
2630 <ol>
2731 <li>
@@ -38,45 +42,62 @@
3842 </li>
3943 </ol>
4044 </nav>
41-<nav class="secs" aria-label="Sections">
45+<div class="hdr-acts">
46+<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
47+<span class="ico i-branch" aria-hidden="true">
48+</span>
49+<span class="ref-name">main</span>
50+<span class="sha">SHA7</span>
51+</button>
52+<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
53+<span class="ico i-search" aria-hidden="true">
54+</span>
55+<span class="search-label">Go to…</span>
56+<kbd>ctrl k</kbd>
57+</button>
58+<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
59+</div>
60+</div>
61+<nav class="tabs" aria-label="Sections">
4262 <ul>
4363 <li>
44-<a href="/files" data-key="s" aria-keyshortcuts="g s">summary</a>
64+<a href="/files" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
65+</a>
4566 </li>
4667 <li>
47-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="g t" aria-current="page">tree</a>
68+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files<kbd aria-hidden="true">t</kbd>
69+</a>
4870 </li>
4971 <li>
50-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a>
72+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits<kbd aria-hidden="true">c</kbd>
73+</a>
5174 </li>
5275 <li>
53-<a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
76+<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
77+</a>
5478 </li>
5579 <li>
56-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
80+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
81+</a>
5782 </li>
5883 </ul>
5984 </nav>
60-<button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
61-</button>
6285 </header>
6386 <div class="work has-rail">
64-<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
87+<main id="main" tabindex="-1">
6588 <h1 class="file-h">
6689 <span>empty.txt</span>
6790 <span class="acts">
68-<a href="/files/raw/main/empty.txt">raw</a>
69-<a href="/files/commits/main/empty.txt">history</a>
70-<a href="/files/tree/main">dir</a>
91+<a class="btn" href="/files/raw/main/empty.txt">Raw</a>
92+<a class="btn" href="/files/commits/main/empty.txt">History</a>
93+<a class="btn" href="/files/tree/main">Folder</a>
7194 </span>
7295 </h1>
7396 <p class="notice" role="note">empty file.</p>
7497 </main>
7598 <aside class="rail" aria-label="Context">
76-<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
77-<summary>file · last change · selection</summary>
7899 <section aria-labelledby="r-file">
79-<h2 id="r-file">file</h2>
100+<h2 id="r-file">File</h2>
80101 <dl class="kv">
81102 <dt>size</dt>
82103 <dd>0 B</dd>
@@ -85,80 +106,80 @@
85106 </dl>
86107 </section>
87108 <section aria-labelledby="r-last">
88-<h2 id="r-last">last change <a href="/files/commits/main/empty.txt">history</a>
109+<h2 id="r-last">Last change <a href="/files/commits/main/empty.txt">History</a>
89110 </h2>
90111 <p>
91112 <a href="/files/commit/SHA40">SHA7</a> add empty.txt</p>
92113 <p class="dim">T · <time datetime="STAMP" title="STAMP">AGO</time>
93114 </p>
94115 </section>
95-</details>
96116 </aside>
97117 </div>
98-<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
118+<footer class="foot">
119+<span>ermite devel</span>
120+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
121+<span class="foot-keys">
122+<kbd>?</kbd> keyboard shortcuts</span>
123+</footer>
124+<div class="hint" role="status" aria-live="polite">
125+<span class="hint-nav" :text="document.body.dataset.nav || ''">
126+</span>
127+<span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''">
128+</span>
129+<span class="hint-flash" :text="document.body.dataset.flash || ''">
130+</span>
131+</div>
132+<dialog id="panel-help" class="panel modal" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
99133 <div class="panel-head">
100-<span>keys</span>
101-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
134+<h2>Keyboard shortcuts</h2>
135+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
136+<kbd>esc</kbd> close</button>
102137 </div>
103138 <dl class="keys">
104139 <div>
105140 <dt>
106-<kbd>j</kbd>
107-<kbd>k</kbd>
141+<kbd>ctrl k</kbd>
142+<kbd>:</kbd>
108143 </dt>
109-<dd>move cursor down / up</dd>
144+<dd>go to a section, branch, tag, file or repository</dd>
110145 </div>
111146 <div>
112147 <dt>
113-<kbd>enter</kbd>
148+<kbd>?</kbd>
114149 </dt>
115-<dd>open row under cursor</dd>
150+<dd>this list</dd>
116151 </div>
117152 <div>
118153 <dt>
119-<kbd>g</kbd>
120-<kbd>g</kbd> / <kbd>G</kbd>
154+<kbd>j</kbd>
155+<kbd>k</kbd>
121156 </dt>
122-<dd>first / last row</dd>
157+<dd>move the row cursor down / up</dd>
123158 </div>
124159 <div>
125160 <dt>
126-<kbd>/</kbd>
161+<kbd>enter</kbd>
127162 </dt>
128-<dd>focus the filter</dd>
163+<dd>open the row under the cursor</dd>
129164 </div>
130165 <div>
131166 <dt>
132-<kbd>g h</kbd>
133-<kbd>g t</kbd>
167+<kbd>g g</kbd>
168+<kbd>G</kbd>
134169 </dt>
135-<dd>go home / tree</dd>
170+<dd>first / last row</dd>
136171 </div>
137172 <div>
138173 <dt>
139-<kbd>g c</kbd>
140-<kbd>g r</kbd>
174+<kbd>/</kbd>
141175 </dt>
142-<dd>go commits / refs</dd>
176+<dd>filter the list</dd>
143177 </div>
144178 <div>
145179 <dt>
146180 <kbd>b</kbd>
147181 </dt>
148-<dd>switch ref (branches, tags)</dd>
149-</div>
150-<div>
151-<dt>
152-<kbd>:</kbd>
153-<kbd>f</kbd>
154-</dt>
155-<dd>go to: section, ref, file, repo</dd>
156-</div>
157-<div>
158-<dt>
159-<kbd>y</kbd>
160-</dt>
161-<dd>permalink: ref becomes the full sha</dd>
182+<dd>switch branch or tag</dd>
162183 </div>
163184 <div>
164185 <dt>
@@ -166,15 +187,21 @@
166187 <kbd>t</kbd>
167188 <kbd>c</kbd>
168189 <kbd>r</kbd>
190+<kbd>m</kbd>
169191 </dt>
170-<dd>repo sections (when shown)</dd>
192+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
171193 </div>
172194 <div>
173195 <dt>
174-<kbd>m</kbd>
175-<kbd>g m</kbd>
196+<kbd>g h</kbd>
197+</dt>
198+<dd>home</dd>
199+</div>
200+<div>
201+<dt>
202+<kbd>y</kbd>
176203 </dt>
177-<dd>merge requests (when shown)</dd>
204+<dd>copy a permalink pinned to the commit</dd>
178205 </div>
179206 <div>
180207 <dt>
@@ -194,19 +221,21 @@
194221 <dt>
195222 <kbd>esc</kbd>
196223 </dt>
197-<dd>close panel</dd>
224+<dd>close a dialog, leave a field</dd>
198225 </div>
199226 </dl>
200227 </dialog>
201-<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
228+<dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
202229 <div class="panel-head">
203-<label for="refs-filter">ref&gt;</label>
204-<input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
205-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
230+<label for="refs-filter" class="sr">filter branches and tags</label>
231+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
232+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
233+<kbd>esc</kbd>
234+</button>
206235 </div>
207236 <div class="panel-cols" data-picklist>
208237 <section aria-label="Branches">
209-<h2>branches <span class="mute">2</span>
238+<h2>Branches <span class="count">2</span>
210239 </h2>
211240 <ul>
212241 <li>
@@ -220,7 +249,7 @@
220249 </ul>
221250 </section>
222251 <section aria-label="Tags">
223-<h2>tags <span class="mute">1</span>
252+<h2>Tags <span class="count">1</span>
224253 </h2>
225254 <ul>
226255 <li>
@@ -231,105 +260,124 @@
231260 </section>
232261 </div>
233262 </dialog>
234-<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
263+<dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
235264 <div class="panel-head">
236-<label for="cmd-q" class="hi">:</label>
237-<input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
265+<label for="cmd-q" class="sr">go to</label>
266+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
238267 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
239268 let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
240269 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
241270 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
242271 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
243272 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
244-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
273+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
274+<kbd>esc</kbd>
275+</button>
245276 </div>
246-<ul id="cmd-list" class="cmd-list">
277+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
247278 <li data-k="go home" data-cursor>
248279 <a href="/" tabindex="-1">
249-<span class="k">go</span>home</a>
280+<span class="lbl">home</span>
281+<span class="k">go</span>
282+</a>
250283 </li>
251284 <li data-k="section summary">
252285 <a href="/files" tabindex="-1">
253-<span class="k">section</span>summary</a>
286+<span class="lbl">summary</span>
287+<span class="k">section</span>
288+</a>
254289 </li>
255-<li data-k="section tree">
290+<li data-k="section files">
256291 <a href="/files/tree/main" tabindex="-1">
257-<span class="k">section</span>tree</a>
292+<span class="lbl">files</span>
293+<span class="k">section</span>
294+</a>
258295 </li>
259296 <li data-k="section commits">
260297 <a href="/files/commits/main" tabindex="-1">
261-<span class="k">section</span>commits</a>
298+<span class="lbl">commits</span>
299+<span class="k">section</span>
300+</a>
262301 </li>
263-<li data-k="section refs">
302+<li data-k="section branches &amp; tags">
264303 <a href="/files/refs" tabindex="-1">
265-<span class="k">section</span>refs</a>
304+<span class="lbl">branches &amp; tags</span>
305+<span class="k">section</span>
306+</a>
266307 </li>
267308 <li data-k="section merge requests">
268309 <a href="/files/merge-requests" tabindex="-1">
269-<span class="k">section</span>merge requests</a>
310+<span class="lbl">merge requests</span>
311+<span class="k">section</span>
312+</a>
270313 </li>
271314 <li data-k="branch feature/x">
272315 <a href="/files/tree/feature/x" tabindex="-1">
273-<span class="k">branch</span>feature/x</a>
316+<span class="lbl">feature/x</span>
317+<span class="k">branch</span>
318+</a>
274319 </li>
275320 <li data-k="branch main">
276321 <a href="/files/tree/main" tabindex="-1">
277-<span class="k">branch</span>main</a>
322+<span class="lbl">main</span>
323+<span class="k">branch</span>
324+</a>
278325 </li>
279326 <li data-k="tag v1.0">
280327 <a href="/files/tree/v1.0" tabindex="-1">
281-<span class="k">tag</span>v1.0</a>
328+<span class="lbl">v1.0</span>
329+<span class="k">tag</span>
330+</a>
282331 </li>
283332 </ul>
333+<p class="panel-foot">
334+<span>
335+<kbd>↑</kbd>
336+<kbd>↓</kbd> move</span>
337+<span>
338+<kbd>enter</kbd> open</span>
339+<span>
340+<kbd>esc</kbd> close</span>
341+</p>
284342 </dialog>
285-<footer class="bar status" aria-label="Status">
286-<span class="mode">blob</span>
287-<span class="st-ctx">empty.txt</span>
288-<button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
289-<span class="hi">:</span> go to…</button>
290-<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
291-</span>
292-<span class="st-hints">click a line number · y permalink · ? help</span>
293-<span class="st-src">
294-<span class="st-ver">ermite devel · </span>
295-<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
296-</span>
297-<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
298-</footer>
299-<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
343+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
300344 if (q('dialog[open]').count) return;
301345 let rows = q('[data-rows] > :not([hidden])').arr();
302346 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
303347 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
304- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
305- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
348+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
349+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
350+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
351+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
306352 })(1);
307- k-k from:body -> ((d) => {
353+ k-k from:body -> (async (d) => {
308354 if (q('dialog[open]').count) return;
309355 let rows = q('[data-rows] > :not([hidden])').arr();
310356 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
311357 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
312- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
313- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
358+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
359+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
360+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
361+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
314362 })(-1);
315363 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
316364 k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
317365 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
318366 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
319- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
320- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus());
321- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
367+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
368+ k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
369+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
322370 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
323- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
324- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
325- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
326- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
327- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
328- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
329- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
330- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
331- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
332- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
371+ k-gh from:body -> document.querySelector('.brand')?.click();
372+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
373+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
374+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
375+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
376+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
377+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
378+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
379+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
380+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
333381 k-next from:body -> {
334382 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
335383 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/blob-image.html +162 -114
@@ -6,6 +6,7 @@
66 <meta name="color-scheme" content="light dark">
77 <title>pic.png · files · example.test</title>
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9+<link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
910 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
1011 <script src="/-/static/js/htmx.min.HASH.js" defer>
1112 </script>
@@ -16,12 +17,15 @@
1617 <script src="/-/static/js/ermite.HASH.js" defer>
1718 </script>
1819 </head>
19-<body data-ref="main" data-sha="SHA40" data-sel="">
20+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
2021 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
23-<span class="fade" aria-hidden="true">
22+<header class="hdr">
23+<div class="hdr-top">
24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
2426 </span>
27+<span>example.test</span>
28+</a>
2529 <nav class="crumbs" aria-label="Path">
2630 <ol>
2731 <li>
@@ -38,36 +42,55 @@
3842 </li>
3943 </ol>
4044 </nav>
41-<nav class="secs" aria-label="Sections">
45+<div class="hdr-acts">
46+<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
47+<span class="ico i-branch" aria-hidden="true">
48+</span>
49+<span class="ref-name">main</span>
50+<span class="sha">SHA7</span>
51+</button>
52+<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
53+<span class="ico i-search" aria-hidden="true">
54+</span>
55+<span class="search-label">Go to…</span>
56+<kbd>ctrl k</kbd>
57+</button>
58+<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
59+</div>
60+</div>
61+<nav class="tabs" aria-label="Sections">
4262 <ul>
4363 <li>
44-<a href="/files" data-key="s" aria-keyshortcuts="g s">summary</a>
64+<a href="/files" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
65+</a>
4566 </li>
4667 <li>
47-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="g t" aria-current="page">tree</a>
68+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files<kbd aria-hidden="true">t</kbd>
69+</a>
4870 </li>
4971 <li>
50-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a>
72+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits<kbd aria-hidden="true">c</kbd>
73+</a>
5174 </li>
5275 <li>
53-<a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
76+<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
77+</a>
5478 </li>
5579 <li>
56-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
80+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
81+</a>
5782 </li>
5883 </ul>
5984 </nav>
60-<button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
61-</button>
6285 </header>
6386 <div class="work has-rail">
64-<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
87+<main id="main" tabindex="-1">
6588 <h1 class="file-h">
6689 <span>pic.png</span>
6790 <span class="acts">
68-<a href="/files/raw/main/pic.png">raw</a>
69-<a href="/files/commits/main/pic.png">history</a>
70-<a href="/files/tree/main">dir</a>
91+<a class="btn" href="/files/raw/main/pic.png">Raw</a>
92+<a class="btn" href="/files/commits/main/pic.png">History</a>
93+<a class="btn" href="/files/tree/main">Folder</a>
7194 </span>
7295 </h1>
7396 <p class="img-view">
@@ -75,10 +98,8 @@
7598 </p>
7699 </main>
77100 <aside class="rail" aria-label="Context">
78-<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
79-<summary>file · last change · selection</summary>
80101 <section aria-labelledby="r-file">
81-<h2 id="r-file">file</h2>
102+<h2 id="r-file">File</h2>
82103 <dl class="kv">
83104 <dt>size</dt>
84105 <dd>88 B</dd>
@@ -87,80 +108,80 @@
87108 </dl>
88109 </section>
89110 <section aria-labelledby="r-last">
90-<h2 id="r-last">last change <a href="/files/commits/main/pic.png">history</a>
111+<h2 id="r-last">Last change <a href="/files/commits/main/pic.png">History</a>
91112 </h2>
92113 <p>
93114 <a href="/files/commit/SHA40">SHA7</a> add pic.png</p>
94115 <p class="dim">T · <time datetime="STAMP" title="STAMP">AGO</time>
95116 </p>
96117 </section>
97-</details>
98118 </aside>
99119 </div>
100-<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
120+<footer class="foot">
121+<span>ermite devel</span>
122+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
123+<span class="foot-keys">
124+<kbd>?</kbd> keyboard shortcuts</span>
125+</footer>
126+<div class="hint" role="status" aria-live="polite">
127+<span class="hint-nav" :text="document.body.dataset.nav || ''">
128+</span>
129+<span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''">
130+</span>
131+<span class="hint-flash" :text="document.body.dataset.flash || ''">
132+</span>
133+</div>
134+<dialog id="panel-help" class="panel modal" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
101135 <div class="panel-head">
102-<span>keys</span>
103-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
136+<h2>Keyboard shortcuts</h2>
137+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
138+<kbd>esc</kbd> close</button>
104139 </div>
105140 <dl class="keys">
106141 <div>
107142 <dt>
108-<kbd>j</kbd>
109-<kbd>k</kbd>
143+<kbd>ctrl k</kbd>
144+<kbd>:</kbd>
110145 </dt>
111-<dd>move cursor down / up</dd>
146+<dd>go to a section, branch, tag, file or repository</dd>
112147 </div>
113148 <div>
114149 <dt>
115-<kbd>enter</kbd>
150+<kbd>?</kbd>
116151 </dt>
117-<dd>open row under cursor</dd>
152+<dd>this list</dd>
118153 </div>
119154 <div>
120155 <dt>
121-<kbd>g</kbd>
122-<kbd>g</kbd> / <kbd>G</kbd>
156+<kbd>j</kbd>
157+<kbd>k</kbd>
123158 </dt>
124-<dd>first / last row</dd>
159+<dd>move the row cursor down / up</dd>
125160 </div>
126161 <div>
127162 <dt>
128-<kbd>/</kbd>
163+<kbd>enter</kbd>
129164 </dt>
130-<dd>focus the filter</dd>
165+<dd>open the row under the cursor</dd>
131166 </div>
132167 <div>
133168 <dt>
134-<kbd>g h</kbd>
135-<kbd>g t</kbd>
169+<kbd>g g</kbd>
170+<kbd>G</kbd>
136171 </dt>
137-<dd>go home / tree</dd>
172+<dd>first / last row</dd>
138173 </div>
139174 <div>
140175 <dt>
141-<kbd>g c</kbd>
142-<kbd>g r</kbd>
176+<kbd>/</kbd>
143177 </dt>
144-<dd>go commits / refs</dd>
178+<dd>filter the list</dd>
145179 </div>
146180 <div>
147181 <dt>
148182 <kbd>b</kbd>
149183 </dt>
150-<dd>switch ref (branches, tags)</dd>
151-</div>
152-<div>
153-<dt>
154-<kbd>:</kbd>
155-<kbd>f</kbd>
156-</dt>
157-<dd>go to: section, ref, file, repo</dd>
158-</div>
159-<div>
160-<dt>
161-<kbd>y</kbd>
162-</dt>
163-<dd>permalink: ref becomes the full sha</dd>
184+<dd>switch branch or tag</dd>
164185 </div>
165186 <div>
166187 <dt>
@@ -168,15 +189,21 @@
168189 <kbd>t</kbd>
169190 <kbd>c</kbd>
170191 <kbd>r</kbd>
192+<kbd>m</kbd>
171193 </dt>
172-<dd>repo sections (when shown)</dd>
194+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
173195 </div>
174196 <div>
175197 <dt>
176-<kbd>m</kbd>
177-<kbd>g m</kbd>
198+<kbd>g h</kbd>
199+</dt>
200+<dd>home</dd>
201+</div>
202+<div>
203+<dt>
204+<kbd>y</kbd>
178205 </dt>
179-<dd>merge requests (when shown)</dd>
206+<dd>copy a permalink pinned to the commit</dd>
180207 </div>
181208 <div>
182209 <dt>
@@ -196,19 +223,21 @@
196223 <dt>
197224 <kbd>esc</kbd>
198225 </dt>
199-<dd>close panel</dd>
226+<dd>close a dialog, leave a field</dd>
200227 </div>
201228 </dl>
202229 </dialog>
203-<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
230+<dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
204231 <div class="panel-head">
205-<label for="refs-filter">ref&gt;</label>
206-<input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
207-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
232+<label for="refs-filter" class="sr">filter branches and tags</label>
233+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
234+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
235+<kbd>esc</kbd>
236+</button>
208237 </div>
209238 <div class="panel-cols" data-picklist>
210239 <section aria-label="Branches">
211-<h2>branches <span class="mute">2</span>
240+<h2>Branches <span class="count">2</span>
212241 </h2>
213242 <ul>
214243 <li>
@@ -222,7 +251,7 @@
222251 </ul>
223252 </section>
224253 <section aria-label="Tags">
225-<h2>tags <span class="mute">1</span>
254+<h2>Tags <span class="count">1</span>
226255 </h2>
227256 <ul>
228257 <li>
@@ -233,105 +262,124 @@
233262 </section>
234263 </div>
235264 </dialog>
236-<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
265+<dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
237266 <div class="panel-head">
238-<label for="cmd-q" class="hi">:</label>
239-<input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
267+<label for="cmd-q" class="sr">go to</label>
268+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
240269 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
241270 let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
242271 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
243272 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
244273 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
245274 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
246-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
275+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
276+<kbd>esc</kbd>
277+</button>
247278 </div>
248-<ul id="cmd-list" class="cmd-list">
279+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
249280 <li data-k="go home" data-cursor>
250281 <a href="/" tabindex="-1">
251-<span class="k">go</span>home</a>
282+<span class="lbl">home</span>
283+<span class="k">go</span>
284+</a>
252285 </li>
253286 <li data-k="section summary">
254287 <a href="/files" tabindex="-1">
255-<span class="k">section</span>summary</a>
288+<span class="lbl">summary</span>
289+<span class="k">section</span>
290+</a>
256291 </li>
257-<li data-k="section tree">
292+<li data-k="section files">
258293 <a href="/files/tree/main" tabindex="-1">
259-<span class="k">section</span>tree</a>
294+<span class="lbl">files</span>
295+<span class="k">section</span>
296+</a>
260297 </li>
261298 <li data-k="section commits">
262299 <a href="/files/commits/main" tabindex="-1">
263-<span class="k">section</span>commits</a>
300+<span class="lbl">commits</span>
301+<span class="k">section</span>
302+</a>
264303 </li>
265-<li data-k="section refs">
304+<li data-k="section branches &amp; tags">
266305 <a href="/files/refs" tabindex="-1">
267-<span class="k">section</span>refs</a>
306+<span class="lbl">branches &amp; tags</span>
307+<span class="k">section</span>
308+</a>
268309 </li>
269310 <li data-k="section merge requests">
270311 <a href="/files/merge-requests" tabindex="-1">
271-<span class="k">section</span>merge requests</a>
312+<span class="lbl">merge requests</span>
313+<span class="k">section</span>
314+</a>
272315 </li>
273316 <li data-k="branch feature/x">
274317 <a href="/files/tree/feature/x" tabindex="-1">
275-<span class="k">branch</span>feature/x</a>
318+<span class="lbl">feature/x</span>
319+<span class="k">branch</span>
320+</a>
276321 </li>
277322 <li data-k="branch main">
278323 <a href="/files/tree/main" tabindex="-1">
279-<span class="k">branch</span>main</a>
324+<span class="lbl">main</span>
325+<span class="k">branch</span>
326+</a>
280327 </li>
281328 <li data-k="tag v1.0">
282329 <a href="/files/tree/v1.0" tabindex="-1">
283-<span class="k">tag</span>v1.0</a>
330+<span class="lbl">v1.0</span>
331+<span class="k">tag</span>
332+</a>
284333 </li>
285334 </ul>
335+<p class="panel-foot">
336+<span>
337+<kbd>↑</kbd>
338+<kbd>↓</kbd> move</span>
339+<span>
340+<kbd>enter</kbd> open</span>
341+<span>
342+<kbd>esc</kbd> close</span>
343+</p>
286344 </dialog>
287-<footer class="bar status" aria-label="Status">
288-<span class="mode">blob</span>
289-<span class="st-ctx">pic.png</span>
290-<button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
291-<span class="hi">:</span> go to…</button>
292-<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
293-</span>
294-<span class="st-hints">click a line number · y permalink · ? help</span>
295-<span class="st-src">
296-<span class="st-ver">ermite devel · </span>
297-<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
298-</span>
299-<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
300-</footer>
301-<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
345+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
302346 if (q('dialog[open]').count) return;
303347 let rows = q('[data-rows] > :not([hidden])').arr();
304348 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
305349 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
306- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
307- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
350+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
351+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
352+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
353+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
308354 })(1);
309- k-k from:body -> ((d) => {
355+ k-k from:body -> (async (d) => {
310356 if (q('dialog[open]').count) return;
311357 let rows = q('[data-rows] > :not([hidden])').arr();
312358 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
313359 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
314- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
315- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
360+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
361+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
362+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
363+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
316364 })(-1);
317365 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
318366 k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
319367 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
320368 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
321- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
322- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus());
323- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
369+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
370+ k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
371+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
324372 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
325- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
326- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
327- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
328- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
329- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
330- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
331- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
332- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
333- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
334- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
373+ k-gh from:body -> document.querySelector('.brand')?.click();
374+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
375+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
376+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
377+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
378+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
379+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
380+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
381+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
382+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
335383 k-next from:body -> {
336384 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
337385 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/blob-markdown.html +165 -118
@@ -6,6 +6,7 @@
66 <meta name="color-scheme" content="light dark">
77 <title>docs/guide.md · files · example.test</title>
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9+<link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
910 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
1011 <script src="/-/static/js/htmx.min.HASH.js" defer>
1112 </script>
@@ -16,12 +17,15 @@
1617 <script src="/-/static/js/ermite.HASH.js" defer>
1718 </script>
1819 </head>
19-<body data-ref="main" data-sha="SHA40" data-sel="">
20+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
2021 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
23-<span class="fade" aria-hidden="true">
22+<header class="hdr">
23+<div class="hdr-top">
24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
2426 </span>
27+<span>example.test</span>
28+</a>
2529 <nav class="crumbs" aria-label="Path">
2630 <ol>
2731 <li>
@@ -41,36 +45,55 @@
4145 </li>
4246 </ol>
4347 </nav>
44-<nav class="secs" aria-label="Sections">
48+<div class="hdr-acts">
49+<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
50+<span class="ico i-branch" aria-hidden="true">
51+</span>
52+<span class="ref-name">main</span>
53+<span class="sha">SHA7</span>
54+</button>
55+<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
56+<span class="ico i-search" aria-hidden="true">
57+</span>
58+<span class="search-label">Go to…</span>
59+<kbd>ctrl k</kbd>
60+</button>
61+<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
62+</div>
63+</div>
64+<nav class="tabs" aria-label="Sections">
4565 <ul>
4666 <li>
47-<a href="/files" data-key="s" aria-keyshortcuts="g s">summary</a>
67+<a href="/files" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
68+</a>
4869 </li>
4970 <li>
50-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="g t" aria-current="page">tree</a>
71+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files<kbd aria-hidden="true">t</kbd>
72+</a>
5173 </li>
5274 <li>
53-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a>
75+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits<kbd aria-hidden="true">c</kbd>
76+</a>
5477 </li>
5578 <li>
56-<a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
79+<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
80+</a>
5781 </li>
5882 <li>
59-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
83+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
84+</a>
6085 </li>
6186 </ul>
6287 </nav>
63-<button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
64-</button>
6588 </header>
66-<div class="work has-rail read">
67-<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
89+<div class="work has-rail">
90+<main id="main" tabindex="-1">
6891 <h1 class="file-h">
6992 <span>guide.md</span>
7093 <span class="acts">
71-<a href="/files/raw/main/docs/guide.md">raw</a>
72-<a href="/files/commits/main/docs/guide.md">history</a>
73-<a href="/files/tree/main/docs">dir</a>
94+<a class="btn" href="/files/raw/main/docs/guide.md">Raw</a>
95+<a class="btn" href="/files/commits/main/docs/guide.md">History</a>
96+<a class="btn" href="/files/tree/main/docs">Folder</a>
7497 </span>
7598 </h1>
7699 <article class="prose" aria-label="guide.md">
@@ -126,10 +149,8 @@
126149 </details>
127150 </main>
128151 <aside class="rail" aria-label="Context">
129-<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
130-<summary>file · last change · selection</summary>
131152 <section aria-labelledby="r-file">
132-<h2 id="r-file">file</h2>
153+<h2 id="r-file">File</h2>
133154 <dl class="kv">
134155 <dt>size</dt>
135156 <dd>47 B</dd>
@@ -140,7 +161,7 @@
140161 </dl>
141162 </section>
142163 <section aria-labelledby="r-last">
143-<h2 id="r-last">last change <a href="/files/commits/main/docs/guide.md">history</a>
164+<h2 id="r-last">Last change <a href="/files/commits/main/docs/guide.md">History</a>
144165 </h2>
145166 <p>
146167 <a href="/files/commit/SHA40">SHA7</a> add docs/guide.md</p>
@@ -148,84 +169,84 @@
148169 </p>
149170 </section>
150171 <section aria-labelledby="r-outline">
151-<h2 id="r-outline">outline</h2>
172+<h2 id="r-outline">On this page</h2>
152173 <nav class="outline" aria-label="Outline">
153174 <a class="l1" href="#md-guide">Guide</a>
154175 <a class="l2" href="#md-steps">Steps</a>
155176 </nav>
156177 </section>
157178 <section aria-labelledby="r-sel">
158-<h2 id="r-sel">selection</h2>
179+<h2 id="r-sel">Selection</h2>
159180 <p :text="document.body.dataset.sel || 'none · click a line number, shift-click extends'">none · click a line number, shift-click extends</p>
160181 <p class="dim">y copies a permalink pinned to the commit sha</p>
161182 </section>
162-</details>
163183 </aside>
164184 </div>
165-<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
185+<footer class="foot">
186+<span>ermite devel</span>
187+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
188+<span class="foot-keys">
189+<kbd>?</kbd> keyboard shortcuts</span>
190+</footer>
191+<div class="hint" role="status" aria-live="polite">
192+<span class="hint-nav" :text="document.body.dataset.nav || ''">
193+</span>
194+<span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''">
195+</span>
196+<span class="hint-flash" :text="document.body.dataset.flash || ''">
197+</span>
198+</div>
199+<dialog id="panel-help" class="panel modal" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
166200 <div class="panel-head">
167-<span>keys</span>
168-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
201+<h2>Keyboard shortcuts</h2>
202+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
203+<kbd>esc</kbd> close</button>
169204 </div>
170205 <dl class="keys">
171206 <div>
172207 <dt>
173-<kbd>j</kbd>
174-<kbd>k</kbd>
208+<kbd>ctrl k</kbd>
209+<kbd>:</kbd>
175210 </dt>
176-<dd>move cursor down / up</dd>
211+<dd>go to a section, branch, tag, file or repository</dd>
177212 </div>
178213 <div>
179214 <dt>
180-<kbd>enter</kbd>
215+<kbd>?</kbd>
181216 </dt>
182-<dd>open row under cursor</dd>
217+<dd>this list</dd>
183218 </div>
184219 <div>
185220 <dt>
186-<kbd>g</kbd>
187-<kbd>g</kbd> / <kbd>G</kbd>
221+<kbd>j</kbd>
222+<kbd>k</kbd>
188223 </dt>
189-<dd>first / last row</dd>
224+<dd>move the row cursor down / up</dd>
190225 </div>
191226 <div>
192227 <dt>
193-<kbd>/</kbd>
228+<kbd>enter</kbd>
194229 </dt>
195-<dd>focus the filter</dd>
230+<dd>open the row under the cursor</dd>
196231 </div>
197232 <div>
198233 <dt>
199-<kbd>g h</kbd>
200-<kbd>g t</kbd>
234+<kbd>g g</kbd>
235+<kbd>G</kbd>
201236 </dt>
202-<dd>go home / tree</dd>
237+<dd>first / last row</dd>
203238 </div>
204239 <div>
205240 <dt>
206-<kbd>g c</kbd>
207-<kbd>g r</kbd>
241+<kbd>/</kbd>
208242 </dt>
209-<dd>go commits / refs</dd>
243+<dd>filter the list</dd>
210244 </div>
211245 <div>
212246 <dt>
213247 <kbd>b</kbd>
214248 </dt>
215-<dd>switch ref (branches, tags)</dd>
216-</div>
217-<div>
218-<dt>
219-<kbd>:</kbd>
220-<kbd>f</kbd>
221-</dt>
222-<dd>go to: section, ref, file, repo</dd>
223-</div>
224-<div>
225-<dt>
226-<kbd>y</kbd>
227-</dt>
228-<dd>permalink: ref becomes the full sha</dd>
249+<dd>switch branch or tag</dd>
229250 </div>
230251 <div>
231252 <dt>
@@ -233,15 +254,21 @@
233254 <kbd>t</kbd>
234255 <kbd>c</kbd>
235256 <kbd>r</kbd>
257+<kbd>m</kbd>
236258 </dt>
237-<dd>repo sections (when shown)</dd>
259+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
238260 </div>
239261 <div>
240262 <dt>
241-<kbd>m</kbd>
242-<kbd>g m</kbd>
263+<kbd>g h</kbd>
264+</dt>
265+<dd>home</dd>
266+</div>
267+<div>
268+<dt>
269+<kbd>y</kbd>
243270 </dt>
244-<dd>merge requests (when shown)</dd>
271+<dd>copy a permalink pinned to the commit</dd>
245272 </div>
246273 <div>
247274 <dt>
@@ -261,19 +288,21 @@
261288 <dt>
262289 <kbd>esc</kbd>
263290 </dt>
264-<dd>close panel</dd>
291+<dd>close a dialog, leave a field</dd>
265292 </div>
266293 </dl>
267294 </dialog>
268-<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
295+<dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
269296 <div class="panel-head">
270-<label for="refs-filter">ref&gt;</label>
271-<input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
272-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
297+<label for="refs-filter" class="sr">filter branches and tags</label>
298+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
299+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
300+<kbd>esc</kbd>
301+</button>
273302 </div>
274303 <div class="panel-cols" data-picklist>
275304 <section aria-label="Branches">
276-<h2>branches <span class="mute">2</span>
305+<h2>Branches <span class="count">2</span>
277306 </h2>
278307 <ul>
279308 <li>
@@ -287,7 +316,7 @@
287316 </ul>
288317 </section>
289318 <section aria-label="Tags">
290-<h2>tags <span class="mute">1</span>
319+<h2>Tags <span class="count">1</span>
291320 </h2>
292321 <ul>
293322 <li>
@@ -298,106 +327,124 @@
298327 </section>
299328 </div>
300329 </dialog>
301-<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
330+<dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
302331 <div class="panel-head">
303-<label for="cmd-q" class="hi">:</label>
304-<input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
332+<label for="cmd-q" class="sr">go to</label>
333+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
305334 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
306335 let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
307336 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
308337 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
309338 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
310339 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
311-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
340+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
341+<kbd>esc</kbd>
342+</button>
312343 </div>
313-<ul id="cmd-list" class="cmd-list">
344+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
314345 <li data-k="go home" data-cursor>
315346 <a href="/" tabindex="-1">
316-<span class="k">go</span>home</a>
347+<span class="lbl">home</span>
348+<span class="k">go</span>
349+</a>
317350 </li>
318351 <li data-k="section summary">
319352 <a href="/files" tabindex="-1">
320-<span class="k">section</span>summary</a>
353+<span class="lbl">summary</span>
354+<span class="k">section</span>
355+</a>
321356 </li>
322-<li data-k="section tree">
357+<li data-k="section files">
323358 <a href="/files/tree/main" tabindex="-1">
324-<span class="k">section</span>tree</a>
359+<span class="lbl">files</span>
360+<span class="k">section</span>
361+</a>
325362 </li>
326363 <li data-k="section commits">
327364 <a href="/files/commits/main" tabindex="-1">
328-<span class="k">section</span>commits</a>
365+<span class="lbl">commits</span>
366+<span class="k">section</span>
367+</a>
329368 </li>
330-<li data-k="section refs">
369+<li data-k="section branches &amp; tags">
331370 <a href="/files/refs" tabindex="-1">
332-<span class="k">section</span>refs</a>
371+<span class="lbl">branches &amp; tags</span>
372+<span class="k">section</span>
373+</a>
333374 </li>
334375 <li data-k="section merge requests">
335376 <a href="/files/merge-requests" tabindex="-1">
336-<span class="k">section</span>merge requests</a>
377+<span class="lbl">merge requests</span>
378+<span class="k">section</span>
379+</a>
337380 </li>
338381 <li data-k="branch feature/x">
339382 <a href="/files/tree/feature/x" tabindex="-1">
340-<span class="k">branch</span>feature/x</a>
383+<span class="lbl">feature/x</span>
384+<span class="k">branch</span>
385+</a>
341386 </li>
342387 <li data-k="branch main">
343388 <a href="/files/tree/main" tabindex="-1">
344-<span class="k">branch</span>main</a>
389+<span class="lbl">main</span>
390+<span class="k">branch</span>
391+</a>
345392 </li>
346393 <li data-k="tag v1.0">
347394 <a href="/files/tree/v1.0" tabindex="-1">
348-<span class="k">tag</span>v1.0</a>
395+<span class="lbl">v1.0</span>
396+<span class="k">tag</span>
397+</a>
349398 </li>
350399 </ul>
400+<p class="panel-foot">
401+<span>
402+<kbd>↑</kbd>
403+<kbd>↓</kbd> move</span>
404+<span>
405+<kbd>enter</kbd> open</span>
406+<span>
407+<kbd>esc</kbd> close</span>
408+</p>
351409 </dialog>
352-<footer class="bar status" aria-label="Status">
353-<span class="mode">blob</span>
354-<span class="st-ctx">guide.md</span>
355-<button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
356-<span class="hi">:</span> go to…</button>
357-<span class="st-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : 'click a line number · shift-click extends'">click a line number · shift-click extends</span>
358-<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
359-</span>
360-<span class="st-hints">click a line number · y permalink · ? help</span>
361-<span class="st-src">
362-<span class="st-ver">ermite devel · </span>
363-<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
364-</span>
365-<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
366-</footer>
367-<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
410+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
368411 if (q('dialog[open]').count) return;
369412 let rows = q('[data-rows] > :not([hidden])').arr();
370413 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
371414 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
372- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
373- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
415+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
416+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
417+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
418+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
374419 })(1);
375- k-k from:body -> ((d) => {
420+ k-k from:body -> (async (d) => {
376421 if (q('dialog[open]').count) return;
377422 let rows = q('[data-rows] > :not([hidden])').arr();
378423 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
379424 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
380- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
381- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
425+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
426+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
427+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
428+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
382429 })(-1);
383430 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
384431 k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
385432 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
386433 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
387- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
388- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus());
389- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
434+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
435+ k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
436+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
390437 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
391- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
392- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
393- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
394- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
395- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
396- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
397- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
398- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
399- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
400- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
438+ k-gh from:body -> document.querySelector('.brand')?.click();
439+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
440+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
441+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
442+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
443+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
444+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
445+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
446+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
447+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
401448 k-next from:body -> {
402449 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
403450 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/blob-svg.html +163 -116
@@ -6,6 +6,7 @@
66 <meta name="color-scheme" content="light dark">
77 <title>logo.svg · files · example.test</title>
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9+<link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
910 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
1011 <script src="/-/static/js/htmx.min.HASH.js" defer>
1112 </script>
@@ -16,12 +17,15 @@
1617 <script src="/-/static/js/ermite.HASH.js" defer>
1718 </script>
1819 </head>
19-<body data-ref="main" data-sha="SHA40" data-sel="">
20+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
2021 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
23-<span class="fade" aria-hidden="true">
22+<header class="hdr">
23+<div class="hdr-top">
24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
2426 </span>
27+<span>example.test</span>
28+</a>
2529 <nav class="crumbs" aria-label="Path">
2630 <ol>
2731 <li>
@@ -38,36 +42,55 @@
3842 </li>
3943 </ol>
4044 </nav>
41-<nav class="secs" aria-label="Sections">
45+<div class="hdr-acts">
46+<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
47+<span class="ico i-branch" aria-hidden="true">
48+</span>
49+<span class="ref-name">main</span>
50+<span class="sha">SHA7</span>
51+</button>
52+<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
53+<span class="ico i-search" aria-hidden="true">
54+</span>
55+<span class="search-label">Go to…</span>
56+<kbd>ctrl k</kbd>
57+</button>
58+<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
59+</div>
60+</div>
61+<nav class="tabs" aria-label="Sections">
4262 <ul>
4363 <li>
44-<a href="/files" data-key="s" aria-keyshortcuts="g s">summary</a>
64+<a href="/files" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
65+</a>
4566 </li>
4667 <li>
47-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="g t" aria-current="page">tree</a>
68+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files<kbd aria-hidden="true">t</kbd>
69+</a>
4870 </li>
4971 <li>
50-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a>
72+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits<kbd aria-hidden="true">c</kbd>
73+</a>
5174 </li>
5275 <li>
53-<a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
76+<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
77+</a>
5478 </li>
5579 <li>
56-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
80+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
81+</a>
5782 </li>
5883 </ul>
5984 </nav>
60-<button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
61-</button>
6285 </header>
6386 <div class="work has-rail">
64-<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
87+<main id="main" tabindex="-1">
6588 <h1 class="file-h">
6689 <span>logo.svg</span>
6790 <span class="acts">
68-<a href="/files/raw/main/logo.svg">raw</a>
69-<a href="/files/commits/main/logo.svg">history</a>
70-<a href="/files/tree/main">dir</a>
91+<a class="btn" href="/files/raw/main/logo.svg">Raw</a>
92+<a class="btn" href="/files/commits/main/logo.svg">History</a>
93+<a class="btn" href="/files/tree/main">Folder</a>
7194 </span>
7295 </h1>
7396 <pre class="chroma code" data-anchor="" hx-on:click="let a = event.target.closest('a.ln'); if (!a) return; event.preventDefault();
@@ -91,10 +114,8 @@
91114 </pre>
92115 </main>
93116 <aside class="rail" aria-label="Context">
94-<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
95-<summary>file · last change · selection</summary>
96117 <section aria-labelledby="r-file">
97-<h2 id="r-file">file</h2>
118+<h2 id="r-file">File</h2>
98119 <dl class="kv">
99120 <dt>size</dt>
100121 <dd>89 B</dd>
@@ -105,7 +126,7 @@
105126 </dl>
106127 </section>
107128 <section aria-labelledby="r-last">
108-<h2 id="r-last">last change <a href="/files/commits/main/logo.svg">history</a>
129+<h2 id="r-last">Last change <a href="/files/commits/main/logo.svg">History</a>
109130 </h2>
110131 <p>
111132 <a href="/files/commit/SHA40">SHA7</a> add logo.svg</p>
@@ -113,77 +134,77 @@
113134 </p>
114135 </section>
115136 <section aria-labelledby="r-sel">
116-<h2 id="r-sel">selection</h2>
137+<h2 id="r-sel">Selection</h2>
117138 <p :text="document.body.dataset.sel || 'none · click a line number, shift-click extends'">none · click a line number, shift-click extends</p>
118139 <p class="dim">y copies a permalink pinned to the commit sha</p>
119140 </section>
120-</details>
121141 </aside>
122142 </div>
123-<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
143+<footer class="foot">
144+<span>ermite devel</span>
145+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
146+<span class="foot-keys">
147+<kbd>?</kbd> keyboard shortcuts</span>
148+</footer>
149+<div class="hint" role="status" aria-live="polite">
150+<span class="hint-nav" :text="document.body.dataset.nav || ''">
151+</span>
152+<span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''">
153+</span>
154+<span class="hint-flash" :text="document.body.dataset.flash || ''">
155+</span>
156+</div>
157+<dialog id="panel-help" class="panel modal" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
124158 <div class="panel-head">
125-<span>keys</span>
126-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
159+<h2>Keyboard shortcuts</h2>
160+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
161+<kbd>esc</kbd> close</button>
127162 </div>
128163 <dl class="keys">
129164 <div>
130165 <dt>
131-<kbd>j</kbd>
132-<kbd>k</kbd>
166+<kbd>ctrl k</kbd>
167+<kbd>:</kbd>
133168 </dt>
134-<dd>move cursor down / up</dd>
169+<dd>go to a section, branch, tag, file or repository</dd>
135170 </div>
136171 <div>
137172 <dt>
138-<kbd>enter</kbd>
173+<kbd>?</kbd>
139174 </dt>
140-<dd>open row under cursor</dd>
175+<dd>this list</dd>
141176 </div>
142177 <div>
143178 <dt>
144-<kbd>g</kbd>
145-<kbd>g</kbd> / <kbd>G</kbd>
179+<kbd>j</kbd>
180+<kbd>k</kbd>
146181 </dt>
147-<dd>first / last row</dd>
182+<dd>move the row cursor down / up</dd>
148183 </div>
149184 <div>
150185 <dt>
151-<kbd>/</kbd>
186+<kbd>enter</kbd>
152187 </dt>
153-<dd>focus the filter</dd>
188+<dd>open the row under the cursor</dd>
154189 </div>
155190 <div>
156191 <dt>
157-<kbd>g h</kbd>
158-<kbd>g t</kbd>
192+<kbd>g g</kbd>
193+<kbd>G</kbd>
159194 </dt>
160-<dd>go home / tree</dd>
195+<dd>first / last row</dd>
161196 </div>
162197 <div>
163198 <dt>
164-<kbd>g c</kbd>
165-<kbd>g r</kbd>
199+<kbd>/</kbd>
166200 </dt>
167-<dd>go commits / refs</dd>
201+<dd>filter the list</dd>
168202 </div>
169203 <div>
170204 <dt>
171205 <kbd>b</kbd>
172206 </dt>
173-<dd>switch ref (branches, tags)</dd>
174-</div>
175-<div>
176-<dt>
177-<kbd>:</kbd>
178-<kbd>f</kbd>
179-</dt>
180-<dd>go to: section, ref, file, repo</dd>
181-</div>
182-<div>
183-<dt>
184-<kbd>y</kbd>
185-</dt>
186-<dd>permalink: ref becomes the full sha</dd>
207+<dd>switch branch or tag</dd>
187208 </div>
188209 <div>
189210 <dt>
@@ -191,15 +212,21 @@
191212 <kbd>t</kbd>
192213 <kbd>c</kbd>
193214 <kbd>r</kbd>
215+<kbd>m</kbd>
194216 </dt>
195-<dd>repo sections (when shown)</dd>
217+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
196218 </div>
197219 <div>
198220 <dt>
199-<kbd>m</kbd>
200-<kbd>g m</kbd>
221+<kbd>g h</kbd>
222+</dt>
223+<dd>home</dd>
224+</div>
225+<div>
226+<dt>
227+<kbd>y</kbd>
201228 </dt>
202-<dd>merge requests (when shown)</dd>
229+<dd>copy a permalink pinned to the commit</dd>
203230 </div>
204231 <div>
205232 <dt>
@@ -219,19 +246,21 @@
219246 <dt>
220247 <kbd>esc</kbd>
221248 </dt>
222-<dd>close panel</dd>
249+<dd>close a dialog, leave a field</dd>
223250 </div>
224251 </dl>
225252 </dialog>
226-<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
253+<dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
227254 <div class="panel-head">
228-<label for="refs-filter">ref&gt;</label>
229-<input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
230-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
255+<label for="refs-filter" class="sr">filter branches and tags</label>
256+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
257+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
258+<kbd>esc</kbd>
259+</button>
231260 </div>
232261 <div class="panel-cols" data-picklist>
233262 <section aria-label="Branches">
234-<h2>branches <span class="mute">2</span>
263+<h2>Branches <span class="count">2</span>
235264 </h2>
236265 <ul>
237266 <li>
@@ -245,7 +274,7 @@
245274 </ul>
246275 </section>
247276 <section aria-label="Tags">
248-<h2>tags <span class="mute">1</span>
277+<h2>Tags <span class="count">1</span>
249278 </h2>
250279 <ul>
251280 <li>
@@ -256,106 +285,124 @@
256285 </section>
257286 </div>
258287 </dialog>
259-<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
288+<dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
260289 <div class="panel-head">
261-<label for="cmd-q" class="hi">:</label>
262-<input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
290+<label for="cmd-q" class="sr">go to</label>
291+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
263292 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
264293 let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
265294 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
266295 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
267296 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
268297 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
269-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
298+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
299+<kbd>esc</kbd>
300+</button>
270301 </div>
271-<ul id="cmd-list" class="cmd-list">
302+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
272303 <li data-k="go home" data-cursor>
273304 <a href="/" tabindex="-1">
274-<span class="k">go</span>home</a>
305+<span class="lbl">home</span>
306+<span class="k">go</span>
307+</a>
275308 </li>
276309 <li data-k="section summary">
277310 <a href="/files" tabindex="-1">
278-<span class="k">section</span>summary</a>
311+<span class="lbl">summary</span>
312+<span class="k">section</span>
313+</a>
279314 </li>
280-<li data-k="section tree">
315+<li data-k="section files">
281316 <a href="/files/tree/main" tabindex="-1">
282-<span class="k">section</span>tree</a>
317+<span class="lbl">files</span>
318+<span class="k">section</span>
319+</a>
283320 </li>
284321 <li data-k="section commits">
285322 <a href="/files/commits/main" tabindex="-1">
286-<span class="k">section</span>commits</a>
323+<span class="lbl">commits</span>
324+<span class="k">section</span>
325+</a>
287326 </li>
288-<li data-k="section refs">
327+<li data-k="section branches &amp; tags">
289328 <a href="/files/refs" tabindex="-1">
290-<span class="k">section</span>refs</a>
329+<span class="lbl">branches &amp; tags</span>
330+<span class="k">section</span>
331+</a>
291332 </li>
292333 <li data-k="section merge requests">
293334 <a href="/files/merge-requests" tabindex="-1">
294-<span class="k">section</span>merge requests</a>
335+<span class="lbl">merge requests</span>
336+<span class="k">section</span>
337+</a>
295338 </li>
296339 <li data-k="branch feature/x">
297340 <a href="/files/tree/feature/x" tabindex="-1">
298-<span class="k">branch</span>feature/x</a>
341+<span class="lbl">feature/x</span>
342+<span class="k">branch</span>
343+</a>
299344 </li>
300345 <li data-k="branch main">
301346 <a href="/files/tree/main" tabindex="-1">
302-<span class="k">branch</span>main</a>
347+<span class="lbl">main</span>
348+<span class="k">branch</span>
349+</a>
303350 </li>
304351 <li data-k="tag v1.0">
305352 <a href="/files/tree/v1.0" tabindex="-1">
306-<span class="k">tag</span>v1.0</a>
353+<span class="lbl">v1.0</span>
354+<span class="k">tag</span>
355+</a>
307356 </li>
308357 </ul>
358+<p class="panel-foot">
359+<span>
360+<kbd>↑</kbd>
361+<kbd>↓</kbd> move</span>
362+<span>
363+<kbd>enter</kbd> open</span>
364+<span>
365+<kbd>esc</kbd> close</span>
366+</p>
309367 </dialog>
310-<footer class="bar status" aria-label="Status">
311-<span class="mode">blob</span>
312-<span class="st-ctx">logo.svg</span>
313-<button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
314-<span class="hi">:</span> go to…</button>
315-<span class="st-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : 'click a line number · shift-click extends'">click a line number · shift-click extends</span>
316-<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
317-</span>
318-<span class="st-hints">click a line number · y permalink · ? help</span>
319-<span class="st-src">
320-<span class="st-ver">ermite devel · </span>
321-<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
322-</span>
323-<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
324-</footer>
325-<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
368+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
326369 if (q('dialog[open]').count) return;
327370 let rows = q('[data-rows] > :not([hidden])').arr();
328371 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
329372 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
330- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
331- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
373+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
374+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
375+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
376+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
332377 })(1);
333- k-k from:body -> ((d) => {
378+ k-k from:body -> (async (d) => {
334379 if (q('dialog[open]').count) return;
335380 let rows = q('[data-rows] > :not([hidden])').arr();
336381 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
337382 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
338- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
339- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
383+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
384+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
385+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
386+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
340387 })(-1);
341388 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
342389 k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
343390 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
344391 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
345- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
346- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus());
347- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
392+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
393+ k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
394+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
348395 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
349- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
350- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
351- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
352- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
353- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
354- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
355- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
356- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
357- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
358- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
396+ k-gh from:body -> document.querySelector('.brand')?.click();
397+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
398+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
399+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
400+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
401+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
402+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
403+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
404+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
405+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
359406 k-next from:body -> {
360407 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
361408 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/blob-text.html +163 -116
@@ -6,6 +6,7 @@
66 <meta name="color-scheme" content="light dark">
77 <title>notes.txt · files · example.test</title>
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9+<link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
910 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
1011 <script src="/-/static/js/htmx.min.HASH.js" defer>
1112 </script>
@@ -16,12 +17,15 @@
1617 <script src="/-/static/js/ermite.HASH.js" defer>
1718 </script>
1819 </head>
19-<body data-ref="main" data-sha="SHA40" data-sel="">
20+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
2021 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
23-<span class="fade" aria-hidden="true">
22+<header class="hdr">
23+<div class="hdr-top">
24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
2426 </span>
27+<span>example.test</span>
28+</a>
2529 <nav class="crumbs" aria-label="Path">
2630 <ol>
2731 <li>
@@ -38,36 +42,55 @@
3842 </li>
3943 </ol>
4044 </nav>
41-<nav class="secs" aria-label="Sections">
45+<div class="hdr-acts">
46+<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
47+<span class="ico i-branch" aria-hidden="true">
48+</span>
49+<span class="ref-name">main</span>
50+<span class="sha">SHA7</span>
51+</button>
52+<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
53+<span class="ico i-search" aria-hidden="true">
54+</span>
55+<span class="search-label">Go to…</span>
56+<kbd>ctrl k</kbd>
57+</button>
58+<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
59+</div>
60+</div>
61+<nav class="tabs" aria-label="Sections">
4262 <ul>
4363 <li>
44-<a href="/files" data-key="s" aria-keyshortcuts="g s">summary</a>
64+<a href="/files" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
65+</a>
4566 </li>
4667 <li>
47-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="g t" aria-current="page">tree</a>
68+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files<kbd aria-hidden="true">t</kbd>
69+</a>
4870 </li>
4971 <li>
50-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a>
72+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits<kbd aria-hidden="true">c</kbd>
73+</a>
5174 </li>
5275 <li>
53-<a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
76+<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
77+</a>
5478 </li>
5579 <li>
56-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
80+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
81+</a>
5782 </li>
5883 </ul>
5984 </nav>
60-<button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
61-</button>
6285 </header>
6386 <div class="work has-rail">
64-<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
87+<main id="main" tabindex="-1">
6588 <h1 class="file-h">
6689 <span>notes.txt</span>
6790 <span class="acts">
68-<a href="/files/raw/main/notes.txt">raw</a>
69-<a href="/files/commits/main/notes.txt">history</a>
70-<a href="/files/tree/main">dir</a>
91+<a class="btn" href="/files/raw/main/notes.txt">Raw</a>
92+<a class="btn" href="/files/commits/main/notes.txt">History</a>
93+<a class="btn" href="/files/tree/main">Folder</a>
7194 </span>
7295 </h1>
7396 <pre class="chroma code" data-anchor="" hx-on:click="let a = event.target.closest('a.ln'); if (!a) return; event.preventDefault();
@@ -86,10 +109,8 @@
86109 </pre>
87110 </main>
88111 <aside class="rail" aria-label="Context">
89-<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
90-<summary>file · last change · selection</summary>
91112 <section aria-labelledby="r-file">
92-<h2 id="r-file">file</h2>
113+<h2 id="r-file">File</h2>
93114 <dl class="kv">
94115 <dt>size</dt>
95116 <dd>24 B</dd>
@@ -100,7 +121,7 @@
100121 </dl>
101122 </section>
102123 <section aria-labelledby="r-last">
103-<h2 id="r-last">last change <a href="/files/commits/main/notes.txt">history</a>
124+<h2 id="r-last">Last change <a href="/files/commits/main/notes.txt">History</a>
104125 </h2>
105126 <p>
106127 <a href="/files/commit/SHA40">SHA7</a> add notes.txt</p>
@@ -108,77 +129,77 @@
108129 </p>
109130 </section>
110131 <section aria-labelledby="r-sel">
111-<h2 id="r-sel">selection</h2>
132+<h2 id="r-sel">Selection</h2>
112133 <p :text="document.body.dataset.sel || 'none · click a line number, shift-click extends'">none · click a line number, shift-click extends</p>
113134 <p class="dim">y copies a permalink pinned to the commit sha</p>
114135 </section>
115-</details>
116136 </aside>
117137 </div>
118-<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
138+<footer class="foot">
139+<span>ermite devel</span>
140+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
141+<span class="foot-keys">
142+<kbd>?</kbd> keyboard shortcuts</span>
143+</footer>
144+<div class="hint" role="status" aria-live="polite">
145+<span class="hint-nav" :text="document.body.dataset.nav || ''">
146+</span>
147+<span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''">
148+</span>
149+<span class="hint-flash" :text="document.body.dataset.flash || ''">
150+</span>
151+</div>
152+<dialog id="panel-help" class="panel modal" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
119153 <div class="panel-head">
120-<span>keys</span>
121-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
154+<h2>Keyboard shortcuts</h2>
155+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
156+<kbd>esc</kbd> close</button>
122157 </div>
123158 <dl class="keys">
124159 <div>
125160 <dt>
126-<kbd>j</kbd>
127-<kbd>k</kbd>
161+<kbd>ctrl k</kbd>
162+<kbd>:</kbd>
128163 </dt>
129-<dd>move cursor down / up</dd>
164+<dd>go to a section, branch, tag, file or repository</dd>
130165 </div>
131166 <div>
132167 <dt>
133-<kbd>enter</kbd>
168+<kbd>?</kbd>
134169 </dt>
135-<dd>open row under cursor</dd>
170+<dd>this list</dd>
136171 </div>
137172 <div>
138173 <dt>
139-<kbd>g</kbd>
140-<kbd>g</kbd> / <kbd>G</kbd>
174+<kbd>j</kbd>
175+<kbd>k</kbd>
141176 </dt>
142-<dd>first / last row</dd>
177+<dd>move the row cursor down / up</dd>
143178 </div>
144179 <div>
145180 <dt>
146-<kbd>/</kbd>
181+<kbd>enter</kbd>
147182 </dt>
148-<dd>focus the filter</dd>
183+<dd>open the row under the cursor</dd>
149184 </div>
150185 <div>
151186 <dt>
152-<kbd>g h</kbd>
153-<kbd>g t</kbd>
187+<kbd>g g</kbd>
188+<kbd>G</kbd>
154189 </dt>
155-<dd>go home / tree</dd>
190+<dd>first / last row</dd>
156191 </div>
157192 <div>
158193 <dt>
159-<kbd>g c</kbd>
160-<kbd>g r</kbd>
194+<kbd>/</kbd>
161195 </dt>
162-<dd>go commits / refs</dd>
196+<dd>filter the list</dd>
163197 </div>
164198 <div>
165199 <dt>
166200 <kbd>b</kbd>
167201 </dt>
168-<dd>switch ref (branches, tags)</dd>
169-</div>
170-<div>
171-<dt>
172-<kbd>:</kbd>
173-<kbd>f</kbd>
174-</dt>
175-<dd>go to: section, ref, file, repo</dd>
176-</div>
177-<div>
178-<dt>
179-<kbd>y</kbd>
180-</dt>
181-<dd>permalink: ref becomes the full sha</dd>
202+<dd>switch branch or tag</dd>
182203 </div>
183204 <div>
184205 <dt>
@@ -186,15 +207,21 @@
186207 <kbd>t</kbd>
187208 <kbd>c</kbd>
188209 <kbd>r</kbd>
210+<kbd>m</kbd>
189211 </dt>
190-<dd>repo sections (when shown)</dd>
212+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
191213 </div>
192214 <div>
193215 <dt>
194-<kbd>m</kbd>
195-<kbd>g m</kbd>
216+<kbd>g h</kbd>
217+</dt>
218+<dd>home</dd>
219+</div>
220+<div>
221+<dt>
222+<kbd>y</kbd>
196223 </dt>
197-<dd>merge requests (when shown)</dd>
224+<dd>copy a permalink pinned to the commit</dd>
198225 </div>
199226 <div>
200227 <dt>
@@ -214,19 +241,21 @@
214241 <dt>
215242 <kbd>esc</kbd>
216243 </dt>
217-<dd>close panel</dd>
244+<dd>close a dialog, leave a field</dd>
218245 </div>
219246 </dl>
220247 </dialog>
221-<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
248+<dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
222249 <div class="panel-head">
223-<label for="refs-filter">ref&gt;</label>
224-<input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
225-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
250+<label for="refs-filter" class="sr">filter branches and tags</label>
251+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
252+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
253+<kbd>esc</kbd>
254+</button>
226255 </div>
227256 <div class="panel-cols" data-picklist>
228257 <section aria-label="Branches">
229-<h2>branches <span class="mute">2</span>
258+<h2>Branches <span class="count">2</span>
230259 </h2>
231260 <ul>
232261 <li>
@@ -240,7 +269,7 @@
240269 </ul>
241270 </section>
242271 <section aria-label="Tags">
243-<h2>tags <span class="mute">1</span>
272+<h2>Tags <span class="count">1</span>
244273 </h2>
245274 <ul>
246275 <li>
@@ -251,106 +280,124 @@
251280 </section>
252281 </div>
253282 </dialog>
254-<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
283+<dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
255284 <div class="panel-head">
256-<label for="cmd-q" class="hi">:</label>
257-<input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
285+<label for="cmd-q" class="sr">go to</label>
286+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
258287 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
259288 let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
260289 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
261290 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
262291 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
263292 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
264-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
293+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
294+<kbd>esc</kbd>
295+</button>
265296 </div>
266-<ul id="cmd-list" class="cmd-list">
297+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
267298 <li data-k="go home" data-cursor>
268299 <a href="/" tabindex="-1">
269-<span class="k">go</span>home</a>
300+<span class="lbl">home</span>
301+<span class="k">go</span>
302+</a>
270303 </li>
271304 <li data-k="section summary">
272305 <a href="/files" tabindex="-1">
273-<span class="k">section</span>summary</a>
306+<span class="lbl">summary</span>
307+<span class="k">section</span>
308+</a>
274309 </li>
275-<li data-k="section tree">
310+<li data-k="section files">
276311 <a href="/files/tree/main" tabindex="-1">
277-<span class="k">section</span>tree</a>
312+<span class="lbl">files</span>
313+<span class="k">section</span>
314+</a>
278315 </li>
279316 <li data-k="section commits">
280317 <a href="/files/commits/main" tabindex="-1">
281-<span class="k">section</span>commits</a>
318+<span class="lbl">commits</span>
319+<span class="k">section</span>
320+</a>
282321 </li>
283-<li data-k="section refs">
322+<li data-k="section branches &amp; tags">
284323 <a href="/files/refs" tabindex="-1">
285-<span class="k">section</span>refs</a>
324+<span class="lbl">branches &amp; tags</span>
325+<span class="k">section</span>
326+</a>
286327 </li>
287328 <li data-k="section merge requests">
288329 <a href="/files/merge-requests" tabindex="-1">
289-<span class="k">section</span>merge requests</a>
330+<span class="lbl">merge requests</span>
331+<span class="k">section</span>
332+</a>
290333 </li>
291334 <li data-k="branch feature/x">
292335 <a href="/files/tree/feature/x" tabindex="-1">
293-<span class="k">branch</span>feature/x</a>
336+<span class="lbl">feature/x</span>
337+<span class="k">branch</span>
338+</a>
294339 </li>
295340 <li data-k="branch main">
296341 <a href="/files/tree/main" tabindex="-1">
297-<span class="k">branch</span>main</a>
342+<span class="lbl">main</span>
343+<span class="k">branch</span>
344+</a>
298345 </li>
299346 <li data-k="tag v1.0">
300347 <a href="/files/tree/v1.0" tabindex="-1">
301-<span class="k">tag</span>v1.0</a>
348+<span class="lbl">v1.0</span>
349+<span class="k">tag</span>
350+</a>
302351 </li>
303352 </ul>
353+<p class="panel-foot">
354+<span>
355+<kbd>↑</kbd>
356+<kbd>↓</kbd> move</span>
357+<span>
358+<kbd>enter</kbd> open</span>
359+<span>
360+<kbd>esc</kbd> close</span>
361+</p>
304362 </dialog>
305-<footer class="bar status" aria-label="Status">
306-<span class="mode">blob</span>
307-<span class="st-ctx">notes.txt</span>
308-<button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
309-<span class="hi">:</span> go to…</button>
310-<span class="st-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : 'click a line number · shift-click extends'">click a line number · shift-click extends</span>
311-<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
312-</span>
313-<span class="st-hints">click a line number · y permalink · ? help</span>
314-<span class="st-src">
315-<span class="st-ver">ermite devel · </span>
316-<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
317-</span>
318-<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
319-</footer>
320-<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
363+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
321364 if (q('dialog[open]').count) return;
322365 let rows = q('[data-rows] > :not([hidden])').arr();
323366 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
324367 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
325- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
326- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
368+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
369+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
370+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
371+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
327372 })(1);
328- k-k from:body -> ((d) => {
373+ k-k from:body -> (async (d) => {
329374 if (q('dialog[open]').count) return;
330375 let rows = q('[data-rows] > :not([hidden])').arr();
331376 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
332377 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
333- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
334- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
378+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
379+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
380+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
381+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
335382 })(-1);
336383 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
337384 k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
338385 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
339386 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
340- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
341- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus());
342- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
387+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
388+ k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
389+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
343390 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
344- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
345- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
346- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
347- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
348- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
349- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
350- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
351- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
352- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
353- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
391+ k-gh from:body -> document.querySelector('.brand')?.click();
392+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
393+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
394+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
395+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
396+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
397+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
398+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
399+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
400+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
354401 k-next from:body -> {
355402 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
356403 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/blob-too-large.html +162 -114
@@ -6,6 +6,7 @@
66 <meta name="color-scheme" content="light dark">
77 <title>big.txt · files · example.test</title>
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9+<link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
910 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
1011 <script src="/-/static/js/htmx.min.HASH.js" defer>
1112 </script>
@@ -16,12 +17,15 @@
1617 <script src="/-/static/js/ermite.HASH.js" defer>
1718 </script>
1819 </head>
19-<body data-ref="main" data-sha="SHA40" data-sel="">
20+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
2021 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
23-<span class="fade" aria-hidden="true">
22+<header class="hdr">
23+<div class="hdr-top">
24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
2426 </span>
27+<span>example.test</span>
28+</a>
2529 <nav class="crumbs" aria-label="Path">
2630 <ol>
2731 <li>
@@ -38,45 +42,62 @@
3842 </li>
3943 </ol>
4044 </nav>
41-<nav class="secs" aria-label="Sections">
45+<div class="hdr-acts">
46+<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
47+<span class="ico i-branch" aria-hidden="true">
48+</span>
49+<span class="ref-name">main</span>
50+<span class="sha">SHA7</span>
51+</button>
52+<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
53+<span class="ico i-search" aria-hidden="true">
54+</span>
55+<span class="search-label">Go to…</span>
56+<kbd>ctrl k</kbd>
57+</button>
58+<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
59+</div>
60+</div>
61+<nav class="tabs" aria-label="Sections">
4262 <ul>
4363 <li>
44-<a href="/files" data-key="s" aria-keyshortcuts="g s">summary</a>
64+<a href="/files" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
65+</a>
4566 </li>
4667 <li>
47-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="g t" aria-current="page">tree</a>
68+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files<kbd aria-hidden="true">t</kbd>
69+</a>
4870 </li>
4971 <li>
50-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a>
72+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits<kbd aria-hidden="true">c</kbd>
73+</a>
5174 </li>
5275 <li>
53-<a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
76+<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
77+</a>
5478 </li>
5579 <li>
56-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
80+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
81+</a>
5782 </li>
5883 </ul>
5984 </nav>
60-<button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
61-</button>
6285 </header>
6386 <div class="work has-rail">
64-<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
87+<main id="main" tabindex="-1">
6588 <h1 class="file-h">
6689 <span>big.txt</span>
6790 <span class="acts">
68-<a href="/files/raw/main/big.txt">raw</a>
69-<a href="/files/commits/main/big.txt">history</a>
70-<a href="/files/tree/main">dir</a>
91+<a class="btn" href="/files/raw/main/big.txt">Raw</a>
92+<a class="btn" href="/files/commits/main/big.txt">History</a>
93+<a class="btn" href="/files/tree/main">Folder</a>
7194 </span>
7295 </h1>
7396 <p class="notice" role="note">file too large to show (1.1 MiB, the limit is 1.0 MiB). use raw.</p>
7497 </main>
7598 <aside class="rail" aria-label="Context">
76-<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
77-<summary>file · last change · selection</summary>
7899 <section aria-labelledby="r-file">
79-<h2 id="r-file">file</h2>
100+<h2 id="r-file">File</h2>
80101 <dl class="kv">
81102 <dt>size</dt>
82103 <dd>1.1 MiB</dd>
@@ -85,80 +106,80 @@
85106 </dl>
86107 </section>
87108 <section aria-labelledby="r-last">
88-<h2 id="r-last">last change <a href="/files/commits/main/big.txt">history</a>
109+<h2 id="r-last">Last change <a href="/files/commits/main/big.txt">History</a>
89110 </h2>
90111 <p>
91112 <a href="/files/commit/SHA40">SHA7</a> add big.txt</p>
92113 <p class="dim">T · <time datetime="STAMP" title="STAMP">AGO</time>
93114 </p>
94115 </section>
95-</details>
96116 </aside>
97117 </div>
98-<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
118+<footer class="foot">
119+<span>ermite devel</span>
120+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
121+<span class="foot-keys">
122+<kbd>?</kbd> keyboard shortcuts</span>
123+</footer>
124+<div class="hint" role="status" aria-live="polite">
125+<span class="hint-nav" :text="document.body.dataset.nav || ''">
126+</span>
127+<span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''">
128+</span>
129+<span class="hint-flash" :text="document.body.dataset.flash || ''">
130+</span>
131+</div>
132+<dialog id="panel-help" class="panel modal" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
99133 <div class="panel-head">
100-<span>keys</span>
101-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
134+<h2>Keyboard shortcuts</h2>
135+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
136+<kbd>esc</kbd> close</button>
102137 </div>
103138 <dl class="keys">
104139 <div>
105140 <dt>
106-<kbd>j</kbd>
107-<kbd>k</kbd>
141+<kbd>ctrl k</kbd>
142+<kbd>:</kbd>
108143 </dt>
109-<dd>move cursor down / up</dd>
144+<dd>go to a section, branch, tag, file or repository</dd>
110145 </div>
111146 <div>
112147 <dt>
113-<kbd>enter</kbd>
148+<kbd>?</kbd>
114149 </dt>
115-<dd>open row under cursor</dd>
150+<dd>this list</dd>
116151 </div>
117152 <div>
118153 <dt>
119-<kbd>g</kbd>
120-<kbd>g</kbd> / <kbd>G</kbd>
154+<kbd>j</kbd>
155+<kbd>k</kbd>
121156 </dt>
122-<dd>first / last row</dd>
157+<dd>move the row cursor down / up</dd>
123158 </div>
124159 <div>
125160 <dt>
126-<kbd>/</kbd>
161+<kbd>enter</kbd>
127162 </dt>
128-<dd>focus the filter</dd>
163+<dd>open the row under the cursor</dd>
129164 </div>
130165 <div>
131166 <dt>
132-<kbd>g h</kbd>
133-<kbd>g t</kbd>
167+<kbd>g g</kbd>
168+<kbd>G</kbd>
134169 </dt>
135-<dd>go home / tree</dd>
170+<dd>first / last row</dd>
136171 </div>
137172 <div>
138173 <dt>
139-<kbd>g c</kbd>
140-<kbd>g r</kbd>
174+<kbd>/</kbd>
141175 </dt>
142-<dd>go commits / refs</dd>
176+<dd>filter the list</dd>
143177 </div>
144178 <div>
145179 <dt>
146180 <kbd>b</kbd>
147181 </dt>
148-<dd>switch ref (branches, tags)</dd>
149-</div>
150-<div>
151-<dt>
152-<kbd>:</kbd>
153-<kbd>f</kbd>
154-</dt>
155-<dd>go to: section, ref, file, repo</dd>
156-</div>
157-<div>
158-<dt>
159-<kbd>y</kbd>
160-</dt>
161-<dd>permalink: ref becomes the full sha</dd>
182+<dd>switch branch or tag</dd>
162183 </div>
163184 <div>
164185 <dt>
@@ -166,15 +187,21 @@
166187 <kbd>t</kbd>
167188 <kbd>c</kbd>
168189 <kbd>r</kbd>
190+<kbd>m</kbd>
169191 </dt>
170-<dd>repo sections (when shown)</dd>
192+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
171193 </div>
172194 <div>
173195 <dt>
174-<kbd>m</kbd>
175-<kbd>g m</kbd>
196+<kbd>g h</kbd>
197+</dt>
198+<dd>home</dd>
199+</div>
200+<div>
201+<dt>
202+<kbd>y</kbd>
176203 </dt>
177-<dd>merge requests (when shown)</dd>
204+<dd>copy a permalink pinned to the commit</dd>
178205 </div>
179206 <div>
180207 <dt>
@@ -194,19 +221,21 @@
194221 <dt>
195222 <kbd>esc</kbd>
196223 </dt>
197-<dd>close panel</dd>
224+<dd>close a dialog, leave a field</dd>
198225 </div>
199226 </dl>
200227 </dialog>
201-<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
228+<dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
202229 <div class="panel-head">
203-<label for="refs-filter">ref&gt;</label>
204-<input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
205-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
230+<label for="refs-filter" class="sr">filter branches and tags</label>
231+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
232+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
233+<kbd>esc</kbd>
234+</button>
206235 </div>
207236 <div class="panel-cols" data-picklist>
208237 <section aria-label="Branches">
209-<h2>branches <span class="mute">2</span>
238+<h2>Branches <span class="count">2</span>
210239 </h2>
211240 <ul>
212241 <li>
@@ -220,7 +249,7 @@
220249 </ul>
221250 </section>
222251 <section aria-label="Tags">
223-<h2>tags <span class="mute">1</span>
252+<h2>Tags <span class="count">1</span>
224253 </h2>
225254 <ul>
226255 <li>
@@ -231,105 +260,124 @@
231260 </section>
232261 </div>
233262 </dialog>
234-<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
263+<dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
235264 <div class="panel-head">
236-<label for="cmd-q" class="hi">:</label>
237-<input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
265+<label for="cmd-q" class="sr">go to</label>
266+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
238267 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
239268 let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
240269 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
241270 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
242271 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
243272 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
244-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
273+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
274+<kbd>esc</kbd>
275+</button>
245276 </div>
246-<ul id="cmd-list" class="cmd-list">
277+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
247278 <li data-k="go home" data-cursor>
248279 <a href="/" tabindex="-1">
249-<span class="k">go</span>home</a>
280+<span class="lbl">home</span>
281+<span class="k">go</span>
282+</a>
250283 </li>
251284 <li data-k="section summary">
252285 <a href="/files" tabindex="-1">
253-<span class="k">section</span>summary</a>
286+<span class="lbl">summary</span>
287+<span class="k">section</span>
288+</a>
254289 </li>
255-<li data-k="section tree">
290+<li data-k="section files">
256291 <a href="/files/tree/main" tabindex="-1">
257-<span class="k">section</span>tree</a>
292+<span class="lbl">files</span>
293+<span class="k">section</span>
294+</a>
258295 </li>
259296 <li data-k="section commits">
260297 <a href="/files/commits/main" tabindex="-1">
261-<span class="k">section</span>commits</a>
298+<span class="lbl">commits</span>
299+<span class="k">section</span>
300+</a>
262301 </li>
263-<li data-k="section refs">
302+<li data-k="section branches &amp; tags">
264303 <a href="/files/refs" tabindex="-1">
265-<span class="k">section</span>refs</a>
304+<span class="lbl">branches &amp; tags</span>
305+<span class="k">section</span>
306+</a>
266307 </li>
267308 <li data-k="section merge requests">
268309 <a href="/files/merge-requests" tabindex="-1">
269-<span class="k">section</span>merge requests</a>
310+<span class="lbl">merge requests</span>
311+<span class="k">section</span>
312+</a>
270313 </li>
271314 <li data-k="branch feature/x">
272315 <a href="/files/tree/feature/x" tabindex="-1">
273-<span class="k">branch</span>feature/x</a>
316+<span class="lbl">feature/x</span>
317+<span class="k">branch</span>
318+</a>
274319 </li>
275320 <li data-k="branch main">
276321 <a href="/files/tree/main" tabindex="-1">
277-<span class="k">branch</span>main</a>
322+<span class="lbl">main</span>
323+<span class="k">branch</span>
324+</a>
278325 </li>
279326 <li data-k="tag v1.0">
280327 <a href="/files/tree/v1.0" tabindex="-1">
281-<span class="k">tag</span>v1.0</a>
328+<span class="lbl">v1.0</span>
329+<span class="k">tag</span>
330+</a>
282331 </li>
283332 </ul>
333+<p class="panel-foot">
334+<span>
335+<kbd>↑</kbd>
336+<kbd>↓</kbd> move</span>
337+<span>
338+<kbd>enter</kbd> open</span>
339+<span>
340+<kbd>esc</kbd> close</span>
341+</p>
284342 </dialog>
285-<footer class="bar status" aria-label="Status">
286-<span class="mode">blob</span>
287-<span class="st-ctx">big.txt</span>
288-<button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
289-<span class="hi">:</span> go to…</button>
290-<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
291-</span>
292-<span class="st-hints">click a line number · y permalink · ? help</span>
293-<span class="st-src">
294-<span class="st-ver">ermite devel · </span>
295-<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
296-</span>
297-<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
298-</footer>
299-<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
343+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
300344 if (q('dialog[open]').count) return;
301345 let rows = q('[data-rows] > :not([hidden])').arr();
302346 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
303347 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
304- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
305- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
348+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
349+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
350+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
351+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
306352 })(1);
307- k-k from:body -> ((d) => {
353+ k-k from:body -> (async (d) => {
308354 if (q('dialog[open]').count) return;
309355 let rows = q('[data-rows] > :not([hidden])').arr();
310356 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
311357 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
312- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
313- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
358+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
359+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
360+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
361+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
314362 })(-1);
315363 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
316364 k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
317365 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
318366 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
319- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
320- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus());
321- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
367+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
368+ k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
369+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
322370 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
323- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
324- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
325- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
326- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
327- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
328- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
329- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
330- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
331- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
332- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
371+ k-gh from:body -> document.querySelector('.brand')?.click();
372+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
373+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
374+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
375+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
376+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
377+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
378+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
379+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
380+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
333381 k-next from:body -> {
334382 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
335383 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/blob-trojan.html +163 -116
@@ -6,6 +6,7 @@
66 <meta name="color-scheme" content="light dark">
77 <title>evil.go · files · example.test</title>
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9+<link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
910 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
1011 <script src="/-/static/js/htmx.min.HASH.js" defer>
1112 </script>
@@ -16,12 +17,15 @@
1617 <script src="/-/static/js/ermite.HASH.js" defer>
1718 </script>
1819 </head>
19-<body data-ref="main" data-sha="SHA40" data-sel="">
20+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
2021 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
23-<span class="fade" aria-hidden="true">
22+<header class="hdr">
23+<div class="hdr-top">
24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
2426 </span>
27+<span>example.test</span>
28+</a>
2529 <nav class="crumbs" aria-label="Path">
2630 <ol>
2731 <li>
@@ -38,36 +42,55 @@
3842 </li>
3943 </ol>
4044 </nav>
41-<nav class="secs" aria-label="Sections">
45+<div class="hdr-acts">
46+<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
47+<span class="ico i-branch" aria-hidden="true">
48+</span>
49+<span class="ref-name">main</span>
50+<span class="sha">SHA7</span>
51+</button>
52+<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
53+<span class="ico i-search" aria-hidden="true">
54+</span>
55+<span class="search-label">Go to…</span>
56+<kbd>ctrl k</kbd>
57+</button>
58+<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
59+</div>
60+</div>
61+<nav class="tabs" aria-label="Sections">
4262 <ul>
4363 <li>
44-<a href="/files" data-key="s" aria-keyshortcuts="g s">summary</a>
64+<a href="/files" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
65+</a>
4566 </li>
4667 <li>
47-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="g t" aria-current="page">tree</a>
68+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files<kbd aria-hidden="true">t</kbd>
69+</a>
4870 </li>
4971 <li>
50-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a>
72+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits<kbd aria-hidden="true">c</kbd>
73+</a>
5174 </li>
5275 <li>
53-<a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
76+<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
77+</a>
5478 </li>
5579 <li>
56-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
80+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
81+</a>
5782 </li>
5883 </ul>
5984 </nav>
60-<button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
61-</button>
6285 </header>
6386 <div class="work has-rail">
64-<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
87+<main id="main" tabindex="-1">
6588 <h1 class="file-h">
6689 <span>evil.go</span>
6790 <span class="acts">
68-<a href="/files/raw/main/evil.go">raw</a>
69-<a href="/files/commits/main/evil.go">history</a>
70-<a href="/files/tree/main">dir</a>
91+<a class="btn" href="/files/raw/main/evil.go">Raw</a>
92+<a class="btn" href="/files/commits/main/evil.go">History</a>
93+<a class="btn" href="/files/tree/main">Folder</a>
7194 </span>
7295 </h1>
7396 <pre class="chroma code" data-anchor="" hx-on:click="let a = event.target.closest('a.ln'); if (!a) return; event.preventDefault();
@@ -133,10 +156,8 @@
133156 </pre>
134157 </main>
135158 <aside class="rail" aria-label="Context">
136-<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
137-<summary>file · last change · selection</summary>
138159 <section aria-labelledby="r-file">
139-<h2 id="r-file">file</h2>
160+<h2 id="r-file">File</h2>
140161 <dl class="kv">
141162 <dt>size</dt>
142163 <dd>87 B</dd>
@@ -147,7 +168,7 @@
147168 </dl>
148169 </section>
149170 <section aria-labelledby="r-last">
150-<h2 id="r-last">last change <a href="/files/commits/main/evil.go">history</a>
171+<h2 id="r-last">Last change <a href="/files/commits/main/evil.go">History</a>
151172 </h2>
152173 <p>
153174 <a href="/files/commit/SHA40">SHA7</a> add evil.go</p>
@@ -155,77 +176,77 @@
155176 </p>
156177 </section>
157178 <section aria-labelledby="r-sel">
158-<h2 id="r-sel">selection</h2>
179+<h2 id="r-sel">Selection</h2>
159180 <p :text="document.body.dataset.sel || 'none · click a line number, shift-click extends'">none · click a line number, shift-click extends</p>
160181 <p class="dim">y copies a permalink pinned to the commit sha</p>
161182 </section>
162-</details>
163183 </aside>
164184 </div>
165-<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
185+<footer class="foot">
186+<span>ermite devel</span>
187+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
188+<span class="foot-keys">
189+<kbd>?</kbd> keyboard shortcuts</span>
190+</footer>
191+<div class="hint" role="status" aria-live="polite">
192+<span class="hint-nav" :text="document.body.dataset.nav || ''">
193+</span>
194+<span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''">
195+</span>
196+<span class="hint-flash" :text="document.body.dataset.flash || ''">
197+</span>
198+</div>
199+<dialog id="panel-help" class="panel modal" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
166200 <div class="panel-head">
167-<span>keys</span>
168-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
201+<h2>Keyboard shortcuts</h2>
202+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
203+<kbd>esc</kbd> close</button>
169204 </div>
170205 <dl class="keys">
171206 <div>
172207 <dt>
173-<kbd>j</kbd>
174-<kbd>k</kbd>
208+<kbd>ctrl k</kbd>
209+<kbd>:</kbd>
175210 </dt>
176-<dd>move cursor down / up</dd>
211+<dd>go to a section, branch, tag, file or repository</dd>
177212 </div>
178213 <div>
179214 <dt>
180-<kbd>enter</kbd>
215+<kbd>?</kbd>
181216 </dt>
182-<dd>open row under cursor</dd>
217+<dd>this list</dd>
183218 </div>
184219 <div>
185220 <dt>
186-<kbd>g</kbd>
187-<kbd>g</kbd> / <kbd>G</kbd>
221+<kbd>j</kbd>
222+<kbd>k</kbd>
188223 </dt>
189-<dd>first / last row</dd>
224+<dd>move the row cursor down / up</dd>
190225 </div>
191226 <div>
192227 <dt>
193-<kbd>/</kbd>
228+<kbd>enter</kbd>
194229 </dt>
195-<dd>focus the filter</dd>
230+<dd>open the row under the cursor</dd>
196231 </div>
197232 <div>
198233 <dt>
199-<kbd>g h</kbd>
200-<kbd>g t</kbd>
234+<kbd>g g</kbd>
235+<kbd>G</kbd>
201236 </dt>
202-<dd>go home / tree</dd>
237+<dd>first / last row</dd>
203238 </div>
204239 <div>
205240 <dt>
206-<kbd>g c</kbd>
207-<kbd>g r</kbd>
241+<kbd>/</kbd>
208242 </dt>
209-<dd>go commits / refs</dd>
243+<dd>filter the list</dd>
210244 </div>
211245 <div>
212246 <dt>
213247 <kbd>b</kbd>
214248 </dt>
215-<dd>switch ref (branches, tags)</dd>
216-</div>
217-<div>
218-<dt>
219-<kbd>:</kbd>
220-<kbd>f</kbd>
221-</dt>
222-<dd>go to: section, ref, file, repo</dd>
223-</div>
224-<div>
225-<dt>
226-<kbd>y</kbd>
227-</dt>
228-<dd>permalink: ref becomes the full sha</dd>
249+<dd>switch branch or tag</dd>
229250 </div>
230251 <div>
231252 <dt>
@@ -233,15 +254,21 @@
233254 <kbd>t</kbd>
234255 <kbd>c</kbd>
235256 <kbd>r</kbd>
257+<kbd>m</kbd>
236258 </dt>
237-<dd>repo sections (when shown)</dd>
259+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
238260 </div>
239261 <div>
240262 <dt>
241-<kbd>m</kbd>
242-<kbd>g m</kbd>
263+<kbd>g h</kbd>
264+</dt>
265+<dd>home</dd>
266+</div>
267+<div>
268+<dt>
269+<kbd>y</kbd>
243270 </dt>
244-<dd>merge requests (when shown)</dd>
271+<dd>copy a permalink pinned to the commit</dd>
245272 </div>
246273 <div>
247274 <dt>
@@ -261,19 +288,21 @@
261288 <dt>
262289 <kbd>esc</kbd>
263290 </dt>
264-<dd>close panel</dd>
291+<dd>close a dialog, leave a field</dd>
265292 </div>
266293 </dl>
267294 </dialog>
268-<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
295+<dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
269296 <div class="panel-head">
270-<label for="refs-filter">ref&gt;</label>
271-<input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
272-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
297+<label for="refs-filter" class="sr">filter branches and tags</label>
298+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
299+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
300+<kbd>esc</kbd>
301+</button>
273302 </div>
274303 <div class="panel-cols" data-picklist>
275304 <section aria-label="Branches">
276-<h2>branches <span class="mute">2</span>
305+<h2>Branches <span class="count">2</span>
277306 </h2>
278307 <ul>
279308 <li>
@@ -287,7 +316,7 @@
287316 </ul>
288317 </section>
289318 <section aria-label="Tags">
290-<h2>tags <span class="mute">1</span>
319+<h2>Tags <span class="count">1</span>
291320 </h2>
292321 <ul>
293322 <li>
@@ -298,106 +327,124 @@
298327 </section>
299328 </div>
300329 </dialog>
301-<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
330+<dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
302331 <div class="panel-head">
303-<label for="cmd-q" class="hi">:</label>
304-<input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
332+<label for="cmd-q" class="sr">go to</label>
333+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
305334 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
306335 let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
307336 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
308337 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
309338 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
310339 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
311-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
340+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
341+<kbd>esc</kbd>
342+</button>
312343 </div>
313-<ul id="cmd-list" class="cmd-list">
344+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
314345 <li data-k="go home" data-cursor>
315346 <a href="/" tabindex="-1">
316-<span class="k">go</span>home</a>
347+<span class="lbl">home</span>
348+<span class="k">go</span>
349+</a>
317350 </li>
318351 <li data-k="section summary">
319352 <a href="/files" tabindex="-1">
320-<span class="k">section</span>summary</a>
353+<span class="lbl">summary</span>
354+<span class="k">section</span>
355+</a>
321356 </li>
322-<li data-k="section tree">
357+<li data-k="section files">
323358 <a href="/files/tree/main" tabindex="-1">
324-<span class="k">section</span>tree</a>
359+<span class="lbl">files</span>
360+<span class="k">section</span>
361+</a>
325362 </li>
326363 <li data-k="section commits">
327364 <a href="/files/commits/main" tabindex="-1">
328-<span class="k">section</span>commits</a>
365+<span class="lbl">commits</span>
366+<span class="k">section</span>
367+</a>
329368 </li>
330-<li data-k="section refs">
369+<li data-k="section branches &amp; tags">
331370 <a href="/files/refs" tabindex="-1">
332-<span class="k">section</span>refs</a>
371+<span class="lbl">branches &amp; tags</span>
372+<span class="k">section</span>
373+</a>
333374 </li>
334375 <li data-k="section merge requests">
335376 <a href="/files/merge-requests" tabindex="-1">
336-<span class="k">section</span>merge requests</a>
377+<span class="lbl">merge requests</span>
378+<span class="k">section</span>
379+</a>
337380 </li>
338381 <li data-k="branch feature/x">
339382 <a href="/files/tree/feature/x" tabindex="-1">
340-<span class="k">branch</span>feature/x</a>
383+<span class="lbl">feature/x</span>
384+<span class="k">branch</span>
385+</a>
341386 </li>
342387 <li data-k="branch main">
343388 <a href="/files/tree/main" tabindex="-1">
344-<span class="k">branch</span>main</a>
389+<span class="lbl">main</span>
390+<span class="k">branch</span>
391+</a>
345392 </li>
346393 <li data-k="tag v1.0">
347394 <a href="/files/tree/v1.0" tabindex="-1">
348-<span class="k">tag</span>v1.0</a>
395+<span class="lbl">v1.0</span>
396+<span class="k">tag</span>
397+</a>
349398 </li>
350399 </ul>
400+<p class="panel-foot">
401+<span>
402+<kbd>↑</kbd>
403+<kbd>↓</kbd> move</span>
404+<span>
405+<kbd>enter</kbd> open</span>
406+<span>
407+<kbd>esc</kbd> close</span>
408+</p>
351409 </dialog>
352-<footer class="bar status" aria-label="Status">
353-<span class="mode">blob</span>
354-<span class="st-ctx">evil.go</span>
355-<button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
356-<span class="hi">:</span> go to…</button>
357-<span class="st-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : 'click a line number · shift-click extends'">click a line number · shift-click extends</span>
358-<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
359-</span>
360-<span class="st-hints">click a line number · y permalink · ? help</span>
361-<span class="st-src">
362-<span class="st-ver">ermite devel · </span>
363-<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
364-</span>
365-<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
366-</footer>
367-<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
410+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
368411 if (q('dialog[open]').count) return;
369412 let rows = q('[data-rows] > :not([hidden])').arr();
370413 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
371414 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
372- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
373- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
415+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
416+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
417+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
418+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
374419 })(1);
375- k-k from:body -> ((d) => {
420+ k-k from:body -> (async (d) => {
376421 if (q('dialog[open]').count) return;
377422 let rows = q('[data-rows] > :not([hidden])').arr();
378423 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
379424 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
380- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
381- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
425+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
426+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
427+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
428+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
382429 })(-1);
383430 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
384431 k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
385432 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
386433 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
387- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
388- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus());
389- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
434+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
435+ k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
436+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
390437 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
391- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
392- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
393- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
394- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
395- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
396- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
397- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
398- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
399- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
400- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
438+ k-gh from:body -> document.querySelector('.brand')?.click();
439+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
440+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
441+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
442+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
443+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
444+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
445+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
446+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
447+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
401448 k-next from:body -> {
402449 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
403450 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/commit-big.html +167 -114
@@ -6,6 +6,7 @@
66 <meta name="color-scheme" content="light dark">
77 <title>SHA7 add a very large file · diffs · example.test</title>
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9+<link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
910 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
1011 <script src="/-/static/js/htmx.min.HASH.js" defer>
1112 </script>
@@ -16,12 +17,15 @@
1617 <script src="/-/static/js/ermite.HASH.js" defer>
1718 </script>
1819 </head>
19-<body data-ref="" data-sha="SHA40" data-sel="">
20+<body data-ref="" data-sha="SHA40" data-sel="" data-nav="">
2021 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
23-<span class="fade" aria-hidden="true">
22+<header class="hdr">
23+<div class="hdr-top">
24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
2426 </span>
27+<span>example.test</span>
28+</a>
2529 <nav class="crumbs" aria-label="Path">
2630 <ol>
2731 <li>
@@ -35,30 +39,49 @@
3539 </li>
3640 </ol>
3741 </nav>
38-<nav class="secs" aria-label="Sections">
42+<div class="hdr-acts">
43+<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
44+<span class="ico i-branch" aria-hidden="true">
45+</span>
46+<span class="ref-name">detached</span>
47+<span class="sha">SHA7</span>
48+</button>
49+<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
50+<span class="ico i-search" aria-hidden="true">
51+</span>
52+<span class="search-label">Go to…</span>
53+<kbd>ctrl k</kbd>
54+</button>
55+<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
56+</div>
57+</div>
58+<nav class="tabs" aria-label="Sections">
3959 <ul>
4060 <li>
41-<a href="/diffs" data-key="s" aria-keyshortcuts="g s">summary</a>
61+<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
62+</a>
4263 </li>
4364 <li>
44-<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="g t">tree</a>
65+<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files<kbd aria-hidden="true">t</kbd>
66+</a>
4567 </li>
4668 <li>
47-<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="g c" aria-current="page">commits</a>
69+<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits<kbd aria-hidden="true">c</kbd>
70+</a>
4871 </li>
4972 <li>
50-<a href="/diffs/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
73+<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
74+</a>
5175 </li>
5276 <li>
53-<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
77+<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
78+</a>
5479 </li>
5580 </ul>
5681 </nav>
57-<button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">detached<span class="sha">@SHA7</span>
58-</button>
5982 </header>
6083 <div class="work has-rail">
61-<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
84+<main id="main" tabindex="-1">
6285 <header class="show">
6386 <h1>add a very large file</h1>
6487 <dl class="kv show-kv">
@@ -71,7 +94,9 @@
7194 <a href="/diffs/commit/SHA40">SHA7</a>
7295 </dd>
7396 <dt>refs</dt>
74-<dd>[main] </dd>
97+<dd>
98+<span class="pill">main</span>
99+</dd>
75100 </dl>
76101 </header>
77102 <div class="diff-tools" role="toolbar" aria-label="Diff view">
@@ -80,9 +105,12 @@
80105 </span>
81106 <span class="grow">
82107 </span>
83-<a href="/diffs/commit/SHA40?view=unified" class="vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;unified&#39; ? &#39;true&#39; : false">[u]nified</a>
84-<a href="/diffs/commit/SHA40?view=split" class="vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;split&#39; ? &#39;true&#39; : false">[d] split</a>
85-<button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))">[x] collapse all</button>
108+<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;unified&#39; ? &#39;true&#39; : false">Unified<kbd>u</kbd>
109+</a>
110+<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;split&#39; ? &#39;true&#39; : false">Split<kbd>d</kbd>
111+</a>
112+<button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))" class="btn">Collapse all<kbd>x</kbd>
113+</button>
86114 </div>
87115 <p class="notice">this diff is incomplete: 1 file is too large to show. the totals above count everything; clone the repository to read the rest.</p>
88116 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">
@@ -99,10 +127,8 @@
99127 </div>
100128 </main>
101129 <aside class="rail" aria-label="Context">
102-<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
103-<summary>1 changed file</summary>
104130 <section aria-labelledby="r-files">
105-<h2 id="r-files">files <span>1</span>
131+<h2 id="r-files">Files <span class="count">1</span>
106132 </h2>
107133 <ol class="files" aria-label="Changed files">
108134 <li>
@@ -122,73 +148,73 @@
122148 </li>
123149 </ol>
124150 </section>
125-</details>
126151 </aside>
127152 </div>
128-<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
153+<footer class="foot">
154+<span>ermite devel</span>
155+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
156+<span class="foot-keys">
157+<kbd>?</kbd> keyboard shortcuts</span>
158+</footer>
159+<div class="hint" role="status" aria-live="polite">
160+<span class="hint-nav" :text="document.body.dataset.nav || ''">
161+</span>
162+<span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''">
163+</span>
164+<span class="hint-flash" :text="document.body.dataset.flash || ''">
165+</span>
166+</div>
167+<dialog id="panel-help" class="panel modal" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
129168 <div class="panel-head">
130-<span>keys</span>
131-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
169+<h2>Keyboard shortcuts</h2>
170+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
171+<kbd>esc</kbd> close</button>
132172 </div>
133173 <dl class="keys">
134174 <div>
135175 <dt>
136-<kbd>j</kbd>
137-<kbd>k</kbd>
176+<kbd>ctrl k</kbd>
177+<kbd>:</kbd>
138178 </dt>
139-<dd>move cursor down / up</dd>
179+<dd>go to a section, branch, tag, file or repository</dd>
140180 </div>
141181 <div>
142182 <dt>
143-<kbd>enter</kbd>
183+<kbd>?</kbd>
144184 </dt>
145-<dd>open row under cursor</dd>
185+<dd>this list</dd>
146186 </div>
147187 <div>
148188 <dt>
149-<kbd>g</kbd>
150-<kbd>g</kbd> / <kbd>G</kbd>
189+<kbd>j</kbd>
190+<kbd>k</kbd>
151191 </dt>
152-<dd>first / last row</dd>
192+<dd>move the row cursor down / up</dd>
153193 </div>
154194 <div>
155195 <dt>
156-<kbd>/</kbd>
196+<kbd>enter</kbd>
157197 </dt>
158-<dd>focus the filter</dd>
198+<dd>open the row under the cursor</dd>
159199 </div>
160200 <div>
161201 <dt>
162-<kbd>g h</kbd>
163-<kbd>g t</kbd>
202+<kbd>g g</kbd>
203+<kbd>G</kbd>
164204 </dt>
165-<dd>go home / tree</dd>
205+<dd>first / last row</dd>
166206 </div>
167207 <div>
168208 <dt>
169-<kbd>g c</kbd>
170-<kbd>g r</kbd>
209+<kbd>/</kbd>
171210 </dt>
172-<dd>go commits / refs</dd>
211+<dd>filter the list</dd>
173212 </div>
174213 <div>
175214 <dt>
176215 <kbd>b</kbd>
177216 </dt>
178-<dd>switch ref (branches, tags)</dd>
179-</div>
180-<div>
181-<dt>
182-<kbd>:</kbd>
183-<kbd>f</kbd>
184-</dt>
185-<dd>go to: section, ref, file, repo</dd>
186-</div>
187-<div>
188-<dt>
189-<kbd>y</kbd>
190-</dt>
191-<dd>permalink: ref becomes the full sha</dd>
217+<dd>switch branch or tag</dd>
192218 </div>
193219 <div>
194220 <dt>
@@ -196,15 +222,21 @@
196222 <kbd>t</kbd>
197223 <kbd>c</kbd>
198224 <kbd>r</kbd>
225+<kbd>m</kbd>
199226 </dt>
200-<dd>repo sections (when shown)</dd>
227+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
201228 </div>
202229 <div>
203230 <dt>
204-<kbd>m</kbd>
205-<kbd>g m</kbd>
231+<kbd>g h</kbd>
232+</dt>
233+<dd>home</dd>
234+</div>
235+<div>
236+<dt>
237+<kbd>y</kbd>
206238 </dt>
207-<dd>merge requests (when shown)</dd>
239+<dd>copy a permalink pinned to the commit</dd>
208240 </div>
209241 <div>
210242 <dt>
@@ -224,19 +256,21 @@
224256 <dt>
225257 <kbd>esc</kbd>
226258 </dt>
227-<dd>close panel</dd>
259+<dd>close a dialog, leave a field</dd>
228260 </div>
229261 </dl>
230262 </dialog>
231-<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
263+<dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
232264 <div class="panel-head">
233-<label for="refs-filter">ref&gt;</label>
234-<input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
235-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
265+<label for="refs-filter" class="sr">filter branches and tags</label>
266+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
267+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
268+<kbd>esc</kbd>
269+</button>
236270 </div>
237271 <div class="panel-cols" data-picklist>
238272 <section aria-label="Branches">
239-<h2>branches <span class="mute">2</span>
273+<h2>Branches <span class="count">2</span>
240274 </h2>
241275 <ul>
242276 <li>
@@ -250,7 +284,7 @@
250284 </ul>
251285 </section>
252286 <section aria-label="Tags">
253-<h2>tags <span class="mute">1</span>
287+<h2>Tags <span class="count">1</span>
254288 </h2>
255289 <ul>
256290 <li>
@@ -261,105 +295,124 @@
261295 </section>
262296 </div>
263297 </dialog>
264-<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
298+<dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
265299 <div class="panel-head">
266-<label for="cmd-q" class="hi">:</label>
267-<input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
300+<label for="cmd-q" class="sr">go to</label>
301+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
268302 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
269303 let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
270304 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
271305 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
272306 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
273307 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
274-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
308+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
309+<kbd>esc</kbd>
310+</button>
275311 </div>
276-<ul id="cmd-list" class="cmd-list">
312+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
277313 <li data-k="go home" data-cursor>
278314 <a href="/" tabindex="-1">
279-<span class="k">go</span>home</a>
315+<span class="lbl">home</span>
316+<span class="k">go</span>
317+</a>
280318 </li>
281319 <li data-k="section summary">
282320 <a href="/diffs" tabindex="-1">
283-<span class="k">section</span>summary</a>
321+<span class="lbl">summary</span>
322+<span class="k">section</span>
323+</a>
284324 </li>
285-<li data-k="section tree">
325+<li data-k="section files">
286326 <a href="/diffs/tree/main" tabindex="-1">
287-<span class="k">section</span>tree</a>
327+<span class="lbl">files</span>
328+<span class="k">section</span>
329+</a>
288330 </li>
289331 <li data-k="section commits">
290332 <a href="/diffs/commits/main" tabindex="-1">
291-<span class="k">section</span>commits</a>
333+<span class="lbl">commits</span>
334+<span class="k">section</span>
335+</a>
292336 </li>
293-<li data-k="section refs">
337+<li data-k="section branches &amp; tags">
294338 <a href="/diffs/refs" tabindex="-1">
295-<span class="k">section</span>refs</a>
339+<span class="lbl">branches &amp; tags</span>
340+<span class="k">section</span>
341+</a>
296342 </li>
297343 <li data-k="section merge requests">
298344 <a href="/diffs/merge-requests" tabindex="-1">
299-<span class="k">section</span>merge requests</a>
345+<span class="lbl">merge requests</span>
346+<span class="k">section</span>
347+</a>
300348 </li>
301349 <li data-k="branch main">
302350 <a href="/diffs/tree/main" tabindex="-1">
303-<span class="k">branch</span>main</a>
351+<span class="lbl">main</span>
352+<span class="k">branch</span>
353+</a>
304354 </li>
305355 <li data-k="branch side">
306356 <a href="/diffs/tree/side" tabindex="-1">
307-<span class="k">branch</span>side</a>
357+<span class="lbl">side</span>
358+<span class="k">branch</span>
359+</a>
308360 </li>
309361 <li data-k="tag v1.0.0">
310362 <a href="/diffs/tree/v1.0.0" tabindex="-1">
311-<span class="k">tag</span>v1.0.0</a>
363+<span class="lbl">v1.0.0</span>
364+<span class="k">tag</span>
365+</a>
312366 </li>
313367 </ul>
368+<p class="panel-foot">
369+<span>
370+<kbd>↑</kbd>
371+<kbd>↓</kbd> move</span>
372+<span>
373+<kbd>enter</kbd> open</span>
374+<span>
375+<kbd>esc</kbd> close</span>
376+</p>
314377 </dialog>
315-<footer class="bar status" aria-label="Status">
316-<span class="mode">commit</span>
317-<span class="st-ctx">SHA7 · 1 file · +2600 -0</span>
318-<button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
319-<span class="hi">:</span> go to…</button>
320-<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
321-</span>
322-<span class="st-hints">d split · u unified · x collapse all · ? help</span>
323-<span class="st-src">
324-<span class="st-ver">ermite devel · </span>
325-<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
326-</span>
327-<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
328-</footer>
329-<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
378+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
330379 if (q('dialog[open]').count) return;
331380 let rows = q('[data-rows] > :not([hidden])').arr();
332381 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
333382 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
334- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
335- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
383+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
384+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
385+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
386+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
336387 })(1);
337- k-k from:body -> ((d) => {
388+ k-k from:body -> (async (d) => {
338389 if (q('dialog[open]').count) return;
339390 let rows = q('[data-rows] > :not([hidden])').arr();
340391 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
341392 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
342- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
343- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
393+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
394+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
395+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
396+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
344397 })(-1);
345398 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
346399 k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
347400 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
348401 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
349- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
350- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus());
351- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
402+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
403+ k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
404+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
352405 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
353- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
354- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
355- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
356- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
357- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
358- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
359- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
360- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
361- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
362- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
406+ k-gh from:body -> document.querySelector('.brand')?.click();
407+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
408+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
409+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
410+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
411+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
412+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
413+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
414+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
415+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
363416 k-next from:body -> {
364417 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
365418 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/commit-binary.html +164 -113
@@ -6,6 +6,7 @@
66 <meta name="color-scheme" content="light dark">
77 <title>SHA7 change a binary file · diffs · example.test</title>
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9+<link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
910 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
1011 <script src="/-/static/js/htmx.min.HASH.js" defer>
1112 </script>
@@ -16,12 +17,15 @@
1617 <script src="/-/static/js/ermite.HASH.js" defer>
1718 </script>
1819 </head>
19-<body data-ref="" data-sha="SHA40" data-sel="">
20+<body data-ref="" data-sha="SHA40" data-sel="" data-nav="">
2021 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
23-<span class="fade" aria-hidden="true">
22+<header class="hdr">
23+<div class="hdr-top">
24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
2426 </span>
27+<span>example.test</span>
28+</a>
2529 <nav class="crumbs" aria-label="Path">
2630 <ol>
2731 <li>
@@ -35,30 +39,49 @@
3539 </li>
3640 </ol>
3741 </nav>
38-<nav class="secs" aria-label="Sections">
42+<div class="hdr-acts">
43+<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
44+<span class="ico i-branch" aria-hidden="true">
45+</span>
46+<span class="ref-name">detached</span>
47+<span class="sha">SHA7</span>
48+</button>
49+<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
50+<span class="ico i-search" aria-hidden="true">
51+</span>
52+<span class="search-label">Go to…</span>
53+<kbd>ctrl k</kbd>
54+</button>
55+<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
56+</div>
57+</div>
58+<nav class="tabs" aria-label="Sections">
3959 <ul>
4060 <li>
41-<a href="/diffs" data-key="s" aria-keyshortcuts="g s">summary</a>
61+<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
62+</a>
4263 </li>
4364 <li>
44-<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="g t">tree</a>
65+<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files<kbd aria-hidden="true">t</kbd>
66+</a>
4567 </li>
4668 <li>
47-<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="g c" aria-current="page">commits</a>
69+<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits<kbd aria-hidden="true">c</kbd>
70+</a>
4871 </li>
4972 <li>
50-<a href="/diffs/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
73+<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
74+</a>
5175 </li>
5276 <li>
53-<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
77+<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
78+</a>
5479 </li>
5580 </ul>
5681 </nav>
57-<button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">detached<span class="sha">@SHA7</span>
58-</button>
5982 </header>
6083 <div class="work has-rail">
61-<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
84+<main id="main" tabindex="-1">
6285 <header class="show">
6386 <h1>change a binary file</h1>
6487 <dl class="kv show-kv">
@@ -78,9 +101,12 @@
78101 </span>
79102 <span class="grow">
80103 </span>
81-<a href="/diffs/commit/SHA40?view=unified" class="vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;unified&#39; ? &#39;true&#39; : false">[u]nified</a>
82-<a href="/diffs/commit/SHA40?view=split" class="vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;split&#39; ? &#39;true&#39; : false">[d] split</a>
83-<button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))">[x] collapse all</button>
104+<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;unified&#39; ? &#39;true&#39; : false">Unified<kbd>u</kbd>
105+</a>
106+<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;split&#39; ? &#39;true&#39; : false">Split<kbd>d</kbd>
107+</a>
108+<button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))" class="btn">Collapse all<kbd>x</kbd>
109+</button>
84110 </div>
85111 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">
86112 <details class="dfile" id="f1" data-file>
@@ -96,10 +122,8 @@
96122 </div>
97123 </main>
98124 <aside class="rail" aria-label="Context">
99-<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
100-<summary>1 changed file</summary>
101125 <section aria-labelledby="r-files">
102-<h2 id="r-files">files <span>1</span>
126+<h2 id="r-files">Files <span class="count">1</span>
103127 </h2>
104128 <ol class="files" aria-label="Changed files">
105129 <li>
@@ -119,73 +143,73 @@
119143 </li>
120144 </ol>
121145 </section>
122-</details>
123146 </aside>
124147 </div>
125-<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
148+<footer class="foot">
149+<span>ermite devel</span>
150+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
151+<span class="foot-keys">
152+<kbd>?</kbd> keyboard shortcuts</span>
153+</footer>
154+<div class="hint" role="status" aria-live="polite">
155+<span class="hint-nav" :text="document.body.dataset.nav || ''">
156+</span>
157+<span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''">
158+</span>
159+<span class="hint-flash" :text="document.body.dataset.flash || ''">
160+</span>
161+</div>
162+<dialog id="panel-help" class="panel modal" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
126163 <div class="panel-head">
127-<span>keys</span>
128-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
164+<h2>Keyboard shortcuts</h2>
165+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
166+<kbd>esc</kbd> close</button>
129167 </div>
130168 <dl class="keys">
131169 <div>
132170 <dt>
133-<kbd>j</kbd>
134-<kbd>k</kbd>
171+<kbd>ctrl k</kbd>
172+<kbd>:</kbd>
135173 </dt>
136-<dd>move cursor down / up</dd>
174+<dd>go to a section, branch, tag, file or repository</dd>
137175 </div>
138176 <div>
139177 <dt>
140-<kbd>enter</kbd>
178+<kbd>?</kbd>
141179 </dt>
142-<dd>open row under cursor</dd>
180+<dd>this list</dd>
143181 </div>
144182 <div>
145183 <dt>
146-<kbd>g</kbd>
147-<kbd>g</kbd> / <kbd>G</kbd>
184+<kbd>j</kbd>
185+<kbd>k</kbd>
148186 </dt>
149-<dd>first / last row</dd>
187+<dd>move the row cursor down / up</dd>
150188 </div>
151189 <div>
152190 <dt>
153-<kbd>/</kbd>
191+<kbd>enter</kbd>
154192 </dt>
155-<dd>focus the filter</dd>
193+<dd>open the row under the cursor</dd>
156194 </div>
157195 <div>
158196 <dt>
159-<kbd>g h</kbd>
160-<kbd>g t</kbd>
197+<kbd>g g</kbd>
198+<kbd>G</kbd>
161199 </dt>
162-<dd>go home / tree</dd>
200+<dd>first / last row</dd>
163201 </div>
164202 <div>
165203 <dt>
166-<kbd>g c</kbd>
167-<kbd>g r</kbd>
204+<kbd>/</kbd>
168205 </dt>
169-<dd>go commits / refs</dd>
206+<dd>filter the list</dd>
170207 </div>
171208 <div>
172209 <dt>
173210 <kbd>b</kbd>
174211 </dt>
175-<dd>switch ref (branches, tags)</dd>
176-</div>
177-<div>
178-<dt>
179-<kbd>:</kbd>
180-<kbd>f</kbd>
181-</dt>
182-<dd>go to: section, ref, file, repo</dd>
183-</div>
184-<div>
185-<dt>
186-<kbd>y</kbd>
187-</dt>
188-<dd>permalink: ref becomes the full sha</dd>
212+<dd>switch branch or tag</dd>
189213 </div>
190214 <div>
191215 <dt>
@@ -193,15 +217,21 @@
193217 <kbd>t</kbd>
194218 <kbd>c</kbd>
195219 <kbd>r</kbd>
220+<kbd>m</kbd>
196221 </dt>
197-<dd>repo sections (when shown)</dd>
222+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
198223 </div>
199224 <div>
200225 <dt>
201-<kbd>m</kbd>
202-<kbd>g m</kbd>
226+<kbd>g h</kbd>
227+</dt>
228+<dd>home</dd>
229+</div>
230+<div>
231+<dt>
232+<kbd>y</kbd>
203233 </dt>
204-<dd>merge requests (when shown)</dd>
234+<dd>copy a permalink pinned to the commit</dd>
205235 </div>
206236 <div>
207237 <dt>
@@ -221,19 +251,21 @@
221251 <dt>
222252 <kbd>esc</kbd>
223253 </dt>
224-<dd>close panel</dd>
254+<dd>close a dialog, leave a field</dd>
225255 </div>
226256 </dl>
227257 </dialog>
228-<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
258+<dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
229259 <div class="panel-head">
230-<label for="refs-filter">ref&gt;</label>
231-<input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
232-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
260+<label for="refs-filter" class="sr">filter branches and tags</label>
261+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
262+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
263+<kbd>esc</kbd>
264+</button>
233265 </div>
234266 <div class="panel-cols" data-picklist>
235267 <section aria-label="Branches">
236-<h2>branches <span class="mute">2</span>
268+<h2>Branches <span class="count">2</span>
237269 </h2>
238270 <ul>
239271 <li>
@@ -247,7 +279,7 @@
247279 </ul>
248280 </section>
249281 <section aria-label="Tags">
250-<h2>tags <span class="mute">1</span>
282+<h2>Tags <span class="count">1</span>
251283 </h2>
252284 <ul>
253285 <li>
@@ -258,105 +290,124 @@
258290 </section>
259291 </div>
260292 </dialog>
261-<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
293+<dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
262294 <div class="panel-head">
263-<label for="cmd-q" class="hi">:</label>
264-<input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
295+<label for="cmd-q" class="sr">go to</label>
296+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
265297 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
266298 let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
267299 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
268300 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
269301 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
270302 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
271-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
303+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
304+<kbd>esc</kbd>
305+</button>
272306 </div>
273-<ul id="cmd-list" class="cmd-list">
307+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
274308 <li data-k="go home" data-cursor>
275309 <a href="/" tabindex="-1">
276-<span class="k">go</span>home</a>
310+<span class="lbl">home</span>
311+<span class="k">go</span>
312+</a>
277313 </li>
278314 <li data-k="section summary">
279315 <a href="/diffs" tabindex="-1">
280-<span class="k">section</span>summary</a>
316+<span class="lbl">summary</span>
317+<span class="k">section</span>
318+</a>
281319 </li>
282-<li data-k="section tree">
320+<li data-k="section files">
283321 <a href="/diffs/tree/main" tabindex="-1">
284-<span class="k">section</span>tree</a>
322+<span class="lbl">files</span>
323+<span class="k">section</span>
324+</a>
285325 </li>
286326 <li data-k="section commits">
287327 <a href="/diffs/commits/main" tabindex="-1">
288-<span class="k">section</span>commits</a>
328+<span class="lbl">commits</span>
329+<span class="k">section</span>
330+</a>
289331 </li>
290-<li data-k="section refs">
332+<li data-k="section branches &amp; tags">
291333 <a href="/diffs/refs" tabindex="-1">
292-<span class="k">section</span>refs</a>
334+<span class="lbl">branches &amp; tags</span>
335+<span class="k">section</span>
336+</a>
293337 </li>
294338 <li data-k="section merge requests">
295339 <a href="/diffs/merge-requests" tabindex="-1">
296-<span class="k">section</span>merge requests</a>
340+<span class="lbl">merge requests</span>
341+<span class="k">section</span>
342+</a>
297343 </li>
298344 <li data-k="branch main">
299345 <a href="/diffs/tree/main" tabindex="-1">
300-<span class="k">branch</span>main</a>
346+<span class="lbl">main</span>
347+<span class="k">branch</span>
348+</a>
301349 </li>
302350 <li data-k="branch side">
303351 <a href="/diffs/tree/side" tabindex="-1">
304-<span class="k">branch</span>side</a>
352+<span class="lbl">side</span>
353+<span class="k">branch</span>
354+</a>
305355 </li>
306356 <li data-k="tag v1.0.0">
307357 <a href="/diffs/tree/v1.0.0" tabindex="-1">
308-<span class="k">tag</span>v1.0.0</a>
358+<span class="lbl">v1.0.0</span>
359+<span class="k">tag</span>
360+</a>
309361 </li>
310362 </ul>
363+<p class="panel-foot">
364+<span>
365+<kbd>↑</kbd>
366+<kbd>↓</kbd> move</span>
367+<span>
368+<kbd>enter</kbd> open</span>
369+<span>
370+<kbd>esc</kbd> close</span>
371+</p>
311372 </dialog>
312-<footer class="bar status" aria-label="Status">
313-<span class="mode">commit</span>
314-<span class="st-ctx">SHA7 · 1 file · +0 -0</span>
315-<button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
316-<span class="hi">:</span> go to…</button>
317-<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
318-</span>
319-<span class="st-hints">d split · u unified · x collapse all · ? help</span>
320-<span class="st-src">
321-<span class="st-ver">ermite devel · </span>
322-<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
323-</span>
324-<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
325-</footer>
326-<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
373+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
327374 if (q('dialog[open]').count) return;
328375 let rows = q('[data-rows] > :not([hidden])').arr();
329376 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
330377 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
331- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
332- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
378+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
379+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
380+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
381+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
333382 })(1);
334- k-k from:body -> ((d) => {
383+ k-k from:body -> (async (d) => {
335384 if (q('dialog[open]').count) return;
336385 let rows = q('[data-rows] > :not([hidden])').arr();
337386 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
338387 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
339- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
340- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
388+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
389+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
390+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
391+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
341392 })(-1);
342393 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
343394 k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
344395 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
345396 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
346- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
347- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus());
348- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
397+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
398+ k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
399+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
349400 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
350- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
351- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
352- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
353- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
354- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
355- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
356- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
357- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
358- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
359- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
401+ k-gh from:body -> document.querySelector('.brand')?.click();
402+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
403+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
404+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
405+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
406+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
407+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
408+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
409+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
410+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
360411 k-next from:body -> {
361412 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
362413 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/commit-merge.html +164 -113
@@ -6,6 +6,7 @@
66 <meta name="color-scheme" content="light dark">
77 <title>SHA7 merge the side branch · diffs · example.test</title>
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9+<link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
910 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
1011 <script src="/-/static/js/htmx.min.HASH.js" defer>
1112 </script>
@@ -16,12 +17,15 @@
1617 <script src="/-/static/js/ermite.HASH.js" defer>
1718 </script>
1819 </head>
19-<body data-ref="" data-sha="SHA40" data-sel="">
20+<body data-ref="" data-sha="SHA40" data-sel="" data-nav="">
2021 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
23-<span class="fade" aria-hidden="true">
22+<header class="hdr">
23+<div class="hdr-top">
24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
2426 </span>
27+<span>example.test</span>
28+</a>
2529 <nav class="crumbs" aria-label="Path">
2630 <ol>
2731 <li>
@@ -35,30 +39,49 @@
3539 </li>
3640 </ol>
3741 </nav>
38-<nav class="secs" aria-label="Sections">
42+<div class="hdr-acts">
43+<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
44+<span class="ico i-branch" aria-hidden="true">
45+</span>
46+<span class="ref-name">detached</span>
47+<span class="sha">SHA7</span>
48+</button>
49+<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
50+<span class="ico i-search" aria-hidden="true">
51+</span>
52+<span class="search-label">Go to…</span>
53+<kbd>ctrl k</kbd>
54+</button>
55+<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
56+</div>
57+</div>
58+<nav class="tabs" aria-label="Sections">
3959 <ul>
4060 <li>
41-<a href="/diffs" data-key="s" aria-keyshortcuts="g s">summary</a>
61+<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
62+</a>
4263 </li>
4364 <li>
44-<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="g t">tree</a>
65+<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files<kbd aria-hidden="true">t</kbd>
66+</a>
4567 </li>
4668 <li>
47-<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="g c" aria-current="page">commits</a>
69+<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits<kbd aria-hidden="true">c</kbd>
70+</a>
4871 </li>
4972 <li>
50-<a href="/diffs/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
73+<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
74+</a>
5175 </li>
5276 <li>
53-<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
77+<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
78+</a>
5479 </li>
5580 </ul>
5681 </nav>
57-<button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">detached<span class="sha">@SHA7</span>
58-</button>
5982 </header>
6083 <div class="work has-rail">
61-<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
84+<main id="main" tabindex="-1">
6285 <header class="show">
6386 <h1>merge the side branch</h1>
6487 <dl class="kv show-kv">
@@ -77,9 +100,12 @@
77100 </span>
78101 <span class="grow">
79102 </span>
80-<a href="/diffs/commit/SHA40?view=unified" class="vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;unified&#39; ? &#39;true&#39; : false">[u]nified</a>
81-<a href="/diffs/commit/SHA40?view=split" class="vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;split&#39; ? &#39;true&#39; : false">[d] split</a>
82-<button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))">[x] collapse all</button>
103+<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;unified&#39; ? &#39;true&#39; : false">Unified<kbd>u</kbd>
104+</a>
105+<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;split&#39; ? &#39;true&#39; : false">Split<kbd>d</kbd>
106+</a>
107+<button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))" class="btn">Collapse all<kbd>x</kbd>
108+</button>
83109 </div>
84110 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">
85111 <details class="dfile" id="f1" data-file open>
@@ -127,10 +153,8 @@
127153 </div>
128154 </main>
129155 <aside class="rail" aria-label="Context">
130-<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
131-<summary>1 changed file</summary>
132156 <section aria-labelledby="r-files">
133-<h2 id="r-files">files <span>1</span>
157+<h2 id="r-files">Files <span class="count">1</span>
134158 </h2>
135159 <ol class="files" aria-label="Changed files">
136160 <li>
@@ -150,73 +174,73 @@
150174 </li>
151175 </ol>
152176 </section>
153-</details>
154177 </aside>
155178 </div>
156-<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
179+<footer class="foot">
180+<span>ermite devel</span>
181+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
182+<span class="foot-keys">
183+<kbd>?</kbd> keyboard shortcuts</span>
184+</footer>
185+<div class="hint" role="status" aria-live="polite">
186+<span class="hint-nav" :text="document.body.dataset.nav || ''">
187+</span>
188+<span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''">
189+</span>
190+<span class="hint-flash" :text="document.body.dataset.flash || ''">
191+</span>
192+</div>
193+<dialog id="panel-help" class="panel modal" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
157194 <div class="panel-head">
158-<span>keys</span>
159-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
195+<h2>Keyboard shortcuts</h2>
196+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
197+<kbd>esc</kbd> close</button>
160198 </div>
161199 <dl class="keys">
162200 <div>
163201 <dt>
164-<kbd>j</kbd>
165-<kbd>k</kbd>
202+<kbd>ctrl k</kbd>
203+<kbd>:</kbd>
166204 </dt>
167-<dd>move cursor down / up</dd>
205+<dd>go to a section, branch, tag, file or repository</dd>
168206 </div>
169207 <div>
170208 <dt>
171-<kbd>enter</kbd>
209+<kbd>?</kbd>
172210 </dt>
173-<dd>open row under cursor</dd>
211+<dd>this list</dd>
174212 </div>
175213 <div>
176214 <dt>
177-<kbd>g</kbd>
178-<kbd>g</kbd> / <kbd>G</kbd>
215+<kbd>j</kbd>
216+<kbd>k</kbd>
179217 </dt>
180-<dd>first / last row</dd>
218+<dd>move the row cursor down / up</dd>
181219 </div>
182220 <div>
183221 <dt>
184-<kbd>/</kbd>
222+<kbd>enter</kbd>
185223 </dt>
186-<dd>focus the filter</dd>
224+<dd>open the row under the cursor</dd>
187225 </div>
188226 <div>
189227 <dt>
190-<kbd>g h</kbd>
191-<kbd>g t</kbd>
228+<kbd>g g</kbd>
229+<kbd>G</kbd>
192230 </dt>
193-<dd>go home / tree</dd>
231+<dd>first / last row</dd>
194232 </div>
195233 <div>
196234 <dt>
197-<kbd>g c</kbd>
198-<kbd>g r</kbd>
235+<kbd>/</kbd>
199236 </dt>
200-<dd>go commits / refs</dd>
237+<dd>filter the list</dd>
201238 </div>
202239 <div>
203240 <dt>
204241 <kbd>b</kbd>
205242 </dt>
206-<dd>switch ref (branches, tags)</dd>
207-</div>
208-<div>
209-<dt>
210-<kbd>:</kbd>
211-<kbd>f</kbd>
212-</dt>
213-<dd>go to: section, ref, file, repo</dd>
214-</div>
215-<div>
216-<dt>
217-<kbd>y</kbd>
218-</dt>
219-<dd>permalink: ref becomes the full sha</dd>
243+<dd>switch branch or tag</dd>
220244 </div>
221245 <div>
222246 <dt>
@@ -224,15 +248,21 @@
224248 <kbd>t</kbd>
225249 <kbd>c</kbd>
226250 <kbd>r</kbd>
251+<kbd>m</kbd>
227252 </dt>
228-<dd>repo sections (when shown)</dd>
253+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
229254 </div>
230255 <div>
231256 <dt>
232-<kbd>m</kbd>
233-<kbd>g m</kbd>
257+<kbd>g h</kbd>
258+</dt>
259+<dd>home</dd>
260+</div>
261+<div>
262+<dt>
263+<kbd>y</kbd>
234264 </dt>
235-<dd>merge requests (when shown)</dd>
265+<dd>copy a permalink pinned to the commit</dd>
236266 </div>
237267 <div>
238268 <dt>
@@ -252,19 +282,21 @@
252282 <dt>
253283 <kbd>esc</kbd>
254284 </dt>
255-<dd>close panel</dd>
285+<dd>close a dialog, leave a field</dd>
256286 </div>
257287 </dl>
258288 </dialog>
259-<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
289+<dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
260290 <div class="panel-head">
261-<label for="refs-filter">ref&gt;</label>
262-<input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
263-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
291+<label for="refs-filter" class="sr">filter branches and tags</label>
292+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
293+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
294+<kbd>esc</kbd>
295+</button>
264296 </div>
265297 <div class="panel-cols" data-picklist>
266298 <section aria-label="Branches">
267-<h2>branches <span class="mute">2</span>
299+<h2>Branches <span class="count">2</span>
268300 </h2>
269301 <ul>
270302 <li>
@@ -278,7 +310,7 @@
278310 </ul>
279311 </section>
280312 <section aria-label="Tags">
281-<h2>tags <span class="mute">1</span>
313+<h2>Tags <span class="count">1</span>
282314 </h2>
283315 <ul>
284316 <li>
@@ -289,105 +321,124 @@
289321 </section>
290322 </div>
291323 </dialog>
292-<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
324+<dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
293325 <div class="panel-head">
294-<label for="cmd-q" class="hi">:</label>
295-<input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
326+<label for="cmd-q" class="sr">go to</label>
327+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
296328 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
297329 let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
298330 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
299331 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
300332 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
301333 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
302-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
334+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
335+<kbd>esc</kbd>
336+</button>
303337 </div>
304-<ul id="cmd-list" class="cmd-list">
338+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
305339 <li data-k="go home" data-cursor>
306340 <a href="/" tabindex="-1">
307-<span class="k">go</span>home</a>
341+<span class="lbl">home</span>
342+<span class="k">go</span>
343+</a>
308344 </li>
309345 <li data-k="section summary">
310346 <a href="/diffs" tabindex="-1">
311-<span class="k">section</span>summary</a>
347+<span class="lbl">summary</span>
348+<span class="k">section</span>
349+</a>
312350 </li>
313-<li data-k="section tree">
351+<li data-k="section files">
314352 <a href="/diffs/tree/main" tabindex="-1">
315-<span class="k">section</span>tree</a>
353+<span class="lbl">files</span>
354+<span class="k">section</span>
355+</a>
316356 </li>
317357 <li data-k="section commits">
318358 <a href="/diffs/commits/main" tabindex="-1">
319-<span class="k">section</span>commits</a>
359+<span class="lbl">commits</span>
360+<span class="k">section</span>
361+</a>
320362 </li>
321-<li data-k="section refs">
363+<li data-k="section branches &amp; tags">
322364 <a href="/diffs/refs" tabindex="-1">
323-<span class="k">section</span>refs</a>
365+<span class="lbl">branches &amp; tags</span>
366+<span class="k">section</span>
367+</a>
324368 </li>
325369 <li data-k="section merge requests">
326370 <a href="/diffs/merge-requests" tabindex="-1">
327-<span class="k">section</span>merge requests</a>
371+<span class="lbl">merge requests</span>
372+<span class="k">section</span>
373+</a>
328374 </li>
329375 <li data-k="branch main">
330376 <a href="/diffs/tree/main" tabindex="-1">
331-<span class="k">branch</span>main</a>
377+<span class="lbl">main</span>
378+<span class="k">branch</span>
379+</a>
332380 </li>
333381 <li data-k="branch side">
334382 <a href="/diffs/tree/side" tabindex="-1">
335-<span class="k">branch</span>side</a>
383+<span class="lbl">side</span>
384+<span class="k">branch</span>
385+</a>
336386 </li>
337387 <li data-k="tag v1.0.0">
338388 <a href="/diffs/tree/v1.0.0" tabindex="-1">
339-<span class="k">tag</span>v1.0.0</a>
389+<span class="lbl">v1.0.0</span>
390+<span class="k">tag</span>
391+</a>
340392 </li>
341393 </ul>
394+<p class="panel-foot">
395+<span>
396+<kbd>↑</kbd>
397+<kbd>↓</kbd> move</span>
398+<span>
399+<kbd>enter</kbd> open</span>
400+<span>
401+<kbd>esc</kbd> close</span>
402+</p>
342403 </dialog>
343-<footer class="bar status" aria-label="Status">
344-<span class="mode">commit</span>
345-<span class="st-ctx">SHA7 · 1 file · +1 -0</span>
346-<button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
347-<span class="hi">:</span> go to…</button>
348-<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
349-</span>
350-<span class="st-hints">d split · u unified · x collapse all · ? help</span>
351-<span class="st-src">
352-<span class="st-ver">ermite devel · </span>
353-<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
354-</span>
355-<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
356-</footer>
357-<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
404+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
358405 if (q('dialog[open]').count) return;
359406 let rows = q('[data-rows] > :not([hidden])').arr();
360407 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
361408 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
362- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
363- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
409+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
410+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
411+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
412+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
364413 })(1);
365- k-k from:body -> ((d) => {
414+ k-k from:body -> (async (d) => {
366415 if (q('dialog[open]').count) return;
367416 let rows = q('[data-rows] > :not([hidden])').arr();
368417 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
369418 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
370- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
371- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
419+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
420+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
421+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
422+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
372423 })(-1);
373424 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
374425 k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
375426 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
376427 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
377- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
378- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus());
379- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
428+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
429+ k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
430+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
380431 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
381- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
382- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
383- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
384- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
385- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
386- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
387- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
388- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
389- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
390- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
432+ k-gh from:body -> document.querySelector('.brand')?.click();
433+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
434+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
435+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
436+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
437+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
438+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
439+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
440+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
441+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
391442 k-next from:body -> {
392443 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
393444 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/commit-modify-split.html +167 -114
@@ -6,6 +6,7 @@
66 <meta name="color-scheme" content="light dark">
77 <title>SHA7 modify a file and make a script executable · diffs · example.test</title>
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9+<link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
910 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
1011 <script src="/-/static/js/htmx.min.HASH.js" defer>
1112 </script>
@@ -16,12 +17,15 @@
1617 <script src="/-/static/js/ermite.HASH.js" defer>
1718 </script>
1819 </head>
19-<body data-ref="" data-sha="SHA40" data-sel="">
20+<body data-ref="" data-sha="SHA40" data-sel="" data-nav="">
2021 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
23-<span class="fade" aria-hidden="true">
22+<header class="hdr">
23+<div class="hdr-top">
24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
2426 </span>
27+<span>example.test</span>
28+</a>
2529 <nav class="crumbs" aria-label="Path">
2630 <ol>
2731 <li>
@@ -35,30 +39,49 @@
3539 </li>
3640 </ol>
3741 </nav>
38-<nav class="secs" aria-label="Sections">
42+<div class="hdr-acts">
43+<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
44+<span class="ico i-branch" aria-hidden="true">
45+</span>
46+<span class="ref-name">detached</span>
47+<span class="sha">SHA7</span>
48+</button>
49+<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
50+<span class="ico i-search" aria-hidden="true">
51+</span>
52+<span class="search-label">Go to…</span>
53+<kbd>ctrl k</kbd>
54+</button>
55+<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
56+</div>
57+</div>
58+<nav class="tabs" aria-label="Sections">
3959 <ul>
4060 <li>
41-<a href="/diffs" data-key="s" aria-keyshortcuts="g s">summary</a>
61+<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
62+</a>
4263 </li>
4364 <li>
44-<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="g t">tree</a>
65+<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files<kbd aria-hidden="true">t</kbd>
66+</a>
4567 </li>
4668 <li>
47-<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="g c" aria-current="page">commits</a>
69+<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits<kbd aria-hidden="true">c</kbd>
70+</a>
4871 </li>
4972 <li>
50-<a href="/diffs/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
73+<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
74+</a>
5175 </li>
5276 <li>
53-<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
77+<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
78+</a>
5479 </li>
5580 </ul>
5681 </nav>
57-<button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">detached<span class="sha">@SHA7</span>
58-</button>
5982 </header>
6083 <div class="work has-rail">
61-<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
84+<main id="main" tabindex="-1">
6285 <header class="show">
6386 <h1>modify a file and make a script executable</h1>
6487 <pre class="msg">The body has a second paragraph.
@@ -73,7 +96,9 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
7396 <a href="/diffs/commit/SHA40">SHA7</a>
7497 </dd>
7598 <dt>refs</dt>
76-<dd>[tag: v1.0.0] </dd>
99+<dd>
100+<span class="pill">tag: v1.0.0</span>
101+</dd>
77102 </dl>
78103 </header>
79104 <div class="diff-tools" role="toolbar" aria-label="Diff view">
@@ -82,9 +107,12 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
82107 </span>
83108 <span class="grow">
84109 </span>
85-<a href="/diffs/commit/SHA40?view=unified" class="vbtn" data-view="unified" aria-keyshortcuts="u" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;unified&#39; ? &#39;true&#39; : false">[u]nified</a>
86-<a href="/diffs/commit/SHA40?view=split" class="vbtn" data-view="split" aria-keyshortcuts="d" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;split&#39; ? &#39;true&#39; : false">[d] split</a>
87-<button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))">[x] collapse all</button>
110+<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;unified&#39; ? &#39;true&#39; : false">Unified<kbd>u</kbd>
111+</a>
112+<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;split&#39; ? &#39;true&#39; : false">Split<kbd>d</kbd>
113+</a>
114+<button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))" class="btn">Collapse all<kbd>x</kbd>
115+</button>
88116 </div>
89117 <div id="diffs" data-diff="split">
90118 <details class="dfile" id="f1" data-file open>
@@ -232,10 +260,8 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
232260 </div>
233261 </main>
234262 <aside class="rail" aria-label="Context">
235-<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
236-<summary>2 changed files</summary>
237263 <section aria-labelledby="r-files">
238-<h2 id="r-files">files <span>2</span>
264+<h2 id="r-files">Files <span class="count">2</span>
239265 </h2>
240266 <ol class="files" aria-label="Changed files">
241267 <li>
@@ -269,73 +295,73 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
269295 </li>
270296 </ol>
271297 </section>
272-</details>
273298 </aside>
274299 </div>
275-<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
300+<footer class="foot">
301+<span>ermite devel</span>
302+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
303+<span class="foot-keys">
304+<kbd>?</kbd> keyboard shortcuts</span>
305+</footer>
306+<div class="hint" role="status" aria-live="polite">
307+<span class="hint-nav" :text="document.body.dataset.nav || ''">
308+</span>
309+<span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''">
310+</span>
311+<span class="hint-flash" :text="document.body.dataset.flash || ''">
312+</span>
313+</div>
314+<dialog id="panel-help" class="panel modal" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
276315 <div class="panel-head">
277-<span>keys</span>
278-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
316+<h2>Keyboard shortcuts</h2>
317+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
318+<kbd>esc</kbd> close</button>
279319 </div>
280320 <dl class="keys">
281321 <div>
282322 <dt>
283-<kbd>j</kbd>
284-<kbd>k</kbd>
323+<kbd>ctrl k</kbd>
324+<kbd>:</kbd>
285325 </dt>
286-<dd>move cursor down / up</dd>
326+<dd>go to a section, branch, tag, file or repository</dd>
287327 </div>
288328 <div>
289329 <dt>
290-<kbd>enter</kbd>
330+<kbd>?</kbd>
291331 </dt>
292-<dd>open row under cursor</dd>
332+<dd>this list</dd>
293333 </div>
294334 <div>
295335 <dt>
296-<kbd>g</kbd>
297-<kbd>g</kbd> / <kbd>G</kbd>
336+<kbd>j</kbd>
337+<kbd>k</kbd>
298338 </dt>
299-<dd>first / last row</dd>
339+<dd>move the row cursor down / up</dd>
300340 </div>
301341 <div>
302342 <dt>
303-<kbd>/</kbd>
343+<kbd>enter</kbd>
304344 </dt>
305-<dd>focus the filter</dd>
345+<dd>open the row under the cursor</dd>
306346 </div>
307347 <div>
308348 <dt>
309-<kbd>g h</kbd>
310-<kbd>g t</kbd>
349+<kbd>g g</kbd>
350+<kbd>G</kbd>
311351 </dt>
312-<dd>go home / tree</dd>
352+<dd>first / last row</dd>
313353 </div>
314354 <div>
315355 <dt>
316-<kbd>g c</kbd>
317-<kbd>g r</kbd>
356+<kbd>/</kbd>
318357 </dt>
319-<dd>go commits / refs</dd>
358+<dd>filter the list</dd>
320359 </div>
321360 <div>
322361 <dt>
323362 <kbd>b</kbd>
324363 </dt>
325-<dd>switch ref (branches, tags)</dd>
326-</div>
327-<div>
328-<dt>
329-<kbd>:</kbd>
330-<kbd>f</kbd>
331-</dt>
332-<dd>go to: section, ref, file, repo</dd>
333-</div>
334-<div>
335-<dt>
336-<kbd>y</kbd>
337-</dt>
338-<dd>permalink: ref becomes the full sha</dd>
364+<dd>switch branch or tag</dd>
339365 </div>
340366 <div>
341367 <dt>
@@ -343,15 +369,21 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
343369 <kbd>t</kbd>
344370 <kbd>c</kbd>
345371 <kbd>r</kbd>
372+<kbd>m</kbd>
346373 </dt>
347-<dd>repo sections (when shown)</dd>
374+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
348375 </div>
349376 <div>
350377 <dt>
351-<kbd>m</kbd>
352-<kbd>g m</kbd>
378+<kbd>g h</kbd>
379+</dt>
380+<dd>home</dd>
381+</div>
382+<div>
383+<dt>
384+<kbd>y</kbd>
353385 </dt>
354-<dd>merge requests (when shown)</dd>
386+<dd>copy a permalink pinned to the commit</dd>
355387 </div>
356388 <div>
357389 <dt>
@@ -371,19 +403,21 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
371403 <dt>
372404 <kbd>esc</kbd>
373405 </dt>
374-<dd>close panel</dd>
406+<dd>close a dialog, leave a field</dd>
375407 </div>
376408 </dl>
377409 </dialog>
378-<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
410+<dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
379411 <div class="panel-head">
380-<label for="refs-filter">ref&gt;</label>
381-<input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
382-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
412+<label for="refs-filter" class="sr">filter branches and tags</label>
413+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
414+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
415+<kbd>esc</kbd>
416+</button>
383417 </div>
384418 <div class="panel-cols" data-picklist>
385419 <section aria-label="Branches">
386-<h2>branches <span class="mute">2</span>
420+<h2>Branches <span class="count">2</span>
387421 </h2>
388422 <ul>
389423 <li>
@@ -397,7 +431,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
397431 </ul>
398432 </section>
399433 <section aria-label="Tags">
400-<h2>tags <span class="mute">1</span>
434+<h2>Tags <span class="count">1</span>
401435 </h2>
402436 <ul>
403437 <li>
@@ -408,105 +442,124 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
408442 </section>
409443 </div>
410444 </dialog>
411-<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
445+<dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
412446 <div class="panel-head">
413-<label for="cmd-q" class="hi">:</label>
414-<input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
447+<label for="cmd-q" class="sr">go to</label>
448+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
415449 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
416450 let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
417451 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
418452 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
419453 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
420454 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
421-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
455+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
456+<kbd>esc</kbd>
457+</button>
422458 </div>
423-<ul id="cmd-list" class="cmd-list">
459+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
424460 <li data-k="go home" data-cursor>
425461 <a href="/" tabindex="-1">
426-<span class="k">go</span>home</a>
462+<span class="lbl">home</span>
463+<span class="k">go</span>
464+</a>
427465 </li>
428466 <li data-k="section summary">
429467 <a href="/diffs" tabindex="-1">
430-<span class="k">section</span>summary</a>
468+<span class="lbl">summary</span>
469+<span class="k">section</span>
470+</a>
431471 </li>
432-<li data-k="section tree">
472+<li data-k="section files">
433473 <a href="/diffs/tree/main" tabindex="-1">
434-<span class="k">section</span>tree</a>
474+<span class="lbl">files</span>
475+<span class="k">section</span>
476+</a>
435477 </li>
436478 <li data-k="section commits">
437479 <a href="/diffs/commits/main" tabindex="-1">
438-<span class="k">section</span>commits</a>
480+<span class="lbl">commits</span>
481+<span class="k">section</span>
482+</a>
439483 </li>
440-<li data-k="section refs">
484+<li data-k="section branches &amp; tags">
441485 <a href="/diffs/refs" tabindex="-1">
442-<span class="k">section</span>refs</a>
486+<span class="lbl">branches &amp; tags</span>
487+<span class="k">section</span>
488+</a>
443489 </li>
444490 <li data-k="section merge requests">
445491 <a href="/diffs/merge-requests" tabindex="-1">
446-<span class="k">section</span>merge requests</a>
492+<span class="lbl">merge requests</span>
493+<span class="k">section</span>
494+</a>
447495 </li>
448496 <li data-k="branch main">
449497 <a href="/diffs/tree/main" tabindex="-1">
450-<span class="k">branch</span>main</a>
498+<span class="lbl">main</span>
499+<span class="k">branch</span>
500+</a>
451501 </li>
452502 <li data-k="branch side">
453503 <a href="/diffs/tree/side" tabindex="-1">
454-<span class="k">branch</span>side</a>
504+<span class="lbl">side</span>
505+<span class="k">branch</span>
506+</a>
455507 </li>
456508 <li data-k="tag v1.0.0">
457509 <a href="/diffs/tree/v1.0.0" tabindex="-1">
458-<span class="k">tag</span>v1.0.0</a>
510+<span class="lbl">v1.0.0</span>
511+<span class="k">tag</span>
512+</a>
459513 </li>
460514 </ul>
515+<p class="panel-foot">
516+<span>
517+<kbd>↑</kbd>
518+<kbd>↓</kbd> move</span>
519+<span>
520+<kbd>enter</kbd> open</span>
521+<span>
522+<kbd>esc</kbd> close</span>
523+</p>
461524 </dialog>
462-<footer class="bar status" aria-label="Status">
463-<span class="mode">commit</span>
464-<span class="st-ctx">SHA7 · 2 files · +2 -1</span>
465-<button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
466-<span class="hi">:</span> go to…</button>
467-<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
468-</span>
469-<span class="st-hints">d split · u unified · x collapse all · ? help</span>
470-<span class="st-src">
471-<span class="st-ver">ermite devel · </span>
472-<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
473-</span>
474-<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
475-</footer>
476-<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
525+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
477526 if (q('dialog[open]').count) return;
478527 let rows = q('[data-rows] > :not([hidden])').arr();
479528 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
480529 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
481- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
482- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
530+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
531+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
532+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
533+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
483534 })(1);
484- k-k from:body -> ((d) => {
535+ k-k from:body -> (async (d) => {
485536 if (q('dialog[open]').count) return;
486537 let rows = q('[data-rows] > :not([hidden])').arr();
487538 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
488539 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
489- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
490- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
540+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
541+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
542+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
543+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
491544 })(-1);
492545 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
493546 k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
494547 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
495548 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
496- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
497- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus());
498- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
549+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
550+ k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
551+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
499552 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
500- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
501- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
502- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
503- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
504- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
505- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
506- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
507- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
508- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
509- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
553+ k-gh from:body -> document.querySelector('.brand')?.click();
554+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
555+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
556+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
557+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
558+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
559+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
560+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
561+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
562+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
510563 k-next from:body -> {
511564 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
512565 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/commit-modify.html +167 -114
@@ -6,6 +6,7 @@
66 <meta name="color-scheme" content="light dark">
77 <title>SHA7 modify a file and make a script executable · diffs · example.test</title>
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9+<link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
910 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
1011 <script src="/-/static/js/htmx.min.HASH.js" defer>
1112 </script>
@@ -16,12 +17,15 @@
1617 <script src="/-/static/js/ermite.HASH.js" defer>
1718 </script>
1819 </head>
19-<body data-ref="" data-sha="SHA40" data-sel="">
20+<body data-ref="" data-sha="SHA40" data-sel="" data-nav="">
2021 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
23-<span class="fade" aria-hidden="true">
22+<header class="hdr">
23+<div class="hdr-top">
24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
2426 </span>
27+<span>example.test</span>
28+</a>
2529 <nav class="crumbs" aria-label="Path">
2630 <ol>
2731 <li>
@@ -35,30 +39,49 @@
3539 </li>
3640 </ol>
3741 </nav>
38-<nav class="secs" aria-label="Sections">
42+<div class="hdr-acts">
43+<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
44+<span class="ico i-branch" aria-hidden="true">
45+</span>
46+<span class="ref-name">detached</span>
47+<span class="sha">SHA7</span>
48+</button>
49+<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
50+<span class="ico i-search" aria-hidden="true">
51+</span>
52+<span class="search-label">Go to…</span>
53+<kbd>ctrl k</kbd>
54+</button>
55+<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
56+</div>
57+</div>
58+<nav class="tabs" aria-label="Sections">
3959 <ul>
4060 <li>
41-<a href="/diffs" data-key="s" aria-keyshortcuts="g s">summary</a>
61+<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
62+</a>
4263 </li>
4364 <li>
44-<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="g t">tree</a>
65+<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files<kbd aria-hidden="true">t</kbd>
66+</a>
4567 </li>
4668 <li>
47-<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="g c" aria-current="page">commits</a>
69+<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits<kbd aria-hidden="true">c</kbd>
70+</a>
4871 </li>
4972 <li>
50-<a href="/diffs/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
73+<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
74+</a>
5175 </li>
5276 <li>
53-<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
77+<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
78+</a>
5479 </li>
5580 </ul>
5681 </nav>
57-<button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">detached<span class="sha">@SHA7</span>
58-</button>
5982 </header>
6083 <div class="work has-rail">
61-<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
84+<main id="main" tabindex="-1">
6285 <header class="show">
6386 <h1>modify a file and make a script executable</h1>
6487 <pre class="msg">The body has a second paragraph.
@@ -73,7 +96,9 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
7396 <a href="/diffs/commit/SHA40">SHA7</a>
7497 </dd>
7598 <dt>refs</dt>
76-<dd>[tag: v1.0.0] </dd>
99+<dd>
100+<span class="pill">tag: v1.0.0</span>
101+</dd>
77102 </dl>
78103 </header>
79104 <div class="diff-tools" role="toolbar" aria-label="Diff view">
@@ -82,9 +107,12 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
82107 </span>
83108 <span class="grow">
84109 </span>
85-<a href="/diffs/commit/SHA40?view=unified" class="vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;unified&#39; ? &#39;true&#39; : false">[u]nified</a>
86-<a href="/diffs/commit/SHA40?view=split" class="vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;split&#39; ? &#39;true&#39; : false">[d] split</a>
87-<button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))">[x] collapse all</button>
110+<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;unified&#39; ? &#39;true&#39; : false">Unified<kbd>u</kbd>
111+</a>
112+<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;split&#39; ? &#39;true&#39; : false">Split<kbd>d</kbd>
113+</a>
114+<button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))" class="btn">Collapse all<kbd>x</kbd>
115+</button>
88116 </div>
89117 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">
90118 <details class="dfile" id="f1" data-file open>
@@ -232,10 +260,8 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
232260 </div>
233261 </main>
234262 <aside class="rail" aria-label="Context">
235-<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
236-<summary>2 changed files</summary>
237263 <section aria-labelledby="r-files">
238-<h2 id="r-files">files <span>2</span>
264+<h2 id="r-files">Files <span class="count">2</span>
239265 </h2>
240266 <ol class="files" aria-label="Changed files">
241267 <li>
@@ -269,73 +295,73 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
269295 </li>
270296 </ol>
271297 </section>
272-</details>
273298 </aside>
274299 </div>
275-<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
300+<footer class="foot">
301+<span>ermite devel</span>
302+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
303+<span class="foot-keys">
304+<kbd>?</kbd> keyboard shortcuts</span>
305+</footer>
306+<div class="hint" role="status" aria-live="polite">
307+<span class="hint-nav" :text="document.body.dataset.nav || ''">
308+</span>
309+<span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''">
310+</span>
311+<span class="hint-flash" :text="document.body.dataset.flash || ''">
312+</span>
313+</div>
314+<dialog id="panel-help" class="panel modal" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
276315 <div class="panel-head">
277-<span>keys</span>
278-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
316+<h2>Keyboard shortcuts</h2>
317+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
318+<kbd>esc</kbd> close</button>
279319 </div>
280320 <dl class="keys">
281321 <div>
282322 <dt>
283-<kbd>j</kbd>
284-<kbd>k</kbd>
323+<kbd>ctrl k</kbd>
324+<kbd>:</kbd>
285325 </dt>
286-<dd>move cursor down / up</dd>
326+<dd>go to a section, branch, tag, file or repository</dd>
287327 </div>
288328 <div>
289329 <dt>
290-<kbd>enter</kbd>
330+<kbd>?</kbd>
291331 </dt>
292-<dd>open row under cursor</dd>
332+<dd>this list</dd>
293333 </div>
294334 <div>
295335 <dt>
296-<kbd>g</kbd>
297-<kbd>g</kbd> / <kbd>G</kbd>
336+<kbd>j</kbd>
337+<kbd>k</kbd>
298338 </dt>
299-<dd>first / last row</dd>
339+<dd>move the row cursor down / up</dd>
300340 </div>
301341 <div>
302342 <dt>
303-<kbd>/</kbd>
343+<kbd>enter</kbd>
304344 </dt>
305-<dd>focus the filter</dd>
345+<dd>open the row under the cursor</dd>
306346 </div>
307347 <div>
308348 <dt>
309-<kbd>g h</kbd>
310-<kbd>g t</kbd>
349+<kbd>g g</kbd>
350+<kbd>G</kbd>
311351 </dt>
312-<dd>go home / tree</dd>
352+<dd>first / last row</dd>
313353 </div>
314354 <div>
315355 <dt>
316-<kbd>g c</kbd>
317-<kbd>g r</kbd>
356+<kbd>/</kbd>
318357 </dt>
319-<dd>go commits / refs</dd>
358+<dd>filter the list</dd>
320359 </div>
321360 <div>
322361 <dt>
323362 <kbd>b</kbd>
324363 </dt>
325-<dd>switch ref (branches, tags)</dd>
326-</div>
327-<div>
328-<dt>
329-<kbd>:</kbd>
330-<kbd>f</kbd>
331-</dt>
332-<dd>go to: section, ref, file, repo</dd>
333-</div>
334-<div>
335-<dt>
336-<kbd>y</kbd>
337-</dt>
338-<dd>permalink: ref becomes the full sha</dd>
364+<dd>switch branch or tag</dd>
339365 </div>
340366 <div>
341367 <dt>
@@ -343,15 +369,21 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
343369 <kbd>t</kbd>
344370 <kbd>c</kbd>
345371 <kbd>r</kbd>
372+<kbd>m</kbd>
346373 </dt>
347-<dd>repo sections (when shown)</dd>
374+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
348375 </div>
349376 <div>
350377 <dt>
351-<kbd>m</kbd>
352-<kbd>g m</kbd>
378+<kbd>g h</kbd>
379+</dt>
380+<dd>home</dd>
381+</div>
382+<div>
383+<dt>
384+<kbd>y</kbd>
353385 </dt>
354-<dd>merge requests (when shown)</dd>
386+<dd>copy a permalink pinned to the commit</dd>
355387 </div>
356388 <div>
357389 <dt>
@@ -371,19 +403,21 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
371403 <dt>
372404 <kbd>esc</kbd>
373405 </dt>
374-<dd>close panel</dd>
406+<dd>close a dialog, leave a field</dd>
375407 </div>
376408 </dl>
377409 </dialog>
378-<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
410+<dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
379411 <div class="panel-head">
380-<label for="refs-filter">ref&gt;</label>
381-<input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
382-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
412+<label for="refs-filter" class="sr">filter branches and tags</label>
413+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
414+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
415+<kbd>esc</kbd>
416+</button>
383417 </div>
384418 <div class="panel-cols" data-picklist>
385419 <section aria-label="Branches">
386-<h2>branches <span class="mute">2</span>
420+<h2>Branches <span class="count">2</span>
387421 </h2>
388422 <ul>
389423 <li>
@@ -397,7 +431,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
397431 </ul>
398432 </section>
399433 <section aria-label="Tags">
400-<h2>tags <span class="mute">1</span>
434+<h2>Tags <span class="count">1</span>
401435 </h2>
402436 <ul>
403437 <li>
@@ -408,105 +442,124 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
408442 </section>
409443 </div>
410444 </dialog>
411-<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
445+<dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
412446 <div class="panel-head">
413-<label for="cmd-q" class="hi">:</label>
414-<input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
447+<label for="cmd-q" class="sr">go to</label>
448+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
415449 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
416450 let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
417451 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
418452 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
419453 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
420454 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
421-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
455+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
456+<kbd>esc</kbd>
457+</button>
422458 </div>
423-<ul id="cmd-list" class="cmd-list">
459+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
424460 <li data-k="go home" data-cursor>
425461 <a href="/" tabindex="-1">
426-<span class="k">go</span>home</a>
462+<span class="lbl">home</span>
463+<span class="k">go</span>
464+</a>
427465 </li>
428466 <li data-k="section summary">
429467 <a href="/diffs" tabindex="-1">
430-<span class="k">section</span>summary</a>
468+<span class="lbl">summary</span>
469+<span class="k">section</span>
470+</a>
431471 </li>
432-<li data-k="section tree">
472+<li data-k="section files">
433473 <a href="/diffs/tree/main" tabindex="-1">
434-<span class="k">section</span>tree</a>
474+<span class="lbl">files</span>
475+<span class="k">section</span>
476+</a>
435477 </li>
436478 <li data-k="section commits">
437479 <a href="/diffs/commits/main" tabindex="-1">
438-<span class="k">section</span>commits</a>
480+<span class="lbl">commits</span>
481+<span class="k">section</span>
482+</a>
439483 </li>
440-<li data-k="section refs">
484+<li data-k="section branches &amp; tags">
441485 <a href="/diffs/refs" tabindex="-1">
442-<span class="k">section</span>refs</a>
486+<span class="lbl">branches &amp; tags</span>
487+<span class="k">section</span>
488+</a>
443489 </li>
444490 <li data-k="section merge requests">
445491 <a href="/diffs/merge-requests" tabindex="-1">
446-<span class="k">section</span>merge requests</a>
492+<span class="lbl">merge requests</span>
493+<span class="k">section</span>
494+</a>
447495 </li>
448496 <li data-k="branch main">
449497 <a href="/diffs/tree/main" tabindex="-1">
450-<span class="k">branch</span>main</a>
498+<span class="lbl">main</span>
499+<span class="k">branch</span>
500+</a>
451501 </li>
452502 <li data-k="branch side">
453503 <a href="/diffs/tree/side" tabindex="-1">
454-<span class="k">branch</span>side</a>
504+<span class="lbl">side</span>
505+<span class="k">branch</span>
506+</a>
455507 </li>
456508 <li data-k="tag v1.0.0">
457509 <a href="/diffs/tree/v1.0.0" tabindex="-1">
458-<span class="k">tag</span>v1.0.0</a>
510+<span class="lbl">v1.0.0</span>
511+<span class="k">tag</span>
512+</a>
459513 </li>
460514 </ul>
515+<p class="panel-foot">
516+<span>
517+<kbd>↑</kbd>
518+<kbd>↓</kbd> move</span>
519+<span>
520+<kbd>enter</kbd> open</span>
521+<span>
522+<kbd>esc</kbd> close</span>
523+</p>
461524 </dialog>
462-<footer class="bar status" aria-label="Status">
463-<span class="mode">commit</span>
464-<span class="st-ctx">SHA7 · 2 files · +2 -1</span>
465-<button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
466-<span class="hi">:</span> go to…</button>
467-<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
468-</span>
469-<span class="st-hints">d split · u unified · x collapse all · ? help</span>
470-<span class="st-src">
471-<span class="st-ver">ermite devel · </span>
472-<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
473-</span>
474-<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
475-</footer>
476-<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
525+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
477526 if (q('dialog[open]').count) return;
478527 let rows = q('[data-rows] > :not([hidden])').arr();
479528 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
480529 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
481- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
482- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
530+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
531+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
532+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
533+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
483534 })(1);
484- k-k from:body -> ((d) => {
535+ k-k from:body -> (async (d) => {
485536 if (q('dialog[open]').count) return;
486537 let rows = q('[data-rows] > :not([hidden])').arr();
487538 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
488539 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
489- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
490- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
540+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
541+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
542+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
543+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
491544 })(-1);
492545 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
493546 k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
494547 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
495548 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
496- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
497- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus());
498- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
549+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
550+ k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
551+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
499552 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
500- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
501- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
502- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
503- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
504- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
505- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
506- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
507- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
508- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
509- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
553+ k-gh from:body -> document.querySelector('.brand')?.click();
554+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
555+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
556+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
557+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
558+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
559+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
560+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
561+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
562+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
510563 k-next from:body -> {
511564 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
512565 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/commit-rename.html +164 -113
@@ -6,6 +6,7 @@
66 <meta name="color-scheme" content="light dark">
77 <title>SHA7 rename a file and delete another · diffs · example.test</title>
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9+<link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
910 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
1011 <script src="/-/static/js/htmx.min.HASH.js" defer>
1112 </script>
@@ -16,12 +17,15 @@
1617 <script src="/-/static/js/ermite.HASH.js" defer>
1718 </script>
1819 </head>
19-<body data-ref="" data-sha="SHA40" data-sel="">
20+<body data-ref="" data-sha="SHA40" data-sel="" data-nav="">
2021 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
23-<span class="fade" aria-hidden="true">
22+<header class="hdr">
23+<div class="hdr-top">
24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
2426 </span>
27+<span>example.test</span>
28+</a>
2529 <nav class="crumbs" aria-label="Path">
2630 <ol>
2731 <li>
@@ -35,30 +39,49 @@
3539 </li>
3640 </ol>
3741 </nav>
38-<nav class="secs" aria-label="Sections">
42+<div class="hdr-acts">
43+<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
44+<span class="ico i-branch" aria-hidden="true">
45+</span>
46+<span class="ref-name">detached</span>
47+<span class="sha">SHA7</span>
48+</button>
49+<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
50+<span class="ico i-search" aria-hidden="true">
51+</span>
52+<span class="search-label">Go to…</span>
53+<kbd>ctrl k</kbd>
54+</button>
55+<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
56+</div>
57+</div>
58+<nav class="tabs" aria-label="Sections">
3959 <ul>
4060 <li>
41-<a href="/diffs" data-key="s" aria-keyshortcuts="g s">summary</a>
61+<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
62+</a>
4263 </li>
4364 <li>
44-<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="g t">tree</a>
65+<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files<kbd aria-hidden="true">t</kbd>
66+</a>
4567 </li>
4668 <li>
47-<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="g c" aria-current="page">commits</a>
69+<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits<kbd aria-hidden="true">c</kbd>
70+</a>
4871 </li>
4972 <li>
50-<a href="/diffs/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
73+<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
74+</a>
5175 </li>
5276 <li>
53-<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
77+<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
78+</a>
5479 </li>
5580 </ul>
5681 </nav>
57-<button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">detached<span class="sha">@SHA7</span>
58-</button>
5982 </header>
6083 <div class="work has-rail">
61-<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
84+<main id="main" tabindex="-1">
6285 <header class="show">
6386 <h1>rename a file and delete another</h1>
6487 <dl class="kv show-kv">
@@ -78,9 +101,12 @@
78101 </span>
79102 <span class="grow">
80103 </span>
81-<a href="/diffs/commit/SHA40?view=unified" class="vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;unified&#39; ? &#39;true&#39; : false">[u]nified</a>
82-<a href="/diffs/commit/SHA40?view=split" class="vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;split&#39; ? &#39;true&#39; : false">[d] split</a>
83-<button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))">[x] collapse all</button>
104+<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;unified&#39; ? &#39;true&#39; : false">Unified<kbd>u</kbd>
105+</a>
106+<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;split&#39; ? &#39;true&#39; : false">Split<kbd>d</kbd>
107+</a>
108+<button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))" class="btn">Collapse all<kbd>x</kbd>
109+</button>
84110 </div>
85111 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">
86112 <details class="dfile" id="f1" data-file>
@@ -138,10 +164,8 @@
138164 </div>
139165 </main>
140166 <aside class="rail" aria-label="Context">
141-<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
142-<summary>2 changed files</summary>
143167 <section aria-labelledby="r-files">
144-<h2 id="r-files">files <span>2</span>
168+<h2 id="r-files">Files <span class="count">2</span>
145169 </h2>
146170 <ol class="files" aria-label="Changed files">
147171 <li>
@@ -176,73 +200,73 @@
176200 </li>
177201 </ol>
178202 </section>
179-</details>
180203 </aside>
181204 </div>
182-<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
205+<footer class="foot">
206+<span>ermite devel</span>
207+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
208+<span class="foot-keys">
209+<kbd>?</kbd> keyboard shortcuts</span>
210+</footer>
211+<div class="hint" role="status" aria-live="polite">
212+<span class="hint-nav" :text="document.body.dataset.nav || ''">
213+</span>
214+<span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''">
215+</span>
216+<span class="hint-flash" :text="document.body.dataset.flash || ''">
217+</span>
218+</div>
219+<dialog id="panel-help" class="panel modal" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
183220 <div class="panel-head">
184-<span>keys</span>
185-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
221+<h2>Keyboard shortcuts</h2>
222+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
223+<kbd>esc</kbd> close</button>
186224 </div>
187225 <dl class="keys">
188226 <div>
189227 <dt>
190-<kbd>j</kbd>
191-<kbd>k</kbd>
228+<kbd>ctrl k</kbd>
229+<kbd>:</kbd>
192230 </dt>
193-<dd>move cursor down / up</dd>
231+<dd>go to a section, branch, tag, file or repository</dd>
194232 </div>
195233 <div>
196234 <dt>
197-<kbd>enter</kbd>
235+<kbd>?</kbd>
198236 </dt>
199-<dd>open row under cursor</dd>
237+<dd>this list</dd>
200238 </div>
201239 <div>
202240 <dt>
203-<kbd>g</kbd>
204-<kbd>g</kbd> / <kbd>G</kbd>
241+<kbd>j</kbd>
242+<kbd>k</kbd>
205243 </dt>
206-<dd>first / last row</dd>
244+<dd>move the row cursor down / up</dd>
207245 </div>
208246 <div>
209247 <dt>
210-<kbd>/</kbd>
248+<kbd>enter</kbd>
211249 </dt>
212-<dd>focus the filter</dd>
250+<dd>open the row under the cursor</dd>
213251 </div>
214252 <div>
215253 <dt>
216-<kbd>g h</kbd>
217-<kbd>g t</kbd>
254+<kbd>g g</kbd>
255+<kbd>G</kbd>
218256 </dt>
219-<dd>go home / tree</dd>
257+<dd>first / last row</dd>
220258 </div>
221259 <div>
222260 <dt>
223-<kbd>g c</kbd>
224-<kbd>g r</kbd>
261+<kbd>/</kbd>
225262 </dt>
226-<dd>go commits / refs</dd>
263+<dd>filter the list</dd>
227264 </div>
228265 <div>
229266 <dt>
230267 <kbd>b</kbd>
231268 </dt>
232-<dd>switch ref (branches, tags)</dd>
233-</div>
234-<div>
235-<dt>
236-<kbd>:</kbd>
237-<kbd>f</kbd>
238-</dt>
239-<dd>go to: section, ref, file, repo</dd>
240-</div>
241-<div>
242-<dt>
243-<kbd>y</kbd>
244-</dt>
245-<dd>permalink: ref becomes the full sha</dd>
269+<dd>switch branch or tag</dd>
246270 </div>
247271 <div>
248272 <dt>
@@ -250,15 +274,21 @@
250274 <kbd>t</kbd>
251275 <kbd>c</kbd>
252276 <kbd>r</kbd>
277+<kbd>m</kbd>
253278 </dt>
254-<dd>repo sections (when shown)</dd>
279+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
255280 </div>
256281 <div>
257282 <dt>
258-<kbd>m</kbd>
259-<kbd>g m</kbd>
283+<kbd>g h</kbd>
284+</dt>
285+<dd>home</dd>
286+</div>
287+<div>
288+<dt>
289+<kbd>y</kbd>
260290 </dt>
261-<dd>merge requests (when shown)</dd>
291+<dd>copy a permalink pinned to the commit</dd>
262292 </div>
263293 <div>
264294 <dt>
@@ -278,19 +308,21 @@
278308 <dt>
279309 <kbd>esc</kbd>
280310 </dt>
281-<dd>close panel</dd>
311+<dd>close a dialog, leave a field</dd>
282312 </div>
283313 </dl>
284314 </dialog>
285-<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
315+<dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
286316 <div class="panel-head">
287-<label for="refs-filter">ref&gt;</label>
288-<input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
289-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
317+<label for="refs-filter" class="sr">filter branches and tags</label>
318+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
319+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
320+<kbd>esc</kbd>
321+</button>
290322 </div>
291323 <div class="panel-cols" data-picklist>
292324 <section aria-label="Branches">
293-<h2>branches <span class="mute">2</span>
325+<h2>Branches <span class="count">2</span>
294326 </h2>
295327 <ul>
296328 <li>
@@ -304,7 +336,7 @@
304336 </ul>
305337 </section>
306338 <section aria-label="Tags">
307-<h2>tags <span class="mute">1</span>
339+<h2>Tags <span class="count">1</span>
308340 </h2>
309341 <ul>
310342 <li>
@@ -315,105 +347,124 @@
315347 </section>
316348 </div>
317349 </dialog>
318-<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
350+<dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
319351 <div class="panel-head">
320-<label for="cmd-q" class="hi">:</label>
321-<input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
352+<label for="cmd-q" class="sr">go to</label>
353+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
322354 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
323355 let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
324356 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
325357 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
326358 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
327359 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
328-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
360+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
361+<kbd>esc</kbd>
362+</button>
329363 </div>
330-<ul id="cmd-list" class="cmd-list">
364+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
331365 <li data-k="go home" data-cursor>
332366 <a href="/" tabindex="-1">
333-<span class="k">go</span>home</a>
367+<span class="lbl">home</span>
368+<span class="k">go</span>
369+</a>
334370 </li>
335371 <li data-k="section summary">
336372 <a href="/diffs" tabindex="-1">
337-<span class="k">section</span>summary</a>
373+<span class="lbl">summary</span>
374+<span class="k">section</span>
375+</a>
338376 </li>
339-<li data-k="section tree">
377+<li data-k="section files">
340378 <a href="/diffs/tree/main" tabindex="-1">
341-<span class="k">section</span>tree</a>
379+<span class="lbl">files</span>
380+<span class="k">section</span>
381+</a>
342382 </li>
343383 <li data-k="section commits">
344384 <a href="/diffs/commits/main" tabindex="-1">
345-<span class="k">section</span>commits</a>
385+<span class="lbl">commits</span>
386+<span class="k">section</span>
387+</a>
346388 </li>
347-<li data-k="section refs">
389+<li data-k="section branches &amp; tags">
348390 <a href="/diffs/refs" tabindex="-1">
349-<span class="k">section</span>refs</a>
391+<span class="lbl">branches &amp; tags</span>
392+<span class="k">section</span>
393+</a>
350394 </li>
351395 <li data-k="section merge requests">
352396 <a href="/diffs/merge-requests" tabindex="-1">
353-<span class="k">section</span>merge requests</a>
397+<span class="lbl">merge requests</span>
398+<span class="k">section</span>
399+</a>
354400 </li>
355401 <li data-k="branch main">
356402 <a href="/diffs/tree/main" tabindex="-1">
357-<span class="k">branch</span>main</a>
403+<span class="lbl">main</span>
404+<span class="k">branch</span>
405+</a>
358406 </li>
359407 <li data-k="branch side">
360408 <a href="/diffs/tree/side" tabindex="-1">
361-<span class="k">branch</span>side</a>
409+<span class="lbl">side</span>
410+<span class="k">branch</span>
411+</a>
362412 </li>
363413 <li data-k="tag v1.0.0">
364414 <a href="/diffs/tree/v1.0.0" tabindex="-1">
365-<span class="k">tag</span>v1.0.0</a>
415+<span class="lbl">v1.0.0</span>
416+<span class="k">tag</span>
417+</a>
366418 </li>
367419 </ul>
420+<p class="panel-foot">
421+<span>
422+<kbd>↑</kbd>
423+<kbd>↓</kbd> move</span>
424+<span>
425+<kbd>enter</kbd> open</span>
426+<span>
427+<kbd>esc</kbd> close</span>
428+</p>
368429 </dialog>
369-<footer class="bar status" aria-label="Status">
370-<span class="mode">commit</span>
371-<span class="st-ctx">SHA7 · 2 files · +0 -1</span>
372-<button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
373-<span class="hi">:</span> go to…</button>
374-<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
375-</span>
376-<span class="st-hints">d split · u unified · x collapse all · ? help</span>
377-<span class="st-src">
378-<span class="st-ver">ermite devel · </span>
379-<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
380-</span>
381-<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
382-</footer>
383-<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
430+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
384431 if (q('dialog[open]').count) return;
385432 let rows = q('[data-rows] > :not([hidden])').arr();
386433 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
387434 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
388- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
389- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
435+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
436+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
437+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
438+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
390439 })(1);
391- k-k from:body -> ((d) => {
440+ k-k from:body -> (async (d) => {
392441 if (q('dialog[open]').count) return;
393442 let rows = q('[data-rows] > :not([hidden])').arr();
394443 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
395444 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
396- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
397- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
445+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
446+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
447+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
448+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
398449 })(-1);
399450 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
400451 k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
401452 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
402453 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
403- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
404- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus());
405- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
454+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
455+ k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
456+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
406457 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
407- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
408- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
409- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
410- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
411- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
412- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
413- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
414- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
415- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
416- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
458+ k-gh from:body -> document.querySelector('.brand')?.click();
459+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
460+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
461+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
462+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
463+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
464+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
465+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
466+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
467+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
417468 k-next from:body -> {
418469 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
419470 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/commit-root.html +164 -113
@@ -6,6 +6,7 @@
66 <meta name="color-scheme" content="light dark">
77 <title>SHA7 root: the first files · diffs · example.test</title>
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9+<link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
910 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
1011 <script src="/-/static/js/htmx.min.HASH.js" defer>
1112 </script>
@@ -16,12 +17,15 @@
1617 <script src="/-/static/js/ermite.HASH.js" defer>
1718 </script>
1819 </head>
19-<body data-ref="" data-sha="SHA40" data-sel="">
20+<body data-ref="" data-sha="SHA40" data-sel="" data-nav="">
2021 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
23-<span class="fade" aria-hidden="true">
22+<header class="hdr">
23+<div class="hdr-top">
24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
2426 </span>
27+<span>example.test</span>
28+</a>
2529 <nav class="crumbs" aria-label="Path">
2630 <ol>
2731 <li>
@@ -35,30 +39,49 @@
3539 </li>
3640 </ol>
3741 </nav>
38-<nav class="secs" aria-label="Sections">
42+<div class="hdr-acts">
43+<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
44+<span class="ico i-branch" aria-hidden="true">
45+</span>
46+<span class="ref-name">detached</span>
47+<span class="sha">SHA7</span>
48+</button>
49+<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
50+<span class="ico i-search" aria-hidden="true">
51+</span>
52+<span class="search-label">Go to…</span>
53+<kbd>ctrl k</kbd>
54+</button>
55+<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
56+</div>
57+</div>
58+<nav class="tabs" aria-label="Sections">
3959 <ul>
4060 <li>
41-<a href="/diffs" data-key="s" aria-keyshortcuts="g s">summary</a>
61+<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
62+</a>
4263 </li>
4364 <li>
44-<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="g t">tree</a>
65+<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files<kbd aria-hidden="true">t</kbd>
66+</a>
4567 </li>
4668 <li>
47-<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="g c" aria-current="page">commits</a>
69+<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits<kbd aria-hidden="true">c</kbd>
70+</a>
4871 </li>
4972 <li>
50-<a href="/diffs/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
73+<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
74+</a>
5175 </li>
5276 <li>
53-<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
77+<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
78+</a>
5479 </li>
5580 </ul>
5681 </nav>
57-<button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">detached<span class="sha">@SHA7</span>
58-</button>
5982 </header>
6083 <div class="work has-rail">
61-<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
84+<main id="main" tabindex="-1">
6285 <header class="show">
6386 <h1>root: the first files</h1>
6487 <dl class="kv show-kv">
@@ -76,9 +99,12 @@
7699 </span>
77100 <span class="grow">
78101 </span>
79-<a href="/diffs/commit/SHA40?view=unified" class="vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;unified&#39; ? &#39;true&#39; : false">[u]nified</a>
80-<a href="/diffs/commit/SHA40?view=split" class="vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;split&#39; ? &#39;true&#39; : false">[d] split</a>
81-<button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))">[x] collapse all</button>
102+<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;unified&#39; ? &#39;true&#39; : false">Unified<kbd>u</kbd>
103+</a>
104+<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;split&#39; ? &#39;true&#39; : false">Split<kbd>d</kbd>
105+</a>
106+<button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))" class="btn">Collapse all<kbd>x</kbd>
107+</button>
82108 </div>
83109 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">
84110 <details class="dfile" id="f1" data-file open>
@@ -388,10 +414,8 @@
388414 </div>
389415 </main>
390416 <aside class="rail" aria-label="Context">
391-<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
392-<summary>5 changed files</summary>
393417 <section aria-labelledby="r-files">
394-<h2 id="r-files">files <span>5</span>
418+<h2 id="r-files">Files <span class="count">5</span>
395419 </h2>
396420 <ol class="files" aria-label="Changed files">
397421 <li>
@@ -468,73 +492,73 @@
468492 </li>
469493 </ol>
470494 </section>
471-</details>
472495 </aside>
473496 </div>
474-<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
497+<footer class="foot">
498+<span>ermite devel</span>
499+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
500+<span class="foot-keys">
501+<kbd>?</kbd> keyboard shortcuts</span>
502+</footer>
503+<div class="hint" role="status" aria-live="polite">
504+<span class="hint-nav" :text="document.body.dataset.nav || ''">
505+</span>
506+<span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''">
507+</span>
508+<span class="hint-flash" :text="document.body.dataset.flash || ''">
509+</span>
510+</div>
511+<dialog id="panel-help" class="panel modal" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
475512 <div class="panel-head">
476-<span>keys</span>
477-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
513+<h2>Keyboard shortcuts</h2>
514+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
515+<kbd>esc</kbd> close</button>
478516 </div>
479517 <dl class="keys">
480518 <div>
481519 <dt>
482-<kbd>j</kbd>
483-<kbd>k</kbd>
520+<kbd>ctrl k</kbd>
521+<kbd>:</kbd>
484522 </dt>
485-<dd>move cursor down / up</dd>
523+<dd>go to a section, branch, tag, file or repository</dd>
486524 </div>
487525 <div>
488526 <dt>
489-<kbd>enter</kbd>
527+<kbd>?</kbd>
490528 </dt>
491-<dd>open row under cursor</dd>
529+<dd>this list</dd>
492530 </div>
493531 <div>
494532 <dt>
495-<kbd>g</kbd>
496-<kbd>g</kbd> / <kbd>G</kbd>
533+<kbd>j</kbd>
534+<kbd>k</kbd>
497535 </dt>
498-<dd>first / last row</dd>
536+<dd>move the row cursor down / up</dd>
499537 </div>
500538 <div>
501539 <dt>
502-<kbd>/</kbd>
540+<kbd>enter</kbd>
503541 </dt>
504-<dd>focus the filter</dd>
542+<dd>open the row under the cursor</dd>
505543 </div>
506544 <div>
507545 <dt>
508-<kbd>g h</kbd>
509-<kbd>g t</kbd>
546+<kbd>g g</kbd>
547+<kbd>G</kbd>
510548 </dt>
511-<dd>go home / tree</dd>
549+<dd>first / last row</dd>
512550 </div>
513551 <div>
514552 <dt>
515-<kbd>g c</kbd>
516-<kbd>g r</kbd>
553+<kbd>/</kbd>
517554 </dt>
518-<dd>go commits / refs</dd>
555+<dd>filter the list</dd>
519556 </div>
520557 <div>
521558 <dt>
522559 <kbd>b</kbd>
523560 </dt>
524-<dd>switch ref (branches, tags)</dd>
525-</div>
526-<div>
527-<dt>
528-<kbd>:</kbd>
529-<kbd>f</kbd>
530-</dt>
531-<dd>go to: section, ref, file, repo</dd>
532-</div>
533-<div>
534-<dt>
535-<kbd>y</kbd>
536-</dt>
537-<dd>permalink: ref becomes the full sha</dd>
561+<dd>switch branch or tag</dd>
538562 </div>
539563 <div>
540564 <dt>
@@ -542,15 +566,21 @@
542566 <kbd>t</kbd>
543567 <kbd>c</kbd>
544568 <kbd>r</kbd>
569+<kbd>m</kbd>
545570 </dt>
546-<dd>repo sections (when shown)</dd>
571+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
547572 </div>
548573 <div>
549574 <dt>
550-<kbd>m</kbd>
551-<kbd>g m</kbd>
575+<kbd>g h</kbd>
576+</dt>
577+<dd>home</dd>
578+</div>
579+<div>
580+<dt>
581+<kbd>y</kbd>
552582 </dt>
553-<dd>merge requests (when shown)</dd>
583+<dd>copy a permalink pinned to the commit</dd>
554584 </div>
555585 <div>
556586 <dt>
@@ -570,19 +600,21 @@
570600 <dt>
571601 <kbd>esc</kbd>
572602 </dt>
573-<dd>close panel</dd>
603+<dd>close a dialog, leave a field</dd>
574604 </div>
575605 </dl>
576606 </dialog>
577-<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
607+<dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
578608 <div class="panel-head">
579-<label for="refs-filter">ref&gt;</label>
580-<input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
581-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
609+<label for="refs-filter" class="sr">filter branches and tags</label>
610+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
611+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
612+<kbd>esc</kbd>
613+</button>
582614 </div>
583615 <div class="panel-cols" data-picklist>
584616 <section aria-label="Branches">
585-<h2>branches <span class="mute">2</span>
617+<h2>Branches <span class="count">2</span>
586618 </h2>
587619 <ul>
588620 <li>
@@ -596,7 +628,7 @@
596628 </ul>
597629 </section>
598630 <section aria-label="Tags">
599-<h2>tags <span class="mute">1</span>
631+<h2>Tags <span class="count">1</span>
600632 </h2>
601633 <ul>
602634 <li>
@@ -607,105 +639,124 @@
607639 </section>
608640 </div>
609641 </dialog>
610-<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
642+<dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
611643 <div class="panel-head">
612-<label for="cmd-q" class="hi">:</label>
613-<input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
644+<label for="cmd-q" class="sr">go to</label>
645+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
614646 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
615647 let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
616648 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
617649 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
618650 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
619651 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
620-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
652+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
653+<kbd>esc</kbd>
654+</button>
621655 </div>
622-<ul id="cmd-list" class="cmd-list">
656+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
623657 <li data-k="go home" data-cursor>
624658 <a href="/" tabindex="-1">
625-<span class="k">go</span>home</a>
659+<span class="lbl">home</span>
660+<span class="k">go</span>
661+</a>
626662 </li>
627663 <li data-k="section summary">
628664 <a href="/diffs" tabindex="-1">
629-<span class="k">section</span>summary</a>
665+<span class="lbl">summary</span>
666+<span class="k">section</span>
667+</a>
630668 </li>
631-<li data-k="section tree">
669+<li data-k="section files">
632670 <a href="/diffs/tree/main" tabindex="-1">
633-<span class="k">section</span>tree</a>
671+<span class="lbl">files</span>
672+<span class="k">section</span>
673+</a>
634674 </li>
635675 <li data-k="section commits">
636676 <a href="/diffs/commits/main" tabindex="-1">
637-<span class="k">section</span>commits</a>
677+<span class="lbl">commits</span>
678+<span class="k">section</span>
679+</a>
638680 </li>
639-<li data-k="section refs">
681+<li data-k="section branches &amp; tags">
640682 <a href="/diffs/refs" tabindex="-1">
641-<span class="k">section</span>refs</a>
683+<span class="lbl">branches &amp; tags</span>
684+<span class="k">section</span>
685+</a>
642686 </li>
643687 <li data-k="section merge requests">
644688 <a href="/diffs/merge-requests" tabindex="-1">
645-<span class="k">section</span>merge requests</a>
689+<span class="lbl">merge requests</span>
690+<span class="k">section</span>
691+</a>
646692 </li>
647693 <li data-k="branch main">
648694 <a href="/diffs/tree/main" tabindex="-1">
649-<span class="k">branch</span>main</a>
695+<span class="lbl">main</span>
696+<span class="k">branch</span>
697+</a>
650698 </li>
651699 <li data-k="branch side">
652700 <a href="/diffs/tree/side" tabindex="-1">
653-<span class="k">branch</span>side</a>
701+<span class="lbl">side</span>
702+<span class="k">branch</span>
703+</a>
654704 </li>
655705 <li data-k="tag v1.0.0">
656706 <a href="/diffs/tree/v1.0.0" tabindex="-1">
657-<span class="k">tag</span>v1.0.0</a>
707+<span class="lbl">v1.0.0</span>
708+<span class="k">tag</span>
709+</a>
658710 </li>
659711 </ul>
712+<p class="panel-foot">
713+<span>
714+<kbd>↑</kbd>
715+<kbd>↓</kbd> move</span>
716+<span>
717+<kbd>enter</kbd> open</span>
718+<span>
719+<kbd>esc</kbd> close</span>
720+</p>
660721 </dialog>
661-<footer class="bar status" aria-label="Status">
662-<span class="mode">commit</span>
663-<span class="st-ctx">SHA7 · 5 files · +11 -0</span>
664-<button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
665-<span class="hi">:</span> go to…</button>
666-<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
667-</span>
668-<span class="st-hints">d split · u unified · x collapse all · ? help</span>
669-<span class="st-src">
670-<span class="st-ver">ermite devel · </span>
671-<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
672-</span>
673-<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
674-</footer>
675-<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
722+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
676723 if (q('dialog[open]').count) return;
677724 let rows = q('[data-rows] > :not([hidden])').arr();
678725 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
679726 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
680- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
681- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
727+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
728+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
729+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
730+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
682731 })(1);
683- k-k from:body -> ((d) => {
732+ k-k from:body -> (async (d) => {
684733 if (q('dialog[open]').count) return;
685734 let rows = q('[data-rows] > :not([hidden])').arr();
686735 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
687736 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
688- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
689- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
737+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
738+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
739+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
740+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
690741 })(-1);
691742 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
692743 k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
693744 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
694745 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
695- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
696- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus());
697- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
746+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
747+ k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
748+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
698749 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
699- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
700- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
701- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
702- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
703- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
704- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
705- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
706- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
707- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
708- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
750+ k-gh from:body -> document.querySelector('.brand')?.click();
751+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
752+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
753+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
754+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
755+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
756+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
757+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
758+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
759+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
709760 k-next from:body -> {
710761 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
711762 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/commit-trojan.html +164 -113
@@ -6,6 +6,7 @@
66 <meta name="color-scheme" content="light dark">
77 <title>SHA7 add evil.go · diffs · example.test</title>
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9+<link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
910 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
1011 <script src="/-/static/js/htmx.min.HASH.js" defer>
1112 </script>
@@ -16,12 +17,15 @@
1617 <script src="/-/static/js/ermite.HASH.js" defer>
1718 </script>
1819 </head>
19-<body data-ref="" data-sha="SHA40" data-sel="">
20+<body data-ref="" data-sha="SHA40" data-sel="" data-nav="">
2021 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
23-<span class="fade" aria-hidden="true">
22+<header class="hdr">
23+<div class="hdr-top">
24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
2426 </span>
27+<span>example.test</span>
28+</a>
2529 <nav class="crumbs" aria-label="Path">
2630 <ol>
2731 <li>
@@ -35,30 +39,49 @@
3539 </li>
3640 </ol>
3741 </nav>
38-<nav class="secs" aria-label="Sections">
42+<div class="hdr-acts">
43+<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
44+<span class="ico i-branch" aria-hidden="true">
45+</span>
46+<span class="ref-name">detached</span>
47+<span class="sha">SHA7</span>
48+</button>
49+<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
50+<span class="ico i-search" aria-hidden="true">
51+</span>
52+<span class="search-label">Go to…</span>
53+<kbd>ctrl k</kbd>
54+</button>
55+<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
56+</div>
57+</div>
58+<nav class="tabs" aria-label="Sections">
3959 <ul>
4060 <li>
41-<a href="/diffs" data-key="s" aria-keyshortcuts="g s">summary</a>
61+<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
62+</a>
4263 </li>
4364 <li>
44-<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="g t">tree</a>
65+<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files<kbd aria-hidden="true">t</kbd>
66+</a>
4567 </li>
4668 <li>
47-<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="g c" aria-current="page">commits</a>
69+<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits<kbd aria-hidden="true">c</kbd>
70+</a>
4871 </li>
4972 <li>
50-<a href="/diffs/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
73+<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
74+</a>
5175 </li>
5276 <li>
53-<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
77+<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
78+</a>
5479 </li>
5580 </ul>
5681 </nav>
57-<button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">detached<span class="sha">@SHA7</span>
58-</button>
5982 </header>
6083 <div class="work has-rail">
61-<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
84+<main id="main" tabindex="-1">
6285 <header class="show">
6386 <h1>add evil.go</h1>
6487 <pre class="msg">looks &lt;U+202E&gt;fine&lt;U+202C&gt;</pre>
@@ -79,9 +102,12 @@
79102 </span>
80103 <span class="grow">
81104 </span>
82-<a href="/diffs/commit/SHA40?view=unified" class="vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;unified&#39; ? &#39;true&#39; : false">[u]nified</a>
83-<a href="/diffs/commit/SHA40?view=split" class="vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;split&#39; ? &#39;true&#39; : false">[d] split</a>
84-<button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))">[x] collapse all</button>
105+<a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;unified&#39; ? &#39;true&#39; : false">Unified<kbd>u</kbd>
106+</a>
107+<a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q(&#39;#diffs&#39;).attr[&#39;data-diff&#39;] === &#39;split&#39; ? &#39;true&#39; : false">Split<kbd>d</kbd>
108+</a>
109+<button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))" class="btn">Collapse all<kbd>x</kbd>
110+</button>
85111 </div>
86112 <p class="notice">this diff holds invisible characters (direction controls, zero-width characters). They are shown as <span class="inv">&lt;U+XXXX&gt;</span> markers, never applied.</p>
87113 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">
@@ -262,10 +288,8 @@
262288 </div>
263289 </main>
264290 <aside class="rail" aria-label="Context">
265-<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
266-<summary>1 changed file</summary>
267291 <section aria-labelledby="r-files">
268-<h2 id="r-files">files <span>1</span>
292+<h2 id="r-files">Files <span class="count">1</span>
269293 </h2>
270294 <ol class="files" aria-label="Changed files">
271295 <li>
@@ -285,73 +309,73 @@
285309 </li>
286310 </ol>
287311 </section>
288-</details>
289312 </aside>
290313 </div>
291-<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
314+<footer class="foot">
315+<span>ermite devel</span>
316+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
317+<span class="foot-keys">
318+<kbd>?</kbd> keyboard shortcuts</span>
319+</footer>
320+<div class="hint" role="status" aria-live="polite">
321+<span class="hint-nav" :text="document.body.dataset.nav || ''">
322+</span>
323+<span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''">
324+</span>
325+<span class="hint-flash" :text="document.body.dataset.flash || ''">
326+</span>
327+</div>
328+<dialog id="panel-help" class="panel modal" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
292329 <div class="panel-head">
293-<span>keys</span>
294-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
330+<h2>Keyboard shortcuts</h2>
331+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
332+<kbd>esc</kbd> close</button>
295333 </div>
296334 <dl class="keys">
297335 <div>
298336 <dt>
299-<kbd>j</kbd>
300-<kbd>k</kbd>
337+<kbd>ctrl k</kbd>
338+<kbd>:</kbd>
301339 </dt>
302-<dd>move cursor down / up</dd>
340+<dd>go to a section, branch, tag, file or repository</dd>
303341 </div>
304342 <div>
305343 <dt>
306-<kbd>enter</kbd>
344+<kbd>?</kbd>
307345 </dt>
308-<dd>open row under cursor</dd>
346+<dd>this list</dd>
309347 </div>
310348 <div>
311349 <dt>
312-<kbd>g</kbd>
313-<kbd>g</kbd> / <kbd>G</kbd>
350+<kbd>j</kbd>
351+<kbd>k</kbd>
314352 </dt>
315-<dd>first / last row</dd>
353+<dd>move the row cursor down / up</dd>
316354 </div>
317355 <div>
318356 <dt>
319-<kbd>/</kbd>
357+<kbd>enter</kbd>
320358 </dt>
321-<dd>focus the filter</dd>
359+<dd>open the row under the cursor</dd>
322360 </div>
323361 <div>
324362 <dt>
325-<kbd>g h</kbd>
326-<kbd>g t</kbd>
363+<kbd>g g</kbd>
364+<kbd>G</kbd>
327365 </dt>
328-<dd>go home / tree</dd>
366+<dd>first / last row</dd>
329367 </div>
330368 <div>
331369 <dt>
332-<kbd>g c</kbd>
333-<kbd>g r</kbd>
370+<kbd>/</kbd>
334371 </dt>
335-<dd>go commits / refs</dd>
372+<dd>filter the list</dd>
336373 </div>
337374 <div>
338375 <dt>
339376 <kbd>b</kbd>
340377 </dt>
341-<dd>switch ref (branches, tags)</dd>
342-</div>
343-<div>
344-<dt>
345-<kbd>:</kbd>
346-<kbd>f</kbd>
347-</dt>
348-<dd>go to: section, ref, file, repo</dd>
349-</div>
350-<div>
351-<dt>
352-<kbd>y</kbd>
353-</dt>
354-<dd>permalink: ref becomes the full sha</dd>
378+<dd>switch branch or tag</dd>
355379 </div>
356380 <div>
357381 <dt>
@@ -359,15 +383,21 @@
359383 <kbd>t</kbd>
360384 <kbd>c</kbd>
361385 <kbd>r</kbd>
386+<kbd>m</kbd>
362387 </dt>
363-<dd>repo sections (when shown)</dd>
388+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
364389 </div>
365390 <div>
366391 <dt>
367-<kbd>m</kbd>
368-<kbd>g m</kbd>
392+<kbd>g h</kbd>
393+</dt>
394+<dd>home</dd>
395+</div>
396+<div>
397+<dt>
398+<kbd>y</kbd>
369399 </dt>
370-<dd>merge requests (when shown)</dd>
400+<dd>copy a permalink pinned to the commit</dd>
371401 </div>
372402 <div>
373403 <dt>
@@ -387,19 +417,21 @@
387417 <dt>
388418 <kbd>esc</kbd>
389419 </dt>
390-<dd>close panel</dd>
420+<dd>close a dialog, leave a field</dd>
391421 </div>
392422 </dl>
393423 </dialog>
394-<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
424+<dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
395425 <div class="panel-head">
396-<label for="refs-filter">ref&gt;</label>
397-<input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
398-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
426+<label for="refs-filter" class="sr">filter branches and tags</label>
427+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
428+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
429+<kbd>esc</kbd>
430+</button>
399431 </div>
400432 <div class="panel-cols" data-picklist>
401433 <section aria-label="Branches">
402-<h2>branches <span class="mute">2</span>
434+<h2>Branches <span class="count">2</span>
403435 </h2>
404436 <ul>
405437 <li>
@@ -413,7 +445,7 @@
413445 </ul>
414446 </section>
415447 <section aria-label="Tags">
416-<h2>tags <span class="mute">1</span>
448+<h2>Tags <span class="count">1</span>
417449 </h2>
418450 <ul>
419451 <li>
@@ -424,105 +456,124 @@
424456 </section>
425457 </div>
426458 </dialog>
427-<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
459+<dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
428460 <div class="panel-head">
429-<label for="cmd-q" class="hi">:</label>
430-<input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
461+<label for="cmd-q" class="sr">go to</label>
462+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
431463 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
432464 let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
433465 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
434466 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
435467 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
436468 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
437-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
469+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
470+<kbd>esc</kbd>
471+</button>
438472 </div>
439-<ul id="cmd-list" class="cmd-list">
473+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
440474 <li data-k="go home" data-cursor>
441475 <a href="/" tabindex="-1">
442-<span class="k">go</span>home</a>
476+<span class="lbl">home</span>
477+<span class="k">go</span>
478+</a>
443479 </li>
444480 <li data-k="section summary">
445481 <a href="/diffs" tabindex="-1">
446-<span class="k">section</span>summary</a>
482+<span class="lbl">summary</span>
483+<span class="k">section</span>
484+</a>
447485 </li>
448-<li data-k="section tree">
486+<li data-k="section files">
449487 <a href="/diffs/tree/main" tabindex="-1">
450-<span class="k">section</span>tree</a>
488+<span class="lbl">files</span>
489+<span class="k">section</span>
490+</a>
451491 </li>
452492 <li data-k="section commits">
453493 <a href="/diffs/commits/main" tabindex="-1">
454-<span class="k">section</span>commits</a>
494+<span class="lbl">commits</span>
495+<span class="k">section</span>
496+</a>
455497 </li>
456-<li data-k="section refs">
498+<li data-k="section branches &amp; tags">
457499 <a href="/diffs/refs" tabindex="-1">
458-<span class="k">section</span>refs</a>
500+<span class="lbl">branches &amp; tags</span>
501+<span class="k">section</span>
502+</a>
459503 </li>
460504 <li data-k="section merge requests">
461505 <a href="/diffs/merge-requests" tabindex="-1">
462-<span class="k">section</span>merge requests</a>
506+<span class="lbl">merge requests</span>
507+<span class="k">section</span>
508+</a>
463509 </li>
464510 <li data-k="branch main">
465511 <a href="/diffs/tree/main" tabindex="-1">
466-<span class="k">branch</span>main</a>
512+<span class="lbl">main</span>
513+<span class="k">branch</span>
514+</a>
467515 </li>
468516 <li data-k="branch side">
469517 <a href="/diffs/tree/side" tabindex="-1">
470-<span class="k">branch</span>side</a>
518+<span class="lbl">side</span>
519+<span class="k">branch</span>
520+</a>
471521 </li>
472522 <li data-k="tag v1.0.0">
473523 <a href="/diffs/tree/v1.0.0" tabindex="-1">
474-<span class="k">tag</span>v1.0.0</a>
524+<span class="lbl">v1.0.0</span>
525+<span class="k">tag</span>
526+</a>
475527 </li>
476528 </ul>
529+<p class="panel-foot">
530+<span>
531+<kbd>↑</kbd>
532+<kbd>↓</kbd> move</span>
533+<span>
534+<kbd>enter</kbd> open</span>
535+<span>
536+<kbd>esc</kbd> close</span>
537+</p>
477538 </dialog>
478-<footer class="bar status" aria-label="Status">
479-<span class="mode">commit</span>
480-<span class="st-ctx">SHA7 · 1 file · +8 -0</span>
481-<button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
482-<span class="hi">:</span> go to…</button>
483-<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
484-</span>
485-<span class="st-hints">d split · u unified · x collapse all · ? help</span>
486-<span class="st-src">
487-<span class="st-ver">ermite devel · </span>
488-<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
489-</span>
490-<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
491-</footer>
492-<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
539+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
493540 if (q('dialog[open]').count) return;
494541 let rows = q('[data-rows] > :not([hidden])').arr();
495542 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
496543 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
497- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
498- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
544+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
545+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
546+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
547+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
499548 })(1);
500- k-k from:body -> ((d) => {
549+ k-k from:body -> (async (d) => {
501550 if (q('dialog[open]').count) return;
502551 let rows = q('[data-rows] > :not([hidden])').arr();
503552 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
504553 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
505- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
506- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
554+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
555+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
556+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
557+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
507558 })(-1);
508559 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
509560 k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
510561 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
511562 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
512- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
513- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus());
514- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
563+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
564+ k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
565+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
515566 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
516- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
517- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
518- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
519- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
520- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
521- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
522- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
523- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
524- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
525- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
567+ k-gh from:body -> document.querySelector('.brand')?.click();
568+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
569+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
570+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
571+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
572+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
573+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
574+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
575+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
576+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
526577 k-next from:body -> {
527578 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
528579 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/commits-page2.html +160 -115
@@ -6,6 +6,7 @@
66 <meta name="color-scheme" content="light dark">
77 <title>commits · long · example.test</title>
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9+<link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
910 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
1011 <script src="/-/static/js/htmx.min.HASH.js" defer>
1112 </script>
@@ -16,12 +17,15 @@
1617 <script src="/-/static/js/ermite.HASH.js" defer>
1718 </script>
1819 </head>
19-<body data-ref="main" data-sha="SHA40" data-sel="">
20+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
2021 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
23-<span class="fade" aria-hidden="true">
22+<header class="hdr">
23+<div class="hdr-top">
24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
2426 </span>
27+<span>example.test</span>
28+</a>
2529 <nav class="crumbs" aria-label="Path">
2630 <ol>
2731 <li>
@@ -35,33 +39,52 @@
3539 </li>
3640 </ol>
3741 </nav>
38-<nav class="secs" aria-label="Sections">
42+<div class="hdr-acts">
43+<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
44+<span class="ico i-branch" aria-hidden="true">
45+</span>
46+<span class="ref-name">main</span>
47+<span class="sha">SHA7</span>
48+</button>
49+<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
50+<span class="ico i-search" aria-hidden="true">
51+</span>
52+<span class="search-label">Go to…</span>
53+<kbd>ctrl k</kbd>
54+</button>
55+<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
56+</div>
57+</div>
58+<nav class="tabs" aria-label="Sections">
3959 <ul>
4060 <li>
41-<a href="/long" data-key="s" aria-keyshortcuts="g s">summary</a>
61+<a href="/long" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
62+</a>
4263 </li>
4364 <li>
44-<a href="/long/tree/main" data-key="t" aria-keyshortcuts="g t">tree</a>
65+<a href="/long/tree/main" data-key="t" aria-keyshortcuts="t">Files<kbd aria-hidden="true">t</kbd>
66+</a>
4567 </li>
4668 <li>
47-<a href="/long/commits/main" data-key="c" aria-keyshortcuts="g c" aria-current="page">commits</a>
69+<a href="/long/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits<kbd aria-hidden="true">c</kbd>
70+</a>
4871 </li>
4972 <li>
50-<a href="/long/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
73+<a href="/long/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
74+</a>
5175 </li>
5276 <li>
53-<a href="/long/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
77+<a href="/long/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
78+</a>
5479 </li>
5580 </ul>
5681 </nav>
57-<button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
58-</button>
5982 </header>
6083 <div class="work has-rail">
61-<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
84+<main id="main" tabindex="-1">
6285 <div class="filter">
63-<label for="commit-filter">filter&gt;</label>
64-<input id="commit-filter" type="search" data-filter placeholder="subject, sha or author ( / )" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('.days tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t));
86+<label for="commit-filter" class="sr">filter</label>
87+<input id="commit-filter" type="search" data-filter placeholder="Filter by subject, sha or author…" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('.days tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t));
6588 q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])'));
6689 let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')">
6790 </div>
@@ -101,15 +124,13 @@
101124 </section>
102125 </div>
103126 <nav class="pager" aria-label="Pages">
104-<a href="/long/commits/main">← newest</a>
105-<span class="mute">older →</span>
127+<a class="btn" href="/long/commits/main">← Newest</a>
128+<span class="btn" aria-disabled="true">Older →</span>
106129 </nav>
107130 </main>
108131 <aside class="rail" aria-label="Context">
109-<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
110-<summary>history · refs on this page</summary>
111132 <section aria-labelledby="r-hist">
112-<h2 id="r-hist">history</h2>
133+<h2 id="r-hist">History</h2>
113134 <dl class="kv">
114135 <dt>ref</dt>
115136 <dd>main</dd>
@@ -118,76 +139,76 @@
118139 </dl>
119140 </section>
120141 <section aria-labelledby="r-refs">
121-<h2 id="r-refs">refs on this page</h2>
142+<h2 id="r-refs">Refs on this page</h2>
122143 <p class="dim">no tag or branch head here</p>
123144 </section>
124-</details>
125145 </aside>
126146 </div>
127-<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
147+<footer class="foot">
148+<span>ermite devel</span>
149+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
150+<span class="foot-keys">
151+<kbd>?</kbd> keyboard shortcuts</span>
152+</footer>
153+<div class="hint" role="status" aria-live="polite">
154+<span class="hint-nav" :text="document.body.dataset.nav || ''">
155+</span>
156+<span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''">
157+</span>
158+<span class="hint-flash" :text="document.body.dataset.flash || ''">
159+</span>
160+</div>
161+<dialog id="panel-help" class="panel modal" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
128162 <div class="panel-head">
129-<span>keys</span>
130-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
163+<h2>Keyboard shortcuts</h2>
164+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
165+<kbd>esc</kbd> close</button>
131166 </div>
132167 <dl class="keys">
133168 <div>
134169 <dt>
135-<kbd>j</kbd>
136-<kbd>k</kbd>
170+<kbd>ctrl k</kbd>
171+<kbd>:</kbd>
137172 </dt>
138-<dd>move cursor down / up</dd>
173+<dd>go to a section, branch, tag, file or repository</dd>
139174 </div>
140175 <div>
141176 <dt>
142-<kbd>enter</kbd>
177+<kbd>?</kbd>
143178 </dt>
144-<dd>open row under cursor</dd>
179+<dd>this list</dd>
145180 </div>
146181 <div>
147182 <dt>
148-<kbd>g</kbd>
149-<kbd>g</kbd> / <kbd>G</kbd>
183+<kbd>j</kbd>
184+<kbd>k</kbd>
150185 </dt>
151-<dd>first / last row</dd>
186+<dd>move the row cursor down / up</dd>
152187 </div>
153188 <div>
154189 <dt>
155-<kbd>/</kbd>
190+<kbd>enter</kbd>
156191 </dt>
157-<dd>focus the filter</dd>
192+<dd>open the row under the cursor</dd>
158193 </div>
159194 <div>
160195 <dt>
161-<kbd>g h</kbd>
162-<kbd>g t</kbd>
196+<kbd>g g</kbd>
197+<kbd>G</kbd>
163198 </dt>
164-<dd>go home / tree</dd>
199+<dd>first / last row</dd>
165200 </div>
166201 <div>
167202 <dt>
168-<kbd>g c</kbd>
169-<kbd>g r</kbd>
203+<kbd>/</kbd>
170204 </dt>
171-<dd>go commits / refs</dd>
205+<dd>filter the list</dd>
172206 </div>
173207 <div>
174208 <dt>
175209 <kbd>b</kbd>
176210 </dt>
177-<dd>switch ref (branches, tags)</dd>
178-</div>
179-<div>
180-<dt>
181-<kbd>:</kbd>
182-<kbd>f</kbd>
183-</dt>
184-<dd>go to: section, ref, file, repo</dd>
185-</div>
186-<div>
187-<dt>
188-<kbd>y</kbd>
189-</dt>
190-<dd>permalink: ref becomes the full sha</dd>
211+<dd>switch branch or tag</dd>
191212 </div>
192213 <div>
193214 <dt>
@@ -195,15 +216,21 @@
195216 <kbd>t</kbd>
196217 <kbd>c</kbd>
197218 <kbd>r</kbd>
219+<kbd>m</kbd>
198220 </dt>
199-<dd>repo sections (when shown)</dd>
221+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
200222 </div>
201223 <div>
202224 <dt>
203-<kbd>m</kbd>
204-<kbd>g m</kbd>
225+<kbd>g h</kbd>
205226 </dt>
206-<dd>merge requests (when shown)</dd>
227+<dd>home</dd>
228+</div>
229+<div>
230+<dt>
231+<kbd>y</kbd>
232+</dt>
233+<dd>copy a permalink pinned to the commit</dd>
207234 </div>
208235 <div>
209236 <dt>
@@ -223,19 +250,21 @@
223250 <dt>
224251 <kbd>esc</kbd>
225252 </dt>
226-<dd>close panel</dd>
253+<dd>close a dialog, leave a field</dd>
227254 </div>
228255 </dl>
229256 </dialog>
230-<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
257+<dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
231258 <div class="panel-head">
232-<label for="refs-filter">ref&gt;</label>
233-<input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
234-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
259+<label for="refs-filter" class="sr">filter branches and tags</label>
260+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
261+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
262+<kbd>esc</kbd>
263+</button>
235264 </div>
236265 <div class="panel-cols" data-picklist>
237266 <section aria-label="Branches">
238-<h2>branches <span class="mute">1</span>
267+<h2>Branches <span class="count">1</span>
239268 </h2>
240269 <ul>
241270 <li>
@@ -245,109 +274,125 @@
245274 </ul>
246275 </section>
247276 <section aria-label="Tags">
248-<h2>tags <span class="mute">0</span>
277+<h2>Tags <span class="count">0</span>
249278 </h2>
250279 <ul>
251280 </ul>
252281 </section>
253282 </div>
254283 </dialog>
255-<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
284+<dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
256285 <div class="panel-head">
257-<label for="cmd-q" class="hi">:</label>
258-<input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
286+<label for="cmd-q" class="sr">go to</label>
287+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
259288 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
260289 let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
261290 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
262291 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
263292 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
264293 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
265-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
294+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
295+<kbd>esc</kbd>
296+</button>
266297 </div>
267-<ul id="cmd-list" class="cmd-list">
298+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
268299 <li data-k="go home" data-cursor>
269300 <a href="/" tabindex="-1">
270-<span class="k">go</span>home</a>
301+<span class="lbl">home</span>
302+<span class="k">go</span>
303+</a>
271304 </li>
272305 <li data-k="section summary">
273306 <a href="/long" tabindex="-1">
274-<span class="k">section</span>summary</a>
307+<span class="lbl">summary</span>
308+<span class="k">section</span>
309+</a>
275310 </li>
276-<li data-k="section tree">
311+<li data-k="section files">
277312 <a href="/long/tree/main" tabindex="-1">
278-<span class="k">section</span>tree</a>
313+<span class="lbl">files</span>
314+<span class="k">section</span>
315+</a>
279316 </li>
280317 <li data-k="section commits">
281318 <a href="/long/commits/main" tabindex="-1">
282-<span class="k">section</span>commits</a>
319+<span class="lbl">commits</span>
320+<span class="k">section</span>
321+</a>
283322 </li>
284-<li data-k="section refs">
323+<li data-k="section branches &amp; tags">
285324 <a href="/long/refs" tabindex="-1">
286-<span class="k">section</span>refs</a>
325+<span class="lbl">branches &amp; tags</span>
326+<span class="k">section</span>
327+</a>
287328 </li>
288329 <li data-k="section merge requests">
289330 <a href="/long/merge-requests" tabindex="-1">
290-<span class="k">section</span>merge requests</a>
331+<span class="lbl">merge requests</span>
332+<span class="k">section</span>
333+</a>
291334 </li>
292335 <li data-k="branch main">
293336 <a href="/long/tree/main" tabindex="-1">
294-<span class="k">branch</span>main</a>
337+<span class="lbl">main</span>
338+<span class="k">branch</span>
339+</a>
295340 </li>
296341 <li data-k="repo diffs">
297342 <a href="/diffs" tabindex="-1">
298-<span class="k">repo</span>diffs</a>
343+<span class="lbl">diffs</span>
344+<span class="k">repo</span>
345+</a>
299346 </li>
300347 </ul>
348+<p class="panel-foot">
349+<span>
350+<kbd>↑</kbd>
351+<kbd>↓</kbd> move</span>
352+<span>
353+<kbd>enter</kbd> open</span>
354+<span>
355+<kbd>esc</kbd> close</span>
356+</p>
301357 </dialog>
302-<footer class="bar status" aria-label="Status">
303-<span class="mode">commits</span>
304-<span class="st-ctx">main · 2 commits</span>
305-<button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
306-<span class="hi">:</span> go to…</button>
307-<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
308-</span>
309-<span class="st-hints">j/k move · enter open · / filter · ? help</span>
310-<span class="st-src">
311-<span class="st-ver">ermite devel · </span>
312-<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
313-</span>
314-<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
315-<span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/2</span>
316-</footer>
317-<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
358+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
318359 if (q('dialog[open]').count) return;
319360 let rows = q('[data-rows] > :not([hidden])').arr();
320361 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
321362 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
322- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
323- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
363+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
364+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
365+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
366+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
324367 })(1);
325- k-k from:body -> ((d) => {
368+ k-k from:body -> (async (d) => {
326369 if (q('dialog[open]').count) return;
327370 let rows = q('[data-rows] > :not([hidden])').arr();
328371 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
329372 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
330- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
331- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
373+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
374+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
375+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
376+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
332377 })(-1);
333378 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
334379 k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
335380 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
336381 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
337- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
338- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus());
339- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
382+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
383+ k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
384+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
340385 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
341- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
342- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
343- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
344- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
345- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
346- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
347- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
348- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
349- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
350- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
386+ k-gh from:body -> document.querySelector('.brand')?.click();
387+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
388+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
389+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
390+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
391+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
392+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
393+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
394+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
395+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
351396 k-next from:body -> {
352397 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
353398 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/commits-path.html +158 -113
@@ -6,6 +6,7 @@
66 <meta name="color-scheme" content="light dark">
77 <title>history of a.txt · long · example.test</title>
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9+<link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
910 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
1011 <script src="/-/static/js/htmx.min.HASH.js" defer>
1112 </script>
@@ -16,12 +17,15 @@
1617 <script src="/-/static/js/ermite.HASH.js" defer>
1718 </script>
1819 </head>
19-<body data-ref="main" data-sha="SHA40" data-sel="">
20+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
2021 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
23-<span class="fade" aria-hidden="true">
22+<header class="hdr">
23+<div class="hdr-top">
24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
2426 </span>
27+<span>example.test</span>
28+</a>
2529 <nav class="crumbs" aria-label="Path">
2630 <ol>
2731 <li>
@@ -38,33 +42,52 @@
3842 </li>
3943 </ol>
4044 </nav>
41-<nav class="secs" aria-label="Sections">
45+<div class="hdr-acts">
46+<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
47+<span class="ico i-branch" aria-hidden="true">
48+</span>
49+<span class="ref-name">main</span>
50+<span class="sha">SHA7</span>
51+</button>
52+<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
53+<span class="ico i-search" aria-hidden="true">
54+</span>
55+<span class="search-label">Go to…</span>
56+<kbd>ctrl k</kbd>
57+</button>
58+<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
59+</div>
60+</div>
61+<nav class="tabs" aria-label="Sections">
4262 <ul>
4363 <li>
44-<a href="/long" data-key="s" aria-keyshortcuts="g s">summary</a>
64+<a href="/long" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
65+</a>
4566 </li>
4667 <li>
47-<a href="/long/tree/main" data-key="t" aria-keyshortcuts="g t">tree</a>
68+<a href="/long/tree/main" data-key="t" aria-keyshortcuts="t">Files<kbd aria-hidden="true">t</kbd>
69+</a>
4870 </li>
4971 <li>
50-<a href="/long/commits/main" data-key="c" aria-keyshortcuts="g c" aria-current="page">commits</a>
72+<a href="/long/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits<kbd aria-hidden="true">c</kbd>
73+</a>
5174 </li>
5275 <li>
53-<a href="/long/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
76+<a href="/long/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
77+</a>
5478 </li>
5579 <li>
56-<a href="/long/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
80+<a href="/long/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
81+</a>
5782 </li>
5883 </ul>
5984 </nav>
60-<button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
61-</button>
6285 </header>
6386 <div class="work has-rail">
64-<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
87+<main id="main" tabindex="-1">
6588 <div class="filter">
66-<label for="commit-filter">filter&gt;</label>
67-<input id="commit-filter" type="search" data-filter placeholder="subject, sha or author ( / )" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('.days tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t));
89+<label for="commit-filter" class="sr">filter</label>
90+<input id="commit-filter" type="search" data-filter placeholder="Filter by subject, sha or author…" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('.days tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t));
6891 q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])'));
6992 let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')">
7093 </div>
@@ -115,10 +138,8 @@
115138 </div>
116139 </main>
117140 <aside class="rail" aria-label="Context">
118-<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
119-<summary>history · refs on this page</summary>
120141 <section aria-labelledby="r-hist">
121-<h2 id="r-hist">history</h2>
142+<h2 id="r-hist">History</h2>
122143 <dl class="kv">
123144 <dt>ref</dt>
124145 <dd>main</dd>
@@ -133,76 +154,76 @@
133154 </dl>
134155 </section>
135156 <section aria-labelledby="r-refs">
136-<h2 id="r-refs">refs on this page</h2>
157+<h2 id="r-refs">Refs on this page</h2>
137158 <p class="dim">no tag or branch head here</p>
138159 </section>
139-</details>
140160 </aside>
141161 </div>
142-<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
162+<footer class="foot">
163+<span>ermite devel</span>
164+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
165+<span class="foot-keys">
166+<kbd>?</kbd> keyboard shortcuts</span>
167+</footer>
168+<div class="hint" role="status" aria-live="polite">
169+<span class="hint-nav" :text="document.body.dataset.nav || ''">
170+</span>
171+<span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''">
172+</span>
173+<span class="hint-flash" :text="document.body.dataset.flash || ''">
174+</span>
175+</div>
176+<dialog id="panel-help" class="panel modal" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
143177 <div class="panel-head">
144-<span>keys</span>
145-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
178+<h2>Keyboard shortcuts</h2>
179+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
180+<kbd>esc</kbd> close</button>
146181 </div>
147182 <dl class="keys">
148183 <div>
149184 <dt>
150-<kbd>j</kbd>
151-<kbd>k</kbd>
185+<kbd>ctrl k</kbd>
186+<kbd>:</kbd>
152187 </dt>
153-<dd>move cursor down / up</dd>
188+<dd>go to a section, branch, tag, file or repository</dd>
154189 </div>
155190 <div>
156191 <dt>
157-<kbd>enter</kbd>
192+<kbd>?</kbd>
158193 </dt>
159-<dd>open row under cursor</dd>
194+<dd>this list</dd>
160195 </div>
161196 <div>
162197 <dt>
163-<kbd>g</kbd>
164-<kbd>g</kbd> / <kbd>G</kbd>
198+<kbd>j</kbd>
199+<kbd>k</kbd>
165200 </dt>
166-<dd>first / last row</dd>
201+<dd>move the row cursor down / up</dd>
167202 </div>
168203 <div>
169204 <dt>
170-<kbd>/</kbd>
205+<kbd>enter</kbd>
171206 </dt>
172-<dd>focus the filter</dd>
207+<dd>open the row under the cursor</dd>
173208 </div>
174209 <div>
175210 <dt>
176-<kbd>g h</kbd>
177-<kbd>g t</kbd>
211+<kbd>g g</kbd>
212+<kbd>G</kbd>
178213 </dt>
179-<dd>go home / tree</dd>
214+<dd>first / last row</dd>
180215 </div>
181216 <div>
182217 <dt>
183-<kbd>g c</kbd>
184-<kbd>g r</kbd>
218+<kbd>/</kbd>
185219 </dt>
186-<dd>go commits / refs</dd>
220+<dd>filter the list</dd>
187221 </div>
188222 <div>
189223 <dt>
190224 <kbd>b</kbd>
191225 </dt>
192-<dd>switch ref (branches, tags)</dd>
193-</div>
194-<div>
195-<dt>
196-<kbd>:</kbd>
197-<kbd>f</kbd>
198-</dt>
199-<dd>go to: section, ref, file, repo</dd>
200-</div>
201-<div>
202-<dt>
203-<kbd>y</kbd>
204-</dt>
205-<dd>permalink: ref becomes the full sha</dd>
226+<dd>switch branch or tag</dd>
206227 </div>
207228 <div>
208229 <dt>
@@ -210,15 +231,21 @@
210231 <kbd>t</kbd>
211232 <kbd>c</kbd>
212233 <kbd>r</kbd>
234+<kbd>m</kbd>
213235 </dt>
214-<dd>repo sections (when shown)</dd>
236+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
215237 </div>
216238 <div>
217239 <dt>
218-<kbd>m</kbd>
219-<kbd>g m</kbd>
240+<kbd>g h</kbd>
220241 </dt>
221-<dd>merge requests (when shown)</dd>
242+<dd>home</dd>
243+</div>
244+<div>
245+<dt>
246+<kbd>y</kbd>
247+</dt>
248+<dd>copy a permalink pinned to the commit</dd>
222249 </div>
223250 <div>
224251 <dt>
@@ -238,19 +265,21 @@
238265 <dt>
239266 <kbd>esc</kbd>
240267 </dt>
241-<dd>close panel</dd>
268+<dd>close a dialog, leave a field</dd>
242269 </div>
243270 </dl>
244271 </dialog>
245-<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
272+<dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
246273 <div class="panel-head">
247-<label for="refs-filter">ref&gt;</label>
248-<input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
249-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
274+<label for="refs-filter" class="sr">filter branches and tags</label>
275+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
276+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
277+<kbd>esc</kbd>
278+</button>
250279 </div>
251280 <div class="panel-cols" data-picklist>
252281 <section aria-label="Branches">
253-<h2>branches <span class="mute">1</span>
282+<h2>Branches <span class="count">1</span>
254283 </h2>
255284 <ul>
256285 <li>
@@ -260,109 +289,125 @@
260289 </ul>
261290 </section>
262291 <section aria-label="Tags">
263-<h2>tags <span class="mute">0</span>
292+<h2>Tags <span class="count">0</span>
264293 </h2>
265294 <ul>
266295 </ul>
267296 </section>
268297 </div>
269298 </dialog>
270-<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
299+<dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
271300 <div class="panel-head">
272-<label for="cmd-q" class="hi">:</label>
273-<input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
301+<label for="cmd-q" class="sr">go to</label>
302+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
274303 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
275304 let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
276305 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
277306 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
278307 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
279308 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
280-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
309+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
310+<kbd>esc</kbd>
311+</button>
281312 </div>
282-<ul id="cmd-list" class="cmd-list">
313+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
283314 <li data-k="go home" data-cursor>
284315 <a href="/" tabindex="-1">
285-<span class="k">go</span>home</a>
316+<span class="lbl">home</span>
317+<span class="k">go</span>
318+</a>
286319 </li>
287320 <li data-k="section summary">
288321 <a href="/long" tabindex="-1">
289-<span class="k">section</span>summary</a>
322+<span class="lbl">summary</span>
323+<span class="k">section</span>
324+</a>
290325 </li>
291-<li data-k="section tree">
326+<li data-k="section files">
292327 <a href="/long/tree/main" tabindex="-1">
293-<span class="k">section</span>tree</a>
328+<span class="lbl">files</span>
329+<span class="k">section</span>
330+</a>
294331 </li>
295332 <li data-k="section commits">
296333 <a href="/long/commits/main" tabindex="-1">
297-<span class="k">section</span>commits</a>
334+<span class="lbl">commits</span>
335+<span class="k">section</span>
336+</a>
298337 </li>
299-<li data-k="section refs">
338+<li data-k="section branches &amp; tags">
300339 <a href="/long/refs" tabindex="-1">
301-<span class="k">section</span>refs</a>
340+<span class="lbl">branches &amp; tags</span>
341+<span class="k">section</span>
342+</a>
302343 </li>
303344 <li data-k="section merge requests">
304345 <a href="/long/merge-requests" tabindex="-1">
305-<span class="k">section</span>merge requests</a>
346+<span class="lbl">merge requests</span>
347+<span class="k">section</span>
348+</a>
306349 </li>
307350 <li data-k="branch main">
308351 <a href="/long/tree/main" tabindex="-1">
309-<span class="k">branch</span>main</a>
352+<span class="lbl">main</span>
353+<span class="k">branch</span>
354+</a>
310355 </li>
311356 <li data-k="repo diffs">
312357 <a href="/diffs" tabindex="-1">
313-<span class="k">repo</span>diffs</a>
358+<span class="lbl">diffs</span>
359+<span class="k">repo</span>
360+</a>
314361 </li>
315362 </ul>
363+<p class="panel-foot">
364+<span>
365+<kbd>↑</kbd>
366+<kbd>↓</kbd> move</span>
367+<span>
368+<kbd>enter</kbd> open</span>
369+<span>
370+<kbd>esc</kbd> close</span>
371+</p>
316372 </dialog>
317-<footer class="bar status" aria-label="Status">
318-<span class="mode">commits</span>
319-<span class="st-ctx">main · history of a.txt · 2 commits</span>
320-<button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
321-<span class="hi">:</span> go to…</button>
322-<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
323-</span>
324-<span class="st-hints">j/k move · enter open · / filter · ? help</span>
325-<span class="st-src">
326-<span class="st-ver">ermite devel · </span>
327-<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
328-</span>
329-<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
330-<span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/2</span>
331-</footer>
332-<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
373+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
333374 if (q('dialog[open]').count) return;
334375 let rows = q('[data-rows] > :not([hidden])').arr();
335376 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
336377 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
337- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
338- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
378+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
379+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
380+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
381+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
339382 })(1);
340- k-k from:body -> ((d) => {
383+ k-k from:body -> (async (d) => {
341384 if (q('dialog[open]').count) return;
342385 let rows = q('[data-rows] > :not([hidden])').arr();
343386 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
344387 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
345- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
346- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
388+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
389+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
390+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
391+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
347392 })(-1);
348393 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
349394 k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
350395 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
351396 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
352- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
353- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus());
354- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
397+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
398+ k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
399+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
355400 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
356- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
357- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
358- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
359- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
360- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
361- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
362- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
363- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
364- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
365- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
401+ k-gh from:body -> document.querySelector('.brand')?.click();
402+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
403+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
404+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
405+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
406+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
407+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
408+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
409+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
410+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
366411 k-next from:body -> {
367412 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
368413 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/commits-root.html +166 -119
@@ -6,6 +6,7 @@
66 <meta name="color-scheme" content="light dark">
77 <title>commits · long · example.test</title>
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9+<link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
910 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
1011 <script src="/-/static/js/htmx.min.HASH.js" defer>
1112 </script>
@@ -16,12 +17,15 @@
1617 <script src="/-/static/js/ermite.HASH.js" defer>
1718 </script>
1819 </head>
19-<body data-ref="main" data-sha="SHA40" data-sel="">
20+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
2021 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
23-<span class="fade" aria-hidden="true">
22+<header class="hdr">
23+<div class="hdr-top">
24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
2426 </span>
27+<span>example.test</span>
28+</a>
2529 <nav class="crumbs" aria-label="Path">
2630 <ol>
2731 <li>
@@ -35,33 +39,52 @@
3539 </li>
3640 </ol>
3741 </nav>
38-<nav class="secs" aria-label="Sections">
42+<div class="hdr-acts">
43+<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
44+<span class="ico i-branch" aria-hidden="true">
45+</span>
46+<span class="ref-name">main</span>
47+<span class="sha">SHA7</span>
48+</button>
49+<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
50+<span class="ico i-search" aria-hidden="true">
51+</span>
52+<span class="search-label">Go to…</span>
53+<kbd>ctrl k</kbd>
54+</button>
55+<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
56+</div>
57+</div>
58+<nav class="tabs" aria-label="Sections">
3959 <ul>
4060 <li>
41-<a href="/long" data-key="s" aria-keyshortcuts="g s">summary</a>
61+<a href="/long" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
62+</a>
4263 </li>
4364 <li>
44-<a href="/long/tree/main" data-key="t" aria-keyshortcuts="g t">tree</a>
65+<a href="/long/tree/main" data-key="t" aria-keyshortcuts="t">Files<kbd aria-hidden="true">t</kbd>
66+</a>
4567 </li>
4668 <li>
47-<a href="/long/commits/main" data-key="c" aria-keyshortcuts="g c" aria-current="page">commits</a>
69+<a href="/long/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits<kbd aria-hidden="true">c</kbd>
70+</a>
4871 </li>
4972 <li>
50-<a href="/long/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
73+<a href="/long/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
74+</a>
5175 </li>
5276 <li>
53-<a href="/long/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
77+<a href="/long/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
78+</a>
5479 </li>
5580 </ul>
5681 </nav>
57-<button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
58-</button>
5982 </header>
6083 <div class="work has-rail">
61-<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
84+<main id="main" tabindex="-1">
6285 <div class="filter">
63-<label for="commit-filter">filter&gt;</label>
64-<input id="commit-filter" type="search" data-filter placeholder="subject, sha or author ( / )" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('.days tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t));
86+<label for="commit-filter" class="sr">filter</label>
87+<input id="commit-filter" type="search" data-filter placeholder="Filter by subject, sha or author…" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('.days tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t));
6588 q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])'));
6689 let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')">
6790 </div>
@@ -80,7 +103,7 @@
80103 <td class="c-sha mute">SHA7</td>
81104 <td class="c-subject">
82105 <a href="/long/commit/SHA40">commit number 51</a>
83-<span class="tagged">[main]</span>
106+<span class="pill">main</span>
84107 </td>
85108 <td class="c-who dim">T</td>
86109 <td class="c-when nowrap dim">
@@ -896,15 +919,13 @@
896919 </section>
897920 </div>
898921 <nav class="pager" aria-label="Pages">
899-<span class="mute">← newest</span>
900-<a href="/long/commits/main?page=YzE6MzMzZGFjMWY5YmY4MjIzMmQxOTAzNGVlZTliZmI2NzFmM2QxMWEzNDo1MDplM2IwYzQ0Mjk4ZmM" rel="next">older →</a>
922+<span class="btn" aria-disabled="true">← Newest</span>
923+<a class="btn" href="/long/commits/main?page=YzE6MzMzZGFjMWY5YmY4MjIzMmQxOTAzNGVlZTliZmI2NzFmM2QxMWEzNDo1MDplM2IwYzQ0Mjk4ZmM" rel="next">Older →</a>
901924 </nav>
902925 </main>
903926 <aside class="rail" aria-label="Context">
904-<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
905-<summary>history · refs on this page</summary>
906927 <section aria-labelledby="r-hist">
907-<h2 id="r-hist">history</h2>
928+<h2 id="r-hist">History</h2>
908929 <dl class="kv">
909930 <dt>ref</dt>
910931 <dd>main</dd>
@@ -913,78 +934,83 @@
913934 </dl>
914935 </section>
915936 <section aria-labelledby="r-refs">
916-<h2 id="r-refs">refs on this page</h2>
937+<h2 id="r-refs">Refs on this page</h2>
917938 <nav class="outline" aria-label="Refs on this page">
918939 <a href="#c-SHA7">branch main</a>
919940 </nav>
920941 </section>
921-</details>
922942 </aside>
923943 </div>
924-<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
944+<footer class="foot">
945+<span>ermite devel</span>
946+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
947+<span class="foot-keys">
948+<kbd>?</kbd> keyboard shortcuts</span>
949+</footer>
950+<div class="hint" role="status" aria-live="polite">
951+<span class="hint-nav" :text="document.body.dataset.nav || ''">
952+</span>
953+<span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''">
954+</span>
955+<span class="hint-flash" :text="document.body.dataset.flash || ''">
956+</span>
957+<span class="hint-live" data-state="connecting" hx-sse:connect="/-/events?topic=repo%3Along&amp;view=commits&amp;ref=main&amp;head=SHA40" hx-swap="none" :text="data.state === 'offline' ? 'live updates paused' : ''" hx-on="htmx:sse:after:connection -> data.state = 'live';
958+ htmx:sse:close, htmx:sse:error -> data.state = 'offline';
959+ home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
960+ gap -> ermiteLive.reload()">
961+</span>
962+</div>
963+<dialog id="panel-help" class="panel modal" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
925964 <div class="panel-head">
926-<span>keys</span>
927-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
965+<h2>Keyboard shortcuts</h2>
966+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
967+<kbd>esc</kbd> close</button>
928968 </div>
929969 <dl class="keys">
930970 <div>
931971 <dt>
932-<kbd>j</kbd>
933-<kbd>k</kbd>
972+<kbd>ctrl k</kbd>
973+<kbd>:</kbd>
934974 </dt>
935-<dd>move cursor down / up</dd>
975+<dd>go to a section, branch, tag, file or repository</dd>
936976 </div>
937977 <div>
938978 <dt>
939-<kbd>enter</kbd>
979+<kbd>?</kbd>
940980 </dt>
941-<dd>open row under cursor</dd>
981+<dd>this list</dd>
942982 </div>
943983 <div>
944984 <dt>
945-<kbd>g</kbd>
946-<kbd>g</kbd> / <kbd>G</kbd>
985+<kbd>j</kbd>
986+<kbd>k</kbd>
947987 </dt>
948-<dd>first / last row</dd>
988+<dd>move the row cursor down / up</dd>
949989 </div>
950990 <div>
951991 <dt>
952-<kbd>/</kbd>
992+<kbd>enter</kbd>
953993 </dt>
954-<dd>focus the filter</dd>
994+<dd>open the row under the cursor</dd>
955995 </div>
956996 <div>
957997 <dt>
958-<kbd>g h</kbd>
959-<kbd>g t</kbd>
998+<kbd>g g</kbd>
999+<kbd>G</kbd>
9601000 </dt>
961-<dd>go home / tree</dd>
1001+<dd>first / last row</dd>
9621002 </div>
9631003 <div>
9641004 <dt>
965-<kbd>g c</kbd>
966-<kbd>g r</kbd>
1005+<kbd>/</kbd>
9671006 </dt>
968-<dd>go commits / refs</dd>
1007+<dd>filter the list</dd>
9691008 </div>
9701009 <div>
9711010 <dt>
9721011 <kbd>b</kbd>
9731012 </dt>
974-<dd>switch ref (branches, tags)</dd>
975-</div>
976-<div>
977-<dt>
978-<kbd>:</kbd>
979-<kbd>f</kbd>
980-</dt>
981-<dd>go to: section, ref, file, repo</dd>
982-</div>
983-<div>
984-<dt>
985-<kbd>y</kbd>
986-</dt>
987-<dd>permalink: ref becomes the full sha</dd>
1013+<dd>switch branch or tag</dd>
9881014 </div>
9891015 <div>
9901016 <dt>
@@ -992,15 +1018,21 @@
9921018 <kbd>t</kbd>
9931019 <kbd>c</kbd>
9941020 <kbd>r</kbd>
1021+<kbd>m</kbd>
9951022 </dt>
996-<dd>repo sections (when shown)</dd>
1023+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
9971024 </div>
9981025 <div>
9991026 <dt>
1000-<kbd>m</kbd>
1001-<kbd>g m</kbd>
1027+<kbd>g h</kbd>
1028+</dt>
1029+<dd>home</dd>
1030+</div>
1031+<div>
1032+<dt>
1033+<kbd>y</kbd>
10021034 </dt>
1003-<dd>merge requests (when shown)</dd>
1035+<dd>copy a permalink pinned to the commit</dd>
10041036 </div>
10051037 <div>
10061038 <dt>
@@ -1020,19 +1052,21 @@
10201052 <dt>
10211053 <kbd>esc</kbd>
10221054 </dt>
1023-<dd>close panel</dd>
1055+<dd>close a dialog, leave a field</dd>
10241056 </div>
10251057 </dl>
10261058 </dialog>
1027-<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
1059+<dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
10281060 <div class="panel-head">
1029-<label for="refs-filter">ref&gt;</label>
1030-<input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
1031-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
1061+<label for="refs-filter" class="sr">filter branches and tags</label>
1062+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
1063+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
1064+<kbd>esc</kbd>
1065+</button>
10321066 </div>
10331067 <div class="panel-cols" data-picklist>
10341068 <section aria-label="Branches">
1035-<h2>branches <span class="mute">1</span>
1069+<h2>Branches <span class="count">1</span>
10361070 </h2>
10371071 <ul>
10381072 <li>
@@ -1042,112 +1076,125 @@
10421076 </ul>
10431077 </section>
10441078 <section aria-label="Tags">
1045-<h2>tags <span class="mute">0</span>
1079+<h2>Tags <span class="count">0</span>
10461080 </h2>
10471081 <ul>
10481082 </ul>
10491083 </section>
10501084 </div>
10511085 </dialog>
1052-<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
1086+<dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
10531087 <div class="panel-head">
1054-<label for="cmd-q" class="hi">:</label>
1055-<input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
1088+<label for="cmd-q" class="sr">go to</label>
1089+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
10561090 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
10571091 let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
10581092 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
10591093 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
10601094 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
10611095 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
1062-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
1096+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
1097+<kbd>esc</kbd>
1098+</button>
10631099 </div>
1064-<ul id="cmd-list" class="cmd-list">
1100+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
10651101 <li data-k="go home" data-cursor>
10661102 <a href="/" tabindex="-1">
1067-<span class="k">go</span>home</a>
1103+<span class="lbl">home</span>
1104+<span class="k">go</span>
1105+</a>
10681106 </li>
10691107 <li data-k="section summary">
10701108 <a href="/long" tabindex="-1">
1071-<span class="k">section</span>summary</a>
1109+<span class="lbl">summary</span>
1110+<span class="k">section</span>
1111+</a>
10721112 </li>
1073-<li data-k="section tree">
1113+<li data-k="section files">
10741114 <a href="/long/tree/main" tabindex="-1">
1075-<span class="k">section</span>tree</a>
1115+<span class="lbl">files</span>
1116+<span class="k">section</span>
1117+</a>
10761118 </li>
10771119 <li data-k="section commits">
10781120 <a href="/long/commits/main" tabindex="-1">
1079-<span class="k">section</span>commits</a>
1121+<span class="lbl">commits</span>
1122+<span class="k">section</span>
1123+</a>
10801124 </li>
1081-<li data-k="section refs">
1125+<li data-k="section branches &amp; tags">
10821126 <a href="/long/refs" tabindex="-1">
1083-<span class="k">section</span>refs</a>
1127+<span class="lbl">branches &amp; tags</span>
1128+<span class="k">section</span>
1129+</a>
10841130 </li>
10851131 <li data-k="section merge requests">
10861132 <a href="/long/merge-requests" tabindex="-1">
1087-<span class="k">section</span>merge requests</a>
1133+<span class="lbl">merge requests</span>
1134+<span class="k">section</span>
1135+</a>
10881136 </li>
10891137 <li data-k="branch main">
10901138 <a href="/long/tree/main" tabindex="-1">
1091-<span class="k">branch</span>main</a>
1139+<span class="lbl">main</span>
1140+<span class="k">branch</span>
1141+</a>
10921142 </li>
10931143 <li data-k="repo diffs">
10941144 <a href="/diffs" tabindex="-1">
1095-<span class="k">repo</span>diffs</a>
1145+<span class="lbl">diffs</span>
1146+<span class="k">repo</span>
1147+</a>
10961148 </li>
10971149 </ul>
1150+<p class="panel-foot">
1151+<span>
1152+<kbd>↑</kbd>
1153+<kbd>↓</kbd> move</span>
1154+<span>
1155+<kbd>enter</kbd> open</span>
1156+<span>
1157+<kbd>esc</kbd> close</span>
1158+</p>
10981159 </dialog>
1099-<footer class="bar status" aria-label="Status">
1100-<span class="mode">commits</span>
1101-<span class="st-ctx">main · 50 commits · more</span>
1102-<button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
1103-<span class="hi">:</span> go to…</button>
1104-<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
1105-</span>
1106-<span class="st-hints">j/k move · enter open · / filter · ? help</span>
1107-<span class="st-src">
1108-<span class="st-ver">ermite devel · </span>
1109-<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
1110-</span>
1111-<span class="st-live" data-state="offline" role="status" hx-sse:connect="/-/events?topic=repo%3Along&amp;view=commits&amp;ref=main&amp;head=SHA40" hx-swap="none" :text="data.state" :title="data.state === 'live' ? 'receiving updates over SSE' : 'not connected to the event stream'" hx-on="htmx:sse:after:connection -> data.state = 'live';
1112- htmx:sse:close, htmx:sse:error -> data.state = 'offline';
1113- home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
1114- gap -> ermiteLive.reload()">offline</span>
1115-<span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/50</span>
1116-</footer>
1117-<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
1160+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
11181161 if (q('dialog[open]').count) return;
11191162 let rows = q('[data-rows] > :not([hidden])').arr();
11201163 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
11211164 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
1122- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
1123- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
1165+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
1166+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
1167+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
1168+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
11241169 })(1);
1125- k-k from:body -> ((d) => {
1170+ k-k from:body -> (async (d) => {
11261171 if (q('dialog[open]').count) return;
11271172 let rows = q('[data-rows] > :not([hidden])').arr();
11281173 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
11291174 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
1130- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
1131- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
1175+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
1176+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
1177+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
1178+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
11321179 })(-1);
11331180 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
11341181 k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
11351182 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
11361183 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
1137- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
1138- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus());
1139- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
1184+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
1185+ k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
1186+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
11401187 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
1141- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
1142- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
1143- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
1144- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
1145- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
1146- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
1147- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
1148- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
1149- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
1150- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
1188+ k-gh from:body -> document.querySelector('.brand')?.click();
1189+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
1190+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
1191+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
1192+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
1193+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
1194+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
1195+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
1196+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
1197+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
11511198 k-next from:body -> {
11521199 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
11531200 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/commits-small.html +174 -122
@@ -6,6 +6,7 @@
66 <meta name="color-scheme" content="light dark">
77 <title>commits · diffs · example.test</title>
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9+<link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
910 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
1011 <script src="/-/static/js/htmx.min.HASH.js" defer>
1112 </script>
@@ -16,12 +17,15 @@
1617 <script src="/-/static/js/ermite.HASH.js" defer>
1718 </script>
1819 </head>
19-<body data-ref="main" data-sha="SHA40" data-sel="">
20+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
2021 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
23-<span class="fade" aria-hidden="true">
22+<header class="hdr">
23+<div class="hdr-top">
24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
2426 </span>
27+<span>example.test</span>
28+</a>
2529 <nav class="crumbs" aria-label="Path">
2630 <ol>
2731 <li>
@@ -35,33 +39,52 @@
3539 </li>
3640 </ol>
3741 </nav>
38-<nav class="secs" aria-label="Sections">
42+<div class="hdr-acts">
43+<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
44+<span class="ico i-branch" aria-hidden="true">
45+</span>
46+<span class="ref-name">main</span>
47+<span class="sha">SHA7</span>
48+</button>
49+<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
50+<span class="ico i-search" aria-hidden="true">
51+</span>
52+<span class="search-label">Go to…</span>
53+<kbd>ctrl k</kbd>
54+</button>
55+<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
56+</div>
57+</div>
58+<nav class="tabs" aria-label="Sections">
3959 <ul>
4060 <li>
41-<a href="/diffs" data-key="s" aria-keyshortcuts="g s">summary</a>
61+<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
62+</a>
4263 </li>
4364 <li>
44-<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="g t">tree</a>
65+<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files<kbd aria-hidden="true">t</kbd>
66+</a>
4567 </li>
4668 <li>
47-<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="g c" aria-current="page">commits</a>
69+<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits<kbd aria-hidden="true">c</kbd>
70+</a>
4871 </li>
4972 <li>
50-<a href="/diffs/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
73+<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
74+</a>
5175 </li>
5276 <li>
53-<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
77+<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
78+</a>
5479 </li>
5580 </ul>
5681 </nav>
57-<button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
58-</button>
5982 </header>
6083 <div class="work has-rail">
61-<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
84+<main id="main" tabindex="-1">
6285 <div class="filter">
63-<label for="commit-filter">filter&gt;</label>
64-<input id="commit-filter" type="search" data-filter placeholder="subject, sha or author ( / )" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('.days tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t));
86+<label for="commit-filter" class="sr">filter</label>
87+<input id="commit-filter" type="search" data-filter placeholder="Filter by subject, sha or author…" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('.days tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t));
6588 q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])'));
6689 let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')">
6790 </div>
@@ -80,7 +103,7 @@
80103 <td class="c-sha mute">SHA7</td>
81104 <td class="c-subject">
82105 <a href="/diffs/commit/SHA40">add a very large file</a>
83-<span class="tagged">[main]</span>
106+<span class="pill">main</span>
84107 </td>
85108 <td class="c-who dim">T</td>
86109 <td class="c-when nowrap dim">
@@ -123,7 +146,7 @@
123146 <td class="c-sha mute">SHA7</td>
124147 <td class="c-subject">
125148 <a href="/diffs/commit/SHA40">merge the side branch</a>
126-<span class="tagged">(merge)</span>
149+<span class="pill">merge</span>
127150 </td>
128151 <td class="c-who dim">T</td>
129152 <td class="c-when nowrap dim">
@@ -145,7 +168,7 @@
145168 <td class="c-sha mute">SHA7</td>
146169 <td class="c-subject">
147170 <a href="/diffs/commit/SHA40">work on the side</a>
148-<span class="tagged">[side]</span>
171+<span class="pill">side</span>
149172 </td>
150173 <td class="c-who dim">T</td>
151174 <td class="c-when nowrap dim">
@@ -209,7 +232,8 @@
209232 <td class="c-sha mute">SHA7</td>
210233 <td class="c-subject">
211234 <a href="/diffs/commit/SHA40">modify a file and make a script executable</a>
212-<span class="tagged">[tag: v1.0.0]</span>
235+<span class="pill tag">
236+<span class="sr">tag </span>v1.0.0</span>
213237 </td>
214238 <td class="c-who dim">T</td>
215239 <td class="c-when nowrap dim">
@@ -243,10 +267,8 @@
243267 </div>
244268 </main>
245269 <aside class="rail" aria-label="Context">
246-<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
247-<summary>history · refs on this page</summary>
248270 <section aria-labelledby="r-hist">
249-<h2 id="r-hist">history</h2>
271+<h2 id="r-hist">History</h2>
250272 <dl class="kv">
251273 <dt>ref</dt>
252274 <dd>main</dd>
@@ -255,80 +277,85 @@
255277 </dl>
256278 </section>
257279 <section aria-labelledby="r-refs">
258-<h2 id="r-refs">refs on this page</h2>
280+<h2 id="r-refs">Refs on this page</h2>
259281 <nav class="outline" aria-label="Refs on this page">
260282 <a href="#c-SHA7">branch main</a>
261283 <a href="#c-SHA7">branch side</a>
262284 <a href="#c-SHA7">tag v1.0.0</a>
263285 </nav>
264286 </section>
265-</details>
266287 </aside>
267288 </div>
268-<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
289+<footer class="foot">
290+<span>ermite devel</span>
291+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
292+<span class="foot-keys">
293+<kbd>?</kbd> keyboard shortcuts</span>
294+</footer>
295+<div class="hint" role="status" aria-live="polite">
296+<span class="hint-nav" :text="document.body.dataset.nav || ''">
297+</span>
298+<span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''">
299+</span>
300+<span class="hint-flash" :text="document.body.dataset.flash || ''">
301+</span>
302+<span class="hint-live" data-state="connecting" hx-sse:connect="/-/events?topic=repo%3Adiffs&amp;view=commits&amp;ref=main&amp;head=SHA40" hx-swap="none" :text="data.state === 'offline' ? 'live updates paused' : ''" hx-on="htmx:sse:after:connection -> data.state = 'live';
303+ htmx:sse:close, htmx:sse:error -> data.state = 'offline';
304+ home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
305+ gap -> ermiteLive.reload()">
306+</span>
307+</div>
308+<dialog id="panel-help" class="panel modal" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
269309 <div class="panel-head">
270-<span>keys</span>
271-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
310+<h2>Keyboard shortcuts</h2>
311+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
312+<kbd>esc</kbd> close</button>
272313 </div>
273314 <dl class="keys">
274315 <div>
275316 <dt>
276-<kbd>j</kbd>
277-<kbd>k</kbd>
317+<kbd>ctrl k</kbd>
318+<kbd>:</kbd>
278319 </dt>
279-<dd>move cursor down / up</dd>
320+<dd>go to a section, branch, tag, file or repository</dd>
280321 </div>
281322 <div>
282323 <dt>
283-<kbd>enter</kbd>
324+<kbd>?</kbd>
284325 </dt>
285-<dd>open row under cursor</dd>
326+<dd>this list</dd>
286327 </div>
287328 <div>
288329 <dt>
289-<kbd>g</kbd>
290-<kbd>g</kbd> / <kbd>G</kbd>
330+<kbd>j</kbd>
331+<kbd>k</kbd>
291332 </dt>
292-<dd>first / last row</dd>
333+<dd>move the row cursor down / up</dd>
293334 </div>
294335 <div>
295336 <dt>
296-<kbd>/</kbd>
337+<kbd>enter</kbd>
297338 </dt>
298-<dd>focus the filter</dd>
339+<dd>open the row under the cursor</dd>
299340 </div>
300341 <div>
301342 <dt>
302-<kbd>g h</kbd>
303-<kbd>g t</kbd>
343+<kbd>g g</kbd>
344+<kbd>G</kbd>
304345 </dt>
305-<dd>go home / tree</dd>
346+<dd>first / last row</dd>
306347 </div>
307348 <div>
308349 <dt>
309-<kbd>g c</kbd>
310-<kbd>g r</kbd>
350+<kbd>/</kbd>
311351 </dt>
312-<dd>go commits / refs</dd>
352+<dd>filter the list</dd>
313353 </div>
314354 <div>
315355 <dt>
316356 <kbd>b</kbd>
317357 </dt>
318-<dd>switch ref (branches, tags)</dd>
319-</div>
320-<div>
321-<dt>
322-<kbd>:</kbd>
323-<kbd>f</kbd>
324-</dt>
325-<dd>go to: section, ref, file, repo</dd>
326-</div>
327-<div>
328-<dt>
329-<kbd>y</kbd>
330-</dt>
331-<dd>permalink: ref becomes the full sha</dd>
358+<dd>switch branch or tag</dd>
332359 </div>
333360 <div>
334361 <dt>
@@ -336,15 +363,21 @@
336363 <kbd>t</kbd>
337364 <kbd>c</kbd>
338365 <kbd>r</kbd>
366+<kbd>m</kbd>
339367 </dt>
340-<dd>repo sections (when shown)</dd>
368+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
341369 </div>
342370 <div>
343371 <dt>
344-<kbd>m</kbd>
345-<kbd>g m</kbd>
372+<kbd>g h</kbd>
373+</dt>
374+<dd>home</dd>
375+</div>
376+<div>
377+<dt>
378+<kbd>y</kbd>
346379 </dt>
347-<dd>merge requests (when shown)</dd>
380+<dd>copy a permalink pinned to the commit</dd>
348381 </div>
349382 <div>
350383 <dt>
@@ -364,19 +397,21 @@
364397 <dt>
365398 <kbd>esc</kbd>
366399 </dt>
367-<dd>close panel</dd>
400+<dd>close a dialog, leave a field</dd>
368401 </div>
369402 </dl>
370403 </dialog>
371-<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
404+<dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
372405 <div class="panel-head">
373-<label for="refs-filter">ref&gt;</label>
374-<input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
375-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
406+<label for="refs-filter" class="sr">filter branches and tags</label>
407+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
408+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
409+<kbd>esc</kbd>
410+</button>
376411 </div>
377412 <div class="panel-cols" data-picklist>
378413 <section aria-label="Branches">
379-<h2>branches <span class="mute">2</span>
414+<h2>Branches <span class="count">2</span>
380415 </h2>
381416 <ul>
382417 <li>
@@ -390,7 +425,7 @@
390425 </ul>
391426 </section>
392427 <section aria-label="Tags">
393-<h2>tags <span class="mute">1</span>
428+<h2>Tags <span class="count">1</span>
394429 </h2>
395430 <ul>
396431 <li>
@@ -401,113 +436,130 @@
401436 </section>
402437 </div>
403438 </dialog>
404-<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
439+<dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
405440 <div class="panel-head">
406-<label for="cmd-q" class="hi">:</label>
407-<input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
441+<label for="cmd-q" class="sr">go to</label>
442+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
408443 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
409444 let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
410445 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
411446 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
412447 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
413448 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
414-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
449+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
450+<kbd>esc</kbd>
451+</button>
415452 </div>
416-<ul id="cmd-list" class="cmd-list">
453+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
417454 <li data-k="go home" data-cursor>
418455 <a href="/" tabindex="-1">
419-<span class="k">go</span>home</a>
456+<span class="lbl">home</span>
457+<span class="k">go</span>
458+</a>
420459 </li>
421460 <li data-k="section summary">
422461 <a href="/diffs" tabindex="-1">
423-<span class="k">section</span>summary</a>
462+<span class="lbl">summary</span>
463+<span class="k">section</span>
464+</a>
424465 </li>
425-<li data-k="section tree">
466+<li data-k="section files">
426467 <a href="/diffs/tree/main" tabindex="-1">
427-<span class="k">section</span>tree</a>
468+<span class="lbl">files</span>
469+<span class="k">section</span>
470+</a>
428471 </li>
429472 <li data-k="section commits">
430473 <a href="/diffs/commits/main" tabindex="-1">
431-<span class="k">section</span>commits</a>
474+<span class="lbl">commits</span>
475+<span class="k">section</span>
476+</a>
432477 </li>
433-<li data-k="section refs">
478+<li data-k="section branches &amp; tags">
434479 <a href="/diffs/refs" tabindex="-1">
435-<span class="k">section</span>refs</a>
480+<span class="lbl">branches &amp; tags</span>
481+<span class="k">section</span>
482+</a>
436483 </li>
437484 <li data-k="section merge requests">
438485 <a href="/diffs/merge-requests" tabindex="-1">
439-<span class="k">section</span>merge requests</a>
486+<span class="lbl">merge requests</span>
487+<span class="k">section</span>
488+</a>
440489 </li>
441490 <li data-k="branch main">
442491 <a href="/diffs/tree/main" tabindex="-1">
443-<span class="k">branch</span>main</a>
492+<span class="lbl">main</span>
493+<span class="k">branch</span>
494+</a>
444495 </li>
445496 <li data-k="branch side">
446497 <a href="/diffs/tree/side" tabindex="-1">
447-<span class="k">branch</span>side</a>
498+<span class="lbl">side</span>
499+<span class="k">branch</span>
500+</a>
448501 </li>
449502 <li data-k="tag v1.0.0">
450503 <a href="/diffs/tree/v1.0.0" tabindex="-1">
451-<span class="k">tag</span>v1.0.0</a>
504+<span class="lbl">v1.0.0</span>
505+<span class="k">tag</span>
506+</a>
452507 </li>
453508 <li data-k="repo long">
454509 <a href="/long" tabindex="-1">
455-<span class="k">repo</span>long</a>
510+<span class="lbl">long</span>
511+<span class="k">repo</span>
512+</a>
456513 </li>
457514 </ul>
515+<p class="panel-foot">
516+<span>
517+<kbd>↑</kbd>
518+<kbd>↓</kbd> move</span>
519+<span>
520+<kbd>enter</kbd> open</span>
521+<span>
522+<kbd>esc</kbd> close</span>
523+</p>
458524 </dialog>
459-<footer class="bar status" aria-label="Status">
460-<span class="mode">commits</span>
461-<span class="st-ctx">main · 8 commits</span>
462-<button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
463-<span class="hi">:</span> go to…</button>
464-<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
465-</span>
466-<span class="st-hints">j/k move · enter open · / filter · ? help</span>
467-<span class="st-src">
468-<span class="st-ver">ermite devel · </span>
469-<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
470-</span>
471-<span class="st-live" data-state="offline" role="status" hx-sse:connect="/-/events?topic=repo%3Adiffs&amp;view=commits&amp;ref=main&amp;head=SHA40" hx-swap="none" :text="data.state" :title="data.state === 'live' ? 'receiving updates over SSE' : 'not connected to the event stream'" hx-on="htmx:sse:after:connection -> data.state = 'live';
472- htmx:sse:close, htmx:sse:error -> data.state = 'offline';
473- home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
474- gap -> ermiteLive.reload()">offline</span>
475-<span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/8</span>
476-</footer>
477-<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
525+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
478526 if (q('dialog[open]').count) return;
479527 let rows = q('[data-rows] > :not([hidden])').arr();
480528 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
481529 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
482- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
483- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
530+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
531+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
532+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
533+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
484534 })(1);
485- k-k from:body -> ((d) => {
535+ k-k from:body -> (async (d) => {
486536 if (q('dialog[open]').count) return;
487537 let rows = q('[data-rows] > :not([hidden])').arr();
488538 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
489539 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
490- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
491- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
540+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
541+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
542+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
543+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
492544 })(-1);
493545 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
494546 k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
495547 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
496548 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
497- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
498- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus());
499- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
549+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
550+ k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
551+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
500552 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
501- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
502- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
503- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
504- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
505- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
506- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
507- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
508- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
509- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
510- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
553+ k-gh from:body -> document.querySelector('.brand')?.click();
554+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
555+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
556+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
557+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
558+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
559+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
560+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
561+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
562+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
511563 k-next from:body -> {
512564 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
513565 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/empty.html +113 -87
@@ -6,6 +6,7 @@
66 <meta name="color-scheme" content="light dark">
77 <title>quiet · example.test</title>
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9+<link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
910 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
1011 <script src="/-/static/js/htmx.min.HASH.js" defer>
1112 </script>
@@ -16,12 +17,15 @@
1617 <script src="/-/static/js/ermite.HASH.js" defer>
1718 </script>
1819 </head>
19-<body data-ref="" data-sha="" data-sel="">
20+<body data-ref="" data-sha="" data-sel="" data-nav="">
2021 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
23-<span class="fade" aria-hidden="true">
22+<header class="hdr">
23+<div class="hdr-top">
24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
2426 </span>
27+<span>example.test</span>
28+</a>
2529 <nav class="crumbs" aria-label="Path">
2630 <ol>
2731 <li>
@@ -29,23 +33,33 @@
2933 </li>
3034 </ol>
3135 </nav>
36+<div class="hdr-acts">
37+<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
38+<span class="ico i-search" aria-hidden="true">
39+</span>
40+<span class="search-label">Go to…</span>
41+<kbd>ctrl k</kbd>
42+</button>
43+<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
44+</div>
45+</div>
3246 </header>
3347 <div class="work">
34-<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
48+<main id="main" tabindex="-1">
3549 <section class="state" aria-labelledby="state-title">
36-<h1 id="state-title">
37-<span class="code">empty</span> quiet is empty</h1>
50+<p class="eyebrow">empty</p>
51+<h1 id="state-title">quiet is empty</h1>
3852 <p class="dim">no commits yet. push a branch to create one.</p>
3953 <div class="clone" id="clone-quiet">
4054 <div class="clone-line">
4155 <span class="mute" aria-hidden="true">$ </span>
4256 <code>git remote add origin http://example.test/quiet.git</code>
43-<button type="button" class="copy" data-label="[copy]" aria-label="copy: git remote add origin http://example.test/quiet.git" hx-on:click="navigator.clipboard?.writeText(closest('.clone-line').querySelector('code').textContent); this.textContent = '[copied]'; await timeout('1500ms'); this.textContent = this.dataset.label">[copy]</button>
57+<button type="button" class="btn copy" data-label="Copy" aria-label="copy: git remote add origin http://example.test/quiet.git" hx-on:click="navigator.clipboard?.writeText(closest('.clone-line').querySelector('code').textContent); this.textContent = 'Copied'; await timeout('1500ms'); this.textContent = this.dataset.label">Copy</button>
4458 </div>
4559 <div class="clone-line">
4660 <span class="mute" aria-hidden="true">$ </span>
4761 <code>git push -u origin main</code>
48-<button type="button" class="copy" data-label="[copy]" aria-label="copy: git push -u origin main" hx-on:click="navigator.clipboard?.writeText(closest('.clone-line').querySelector('code').textContent); this.textContent = '[copied]'; await timeout('1500ms'); this.textContent = this.dataset.label">[copy]</button>
62+<button type="button" class="btn copy" data-label="Copy" aria-label="copy: git push -u origin main" hx-on:click="navigator.clipboard?.writeText(closest('.clone-line').querySelector('code').textContent); this.textContent = 'Copied'; await timeout('1500ms'); this.textContent = this.dataset.label">Copy</button>
4963 </div>
5064 </div>
5165 <p class="state-back">
@@ -54,70 +68,71 @@
5468 </section>
5569 </main>
5670 </div>
57-<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
71+<footer class="foot">
72+<span>ermite devel</span>
73+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
74+<span class="foot-keys">
75+<kbd>?</kbd> keyboard shortcuts</span>
76+</footer>
77+<div class="hint" role="status" aria-live="polite">
78+<span class="hint-nav" :text="document.body.dataset.nav || ''">
79+</span>
80+<span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''">
81+</span>
82+<span class="hint-flash" :text="document.body.dataset.flash || ''">
83+</span>
84+</div>
85+<dialog id="panel-help" class="panel modal" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
5886 <div class="panel-head">
59-<span>keys</span>
60-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
87+<h2>Keyboard shortcuts</h2>
88+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
89+<kbd>esc</kbd> close</button>
6190 </div>
6291 <dl class="keys">
6392 <div>
6493 <dt>
65-<kbd>j</kbd>
66-<kbd>k</kbd>
94+<kbd>ctrl k</kbd>
95+<kbd>:</kbd>
6796 </dt>
68-<dd>move cursor down / up</dd>
97+<dd>go to a section, branch, tag, file or repository</dd>
6998 </div>
7099 <div>
71100 <dt>
72-<kbd>enter</kbd>
101+<kbd>?</kbd>
73102 </dt>
74-<dd>open row under cursor</dd>
103+<dd>this list</dd>
75104 </div>
76105 <div>
77106 <dt>
78-<kbd>g</kbd>
79-<kbd>g</kbd> / <kbd>G</kbd>
107+<kbd>j</kbd>
108+<kbd>k</kbd>
80109 </dt>
81-<dd>first / last row</dd>
110+<dd>move the row cursor down / up</dd>
82111 </div>
83112 <div>
84113 <dt>
85-<kbd>/</kbd>
114+<kbd>enter</kbd>
86115 </dt>
87-<dd>focus the filter</dd>
116+<dd>open the row under the cursor</dd>
88117 </div>
89118 <div>
90119 <dt>
91-<kbd>g h</kbd>
92-<kbd>g t</kbd>
120+<kbd>g g</kbd>
121+<kbd>G</kbd>
93122 </dt>
94-<dd>go home / tree</dd>
123+<dd>first / last row</dd>
95124 </div>
96125 <div>
97126 <dt>
98-<kbd>g c</kbd>
99-<kbd>g r</kbd>
127+<kbd>/</kbd>
100128 </dt>
101-<dd>go commits / refs</dd>
129+<dd>filter the list</dd>
102130 </div>
103131 <div>
104132 <dt>
105133 <kbd>b</kbd>
106134 </dt>
107-<dd>switch ref (branches, tags)</dd>
108-</div>
109-<div>
110-<dt>
111-<kbd>:</kbd>
112-<kbd>f</kbd>
113-</dt>
114-<dd>go to: section, ref, file, repo</dd>
115-</div>
116-<div>
117-<dt>
118-<kbd>y</kbd>
119-</dt>
120-<dd>permalink: ref becomes the full sha</dd>
135+<dd>switch branch or tag</dd>
121136 </div>
122137 <div>
123138 <dt>
@@ -125,15 +140,21 @@
125140 <kbd>t</kbd>
126141 <kbd>c</kbd>
127142 <kbd>r</kbd>
143+<kbd>m</kbd>
128144 </dt>
129-<dd>repo sections (when shown)</dd>
145+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
130146 </div>
131147 <div>
132148 <dt>
133-<kbd>m</kbd>
134-<kbd>g m</kbd>
149+<kbd>g h</kbd>
135150 </dt>
136-<dd>merge requests (when shown)</dd>
151+<dd>home</dd>
152+</div>
153+<div>
154+<dt>
155+<kbd>y</kbd>
156+</dt>
157+<dd>copy a permalink pinned to the commit</dd>
137158 </div>
138159 <div>
139160 <dt>
@@ -153,81 +174,86 @@
153174 <dt>
154175 <kbd>esc</kbd>
155176 </dt>
156-<dd>close panel</dd>
177+<dd>close a dialog, leave a field</dd>
157178 </div>
158179 </dl>
159180 </dialog>
160-<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
181+<dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
161182 <div class="panel-head">
162-<label for="cmd-q" class="hi">:</label>
163-<input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
183+<label for="cmd-q" class="sr">go to</label>
184+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
164185 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
165186 let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
166187 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
167188 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
168189 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
169190 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
170-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
191+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
192+<kbd>esc</kbd>
193+</button>
171194 </div>
172-<ul id="cmd-list" class="cmd-list">
195+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
173196 <li data-k="go home" data-cursor>
174197 <a href="/" tabindex="-1">
175-<span class="k">go</span>home</a>
198+<span class="lbl">home</span>
199+<span class="k">go</span>
200+</a>
176201 </li>
177202 <li data-k="repo demo">
178203 <a href="/demo" tabindex="-1">
179-<span class="k">repo</span>demo</a>
204+<span class="lbl">demo</span>
205+<span class="k">repo</span>
206+</a>
180207 </li>
181208 </ul>
209+<p class="panel-foot">
210+<span>
211+<kbd>↑</kbd>
212+<kbd>↓</kbd> move</span>
213+<span>
214+<kbd>enter</kbd> open</span>
215+<span>
216+<kbd>esc</kbd> close</span>
217+</p>
182218 </dialog>
183-<footer class="bar status" aria-label="Status">
184-<span class="mode">empty</span>
185-<span class="st-ctx">empty repository · 0 commits</span>
186-<button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
187-<span class="hi">:</span> go to…</button>
188-<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
189-</span>
190-<span class="st-hints">g h home · ? help</span>
191-<span class="st-src">
192-<span class="st-ver">ermite devel · </span>
193-<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
194-</span>
195-<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
196-</footer>
197-<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
219+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
198220 if (q('dialog[open]').count) return;
199221 let rows = q('[data-rows] > :not([hidden])').arr();
200222 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
201223 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
202- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
203- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
224+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
225+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
226+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
227+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
204228 })(1);
205- k-k from:body -> ((d) => {
229+ k-k from:body -> (async (d) => {
206230 if (q('dialog[open]').count) return;
207231 let rows = q('[data-rows] > :not([hidden])').arr();
208232 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
209233 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
210- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
211- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
234+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
235+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
236+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
237+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
212238 })(-1);
213239 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
214240 k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
215241 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
216242 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
217- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
218- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus());
219- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
243+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
244+ k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
245+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
220246 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
221- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
222- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
223- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
224- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
225- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
226- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
227- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
228- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
229- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
230- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
247+ k-gh from:body -> document.querySelector('.brand')?.click();
248+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
249+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
250+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
251+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
252+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
253+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
254+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
255+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
256+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
231257 k-next from:body -> {
232258 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
233259 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/home.html +119 -101
@@ -6,6 +6,7 @@
66 <meta name="color-scheme" content="light dark">
77 <title>example.test</title>
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9+<link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
910 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
1011 <script src="/-/static/js/htmx.min.HASH.js" defer>
1112 </script>
@@ -16,29 +17,31 @@
1617 <script src="/-/static/js/ermite.HASH.js" defer>
1718 </script>
1819 </head>
19-<body data-ref="" data-sha="" data-sel="">
20+<body data-ref="" data-sha="" data-sel="" data-nav="">
2021 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
23-<span class="fade" aria-hidden="true">
22+<header class="hdr">
23+<div class="hdr-top">
24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
2426 </span>
25-<nav class="crumbs" aria-label="Path">
26-<ol>
27-</ol>
28-</nav>
29-<nav class="secs" aria-label="Sections">
30-<ul>
31-<li>
32-<a href="/" data-key="h" aria-keyshortcuts="g h" aria-current="page">home</a>
33-</li>
34-</ul>
35-</nav>
27+<span>example.test</span>
28+</a>
29+<div class="hdr-acts">
30+<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
31+<span class="ico i-search" aria-hidden="true">
32+</span>
33+<span class="search-label">Go to…</span>
34+<kbd>ctrl k</kbd>
35+</button>
36+<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
37+</div>
38+</div>
3639 </header>
3740 <div class="work">
38-<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
41+<main id="main" tabindex="-1">
3942 <div class="filter">
40-<label for="home-filter">filter&gt;</label>
41-<input id="home-filter" type="search" data-filter placeholder="name or description ( / )" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#repo-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#repo-rows > tr:not([hidden])')">
43+<label for="home-filter" class="sr">filter</label>
44+<input id="home-filter" type="search" data-filter placeholder="Filter repositories…" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#repo-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#repo-rows > tr:not([hidden])')">
4245 </div>
4346 <table class="list home" aria-label="Repositories">
4447 <thead>
@@ -90,70 +93,76 @@
9093 </table>
9194 </main>
9295 </div>
93-<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
96+<footer class="foot">
97+<span>ermite devel</span>
98+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
99+<span class="foot-keys">
100+<kbd>?</kbd> keyboard shortcuts</span>
101+</footer>
102+<div class="hint" role="status" aria-live="polite">
103+<span class="hint-nav" :text="document.body.dataset.nav || ''">
104+</span>
105+<span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''">
106+</span>
107+<span class="hint-flash" :text="document.body.dataset.flash || ''">
108+</span>
109+<span class="hint-live" data-state="connecting" hx-sse:connect="/-/events?topic=home" hx-swap="none" :text="data.state === 'offline' ? 'live updates paused' : ''" hx-on="htmx:sse:after:connection -> data.state = 'live';
110+ htmx:sse:close, htmx:sse:error -> data.state = 'offline';
111+ home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
112+ gap -> ermiteLive.reload()">
113+</span>
114+</div>
115+<dialog id="panel-help" class="panel modal" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
94116 <div class="panel-head">
95-<span>keys</span>
96-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
117+<h2>Keyboard shortcuts</h2>
118+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
119+<kbd>esc</kbd> close</button>
97120 </div>
98121 <dl class="keys">
99122 <div>
100123 <dt>
101-<kbd>j</kbd>
102-<kbd>k</kbd>
124+<kbd>ctrl k</kbd>
125+<kbd>:</kbd>
103126 </dt>
104-<dd>move cursor down / up</dd>
127+<dd>go to a section, branch, tag, file or repository</dd>
105128 </div>
106129 <div>
107130 <dt>
108-<kbd>enter</kbd>
131+<kbd>?</kbd>
109132 </dt>
110-<dd>open row under cursor</dd>
133+<dd>this list</dd>
111134 </div>
112135 <div>
113136 <dt>
114-<kbd>g</kbd>
115-<kbd>g</kbd> / <kbd>G</kbd>
137+<kbd>j</kbd>
138+<kbd>k</kbd>
116139 </dt>
117-<dd>first / last row</dd>
140+<dd>move the row cursor down / up</dd>
118141 </div>
119142 <div>
120143 <dt>
121-<kbd>/</kbd>
144+<kbd>enter</kbd>
122145 </dt>
123-<dd>focus the filter</dd>
146+<dd>open the row under the cursor</dd>
124147 </div>
125148 <div>
126149 <dt>
127-<kbd>g h</kbd>
128-<kbd>g t</kbd>
150+<kbd>g g</kbd>
151+<kbd>G</kbd>
129152 </dt>
130-<dd>go home / tree</dd>
153+<dd>first / last row</dd>
131154 </div>
132155 <div>
133156 <dt>
134-<kbd>g c</kbd>
135-<kbd>g r</kbd>
157+<kbd>/</kbd>
136158 </dt>
137-<dd>go commits / refs</dd>
159+<dd>filter the list</dd>
138160 </div>
139161 <div>
140162 <dt>
141163 <kbd>b</kbd>
142164 </dt>
143-<dd>switch ref (branches, tags)</dd>
144-</div>
145-<div>
146-<dt>
147-<kbd>:</kbd>
148-<kbd>f</kbd>
149-</dt>
150-<dd>go to: section, ref, file, repo</dd>
151-</div>
152-<div>
153-<dt>
154-<kbd>y</kbd>
155-</dt>
156-<dd>permalink: ref becomes the full sha</dd>
165+<dd>switch branch or tag</dd>
157166 </div>
158167 <div>
159168 <dt>
@@ -161,15 +170,21 @@
161170 <kbd>t</kbd>
162171 <kbd>c</kbd>
163172 <kbd>r</kbd>
173+<kbd>m</kbd>
164174 </dt>
165-<dd>repo sections (when shown)</dd>
175+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
166176 </div>
167177 <div>
168178 <dt>
169-<kbd>m</kbd>
170-<kbd>g m</kbd>
179+<kbd>g h</kbd>
171180 </dt>
172-<dd>merge requests (when shown)</dd>
181+<dd>home</dd>
182+</div>
183+<div>
184+<dt>
185+<kbd>y</kbd>
186+</dt>
187+<dd>copy a permalink pinned to the commit</dd>
173188 </div>
174189 <div>
175190 <dt>
@@ -189,89 +204,92 @@
189204 <dt>
190205 <kbd>esc</kbd>
191206 </dt>
192-<dd>close panel</dd>
207+<dd>close a dialog, leave a field</dd>
193208 </div>
194209 </dl>
195210 </dialog>
196-<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
211+<dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
197212 <div class="panel-head">
198-<label for="cmd-q" class="hi">:</label>
199-<input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
213+<label for="cmd-q" class="sr">go to</label>
214+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
200215 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
201216 let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
202217 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
203218 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
204219 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
205220 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
206-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
221+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
222+<kbd>esc</kbd>
223+</button>
207224 </div>
208-<ul id="cmd-list" class="cmd-list">
225+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
209226 <li data-k="go home" data-cursor>
210227 <a href="/" tabindex="-1">
211-<span class="k">go</span>home</a>
228+<span class="lbl">home</span>
229+<span class="k">go</span>
230+</a>
212231 </li>
213232 <li data-k="repo demo">
214233 <a href="/demo" tabindex="-1">
215-<span class="k">repo</span>demo</a>
234+<span class="lbl">demo</span>
235+<span class="k">repo</span>
236+</a>
216237 </li>
217238 <li data-k="repo quiet">
218239 <a href="/quiet" tabindex="-1">
219-<span class="k">repo</span>quiet</a>
240+<span class="lbl">quiet</span>
241+<span class="k">repo</span>
242+</a>
220243 </li>
221244 </ul>
245+<p class="panel-foot">
246+<span>
247+<kbd>↑</kbd>
248+<kbd>↓</kbd> move</span>
249+<span>
250+<kbd>enter</kbd> open</span>
251+<span>
252+<kbd>esc</kbd> close</span>
253+</p>
222254 </dialog>
223-<footer class="bar status" aria-label="Status">
224-<span class="mode">home</span>
225-<span class="st-ctx">2 repos · sort push ▼</span>
226-<button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
227-<span class="hi">:</span> go to…</button>
228-<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
229-</span>
230-<span class="st-hints">j/k move · enter open · / filter · ? help</span>
231-<span class="st-src">
232-<span class="st-ver">ermite devel · </span>
233-<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
234-</span>
235-<span class="st-live" data-state="offline" role="status" hx-sse:connect="/-/events?topic=home" hx-swap="none" :text="data.state" :title="data.state === 'live' ? 'receiving updates over SSE' : 'not connected to the event stream'" hx-on="htmx:sse:after:connection -> data.state = 'live';
236- htmx:sse:close, htmx:sse:error -> data.state = 'offline';
237- home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
238- gap -> ermiteLive.reload()">offline</span>
239-<span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/2</span>
240-</footer>
241-<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
255+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
242256 if (q('dialog[open]').count) return;
243257 let rows = q('[data-rows] > :not([hidden])').arr();
244258 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
245259 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
246- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
247- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
260+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
261+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
262+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
263+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
248264 })(1);
249- k-k from:body -> ((d) => {
265+ k-k from:body -> (async (d) => {
250266 if (q('dialog[open]').count) return;
251267 let rows = q('[data-rows] > :not([hidden])').arr();
252268 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
253269 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
254- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
255- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
270+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
271+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
272+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
273+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
256274 })(-1);
257275 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
258276 k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
259277 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
260278 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
261- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
262- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus());
263- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
279+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
280+ k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
281+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
264282 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
265- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
266- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
267- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
268- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
269- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
270- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
271- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
272- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
273- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
274- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
283+ k-gh from:body -> document.querySelector('.brand')?.click();
284+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
285+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
286+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
287+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
288+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
289+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
290+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
291+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
292+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
275293 k-next from:body -> {
276294 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
277295 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/not-found-page.html +109 -95
@@ -6,6 +6,7 @@
66 <meta name="color-scheme" content="light dark">
77 <title>404 · example.test</title>
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9+<link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
910 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
1011 <script src="/-/static/js/htmx.min.HASH.js" defer>
1112 </script>
@@ -16,35 +17,38 @@
1617 <script src="/-/static/js/ermite.HASH.js" defer>
1718 </script>
1819 </head>
19-<body data-ref="" data-sha="" data-sel="">
20+<body data-ref="" data-sha="" data-sel="" data-nav="">
2021 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
23-<span class="fade" aria-hidden="true">
22+<header class="hdr">
23+<div class="hdr-top">
24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
2426 </span>
27+<span>example.test</span>
28+</a>
2529 <nav class="crumbs" aria-label="Path">
2630 <ol>
2731 <li>
28-<span aria-current="page">-</span>
29-</li>
30-<li>
31-<span aria-current="page">404</span>
32+<span aria-current="page">not found</span>
3233 </li>
3334 </ol>
3435 </nav>
35-<nav class="secs" aria-label="Sections">
36-<ul>
37-<li>
38-<a href="/" data-key="h" aria-keyshortcuts="g h">home</a>
39-</li>
40-</ul>
41-</nav>
36+<div class="hdr-acts">
37+<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
38+<span class="ico i-search" aria-hidden="true">
39+</span>
40+<span class="search-label">Go to…</span>
41+<kbd>ctrl k</kbd>
42+</button>
43+<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
44+</div>
45+</div>
4246 </header>
4347 <div class="work">
44-<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
48+<main id="main" tabindex="-1">
4549 <section class="state" aria-labelledby="state-title">
46-<h1 id="state-title">
47-<span class="code">404</span> not found</h1>
50+<p class="eyebrow">404</p>
51+<h1 id="state-title">not found</h1>
4852 <p class="dim">no such page, ref or path.</p>
4953 <p class="dim">check the URL, or start from the repo list.</p>
5054 <p class="state-back">
@@ -53,70 +57,71 @@
5357 </section>
5458 </main>
5559 </div>
56-<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
60+<footer class="foot">
61+<span>ermite devel</span>
62+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
63+<span class="foot-keys">
64+<kbd>?</kbd> keyboard shortcuts</span>
65+</footer>
66+<div class="hint" role="status" aria-live="polite">
67+<span class="hint-nav" :text="document.body.dataset.nav || ''">
68+</span>
69+<span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''">
70+</span>
71+<span class="hint-flash" :text="document.body.dataset.flash || ''">
72+</span>
73+</div>
74+<dialog id="panel-help" class="panel modal" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
5775 <div class="panel-head">
58-<span>keys</span>
59-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
76+<h2>Keyboard shortcuts</h2>
77+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
78+<kbd>esc</kbd> close</button>
6079 </div>
6180 <dl class="keys">
6281 <div>
6382 <dt>
64-<kbd>j</kbd>
65-<kbd>k</kbd>
83+<kbd>ctrl k</kbd>
84+<kbd>:</kbd>
6685 </dt>
67-<dd>move cursor down / up</dd>
86+<dd>go to a section, branch, tag, file or repository</dd>
6887 </div>
6988 <div>
7089 <dt>
71-<kbd>enter</kbd>
90+<kbd>?</kbd>
7291 </dt>
73-<dd>open row under cursor</dd>
92+<dd>this list</dd>
7493 </div>
7594 <div>
7695 <dt>
77-<kbd>g</kbd>
78-<kbd>g</kbd> / <kbd>G</kbd>
96+<kbd>j</kbd>
97+<kbd>k</kbd>
7998 </dt>
80-<dd>first / last row</dd>
99+<dd>move the row cursor down / up</dd>
81100 </div>
82101 <div>
83102 <dt>
84-<kbd>/</kbd>
103+<kbd>enter</kbd>
85104 </dt>
86-<dd>focus the filter</dd>
105+<dd>open the row under the cursor</dd>
87106 </div>
88107 <div>
89108 <dt>
90-<kbd>g h</kbd>
91-<kbd>g t</kbd>
109+<kbd>g g</kbd>
110+<kbd>G</kbd>
92111 </dt>
93-<dd>go home / tree</dd>
112+<dd>first / last row</dd>
94113 </div>
95114 <div>
96115 <dt>
97-<kbd>g c</kbd>
98-<kbd>g r</kbd>
116+<kbd>/</kbd>
99117 </dt>
100-<dd>go commits / refs</dd>
118+<dd>filter the list</dd>
101119 </div>
102120 <div>
103121 <dt>
104122 <kbd>b</kbd>
105123 </dt>
106-<dd>switch ref (branches, tags)</dd>
107-</div>
108-<div>
109-<dt>
110-<kbd>:</kbd>
111-<kbd>f</kbd>
112-</dt>
113-<dd>go to: section, ref, file, repo</dd>
114-</div>
115-<div>
116-<dt>
117-<kbd>y</kbd>
118-</dt>
119-<dd>permalink: ref becomes the full sha</dd>
124+<dd>switch branch or tag</dd>
120125 </div>
121126 <div>
122127 <dt>
@@ -124,15 +129,21 @@
124129 <kbd>t</kbd>
125130 <kbd>c</kbd>
126131 <kbd>r</kbd>
132+<kbd>m</kbd>
127133 </dt>
128-<dd>repo sections (when shown)</dd>
134+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
129135 </div>
130136 <div>
131137 <dt>
132-<kbd>m</kbd>
133-<kbd>g m</kbd>
138+<kbd>g h</kbd>
139+</dt>
140+<dd>home</dd>
141+</div>
142+<div>
143+<dt>
144+<kbd>y</kbd>
134145 </dt>
135-<dd>merge requests (when shown)</dd>
146+<dd>copy a permalink pinned to the commit</dd>
136147 </div>
137148 <div>
138149 <dt>
@@ -152,77 +163,80 @@
152163 <dt>
153164 <kbd>esc</kbd>
154165 </dt>
155-<dd>close panel</dd>
166+<dd>close a dialog, leave a field</dd>
156167 </div>
157168 </dl>
158169 </dialog>
159-<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
170+<dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
160171 <div class="panel-head">
161-<label for="cmd-q" class="hi">:</label>
162-<input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
172+<label for="cmd-q" class="sr">go to</label>
173+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
163174 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
164175 let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
165176 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
166177 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
167178 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
168179 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
169-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
180+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
181+<kbd>esc</kbd>
182+</button>
170183 </div>
171-<ul id="cmd-list" class="cmd-list">
184+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
172185 <li data-k="go home" data-cursor>
173186 <a href="/" tabindex="-1">
174-<span class="k">go</span>home</a>
187+<span class="lbl">home</span>
188+<span class="k">go</span>
189+</a>
175190 </li>
176191 </ul>
192+<p class="panel-foot">
193+<span>
194+<kbd>↑</kbd>
195+<kbd>↓</kbd> move</span>
196+<span>
197+<kbd>enter</kbd> open</span>
198+<span>
199+<kbd>esc</kbd> close</span>
200+</p>
177201 </dialog>
178-<footer class="bar status" aria-label="Status">
179-<span class="mode">state</span>
180-<span class="st-ctx">404 · not found</span>
181-<button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
182-<span class="hi">:</span> go to…</button>
183-<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
184-</span>
185-<span class="st-hints">g h home · ? help</span>
186-<span class="st-src">
187-<span class="st-ver">ermite devel · </span>
188-<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
189-</span>
190-<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
191-</footer>
192-<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
202+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
193203 if (q('dialog[open]').count) return;
194204 let rows = q('[data-rows] > :not([hidden])').arr();
195205 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
196206 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
197- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
198- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
207+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
208+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
209+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
210+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
199211 })(1);
200- k-k from:body -> ((d) => {
212+ k-k from:body -> (async (d) => {
201213 if (q('dialog[open]').count) return;
202214 let rows = q('[data-rows] > :not([hidden])').arr();
203215 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
204216 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
205- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
206- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
217+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
218+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
219+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
220+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
207221 })(-1);
208222 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
209223 k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
210224 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
211225 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
212- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
213- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus());
214- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
226+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
227+ k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
228+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
215229 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
216- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
217- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
218- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
219- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
220- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
221- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
222- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
223- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
224- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
225- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
230+ k-gh from:body -> document.querySelector('.brand')?.click();
231+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
232+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
233+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
234+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
235+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
236+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
237+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
238+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
239+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
226240 k-next from:body -> {
227241 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
228242 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/not-found.html +109 -95
@@ -6,6 +6,7 @@
66 <meta name="color-scheme" content="light dark">
77 <title>404 · example.test</title>
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9+<link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
910 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
1011 <script src="/-/static/js/htmx.min.HASH.js" defer>
1112 </script>
@@ -16,35 +17,38 @@
1617 <script src="/-/static/js/ermite.HASH.js" defer>
1718 </script>
1819 </head>
19-<body data-ref="" data-sha="" data-sel="">
20+<body data-ref="" data-sha="" data-sel="" data-nav="">
2021 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
23-<span class="fade" aria-hidden="true">
22+<header class="hdr">
23+<div class="hdr-top">
24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
2426 </span>
27+<span>example.test</span>
28+</a>
2529 <nav class="crumbs" aria-label="Path">
2630 <ol>
2731 <li>
28-<span aria-current="page">-</span>
29-</li>
30-<li>
31-<span aria-current="page">404</span>
32+<span aria-current="page">not found</span>
3233 </li>
3334 </ol>
3435 </nav>
35-<nav class="secs" aria-label="Sections">
36-<ul>
37-<li>
38-<a href="/" data-key="h" aria-keyshortcuts="g h">home</a>
39-</li>
40-</ul>
41-</nav>
36+<div class="hdr-acts">
37+<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
38+<span class="ico i-search" aria-hidden="true">
39+</span>
40+<span class="search-label">Go to…</span>
41+<kbd>ctrl k</kbd>
42+</button>
43+<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
44+</div>
45+</div>
4246 </header>
4347 <div class="work">
44-<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
48+<main id="main" tabindex="-1">
4549 <section class="state" aria-labelledby="state-title">
46-<h1 id="state-title">
47-<span class="code">404</span> not found</h1>
50+<p class="eyebrow">404</p>
51+<h1 id="state-title">not found</h1>
4852 <p class="dim">no such page, ref or path.</p>
4953 <p class="dim">check the URL, or start from the repo list.</p>
5054 <p class="state-back">
@@ -53,70 +57,71 @@
5357 </section>
5458 </main>
5559 </div>
56-<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
60+<footer class="foot">
61+<span>ermite devel</span>
62+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
63+<span class="foot-keys">
64+<kbd>?</kbd> keyboard shortcuts</span>
65+</footer>
66+<div class="hint" role="status" aria-live="polite">
67+<span class="hint-nav" :text="document.body.dataset.nav || ''">
68+</span>
69+<span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''">
70+</span>
71+<span class="hint-flash" :text="document.body.dataset.flash || ''">
72+</span>
73+</div>
74+<dialog id="panel-help" class="panel modal" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
5775 <div class="panel-head">
58-<span>keys</span>
59-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
76+<h2>Keyboard shortcuts</h2>
77+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
78+<kbd>esc</kbd> close</button>
6079 </div>
6180 <dl class="keys">
6281 <div>
6382 <dt>
64-<kbd>j</kbd>
65-<kbd>k</kbd>
83+<kbd>ctrl k</kbd>
84+<kbd>:</kbd>
6685 </dt>
67-<dd>move cursor down / up</dd>
86+<dd>go to a section, branch, tag, file or repository</dd>
6887 </div>
6988 <div>
7089 <dt>
71-<kbd>enter</kbd>
90+<kbd>?</kbd>
7291 </dt>
73-<dd>open row under cursor</dd>
92+<dd>this list</dd>
7493 </div>
7594 <div>
7695 <dt>
77-<kbd>g</kbd>
78-<kbd>g</kbd> / <kbd>G</kbd>
96+<kbd>j</kbd>
97+<kbd>k</kbd>
7998 </dt>
80-<dd>first / last row</dd>
99+<dd>move the row cursor down / up</dd>
81100 </div>
82101 <div>
83102 <dt>
84-<kbd>/</kbd>
103+<kbd>enter</kbd>
85104 </dt>
86-<dd>focus the filter</dd>
105+<dd>open the row under the cursor</dd>
87106 </div>
88107 <div>
89108 <dt>
90-<kbd>g h</kbd>
91-<kbd>g t</kbd>
109+<kbd>g g</kbd>
110+<kbd>G</kbd>
92111 </dt>
93-<dd>go home / tree</dd>
112+<dd>first / last row</dd>
94113 </div>
95114 <div>
96115 <dt>
97-<kbd>g c</kbd>
98-<kbd>g r</kbd>
116+<kbd>/</kbd>
99117 </dt>
100-<dd>go commits / refs</dd>
118+<dd>filter the list</dd>
101119 </div>
102120 <div>
103121 <dt>
104122 <kbd>b</kbd>
105123 </dt>
106-<dd>switch ref (branches, tags)</dd>
107-</div>
108-<div>
109-<dt>
110-<kbd>:</kbd>
111-<kbd>f</kbd>
112-</dt>
113-<dd>go to: section, ref, file, repo</dd>
114-</div>
115-<div>
116-<dt>
117-<kbd>y</kbd>
118-</dt>
119-<dd>permalink: ref becomes the full sha</dd>
124+<dd>switch branch or tag</dd>
120125 </div>
121126 <div>
122127 <dt>
@@ -124,15 +129,21 @@
124129 <kbd>t</kbd>
125130 <kbd>c</kbd>
126131 <kbd>r</kbd>
132+<kbd>m</kbd>
127133 </dt>
128-<dd>repo sections (when shown)</dd>
134+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
129135 </div>
130136 <div>
131137 <dt>
132-<kbd>m</kbd>
133-<kbd>g m</kbd>
138+<kbd>g h</kbd>
139+</dt>
140+<dd>home</dd>
141+</div>
142+<div>
143+<dt>
144+<kbd>y</kbd>
134145 </dt>
135-<dd>merge requests (when shown)</dd>
146+<dd>copy a permalink pinned to the commit</dd>
136147 </div>
137148 <div>
138149 <dt>
@@ -152,77 +163,80 @@
152163 <dt>
153164 <kbd>esc</kbd>
154165 </dt>
155-<dd>close panel</dd>
166+<dd>close a dialog, leave a field</dd>
156167 </div>
157168 </dl>
158169 </dialog>
159-<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
170+<dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
160171 <div class="panel-head">
161-<label for="cmd-q" class="hi">:</label>
162-<input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
172+<label for="cmd-q" class="sr">go to</label>
173+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
163174 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
164175 let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
165176 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
166177 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
167178 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
168179 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
169-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
180+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
181+<kbd>esc</kbd>
182+</button>
170183 </div>
171-<ul id="cmd-list" class="cmd-list">
184+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
172185 <li data-k="go home" data-cursor>
173186 <a href="/" tabindex="-1">
174-<span class="k">go</span>home</a>
187+<span class="lbl">home</span>
188+<span class="k">go</span>
189+</a>
175190 </li>
176191 </ul>
192+<p class="panel-foot">
193+<span>
194+<kbd>↑</kbd>
195+<kbd>↓</kbd> move</span>
196+<span>
197+<kbd>enter</kbd> open</span>
198+<span>
199+<kbd>esc</kbd> close</span>
200+</p>
177201 </dialog>
178-<footer class="bar status" aria-label="Status">
179-<span class="mode">state</span>
180-<span class="st-ctx">404 · not found</span>
181-<button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
182-<span class="hi">:</span> go to…</button>
183-<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
184-</span>
185-<span class="st-hints">g h home · ? help</span>
186-<span class="st-src">
187-<span class="st-ver">ermite devel · </span>
188-<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
189-</span>
190-<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
191-</footer>
192-<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
202+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
193203 if (q('dialog[open]').count) return;
194204 let rows = q('[data-rows] > :not([hidden])').arr();
195205 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
196206 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
197- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
198- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
207+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
208+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
209+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
210+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
199211 })(1);
200- k-k from:body -> ((d) => {
212+ k-k from:body -> (async (d) => {
201213 if (q('dialog[open]').count) return;
202214 let rows = q('[data-rows] > :not([hidden])').arr();
203215 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
204216 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
205- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
206- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
217+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
218+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
219+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
220+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
207221 })(-1);
208222 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
209223 k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
210224 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
211225 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
212- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
213- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus());
214- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
226+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
227+ k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
228+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
215229 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
216- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
217- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
218- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
219- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
220- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
221- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
222- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
223- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
224- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
225- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
230+ k-gh from:body -> document.querySelector('.brand')?.click();
231+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
232+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
233+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
234+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
235+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
236+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
237+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
238+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
239+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
226240 k-next from:body -> {
227241 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
228242 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/refs.html +172 -126
@@ -6,6 +6,7 @@
66 <meta name="color-scheme" content="light dark">
77 <title>refs · demo · example.test</title>
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9+<link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
910 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
1011 <script src="/-/static/js/htmx.min.HASH.js" defer>
1112 </script>
@@ -16,12 +17,15 @@
1617 <script src="/-/static/js/ermite.HASH.js" defer>
1718 </script>
1819 </head>
19-<body data-ref="main" data-sha="SHA40" data-sel="">
20+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
2021 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
23-<span class="fade" aria-hidden="true">
22+<header class="hdr">
23+<div class="hdr-top">
24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
2426 </span>
27+<span>example.test</span>
28+</a>
2529 <nav class="crumbs" aria-label="Path">
2630 <ol>
2731 <li>
@@ -32,33 +36,52 @@
3236 </li>
3337 </ol>
3438 </nav>
35-<nav class="secs" aria-label="Sections">
39+<div class="hdr-acts">
40+<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
41+<span class="ico i-branch" aria-hidden="true">
42+</span>
43+<span class="ref-name">main</span>
44+<span class="sha">SHA7</span>
45+</button>
46+<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
47+<span class="ico i-search" aria-hidden="true">
48+</span>
49+<span class="search-label">Go to…</span>
50+<kbd>ctrl k</kbd>
51+</button>
52+<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
53+</div>
54+</div>
55+<nav class="tabs" aria-label="Sections">
3656 <ul>
3757 <li>
38-<a href="/demo" data-key="s" aria-keyshortcuts="g s">summary</a>
58+<a href="/demo" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
59+</a>
3960 </li>
4061 <li>
41-<a href="/demo/tree/main" data-key="t" aria-keyshortcuts="g t">tree</a>
62+<a href="/demo/tree/main" data-key="t" aria-keyshortcuts="t">Files<kbd aria-hidden="true">t</kbd>
63+</a>
4264 </li>
4365 <li>
44-<a href="/demo/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a>
66+<a href="/demo/commits/main" data-key="c" aria-keyshortcuts="c">Commits<kbd aria-hidden="true">c</kbd>
67+</a>
4568 </li>
4669 <li>
47-<a href="/demo/refs" data-key="r" aria-keyshortcuts="g r" aria-current="page">refs</a>
70+<a href="/demo/refs" data-key="r" aria-keyshortcuts="r" aria-current="page">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
71+</a>
4872 </li>
4973 <li>
50-<a href="/demo/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
74+<a href="/demo/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
75+</a>
5176 </li>
5277 </ul>
5378 </nav>
54-<button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
55-</button>
5679 </header>
5780 <div class="work has-rail">
58-<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
81+<main id="main" tabindex="-1">
5982 <div class="filter">
60-<label for="ref-filter">filter&gt;</label>
61-<input id="ref-filter" type="search" data-filter placeholder="branch or tag ( / )" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#ref-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t));
83+<label for="ref-filter" class="sr">filter</label>
84+<input id="ref-filter" type="search" data-filter placeholder="Filter branches and tags…" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#ref-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t));
6285 let first = q('#ref-rows > tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#ref-rows > tr')">
6386 </div>
6487 <table class="list refs" aria-label="Branches and tags">
@@ -80,7 +103,7 @@
80103 <td class="c-kind mute">branch</td>
81104 <td class="c-name">
82105 <a href="/demo/tree/main">main</a>
83-<span class="tagged">*</span>
106+<span class="pill">default</span>
84107 </td>
85108 <td class="c-sha">
86109 <a href="/demo/commit/SHA40">SHA7</a>
@@ -139,10 +162,8 @@
139162 </table>
140163 </main>
141164 <aside class="rail" aria-label="Context">
142-<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
143-<summary>refs · legend</summary>
144165 <section aria-labelledby="r-refs">
145-<h2 id="r-refs">refs</h2>
166+<h2 id="r-refs">Overview</h2>
146167 <dl class="kv" id="refs-kv">
147168 <dt>default</dt>
148169 <dd>main</dd>
@@ -154,80 +175,78 @@
154175 <dd>v0.2.0</dd>
155176 </dl>
156177 </section>
157-<section aria-labelledby="r-legend">
158-<h2 id="r-legend">legend</h2>
159-<dl class="kv">
160-<dt>*</dt>
161-<dd>default branch</dd>
162-</dl>
163-</section>
164-</details>
165178 </aside>
166179 </div>
167-<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
180+<footer class="foot">
181+<span>ermite devel</span>
182+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
183+<span class="foot-keys">
184+<kbd>?</kbd> keyboard shortcuts</span>
185+</footer>
186+<div class="hint" role="status" aria-live="polite">
187+<span class="hint-nav" :text="document.body.dataset.nav || ''">
188+</span>
189+<span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''">
190+</span>
191+<span class="hint-flash" :text="document.body.dataset.flash || ''">
192+</span>
193+<span class="hint-live" data-state="connecting" hx-sse:connect="/-/events?topic=repo%3Ademo&amp;view=refs" hx-swap="none" :text="data.state === 'offline' ? 'live updates paused' : ''" hx-on="htmx:sse:after:connection -> data.state = 'live';
194+ htmx:sse:close, htmx:sse:error -> data.state = 'offline';
195+ home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
196+ gap -> ermiteLive.reload()">
197+</span>
198+</div>
199+<dialog id="panel-help" class="panel modal" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
168200 <div class="panel-head">
169-<span>keys</span>
170-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
201+<h2>Keyboard shortcuts</h2>
202+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
203+<kbd>esc</kbd> close</button>
171204 </div>
172205 <dl class="keys">
173206 <div>
174207 <dt>
175-<kbd>j</kbd>
176-<kbd>k</kbd>
208+<kbd>ctrl k</kbd>
209+<kbd>:</kbd>
177210 </dt>
178-<dd>move cursor down / up</dd>
211+<dd>go to a section, branch, tag, file or repository</dd>
179212 </div>
180213 <div>
181214 <dt>
182-<kbd>enter</kbd>
215+<kbd>?</kbd>
183216 </dt>
184-<dd>open row under cursor</dd>
217+<dd>this list</dd>
185218 </div>
186219 <div>
187220 <dt>
188-<kbd>g</kbd>
189-<kbd>g</kbd> / <kbd>G</kbd>
221+<kbd>j</kbd>
222+<kbd>k</kbd>
190223 </dt>
191-<dd>first / last row</dd>
224+<dd>move the row cursor down / up</dd>
192225 </div>
193226 <div>
194227 <dt>
195-<kbd>/</kbd>
228+<kbd>enter</kbd>
196229 </dt>
197-<dd>focus the filter</dd>
230+<dd>open the row under the cursor</dd>
198231 </div>
199232 <div>
200233 <dt>
201-<kbd>g h</kbd>
202-<kbd>g t</kbd>
234+<kbd>g g</kbd>
235+<kbd>G</kbd>
203236 </dt>
204-<dd>go home / tree</dd>
237+<dd>first / last row</dd>
205238 </div>
206239 <div>
207240 <dt>
208-<kbd>g c</kbd>
209-<kbd>g r</kbd>
241+<kbd>/</kbd>
210242 </dt>
211-<dd>go commits / refs</dd>
243+<dd>filter the list</dd>
212244 </div>
213245 <div>
214246 <dt>
215247 <kbd>b</kbd>
216248 </dt>
217-<dd>switch ref (branches, tags)</dd>
218-</div>
219-<div>
220-<dt>
221-<kbd>:</kbd>
222-<kbd>f</kbd>
223-</dt>
224-<dd>go to: section, ref, file, repo</dd>
225-</div>
226-<div>
227-<dt>
228-<kbd>y</kbd>
229-</dt>
230-<dd>permalink: ref becomes the full sha</dd>
249+<dd>switch branch or tag</dd>
231250 </div>
232251 <div>
233252 <dt>
@@ -235,15 +254,21 @@
235254 <kbd>t</kbd>
236255 <kbd>c</kbd>
237256 <kbd>r</kbd>
257+<kbd>m</kbd>
238258 </dt>
239-<dd>repo sections (when shown)</dd>
259+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
240260 </div>
241261 <div>
242262 <dt>
243-<kbd>m</kbd>
244-<kbd>g m</kbd>
263+<kbd>g h</kbd>
245264 </dt>
246-<dd>merge requests (when shown)</dd>
265+<dd>home</dd>
266+</div>
267+<div>
268+<dt>
269+<kbd>y</kbd>
270+</dt>
271+<dd>copy a permalink pinned to the commit</dd>
247272 </div>
248273 <div>
249274 <dt>
@@ -263,19 +288,21 @@
263288 <dt>
264289 <kbd>esc</kbd>
265290 </dt>
266-<dd>close panel</dd>
291+<dd>close a dialog, leave a field</dd>
267292 </div>
268293 </dl>
269294 </dialog>
270-<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
295+<dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
271296 <div class="panel-head">
272-<label for="refs-filter">ref&gt;</label>
273-<input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
274-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
297+<label for="refs-filter" class="sr">filter branches and tags</label>
298+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
299+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
300+<kbd>esc</kbd>
301+</button>
275302 </div>
276303 <div class="panel-cols" data-picklist>
277304 <section aria-label="Branches">
278-<h2>branches <span class="mute">2</span>
305+<h2>Branches <span class="count">2</span>
279306 </h2>
280307 <ul>
281308 <li>
@@ -289,7 +316,7 @@
289316 </ul>
290317 </section>
291318 <section aria-label="Tags">
292-<h2>tags <span class="mute">2</span>
319+<h2>Tags <span class="count">2</span>
293320 </h2>
294321 <ul>
295322 <li>
@@ -304,117 +331,136 @@
304331 </section>
305332 </div>
306333 </dialog>
307-<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
334+<dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
308335 <div class="panel-head">
309-<label for="cmd-q" class="hi">:</label>
310-<input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
336+<label for="cmd-q" class="sr">go to</label>
337+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
311338 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
312339 let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
313340 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
314341 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
315342 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
316343 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
317-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
344+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
345+<kbd>esc</kbd>
346+</button>
318347 </div>
319-<ul id="cmd-list" class="cmd-list">
348+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
320349 <li data-k="go home" data-cursor>
321350 <a href="/" tabindex="-1">
322-<span class="k">go</span>home</a>
351+<span class="lbl">home</span>
352+<span class="k">go</span>
353+</a>
323354 </li>
324355 <li data-k="section summary">
325356 <a href="/demo" tabindex="-1">
326-<span class="k">section</span>summary</a>
357+<span class="lbl">summary</span>
358+<span class="k">section</span>
359+</a>
327360 </li>
328-<li data-k="section tree">
361+<li data-k="section files">
329362 <a href="/demo/tree/main" tabindex="-1">
330-<span class="k">section</span>tree</a>
363+<span class="lbl">files</span>
364+<span class="k">section</span>
365+</a>
331366 </li>
332367 <li data-k="section commits">
333368 <a href="/demo/commits/main" tabindex="-1">
334-<span class="k">section</span>commits</a>
369+<span class="lbl">commits</span>
370+<span class="k">section</span>
371+</a>
335372 </li>
336-<li data-k="section refs">
373+<li data-k="section branches &amp; tags">
337374 <a href="/demo/refs" tabindex="-1">
338-<span class="k">section</span>refs</a>
375+<span class="lbl">branches &amp; tags</span>
376+<span class="k">section</span>
377+</a>
339378 </li>
340379 <li data-k="section merge requests">
341380 <a href="/demo/merge-requests" tabindex="-1">
342-<span class="k">section</span>merge requests</a>
381+<span class="lbl">merge requests</span>
382+<span class="k">section</span>
383+</a>
343384 </li>
344385 <li data-k="branch feature/x">
345386 <a href="/demo/tree/feature/x" tabindex="-1">
346-<span class="k">branch</span>feature/x</a>
387+<span class="lbl">feature/x</span>
388+<span class="k">branch</span>
389+</a>
347390 </li>
348391 <li data-k="branch main">
349392 <a href="/demo/tree/main" tabindex="-1">
350-<span class="k">branch</span>main</a>
393+<span class="lbl">main</span>
394+<span class="k">branch</span>
395+</a>
351396 </li>
352397 <li data-k="tag v0.1.0">
353398 <a href="/demo/tree/v0.1.0" tabindex="-1">
354-<span class="k">tag</span>v0.1.0</a>
399+<span class="lbl">v0.1.0</span>
400+<span class="k">tag</span>
401+</a>
355402 </li>
356403 <li data-k="tag v0.2.0">
357404 <a href="/demo/tree/v0.2.0" tabindex="-1">
358-<span class="k">tag</span>v0.2.0</a>
405+<span class="lbl">v0.2.0</span>
406+<span class="k">tag</span>
407+</a>
359408 </li>
360409 <li data-k="repo quiet">
361410 <a href="/quiet" tabindex="-1">
362-<span class="k">repo</span>quiet</a>
411+<span class="lbl">quiet</span>
412+<span class="k">repo</span>
413+</a>
363414 </li>
364415 </ul>
416+<p class="panel-foot">
417+<span>
418+<kbd>↑</kbd>
419+<kbd>↓</kbd> move</span>
420+<span>
421+<kbd>enter</kbd> open</span>
422+<span>
423+<kbd>esc</kbd> close</span>
424+</p>
365425 </dialog>
366-<footer class="bar status" aria-label="Status">
367-<span class="mode">refs</span>
368-<span class="st-ctx">2 branches · 2 tags</span>
369-<button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
370-<span class="hi">:</span> go to…</button>
371-<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
372-</span>
373-<span class="st-hints">j/k move · enter open · / filter · ? help</span>
374-<span class="st-src">
375-<span class="st-ver">ermite devel · </span>
376-<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
377-</span>
378-<span class="st-live" data-state="offline" role="status" hx-sse:connect="/-/events?topic=repo%3Ademo&amp;view=refs" hx-swap="none" :text="data.state" :title="data.state === 'live' ? 'receiving updates over SSE' : 'not connected to the event stream'" hx-on="htmx:sse:after:connection -> data.state = 'live';
379- htmx:sse:close, htmx:sse:error -> data.state = 'offline';
380- home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
381- gap -> ermiteLive.reload()">offline</span>
382-<span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/4</span>
383-</footer>
384-<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
426+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
385427 if (q('dialog[open]').count) return;
386428 let rows = q('[data-rows] > :not([hidden])').arr();
387429 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
388430 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
389- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
390- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
431+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
432+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
433+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
434+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
391435 })(1);
392- k-k from:body -> ((d) => {
436+ k-k from:body -> (async (d) => {
393437 if (q('dialog[open]').count) return;
394438 let rows = q('[data-rows] > :not([hidden])').arr();
395439 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
396440 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
397- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
398- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
441+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
442+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
443+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
444+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
399445 })(-1);
400446 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
401447 k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
402448 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
403449 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
404- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
405- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus());
406- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
450+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
451+ k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
452+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
407453 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
408- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
409- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
410- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
411- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
412- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
413- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
414- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
415- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
416- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
417- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
454+ k-gh from:body -> document.querySelector('.brand')?.click();
455+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
456+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
457+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
458+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
459+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
460+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
461+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
462+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
463+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
418464 k-next from:body -> {
419465 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
420466 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/summary.html +184 -127
@@ -6,6 +6,7 @@
66 <meta name="color-scheme" content="light dark">
77 <title>demo · example.test</title>
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9+<link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
910 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
1011 <script src="/-/static/js/htmx.min.HASH.js" defer>
1112 </script>
@@ -16,12 +17,15 @@
1617 <script src="/-/static/js/ermite.HASH.js" defer>
1718 </script>
1819 </head>
19-<body data-ref="main" data-sha="SHA40" data-sel="">
20+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
2021 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
23-<span class="fade" aria-hidden="true">
22+<header class="hdr">
23+<div class="hdr-top">
24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
2426 </span>
27+<span>example.test</span>
28+</a>
2529 <nav class="crumbs" aria-label="Path">
2630 <ol>
2731 <li>
@@ -29,34 +33,54 @@
2933 </li>
3034 </ol>
3135 </nav>
32-<nav class="secs" aria-label="Sections">
36+<div class="hdr-acts">
37+<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
38+<span class="ico i-branch" aria-hidden="true">
39+</span>
40+<span class="ref-name">main</span>
41+<span class="sha">SHA7</span>
42+</button>
43+<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
44+<span class="ico i-search" aria-hidden="true">
45+</span>
46+<span class="search-label">Go to…</span>
47+<kbd>ctrl k</kbd>
48+</button>
49+<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
50+</div>
51+</div>
52+<nav class="tabs" aria-label="Sections">
3353 <ul>
3454 <li>
35-<a href="/demo" data-key="s" aria-keyshortcuts="g s" aria-current="page">summary</a>
55+<a href="/demo" data-key="s" aria-keyshortcuts="s" aria-current="page">Summary<kbd aria-hidden="true">s</kbd>
56+</a>
3657 </li>
3758 <li>
38-<a href="/demo/tree/main" data-key="t" aria-keyshortcuts="g t">tree</a>
59+<a href="/demo/tree/main" data-key="t" aria-keyshortcuts="t">Files<kbd aria-hidden="true">t</kbd>
60+</a>
3961 </li>
4062 <li>
41-<a href="/demo/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a>
63+<a href="/demo/commits/main" data-key="c" aria-keyshortcuts="c">Commits<kbd aria-hidden="true">c</kbd>
64+</a>
4265 </li>
4366 <li>
44-<a href="/demo/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
67+<a href="/demo/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
68+</a>
4569 </li>
4670 <li>
47-<a href="/demo/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
71+<a href="/demo/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
72+</a>
4873 </li>
4974 </ul>
5075 </nav>
51-<button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
52-</button>
5376 </header>
54-<div class="work has-rail read">
55-<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
77+<div class="work has-rail">
78+<main id="main" tabindex="-1">
5679 <p class="lede">A demo repository</p>
57-<h2 class="rule-h">
58-<span>README.md</span>
59-</h2>
80+<section class="card readme" aria-labelledby="readme-h">
81+<h2 class="card-h" id="readme-h">
82+<span class="ico i-file" aria-hidden="true">
83+</span> README.md</h2>
6084 <article class="prose" aria-label="README.md">
6185 <h1 id="md-demo">Demo</h1>
6286 <p>A small <em>demo</em> with a <a href="/demo/blob/main/docs/guide.md">guide</a> and <a href="https://example.org/x" rel="nofollow noopener noreferrer">the site</a>.</p>
@@ -81,22 +105,21 @@
81105 </tbody>
82106 </table>
83107 </article>
108+</section>
84109 </main>
85110 <aside class="rail" aria-label="Context">
86-<details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
87-<summary>clone · facts · recent commits · outline</summary>
88111 <section aria-labelledby="r-clone">
89-<h2 id="r-clone">clone</h2>
112+<h2 id="r-clone">Clone</h2>
90113 <div class="clone" id="clone-demo">
91114 <div class="clone-line">
92115 <span class="mute" aria-hidden="true">$ </span>
93116 <code>git clone http://example.test/demo.git</code>
94-<button type="button" class="copy" data-label="[copy]" aria-label="copy: git clone http://example.test/demo.git" hx-on:click="navigator.clipboard?.writeText(closest('.clone-line').querySelector('code').textContent); this.textContent = '[copied]'; await timeout('1500ms'); this.textContent = this.dataset.label">[copy]</button>
117+<button type="button" class="btn copy" data-label="Copy" aria-label="copy: git clone http://example.test/demo.git" hx-on:click="navigator.clipboard?.writeText(closest('.clone-line').querySelector('code').textContent); this.textContent = 'Copied'; await timeout('1500ms'); this.textContent = this.dataset.label">Copy</button>
95118 </div>
96119 </div>
97120 </section>
98121 <section aria-labelledby="r-facts">
99-<h2 id="r-facts">facts</h2>
122+<h2 id="r-facts">About</h2>
100123 <dl class="kv">
101124 <dt>last push</dt>
102125 <dd id="last-push-demo">
@@ -113,7 +136,7 @@
113136 </dl>
114137 </section>
115138 <section aria-labelledby="r-recent">
116-<h2 id="r-recent">recent commits <a href="/demo/commits/main">all</a>
139+<h2 id="r-recent">Recent commits <a href="/demo/commits/main">View all</a>
117140 </h2>
118141 <table class="list commits" aria-label="Commits">
119142 <tbody data-rows>
@@ -123,8 +146,9 @@
123146 <td class="c-sha mute">SHA7</td>
124147 <td class="c-subject">
125148 <a href="/demo/commit/SHA40">add main.go</a>
126-<span class="tagged">[main]</span>
127-<span class="tagged">[tag: v0.2.0]</span>
149+<span class="pill">main</span>
150+<span class="pill tag">
151+<span class="sr">tag </span>v0.2.0</span>
128152 </td>
129153 <td class="c-who dim">T</td>
130154 <td class="c-when nowrap dim">
@@ -137,7 +161,8 @@
137161 <td class="c-sha mute">SHA7</td>
138162 <td class="c-subject">
139163 <a href="/demo/commit/SHA40">first commit</a>
140-<span class="tagged">[tag: v0.1.0]</span>
164+<span class="pill tag">
165+<span class="sr">tag </span>v0.1.0</span>
141166 </td>
142167 <td class="c-who dim">T</td>
143168 <td class="c-when nowrap dim">
@@ -148,80 +173,85 @@
148173 </table>
149174 </section>
150175 <section aria-labelledby="r-outline">
151-<h2 id="r-outline">README.md</h2>
176+<h2 id="r-outline">On this page</h2>
152177 <nav class="outline" aria-label="Outline">
153178 <a class="l1" href="#md-demo">Demo</a>
154179 <a class="l2" href="#md-install">Install</a>
155180 <a class="l2" href="#md-usage">Usage</a>
156181 </nav>
157182 </section>
158-</details>
159183 </aside>
160184 </div>
161-<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
185+<footer class="foot">
186+<span>ermite devel</span>
187+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
188+<span class="foot-keys">
189+<kbd>?</kbd> keyboard shortcuts</span>
190+</footer>
191+<div class="hint" role="status" aria-live="polite">
192+<span class="hint-nav" :text="document.body.dataset.nav || ''">
193+</span>
194+<span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''">
195+</span>
196+<span class="hint-flash" :text="document.body.dataset.flash || ''">
197+</span>
198+<span class="hint-live" data-state="connecting" hx-sse:connect="/-/events?topic=repo%3Ademo&amp;view=summary" hx-swap="none" :text="data.state === 'offline' ? 'live updates paused' : ''" hx-on="htmx:sse:after:connection -> data.state = 'live';
199+ htmx:sse:close, htmx:sse:error -> data.state = 'offline';
200+ home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
201+ gap -> ermiteLive.reload()">
202+</span>
203+</div>
204+<dialog id="panel-help" class="panel modal" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
162205 <div class="panel-head">
163-<span>keys</span>
164-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
206+<h2>Keyboard shortcuts</h2>
207+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
208+<kbd>esc</kbd> close</button>
165209 </div>
166210 <dl class="keys">
167211 <div>
168212 <dt>
169-<kbd>j</kbd>
170-<kbd>k</kbd>
213+<kbd>ctrl k</kbd>
214+<kbd>:</kbd>
171215 </dt>
172-<dd>move cursor down / up</dd>
216+<dd>go to a section, branch, tag, file or repository</dd>
173217 </div>
174218 <div>
175219 <dt>
176-<kbd>enter</kbd>
220+<kbd>?</kbd>
177221 </dt>
178-<dd>open row under cursor</dd>
222+<dd>this list</dd>
179223 </div>
180224 <div>
181225 <dt>
182-<kbd>g</kbd>
183-<kbd>g</kbd> / <kbd>G</kbd>
226+<kbd>j</kbd>
227+<kbd>k</kbd>
184228 </dt>
185-<dd>first / last row</dd>
229+<dd>move the row cursor down / up</dd>
186230 </div>
187231 <div>
188232 <dt>
189-<kbd>/</kbd>
233+<kbd>enter</kbd>
190234 </dt>
191-<dd>focus the filter</dd>
235+<dd>open the row under the cursor</dd>
192236 </div>
193237 <div>
194238 <dt>
195-<kbd>g h</kbd>
196-<kbd>g t</kbd>
239+<kbd>g g</kbd>
240+<kbd>G</kbd>
197241 </dt>
198-<dd>go home / tree</dd>
242+<dd>first / last row</dd>
199243 </div>
200244 <div>
201245 <dt>
202-<kbd>g c</kbd>
203-<kbd>g r</kbd>
246+<kbd>/</kbd>
204247 </dt>
205-<dd>go commits / refs</dd>
248+<dd>filter the list</dd>
206249 </div>
207250 <div>
208251 <dt>
209252 <kbd>b</kbd>
210253 </dt>
211-<dd>switch ref (branches, tags)</dd>
212-</div>
213-<div>
214-<dt>
215-<kbd>:</kbd>
216-<kbd>f</kbd>
217-</dt>
218-<dd>go to: section, ref, file, repo</dd>
219-</div>
220-<div>
221-<dt>
222-<kbd>y</kbd>
223-</dt>
224-<dd>permalink: ref becomes the full sha</dd>
254+<dd>switch branch or tag</dd>
225255 </div>
226256 <div>
227257 <dt>
@@ -229,15 +259,21 @@
229259 <kbd>t</kbd>
230260 <kbd>c</kbd>
231261 <kbd>r</kbd>
262+<kbd>m</kbd>
232263 </dt>
233-<dd>repo sections (when shown)</dd>
264+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
234265 </div>
235266 <div>
236267 <dt>
237-<kbd>m</kbd>
238-<kbd>g m</kbd>
268+<kbd>g h</kbd>
239269 </dt>
240-<dd>merge requests (when shown)</dd>
270+<dd>home</dd>
271+</div>
272+<div>
273+<dt>
274+<kbd>y</kbd>
275+</dt>
276+<dd>copy a permalink pinned to the commit</dd>
241277 </div>
242278 <div>
243279 <dt>
@@ -257,19 +293,21 @@
257293 <dt>
258294 <kbd>esc</kbd>
259295 </dt>
260-<dd>close panel</dd>
296+<dd>close a dialog, leave a field</dd>
261297 </div>
262298 </dl>
263299 </dialog>
264-<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
300+<dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
265301 <div class="panel-head">
266-<label for="refs-filter">ref&gt;</label>
267-<input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
268-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
302+<label for="refs-filter" class="sr">filter branches and tags</label>
303+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
304+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
305+<kbd>esc</kbd>
306+</button>
269307 </div>
270308 <div class="panel-cols" data-picklist>
271309 <section aria-label="Branches">
272-<h2>branches <span class="mute">2</span>
310+<h2>Branches <span class="count">2</span>
273311 </h2>
274312 <ul>
275313 <li>
@@ -283,7 +321,7 @@
283321 </ul>
284322 </section>
285323 <section aria-label="Tags">
286-<h2>tags <span class="mute">2</span>
324+<h2>Tags <span class="count">2</span>
287325 </h2>
288326 <ul>
289327 <li>
@@ -298,117 +336,136 @@
298336 </section>
299337 </div>
300338 </dialog>
301-<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
339+<dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
302340 <div class="panel-head">
303-<label for="cmd-q" class="hi">:</label>
304-<input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
341+<label for="cmd-q" class="sr">go to</label>
342+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
305343 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
306344 let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
307345 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
308346 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
309347 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
310348 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
311-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
349+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
350+<kbd>esc</kbd>
351+</button>
312352 </div>
313-<ul id="cmd-list" class="cmd-list">
353+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
314354 <li data-k="go home" data-cursor>
315355 <a href="/" tabindex="-1">
316-<span class="k">go</span>home</a>
356+<span class="lbl">home</span>
357+<span class="k">go</span>
358+</a>
317359 </li>
318360 <li data-k="section summary">
319361 <a href="/demo" tabindex="-1">
320-<span class="k">section</span>summary</a>
362+<span class="lbl">summary</span>
363+<span class="k">section</span>
364+</a>
321365 </li>
322-<li data-k="section tree">
366+<li data-k="section files">
323367 <a href="/demo/tree/main" tabindex="-1">
324-<span class="k">section</span>tree</a>
368+<span class="lbl">files</span>
369+<span class="k">section</span>
370+</a>
325371 </li>
326372 <li data-k="section commits">
327373 <a href="/demo/commits/main" tabindex="-1">
328-<span class="k">section</span>commits</a>
374+<span class="lbl">commits</span>
375+<span class="k">section</span>
376+</a>
329377 </li>
330-<li data-k="section refs">
378+<li data-k="section branches &amp; tags">
331379 <a href="/demo/refs" tabindex="-1">
332-<span class="k">section</span>refs</a>
380+<span class="lbl">branches &amp; tags</span>
381+<span class="k">section</span>
382+</a>
333383 </li>
334384 <li data-k="section merge requests">
335385 <a href="/demo/merge-requests" tabindex="-1">
336-<span class="k">section</span>merge requests</a>
386+<span class="lbl">merge requests</span>
387+<span class="k">section</span>
388+</a>
337389 </li>
338390 <li data-k="branch feature/x">
339391 <a href="/demo/tree/feature/x" tabindex="-1">
340-<span class="k">branch</span>feature/x</a>
392+<span class="lbl">feature/x</span>
393+<span class="k">branch</span>
394+</a>
341395 </li>
342396 <li data-k="branch main">
343397 <a href="/demo/tree/main" tabindex="-1">
344-<span class="k">branch</span>main</a>
398+<span class="lbl">main</span>
399+<span class="k">branch</span>
400+</a>
345401 </li>
346402 <li data-k="tag v0.1.0">
347403 <a href="/demo/tree/v0.1.0" tabindex="-1">
348-<span class="k">tag</span>v0.1.0</a>
404+<span class="lbl">v0.1.0</span>
405+<span class="k">tag</span>
406+</a>
349407 </li>
350408 <li data-k="tag v0.2.0">
351409 <a href="/demo/tree/v0.2.0" tabindex="-1">
352-<span class="k">tag</span>v0.2.0</a>
410+<span class="lbl">v0.2.0</span>
411+<span class="k">tag</span>
412+</a>
353413 </li>
354414 <li data-k="repo quiet">
355415 <a href="/quiet" tabindex="-1">
356-<span class="k">repo</span>quiet</a>
416+<span class="lbl">quiet</span>
417+<span class="k">repo</span>
418+</a>
357419 </li>
358420 </ul>
421+<p class="panel-foot">
422+<span>
423+<kbd>↑</kbd>
424+<kbd>↓</kbd> move</span>
425+<span>
426+<kbd>enter</kbd> open</span>
427+<span>
428+<kbd>esc</kbd> close</span>
429+</p>
359430 </dialog>
360-<footer class="bar status" aria-label="Status">
361-<span class="mode">summary</span>
362-<span class="st-ctx">2 recent commits</span>
363-<button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
364-<span class="hi">:</span> go to…</button>
365-<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
366-</span>
367-<span class="st-hints">j/k move · enter open · b ref · y permalink · ? help</span>
368-<span class="st-src">
369-<span class="st-ver">ermite devel · </span>
370-<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
371-</span>
372-<span class="st-live" data-state="offline" role="status" hx-sse:connect="/-/events?topic=repo%3Ademo&amp;view=summary" hx-swap="none" :text="data.state" :title="data.state === 'live' ? 'receiving updates over SSE' : 'not connected to the event stream'" hx-on="htmx:sse:after:connection -> data.state = 'live';
373- htmx:sse:close, htmx:sse:error -> data.state = 'offline';
374- home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
375- gap -> ermiteLive.reload()">offline</span>
376-<span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/2</span>
377-</footer>
378-<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
431+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
379432 if (q('dialog[open]').count) return;
380433 let rows = q('[data-rows] > :not([hidden])').arr();
381434 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
382435 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
383- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
384- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
436+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
437+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
438+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
439+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
385440 })(1);
386- k-k from:body -> ((d) => {
441+ k-k from:body -> (async (d) => {
387442 if (q('dialog[open]').count) return;
388443 let rows = q('[data-rows] > :not([hidden])').arr();
389444 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
390445 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
391- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
392- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
446+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
447+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
448+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
449+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
393450 })(-1);
394451 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
395452 k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
396453 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
397454 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
398- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
399- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus());
400- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
455+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
456+ k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
457+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
401458 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
402- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
403- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
404- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
405- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
406- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
407- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
408- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
409- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
410- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
411- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
459+ k-gh from:body -> document.querySelector('.brand')?.click();
460+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
461+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
462+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
463+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
464+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
465+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
466+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
467+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
468+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
412469 k-next from:body -> {
413470 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
414471 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/tree-nested.html +162 -110
@@ -6,6 +6,7 @@
66 <meta name="color-scheme" content="light dark">
77 <title>src/lib · files · example.test</title>
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9+<link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
910 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
1011 <script src="/-/static/js/htmx.min.HASH.js" defer>
1112 </script>
@@ -16,12 +17,15 @@
1617 <script src="/-/static/js/ermite.HASH.js" defer>
1718 </script>
1819 </head>
19-<body data-ref="main" data-sha="SHA40" data-sel="">
20+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
2021 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
23-<span class="fade" aria-hidden="true">
22+<header class="hdr">
23+<div class="hdr-top">
24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
2426 </span>
27+<span>example.test</span>
28+</a>
2529 <nav class="crumbs" aria-label="Path">
2630 <ol>
2731 <li>
@@ -41,33 +45,52 @@
4145 </li>
4246 </ol>
4347 </nav>
44-<nav class="secs" aria-label="Sections">
48+<div class="hdr-acts">
49+<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
50+<span class="ico i-branch" aria-hidden="true">
51+</span>
52+<span class="ref-name">main</span>
53+<span class="sha">SHA7</span>
54+</button>
55+<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
56+<span class="ico i-search" aria-hidden="true">
57+</span>
58+<span class="search-label">Go to…</span>
59+<kbd>ctrl k</kbd>
60+</button>
61+<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
62+</div>
63+</div>
64+<nav class="tabs" aria-label="Sections">
4565 <ul>
4666 <li>
47-<a href="/files" data-key="s" aria-keyshortcuts="g s">summary</a>
67+<a href="/files" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
68+</a>
4869 </li>
4970 <li>
50-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="g t" aria-current="page">tree</a>
71+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files<kbd aria-hidden="true">t</kbd>
72+</a>
5173 </li>
5274 <li>
53-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a>
75+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits<kbd aria-hidden="true">c</kbd>
76+</a>
5477 </li>
5578 <li>
56-<a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
79+<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
80+</a>
5781 </li>
5882 <li>
59-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
83+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
84+</a>
6085 </li>
6186 </ul>
6287 </nav>
63-<button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
64-</button>
6588 </header>
6689 <div class="work">
67-<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
90+<main id="main" tabindex="-1">
6891 <div class="filter">
69-<label for="tree-filter">filter&gt;</label>
70-<input id="tree-filter" type="search" data-filter placeholder="this directory ( / )" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')">
92+<label for="tree-filter" class="sr">filter</label>
93+<input id="tree-filter" type="search" data-filter placeholder="Filter this directory…" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')">
7194 </div>
7295 <table class="list tree" aria-label="Entries">
7396 <thead>
@@ -104,70 +127,71 @@
104127 </table>
105128 </main>
106129 </div>
107-<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
130+<footer class="foot">
131+<span>ermite devel</span>
132+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
133+<span class="foot-keys">
134+<kbd>?</kbd> keyboard shortcuts</span>
135+</footer>
136+<div class="hint" role="status" aria-live="polite">
137+<span class="hint-nav" :text="document.body.dataset.nav || ''">
138+</span>
139+<span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''">
140+</span>
141+<span class="hint-flash" :text="document.body.dataset.flash || ''">
142+</span>
143+</div>
144+<dialog id="panel-help" class="panel modal" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
108145 <div class="panel-head">
109-<span>keys</span>
110-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
146+<h2>Keyboard shortcuts</h2>
147+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
148+<kbd>esc</kbd> close</button>
111149 </div>
112150 <dl class="keys">
113151 <div>
114152 <dt>
115-<kbd>j</kbd>
116-<kbd>k</kbd>
153+<kbd>ctrl k</kbd>
154+<kbd>:</kbd>
117155 </dt>
118-<dd>move cursor down / up</dd>
156+<dd>go to a section, branch, tag, file or repository</dd>
119157 </div>
120158 <div>
121159 <dt>
122-<kbd>enter</kbd>
160+<kbd>?</kbd>
123161 </dt>
124-<dd>open row under cursor</dd>
162+<dd>this list</dd>
125163 </div>
126164 <div>
127165 <dt>
128-<kbd>g</kbd>
129-<kbd>g</kbd> / <kbd>G</kbd>
166+<kbd>j</kbd>
167+<kbd>k</kbd>
130168 </dt>
131-<dd>first / last row</dd>
169+<dd>move the row cursor down / up</dd>
132170 </div>
133171 <div>
134172 <dt>
135-<kbd>/</kbd>
173+<kbd>enter</kbd>
136174 </dt>
137-<dd>focus the filter</dd>
175+<dd>open the row under the cursor</dd>
138176 </div>
139177 <div>
140178 <dt>
141-<kbd>g h</kbd>
142-<kbd>g t</kbd>
179+<kbd>g g</kbd>
180+<kbd>G</kbd>
143181 </dt>
144-<dd>go home / tree</dd>
182+<dd>first / last row</dd>
145183 </div>
146184 <div>
147185 <dt>
148-<kbd>g c</kbd>
149-<kbd>g r</kbd>
186+<kbd>/</kbd>
150187 </dt>
151-<dd>go commits / refs</dd>
188+<dd>filter the list</dd>
152189 </div>
153190 <div>
154191 <dt>
155192 <kbd>b</kbd>
156193 </dt>
157-<dd>switch ref (branches, tags)</dd>
158-</div>
159-<div>
160-<dt>
161-<kbd>:</kbd>
162-<kbd>f</kbd>
163-</dt>
164-<dd>go to: section, ref, file, repo</dd>
165-</div>
166-<div>
167-<dt>
168-<kbd>y</kbd>
169-</dt>
170-<dd>permalink: ref becomes the full sha</dd>
194+<dd>switch branch or tag</dd>
171195 </div>
172196 <div>
173197 <dt>
@@ -175,15 +199,21 @@
175199 <kbd>t</kbd>
176200 <kbd>c</kbd>
177201 <kbd>r</kbd>
202+<kbd>m</kbd>
178203 </dt>
179-<dd>repo sections (when shown)</dd>
204+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
180205 </div>
181206 <div>
182207 <dt>
183-<kbd>m</kbd>
184-<kbd>g m</kbd>
208+<kbd>g h</kbd>
185209 </dt>
186-<dd>merge requests (when shown)</dd>
210+<dd>home</dd>
211+</div>
212+<div>
213+<dt>
214+<kbd>y</kbd>
215+</dt>
216+<dd>copy a permalink pinned to the commit</dd>
187217 </div>
188218 <div>
189219 <dt>
@@ -203,19 +233,21 @@
203233 <dt>
204234 <kbd>esc</kbd>
205235 </dt>
206-<dd>close panel</dd>
236+<dd>close a dialog, leave a field</dd>
207237 </div>
208238 </dl>
209239 </dialog>
210-<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
240+<dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
211241 <div class="panel-head">
212-<label for="refs-filter">ref&gt;</label>
213-<input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
214-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
242+<label for="refs-filter" class="sr">filter branches and tags</label>
243+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
244+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
245+<kbd>esc</kbd>
246+</button>
215247 </div>
216248 <div class="panel-cols" data-picklist>
217249 <section aria-label="Branches">
218-<h2>branches <span class="mute">2</span>
250+<h2>Branches <span class="count">2</span>
219251 </h2>
220252 <ul>
221253 <li>
@@ -229,7 +261,7 @@
229261 </ul>
230262 </section>
231263 <section aria-label="Tags">
232-<h2>tags <span class="mute">1</span>
264+<h2>Tags <span class="count">1</span>
233265 </h2>
234266 <ul>
235267 <li>
@@ -240,110 +272,130 @@
240272 </section>
241273 </div>
242274 </dialog>
243-<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
275+<dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
244276 <div class="panel-head">
245-<label for="cmd-q" class="hi">:</label>
246-<input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
277+<label for="cmd-q" class="sr">go to</label>
278+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
247279 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
248280 let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
249281 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
250282 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
251283 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
252284 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
253-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
285+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
286+<kbd>esc</kbd>
287+</button>
254288 </div>
255-<ul id="cmd-list" class="cmd-list">
289+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
256290 <li data-k="go home" data-cursor>
257291 <a href="/" tabindex="-1">
258-<span class="k">go</span>home</a>
292+<span class="lbl">home</span>
293+<span class="k">go</span>
294+</a>
259295 </li>
260296 <li data-k="section summary">
261297 <a href="/files" tabindex="-1">
262-<span class="k">section</span>summary</a>
298+<span class="lbl">summary</span>
299+<span class="k">section</span>
300+</a>
263301 </li>
264-<li data-k="section tree">
302+<li data-k="section files">
265303 <a href="/files/tree/main" tabindex="-1">
266-<span class="k">section</span>tree</a>
304+<span class="lbl">files</span>
305+<span class="k">section</span>
306+</a>
267307 </li>
268308 <li data-k="section commits">
269309 <a href="/files/commits/main" tabindex="-1">
270-<span class="k">section</span>commits</a>
310+<span class="lbl">commits</span>
311+<span class="k">section</span>
312+</a>
271313 </li>
272-<li data-k="section refs">
314+<li data-k="section branches &amp; tags">
273315 <a href="/files/refs" tabindex="-1">
274-<span class="k">section</span>refs</a>
316+<span class="lbl">branches &amp; tags</span>
317+<span class="k">section</span>
318+</a>
275319 </li>
276320 <li data-k="section merge requests">
277321 <a href="/files/merge-requests" tabindex="-1">
278-<span class="k">section</span>merge requests</a>
322+<span class="lbl">merge requests</span>
323+<span class="k">section</span>
324+</a>
279325 </li>
280326 <li data-k="branch feature/x">
281327 <a href="/files/tree/feature/x" tabindex="-1">
282-<span class="k">branch</span>feature/x</a>
328+<span class="lbl">feature/x</span>
329+<span class="k">branch</span>
330+</a>
283331 </li>
284332 <li data-k="branch main">
285333 <a href="/files/tree/main" tabindex="-1">
286-<span class="k">branch</span>main</a>
334+<span class="lbl">main</span>
335+<span class="k">branch</span>
336+</a>
287337 </li>
288338 <li data-k="tag v1.0">
289339 <a href="/files/tree/v1.0" tabindex="-1">
290-<span class="k">tag</span>v1.0</a>
340+<span class="lbl">v1.0</span>
341+<span class="k">tag</span>
342+</a>
291343 </li>
292344 <li data-k="file src/lib/util.go">
293345 <a href="/files/blob/main/src/lib/util.go" tabindex="-1">
294-<span class="k">file</span>src/lib/util.go</a>
346+<span class="lbl">src/lib/util.go</span>
347+<span class="k">file</span>
348+</a>
295349 </li>
296350 </ul>
351+<p class="panel-foot">
352+<span>
353+<kbd>↑</kbd>
354+<kbd>↓</kbd> move</span>
355+<span>
356+<kbd>enter</kbd> open</span>
357+<span>
358+<kbd>esc</kbd> close</span>
359+</p>
297360 </dialog>
298-<footer class="bar status" aria-label="Status">
299-<span class="mode">tree</span>
300-<span class="st-ctx">tree · 1 entry</span>
301-<button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
302-<span class="hi">:</span> go to…</button>
303-<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
304-</span>
305-<span class="st-hints">j/k move · enter open · / filter · b ref · ? help</span>
306-<span class="st-src">
307-<span class="st-ver">ermite devel · </span>
308-<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
309-</span>
310-<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
311-<span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/1</span>
312-</footer>
313-<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
361+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
314362 if (q('dialog[open]').count) return;
315363 let rows = q('[data-rows] > :not([hidden])').arr();
316364 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
317365 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
318- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
319- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
366+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
367+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
368+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
369+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
320370 })(1);
321- k-k from:body -> ((d) => {
371+ k-k from:body -> (async (d) => {
322372 if (q('dialog[open]').count) return;
323373 let rows = q('[data-rows] > :not([hidden])').arr();
324374 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
325375 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
326- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
327- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
376+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
377+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
378+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
379+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
328380 })(-1);
329381 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
330382 k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
331383 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
332384 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
333- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
334- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus());
335- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
385+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
386+ k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
387+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
336388 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
337- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
338- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
339- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
340- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
341- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
342- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
343- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
344- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
345- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
346- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
389+ k-gh from:body -> document.querySelector('.brand')?.click();
390+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
391+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
392+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
393+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
394+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
395+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
396+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
397+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
398+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
347399 k-next from:body -> {
348400 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
349401 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/tree-root.html +203 -125
@@ -6,6 +6,7 @@
66 <meta name="color-scheme" content="light dark">
77 <title>tree · files · example.test</title>
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9+<link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
910 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
1011 <script src="/-/static/js/htmx.min.HASH.js" defer>
1112 </script>
@@ -16,12 +17,15 @@
1617 <script src="/-/static/js/ermite.HASH.js" defer>
1718 </script>
1819 </head>
19-<body data-ref="main" data-sha="SHA40" data-sel="">
20+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
2021 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
23-<span class="fade" aria-hidden="true">
22+<header class="hdr">
23+<div class="hdr-top">
24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
2426 </span>
27+<span>example.test</span>
28+</a>
2529 <nav class="crumbs" aria-label="Path">
2630 <ol>
2731 <li>
@@ -35,33 +39,52 @@
3539 </li>
3640 </ol>
3741 </nav>
38-<nav class="secs" aria-label="Sections">
42+<div class="hdr-acts">
43+<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
44+<span class="ico i-branch" aria-hidden="true">
45+</span>
46+<span class="ref-name">main</span>
47+<span class="sha">SHA7</span>
48+</button>
49+<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
50+<span class="ico i-search" aria-hidden="true">
51+</span>
52+<span class="search-label">Go to…</span>
53+<kbd>ctrl k</kbd>
54+</button>
55+<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
56+</div>
57+</div>
58+<nav class="tabs" aria-label="Sections">
3959 <ul>
4060 <li>
41-<a href="/files" data-key="s" aria-keyshortcuts="g s">summary</a>
61+<a href="/files" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
62+</a>
4263 </li>
4364 <li>
44-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="g t" aria-current="page">tree</a>
65+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files<kbd aria-hidden="true">t</kbd>
66+</a>
4567 </li>
4668 <li>
47-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a>
69+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits<kbd aria-hidden="true">c</kbd>
70+</a>
4871 </li>
4972 <li>
50-<a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
73+<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
74+</a>
5175 </li>
5276 <li>
53-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
77+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
78+</a>
5479 </li>
5580 </ul>
5681 </nav>
57-<button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
58-</button>
5982 </header>
6083 <div class="work">
61-<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
84+<main id="main" tabindex="-1">
6285 <div class="filter">
63-<label for="tree-filter">filter&gt;</label>
64-<input id="tree-filter" type="search" data-filter placeholder="this directory ( / )" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')">
86+<label for="tree-filter" class="sr">filter</label>
87+<input id="tree-filter" type="search" data-filter placeholder="Filter this directory…" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')">
6588 </div>
6689 <table class="list tree" aria-label="Entries">
6790 <thead>
@@ -320,79 +343,82 @@
320343 </tr>
321344 </tbody>
322345 </table>
323-<h2 class="rule-h">
324-<span>README.md</span>
325-</h2>
346+<section class="card readme" aria-labelledby="readme-h">
347+<h2 class="card-h" id="readme-h">
348+<span class="ico i-file" aria-hidden="true">
349+</span> README.md</h2>
326350 <article class="prose" aria-label="README.md">
327351 <h1 id="md-files">Files</h1>
328352 <p>See <a href="/files/blob/main/docs/guide.md">the guide</a>.</p>
329353 </article>
354+</section>
330355 </main>
331356 </div>
332-<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
357+<footer class="foot">
358+<span>ermite devel</span>
359+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
360+<span class="foot-keys">
361+<kbd>?</kbd> keyboard shortcuts</span>
362+</footer>
363+<div class="hint" role="status" aria-live="polite">
364+<span class="hint-nav" :text="document.body.dataset.nav || ''">
365+</span>
366+<span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''">
367+</span>
368+<span class="hint-flash" :text="document.body.dataset.flash || ''">
369+</span>
370+</div>
371+<dialog id="panel-help" class="panel modal" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
333372 <div class="panel-head">
334-<span>keys</span>
335-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
373+<h2>Keyboard shortcuts</h2>
374+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
375+<kbd>esc</kbd> close</button>
336376 </div>
337377 <dl class="keys">
338378 <div>
339379 <dt>
340-<kbd>j</kbd>
341-<kbd>k</kbd>
380+<kbd>ctrl k</kbd>
381+<kbd>:</kbd>
342382 </dt>
343-<dd>move cursor down / up</dd>
383+<dd>go to a section, branch, tag, file or repository</dd>
344384 </div>
345385 <div>
346386 <dt>
347-<kbd>enter</kbd>
387+<kbd>?</kbd>
348388 </dt>
349-<dd>open row under cursor</dd>
389+<dd>this list</dd>
350390 </div>
351391 <div>
352392 <dt>
353-<kbd>g</kbd>
354-<kbd>g</kbd> / <kbd>G</kbd>
393+<kbd>j</kbd>
394+<kbd>k</kbd>
355395 </dt>
356-<dd>first / last row</dd>
396+<dd>move the row cursor down / up</dd>
357397 </div>
358398 <div>
359399 <dt>
360-<kbd>/</kbd>
400+<kbd>enter</kbd>
361401 </dt>
362-<dd>focus the filter</dd>
402+<dd>open the row under the cursor</dd>
363403 </div>
364404 <div>
365405 <dt>
366-<kbd>g h</kbd>
367-<kbd>g t</kbd>
406+<kbd>g g</kbd>
407+<kbd>G</kbd>
368408 </dt>
369-<dd>go home / tree</dd>
409+<dd>first / last row</dd>
370410 </div>
371411 <div>
372412 <dt>
373-<kbd>g c</kbd>
374-<kbd>g r</kbd>
413+<kbd>/</kbd>
375414 </dt>
376-<dd>go commits / refs</dd>
415+<dd>filter the list</dd>
377416 </div>
378417 <div>
379418 <dt>
380419 <kbd>b</kbd>
381420 </dt>
382-<dd>switch ref (branches, tags)</dd>
383-</div>
384-<div>
385-<dt>
386-<kbd>:</kbd>
387-<kbd>f</kbd>
388-</dt>
389-<dd>go to: section, ref, file, repo</dd>
390-</div>
391-<div>
392-<dt>
393-<kbd>y</kbd>
394-</dt>
395-<dd>permalink: ref becomes the full sha</dd>
421+<dd>switch branch or tag</dd>
396422 </div>
397423 <div>
398424 <dt>
@@ -400,15 +426,21 @@
400426 <kbd>t</kbd>
401427 <kbd>c</kbd>
402428 <kbd>r</kbd>
429+<kbd>m</kbd>
403430 </dt>
404-<dd>repo sections (when shown)</dd>
431+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
405432 </div>
406433 <div>
407434 <dt>
408-<kbd>m</kbd>
409-<kbd>g m</kbd>
435+<kbd>g h</kbd>
410436 </dt>
411-<dd>merge requests (when shown)</dd>
437+<dd>home</dd>
438+</div>
439+<div>
440+<dt>
441+<kbd>y</kbd>
442+</dt>
443+<dd>copy a permalink pinned to the commit</dd>
412444 </div>
413445 <div>
414446 <dt>
@@ -428,19 +460,21 @@
428460 <dt>
429461 <kbd>esc</kbd>
430462 </dt>
431-<dd>close panel</dd>
463+<dd>close a dialog, leave a field</dd>
432464 </div>
433465 </dl>
434466 </dialog>
435-<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
467+<dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
436468 <div class="panel-head">
437-<label for="refs-filter">ref&gt;</label>
438-<input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
439-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
469+<label for="refs-filter" class="sr">filter branches and tags</label>
470+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
471+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
472+<kbd>esc</kbd>
473+</button>
440474 </div>
441475 <div class="panel-cols" data-picklist>
442476 <section aria-label="Branches">
443-<h2>branches <span class="mute">2</span>
477+<h2>Branches <span class="count">2</span>
444478 </h2>
445479 <ul>
446480 <li>
@@ -454,7 +488,7 @@
454488 </ul>
455489 </section>
456490 <section aria-label="Tags">
457-<h2>tags <span class="mute">1</span>
491+<h2>Tags <span class="count">1</span>
458492 </h2>
459493 <ul>
460494 <li>
@@ -465,158 +499,202 @@
465499 </section>
466500 </div>
467501 </dialog>
468-<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
502+<dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
469503 <div class="panel-head">
470-<label for="cmd-q" class="hi">:</label>
471-<input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
504+<label for="cmd-q" class="sr">go to</label>
505+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
472506 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
473507 let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
474508 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
475509 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
476510 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
477511 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
478-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
512+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
513+<kbd>esc</kbd>
514+</button>
479515 </div>
480-<ul id="cmd-list" class="cmd-list">
516+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
481517 <li data-k="go home" data-cursor>
482518 <a href="/" tabindex="-1">
483-<span class="k">go</span>home</a>
519+<span class="lbl">home</span>
520+<span class="k">go</span>
521+</a>
484522 </li>
485523 <li data-k="section summary">
486524 <a href="/files" tabindex="-1">
487-<span class="k">section</span>summary</a>
525+<span class="lbl">summary</span>
526+<span class="k">section</span>
527+</a>
488528 </li>
489-<li data-k="section tree">
529+<li data-k="section files">
490530 <a href="/files/tree/main" tabindex="-1">
491-<span class="k">section</span>tree</a>
531+<span class="lbl">files</span>
532+<span class="k">section</span>
533+</a>
492534 </li>
493535 <li data-k="section commits">
494536 <a href="/files/commits/main" tabindex="-1">
495-<span class="k">section</span>commits</a>
537+<span class="lbl">commits</span>
538+<span class="k">section</span>
539+</a>
496540 </li>
497-<li data-k="section refs">
541+<li data-k="section branches &amp; tags">
498542 <a href="/files/refs" tabindex="-1">
499-<span class="k">section</span>refs</a>
543+<span class="lbl">branches &amp; tags</span>
544+<span class="k">section</span>
545+</a>
500546 </li>
501547 <li data-k="section merge requests">
502548 <a href="/files/merge-requests" tabindex="-1">
503-<span class="k">section</span>merge requests</a>
549+<span class="lbl">merge requests</span>
550+<span class="k">section</span>
551+</a>
504552 </li>
505553 <li data-k="branch feature/x">
506554 <a href="/files/tree/feature/x" tabindex="-1">
507-<span class="k">branch</span>feature/x</a>
555+<span class="lbl">feature/x</span>
556+<span class="k">branch</span>
557+</a>
508558 </li>
509559 <li data-k="branch main">
510560 <a href="/files/tree/main" tabindex="-1">
511-<span class="k">branch</span>main</a>
561+<span class="lbl">main</span>
562+<span class="k">branch</span>
563+</a>
512564 </li>
513565 <li data-k="tag v1.0">
514566 <a href="/files/tree/v1.0" tabindex="-1">
515-<span class="k">tag</span>v1.0</a>
567+<span class="lbl">v1.0</span>
568+<span class="k">tag</span>
569+</a>
516570 </li>
517571 <li data-k="file readme.md">
518572 <a href="/files/blob/main/README.md" tabindex="-1">
519-<span class="k">file</span>README.md</a>
573+<span class="lbl">README.md</span>
574+<span class="k">file</span>
575+</a>
520576 </li>
521577 <li data-k="file big.txt">
522578 <a href="/files/blob/main/big.txt" tabindex="-1">
523-<span class="k">file</span>big.txt</a>
579+<span class="lbl">big.txt</span>
580+<span class="k">file</span>
581+</a>
524582 </li>
525583 <li data-k="file data.bin">
526584 <a href="/files/blob/main/data.bin" tabindex="-1">
527-<span class="k">file</span>data.bin</a>
585+<span class="lbl">data.bin</span>
586+<span class="k">file</span>
587+</a>
528588 </li>
529589 <li data-k="file empty.txt">
530590 <a href="/files/blob/main/empty.txt" tabindex="-1">
531-<span class="k">file</span>empty.txt</a>
591+<span class="lbl">empty.txt</span>
592+<span class="k">file</span>
593+</a>
532594 </li>
533595 <li data-k="file evil.go">
534596 <a href="/files/blob/main/evil.go" tabindex="-1">
535-<span class="k">file</span>evil.go</a>
597+<span class="lbl">evil.go</span>
598+<span class="k">file</span>
599+</a>
536600 </li>
537601 <li data-k="file fake.jpg">
538602 <a href="/files/blob/main/fake.jpg" tabindex="-1">
539-<span class="k">file</span>fake.jpg</a>
603+<span class="lbl">fake.jpg</span>
604+<span class="k">file</span>
605+</a>
540606 </li>
541607 <li data-k="file fake.png">
542608 <a href="/files/blob/main/fake.png" tabindex="-1">
543-<span class="k">file</span>fake.png</a>
609+<span class="lbl">fake.png</span>
610+<span class="k">file</span>
611+</a>
544612 </li>
545613 <li data-k="file lines.txt">
546614 <a href="/files/blob/main/lines.txt" tabindex="-1">
547-<span class="k">file</span>lines.txt</a>
615+<span class="lbl">lines.txt</span>
616+<span class="k">file</span>
617+</a>
548618 </li>
549619 <li data-k="file logo.svg">
550620 <a href="/files/blob/main/logo.svg" tabindex="-1">
551-<span class="k">file</span>logo.svg</a>
621+<span class="lbl">logo.svg</span>
622+<span class="k">file</span>
623+</a>
552624 </li>
553625 <li data-k="file main.go">
554626 <a href="/files/blob/main/main.go" tabindex="-1">
555-<span class="k">file</span>main.go</a>
627+<span class="lbl">main.go</span>
628+<span class="k">file</span>
629+</a>
556630 </li>
557631 <li data-k="file notes.txt">
558632 <a href="/files/blob/main/notes.txt" tabindex="-1">
559-<span class="k">file</span>notes.txt</a>
633+<span class="lbl">notes.txt</span>
634+<span class="k">file</span>
635+</a>
560636 </li>
561637 <li data-k="file page.html">
562638 <a href="/files/blob/main/page.html" tabindex="-1">
563-<span class="k">file</span>page.html</a>
639+<span class="lbl">page.html</span>
640+<span class="k">file</span>
641+</a>
564642 </li>
565643 <li data-k="file pic.png">
566644 <a href="/files/blob/main/pic.png" tabindex="-1">
567-<span class="k">file</span>pic.png</a>
645+<span class="lbl">pic.png</span>
646+<span class="k">file</span>
647+</a>
568648 </li>
569649 </ul>
650+<p class="panel-foot">
651+<span>
652+<kbd>↑</kbd>
653+<kbd>↓</kbd> move</span>
654+<span>
655+<kbd>enter</kbd> open</span>
656+<span>
657+<kbd>esc</kbd> close</span>
658+</p>
570659 </dialog>
571-<footer class="bar status" aria-label="Status">
572-<span class="mode">tree</span>
573-<span class="st-ctx">tree · 15 entries</span>
574-<button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
575-<span class="hi">:</span> go to…</button>
576-<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
577-</span>
578-<span class="st-hints">j/k move · enter open · / filter · b ref · ? help</span>
579-<span class="st-src">
580-<span class="st-ver">ermite devel · </span>
581-<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
582-</span>
583-<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
584-<span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/15</span>
585-</footer>
586-<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
660+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
587661 if (q('dialog[open]').count) return;
588662 let rows = q('[data-rows] > :not([hidden])').arr();
589663 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
590664 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
591- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
592- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
665+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
666+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
667+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
668+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
593669 })(1);
594- k-k from:body -> ((d) => {
670+ k-k from:body -> (async (d) => {
595671 if (q('dialog[open]').count) return;
596672 let rows = q('[data-rows] > :not([hidden])').arr();
597673 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
598674 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
599- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
600- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
675+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
676+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
677+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
678+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
601679 })(-1);
602680 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
603681 k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
604682 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
605683 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
606- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
607- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus());
608- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
684+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
685+ k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
686+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
609687 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
610- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
611- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
612- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
613- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
614- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
615- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
616- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
617- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
618- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
619- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
688+ k-gh from:body -> document.querySelector('.brand')?.click();
689+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
690+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
691+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
692+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
693+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
694+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
695+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
696+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
697+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
620698 k-next from:body -> {
621699 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
622700 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/tree-slash-ref.html +162 -110
@@ -6,6 +6,7 @@
66 <meta name="color-scheme" content="light dark">
77 <title>x · files · example.test</title>
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9+<link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
910 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
1011 <script src="/-/static/js/htmx.min.HASH.js" defer>
1112 </script>
@@ -16,12 +17,15 @@
1617 <script src="/-/static/js/ermite.HASH.js" defer>
1718 </script>
1819 </head>
19-<body data-ref="feature/x" data-sha="SHA40" data-sel="">
20+<body data-ref="feature/x" data-sha="SHA40" data-sel="" data-nav="">
2021 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
23-<span class="fade" aria-hidden="true">
22+<header class="hdr">
23+<div class="hdr-top">
24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
2426 </span>
27+<span>example.test</span>
28+</a>
2529 <nav class="crumbs" aria-label="Path">
2630 <ol>
2731 <li>
@@ -38,33 +42,52 @@
3842 </li>
3943 </ol>
4044 </nav>
41-<nav class="secs" aria-label="Sections">
45+<div class="hdr-acts">
46+<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
47+<span class="ico i-branch" aria-hidden="true">
48+</span>
49+<span class="ref-name">feature/x</span>
50+<span class="sha">SHA7</span>
51+</button>
52+<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
53+<span class="ico i-search" aria-hidden="true">
54+</span>
55+<span class="search-label">Go to…</span>
56+<kbd>ctrl k</kbd>
57+</button>
58+<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
59+</div>
60+</div>
61+<nav class="tabs" aria-label="Sections">
4262 <ul>
4363 <li>
44-<a href="/files" data-key="s" aria-keyshortcuts="g s">summary</a>
64+<a href="/files" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
65+</a>
4566 </li>
4667 <li>
47-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="g t" aria-current="page">tree</a>
68+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files<kbd aria-hidden="true">t</kbd>
69+</a>
4870 </li>
4971 <li>
50-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a>
72+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits<kbd aria-hidden="true">c</kbd>
73+</a>
5174 </li>
5275 <li>
53-<a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
76+<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
77+</a>
5478 </li>
5579 <li>
56-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
80+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
81+</a>
5782 </li>
5883 </ul>
5984 </nav>
60-<button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">feature/x<span class="sha">@SHA7</span>
61-</button>
6285 </header>
6386 <div class="work">
64-<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
87+<main id="main" tabindex="-1">
6588 <div class="filter">
66-<label for="tree-filter">filter&gt;</label>
67-<input id="tree-filter" type="search" data-filter placeholder="this directory ( / )" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')">
89+<label for="tree-filter" class="sr">filter</label>
90+<input id="tree-filter" type="search" data-filter placeholder="Filter this directory…" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')">
6891 </div>
6992 <table class="list tree" aria-label="Entries">
7093 <thead>
@@ -101,70 +124,71 @@
101124 </table>
102125 </main>
103126 </div>
104-<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
127+<footer class="foot">
128+<span>ermite devel</span>
129+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
130+<span class="foot-keys">
131+<kbd>?</kbd> keyboard shortcuts</span>
132+</footer>
133+<div class="hint" role="status" aria-live="polite">
134+<span class="hint-nav" :text="document.body.dataset.nav || ''">
135+</span>
136+<span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''">
137+</span>
138+<span class="hint-flash" :text="document.body.dataset.flash || ''">
139+</span>
140+</div>
141+<dialog id="panel-help" class="panel modal" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
105142 <div class="panel-head">
106-<span>keys</span>
107-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
143+<h2>Keyboard shortcuts</h2>
144+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
145+<kbd>esc</kbd> close</button>
108146 </div>
109147 <dl class="keys">
110148 <div>
111149 <dt>
112-<kbd>j</kbd>
113-<kbd>k</kbd>
150+<kbd>ctrl k</kbd>
151+<kbd>:</kbd>
114152 </dt>
115-<dd>move cursor down / up</dd>
153+<dd>go to a section, branch, tag, file or repository</dd>
116154 </div>
117155 <div>
118156 <dt>
119-<kbd>enter</kbd>
157+<kbd>?</kbd>
120158 </dt>
121-<dd>open row under cursor</dd>
159+<dd>this list</dd>
122160 </div>
123161 <div>
124162 <dt>
125-<kbd>g</kbd>
126-<kbd>g</kbd> / <kbd>G</kbd>
163+<kbd>j</kbd>
164+<kbd>k</kbd>
127165 </dt>
128-<dd>first / last row</dd>
166+<dd>move the row cursor down / up</dd>
129167 </div>
130168 <div>
131169 <dt>
132-<kbd>/</kbd>
170+<kbd>enter</kbd>
133171 </dt>
134-<dd>focus the filter</dd>
172+<dd>open the row under the cursor</dd>
135173 </div>
136174 <div>
137175 <dt>
138-<kbd>g h</kbd>
139-<kbd>g t</kbd>
176+<kbd>g g</kbd>
177+<kbd>G</kbd>
140178 </dt>
141-<dd>go home / tree</dd>
179+<dd>first / last row</dd>
142180 </div>
143181 <div>
144182 <dt>
145-<kbd>g c</kbd>
146-<kbd>g r</kbd>
183+<kbd>/</kbd>
147184 </dt>
148-<dd>go commits / refs</dd>
185+<dd>filter the list</dd>
149186 </div>
150187 <div>
151188 <dt>
152189 <kbd>b</kbd>
153190 </dt>
154-<dd>switch ref (branches, tags)</dd>
155-</div>
156-<div>
157-<dt>
158-<kbd>:</kbd>
159-<kbd>f</kbd>
160-</dt>
161-<dd>go to: section, ref, file, repo</dd>
162-</div>
163-<div>
164-<dt>
165-<kbd>y</kbd>
166-</dt>
167-<dd>permalink: ref becomes the full sha</dd>
191+<dd>switch branch or tag</dd>
168192 </div>
169193 <div>
170194 <dt>
@@ -172,15 +196,21 @@
172196 <kbd>t</kbd>
173197 <kbd>c</kbd>
174198 <kbd>r</kbd>
199+<kbd>m</kbd>
175200 </dt>
176-<dd>repo sections (when shown)</dd>
201+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
177202 </div>
178203 <div>
179204 <dt>
180-<kbd>m</kbd>
181-<kbd>g m</kbd>
205+<kbd>g h</kbd>
182206 </dt>
183-<dd>merge requests (when shown)</dd>
207+<dd>home</dd>
208+</div>
209+<div>
210+<dt>
211+<kbd>y</kbd>
212+</dt>
213+<dd>copy a permalink pinned to the commit</dd>
184214 </div>
185215 <div>
186216 <dt>
@@ -200,19 +230,21 @@
200230 <dt>
201231 <kbd>esc</kbd>
202232 </dt>
203-<dd>close panel</dd>
233+<dd>close a dialog, leave a field</dd>
204234 </div>
205235 </dl>
206236 </dialog>
207-<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
237+<dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
208238 <div class="panel-head">
209-<label for="refs-filter">ref&gt;</label>
210-<input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
211-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
239+<label for="refs-filter" class="sr">filter branches and tags</label>
240+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
241+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
242+<kbd>esc</kbd>
243+</button>
212244 </div>
213245 <div class="panel-cols" data-picklist>
214246 <section aria-label="Branches">
215-<h2>branches <span class="mute">2</span>
247+<h2>Branches <span class="count">2</span>
216248 </h2>
217249 <ul>
218250 <li>
@@ -226,7 +258,7 @@
226258 </ul>
227259 </section>
228260 <section aria-label="Tags">
229-<h2>tags <span class="mute">1</span>
261+<h2>Tags <span class="count">1</span>
230262 </h2>
231263 <ul>
232264 <li>
@@ -237,110 +269,130 @@
237269 </section>
238270 </div>
239271 </dialog>
240-<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
272+<dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
241273 <div class="panel-head">
242-<label for="cmd-q" class="hi">:</label>
243-<input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
274+<label for="cmd-q" class="sr">go to</label>
275+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
244276 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
245277 let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
246278 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
247279 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
248280 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
249281 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
250-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
282+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
283+<kbd>esc</kbd>
284+</button>
251285 </div>
252-<ul id="cmd-list" class="cmd-list">
286+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
253287 <li data-k="go home" data-cursor>
254288 <a href="/" tabindex="-1">
255-<span class="k">go</span>home</a>
289+<span class="lbl">home</span>
290+<span class="k">go</span>
291+</a>
256292 </li>
257293 <li data-k="section summary">
258294 <a href="/files" tabindex="-1">
259-<span class="k">section</span>summary</a>
295+<span class="lbl">summary</span>
296+<span class="k">section</span>
297+</a>
260298 </li>
261-<li data-k="section tree">
299+<li data-k="section files">
262300 <a href="/files/tree/main" tabindex="-1">
263-<span class="k">section</span>tree</a>
301+<span class="lbl">files</span>
302+<span class="k">section</span>
303+</a>
264304 </li>
265305 <li data-k="section commits">
266306 <a href="/files/commits/main" tabindex="-1">
267-<span class="k">section</span>commits</a>
307+<span class="lbl">commits</span>
308+<span class="k">section</span>
309+</a>
268310 </li>
269-<li data-k="section refs">
311+<li data-k="section branches &amp; tags">
270312 <a href="/files/refs" tabindex="-1">
271-<span class="k">section</span>refs</a>
313+<span class="lbl">branches &amp; tags</span>
314+<span class="k">section</span>
315+</a>
272316 </li>
273317 <li data-k="section merge requests">
274318 <a href="/files/merge-requests" tabindex="-1">
275-<span class="k">section</span>merge requests</a>
319+<span class="lbl">merge requests</span>
320+<span class="k">section</span>
321+</a>
276322 </li>
277323 <li data-k="branch feature/x">
278324 <a href="/files/tree/feature/x" tabindex="-1">
279-<span class="k">branch</span>feature/x</a>
325+<span class="lbl">feature/x</span>
326+<span class="k">branch</span>
327+</a>
280328 </li>
281329 <li data-k="branch main">
282330 <a href="/files/tree/main" tabindex="-1">
283-<span class="k">branch</span>main</a>
331+<span class="lbl">main</span>
332+<span class="k">branch</span>
333+</a>
284334 </li>
285335 <li data-k="tag v1.0">
286336 <a href="/files/tree/v1.0" tabindex="-1">
287-<span class="k">tag</span>v1.0</a>
337+<span class="lbl">v1.0</span>
338+<span class="k">tag</span>
339+</a>
288340 </li>
289341 <li data-k="file x/y.txt">
290342 <a href="/files/blob/feature/x/x/y.txt" tabindex="-1">
291-<span class="k">file</span>x/y.txt</a>
343+<span class="lbl">x/y.txt</span>
344+<span class="k">file</span>
345+</a>
292346 </li>
293347 </ul>
348+<p class="panel-foot">
349+<span>
350+<kbd>↑</kbd>
351+<kbd>↓</kbd> move</span>
352+<span>
353+<kbd>enter</kbd> open</span>
354+<span>
355+<kbd>esc</kbd> close</span>
356+</p>
294357 </dialog>
295-<footer class="bar status" aria-label="Status">
296-<span class="mode">tree</span>
297-<span class="st-ctx">tree · 1 entry</span>
298-<button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
299-<span class="hi">:</span> go to…</button>
300-<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
301-</span>
302-<span class="st-hints">j/k move · enter open · / filter · b ref · ? help</span>
303-<span class="st-src">
304-<span class="st-ver">ermite devel · </span>
305-<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
306-</span>
307-<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
308-<span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/1</span>
309-</footer>
310-<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
358+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
311359 if (q('dialog[open]').count) return;
312360 let rows = q('[data-rows] > :not([hidden])').arr();
313361 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
314362 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
315- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
316- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
363+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
364+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
365+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
366+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
317367 })(1);
318- k-k from:body -> ((d) => {
368+ k-k from:body -> (async (d) => {
319369 if (q('dialog[open]').count) return;
320370 let rows = q('[data-rows] > :not([hidden])').arr();
321371 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
322372 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
323- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
324- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
373+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
374+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
375+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
376+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
325377 })(-1);
326378 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
327379 k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
328380 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
329381 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
330- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
331- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus());
332- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
382+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
383+ k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
384+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
333385 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
334- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
335- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
336- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
337- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
338- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
339- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
340- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
341- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
342- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
343- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
386+ k-gh from:body -> document.querySelector('.brand')?.click();
387+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
388+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
389+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
390+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
391+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
392+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
393+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
394+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
395+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
344396 k-next from:body -> {
345397 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
346398 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/tree-subdir.html +170 -114
@@ -6,6 +6,7 @@
66 <meta name="color-scheme" content="light dark">
77 <title>docs · files · example.test</title>
88 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">
9+<link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin>
910 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
1011 <script src="/-/static/js/htmx.min.HASH.js" defer>
1112 </script>
@@ -16,12 +17,15 @@
1617 <script src="/-/static/js/ermite.HASH.js" defer>
1718 </script>
1819 </head>
19-<body data-ref="main" data-sha="SHA40" data-sel="">
20+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
2021 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>
23-<span class="fade" aria-hidden="true">
22+<header class="hdr">
23+<div class="hdr-top">
24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
2426 </span>
27+<span>example.test</span>
28+</a>
2529 <nav class="crumbs" aria-label="Path">
2630 <ol>
2731 <li>
@@ -38,33 +42,52 @@
3842 </li>
3943 </ol>
4044 </nav>
41-<nav class="secs" aria-label="Sections">
45+<div class="hdr-acts">
46+<button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">
47+<span class="ico i-branch" aria-hidden="true">
48+</span>
49+<span class="ref-name">main</span>
50+<span class="sha">SHA7</span>
51+</button>
52+<button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
53+<span class="ico i-search" aria-hidden="true">
54+</span>
55+<span class="search-label">Go to…</span>
56+<kbd>ctrl k</kbd>
57+</button>
58+<button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
59+</div>
60+</div>
61+<nav class="tabs" aria-label="Sections">
4262 <ul>
4363 <li>
44-<a href="/files" data-key="s" aria-keyshortcuts="g s">summary</a>
64+<a href="/files" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
65+</a>
4566 </li>
4667 <li>
47-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="g t" aria-current="page">tree</a>
68+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files<kbd aria-hidden="true">t</kbd>
69+</a>
4870 </li>
4971 <li>
50-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a>
72+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits<kbd aria-hidden="true">c</kbd>
73+</a>
5174 </li>
5275 <li>
53-<a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
76+<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
77+</a>
5478 </li>
5579 <li>
56-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
80+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
81+</a>
5782 </li>
5883 </ul>
5984 </nav>
60-<button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
61-</button>
6285 </header>
6386 <div class="work">
64-<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
87+<main id="main" tabindex="-1">
6588 <div class="filter">
66-<label for="tree-filter">filter&gt;</label>
67-<input id="tree-filter" type="search" data-filter placeholder="this directory ( / )" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')">
89+<label for="tree-filter" class="sr">filter</label>
90+<input id="tree-filter" type="search" data-filter placeholder="Filter this directory…" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')">
6891 </div>
6992 <table class="list tree" aria-label="Entries">
7093 <thead>
@@ -115,79 +138,82 @@
115138 </tr>
116139 </tbody>
117140 </table>
118-<h2 class="rule-h">
119-<span>README.md</span>
120-</h2>
141+<section class="card readme" aria-labelledby="readme-h">
142+<h2 class="card-h" id="readme-h">
143+<span class="ico i-file" aria-hidden="true">
144+</span> README.md</h2>
121145 <article class="prose" aria-label="README.md">
122146 <h1 id="md-docs">Docs</h1>
123147 <p>Back to <a href="/files/blob/main/docs/guide.md">the guide</a> and <a href="/files/blob/main/main.go">root</a>.</p>
124148 </article>
149+</section>
125150 </main>
126151 </div>
127-<dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
152+<footer class="foot">
153+<span>ermite devel</span>
154+<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
155+<span class="foot-keys">
156+<kbd>?</kbd> keyboard shortcuts</span>
157+</footer>
158+<div class="hint" role="status" aria-live="polite">
159+<span class="hint-nav" :text="document.body.dataset.nav || ''">
160+</span>
161+<span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''">
162+</span>
163+<span class="hint-flash" :text="document.body.dataset.flash || ''">
164+</span>
165+</div>
166+<dialog id="panel-help" class="panel modal" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
128167 <div class="panel-head">
129-<span>keys</span>
130-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
168+<h2>Keyboard shortcuts</h2>
169+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
170+<kbd>esc</kbd> close</button>
131171 </div>
132172 <dl class="keys">
133173 <div>
134174 <dt>
135-<kbd>j</kbd>
136-<kbd>k</kbd>
175+<kbd>ctrl k</kbd>
176+<kbd>:</kbd>
137177 </dt>
138-<dd>move cursor down / up</dd>
178+<dd>go to a section, branch, tag, file or repository</dd>
139179 </div>
140180 <div>
141181 <dt>
142-<kbd>enter</kbd>
182+<kbd>?</kbd>
143183 </dt>
144-<dd>open row under cursor</dd>
184+<dd>this list</dd>
145185 </div>
146186 <div>
147187 <dt>
148-<kbd>g</kbd>
149-<kbd>g</kbd> / <kbd>G</kbd>
188+<kbd>j</kbd>
189+<kbd>k</kbd>
150190 </dt>
151-<dd>first / last row</dd>
191+<dd>move the row cursor down / up</dd>
152192 </div>
153193 <div>
154194 <dt>
155-<kbd>/</kbd>
195+<kbd>enter</kbd>
156196 </dt>
157-<dd>focus the filter</dd>
197+<dd>open the row under the cursor</dd>
158198 </div>
159199 <div>
160200 <dt>
161-<kbd>g h</kbd>
162-<kbd>g t</kbd>
201+<kbd>g g</kbd>
202+<kbd>G</kbd>
163203 </dt>
164-<dd>go home / tree</dd>
204+<dd>first / last row</dd>
165205 </div>
166206 <div>
167207 <dt>
168-<kbd>g c</kbd>
169-<kbd>g r</kbd>
208+<kbd>/</kbd>
170209 </dt>
171-<dd>go commits / refs</dd>
210+<dd>filter the list</dd>
172211 </div>
173212 <div>
174213 <dt>
175214 <kbd>b</kbd>
176215 </dt>
177-<dd>switch ref (branches, tags)</dd>
178-</div>
179-<div>
180-<dt>
181-<kbd>:</kbd>
182-<kbd>f</kbd>
183-</dt>
184-<dd>go to: section, ref, file, repo</dd>
185-</div>
186-<div>
187-<dt>
188-<kbd>y</kbd>
189-</dt>
190-<dd>permalink: ref becomes the full sha</dd>
216+<dd>switch branch or tag</dd>
191217 </div>
192218 <div>
193219 <dt>
@@ -195,15 +221,21 @@
195221 <kbd>t</kbd>
196222 <kbd>c</kbd>
197223 <kbd>r</kbd>
224+<kbd>m</kbd>
198225 </dt>
199-<dd>repo sections (when shown)</dd>
226+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
200227 </div>
201228 <div>
202229 <dt>
203-<kbd>m</kbd>
204-<kbd>g m</kbd>
230+<kbd>g h</kbd>
205231 </dt>
206-<dd>merge requests (when shown)</dd>
232+<dd>home</dd>
233+</div>
234+<div>
235+<dt>
236+<kbd>y</kbd>
237+</dt>
238+<dd>copy a permalink pinned to the commit</dd>
207239 </div>
208240 <div>
209241 <dt>
@@ -223,19 +255,21 @@
223255 <dt>
224256 <kbd>esc</kbd>
225257 </dt>
226-<dd>close panel</dd>
258+<dd>close a dialog, leave a field</dd>
227259 </div>
228260 </dl>
229261 </dialog>
230-<dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
262+<dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
231263 <div class="panel-head">
232-<label for="refs-filter">ref&gt;</label>
233-<input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
234-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
264+<label for="refs-filter" class="sr">filter branches and tags</label>
265+<input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))">
266+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
267+<kbd>esc</kbd>
268+</button>
235269 </div>
236270 <div class="panel-cols" data-picklist>
237271 <section aria-label="Branches">
238-<h2>branches <span class="mute">2</span>
272+<h2>Branches <span class="count">2</span>
239273 </h2>
240274 <ul>
241275 <li>
@@ -249,7 +283,7 @@
249283 </ul>
250284 </section>
251285 <section aria-label="Tags">
252-<h2>tags <span class="mute">1</span>
286+<h2>Tags <span class="count">1</span>
253287 </h2>
254288 <ul>
255289 <li>
@@ -260,114 +294,136 @@
260294 </section>
261295 </div>
262296 </dialog>
263-<dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
297+<dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
264298 <div class="panel-head">
265-<label for="cmd-q" class="hi">:</label>
266-<input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
299+<label for="cmd-q" class="sr">go to</label>
300+<input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim();
267301 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
268302 let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr();
269303 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
270304 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
271305 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
272306 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">
273-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
307+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
308+<kbd>esc</kbd>
309+</button>
274310 </div>
275-<ul id="cmd-list" class="cmd-list">
311+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
276312 <li data-k="go home" data-cursor>
277313 <a href="/" tabindex="-1">
278-<span class="k">go</span>home</a>
314+<span class="lbl">home</span>
315+<span class="k">go</span>
316+</a>
279317 </li>
280318 <li data-k="section summary">
281319 <a href="/files" tabindex="-1">
282-<span class="k">section</span>summary</a>
320+<span class="lbl">summary</span>
321+<span class="k">section</span>
322+</a>
283323 </li>
284-<li data-k="section tree">
324+<li data-k="section files">
285325 <a href="/files/tree/main" tabindex="-1">
286-<span class="k">section</span>tree</a>
326+<span class="lbl">files</span>
327+<span class="k">section</span>
328+</a>
287329 </li>
288330 <li data-k="section commits">
289331 <a href="/files/commits/main" tabindex="-1">
290-<span class="k">section</span>commits</a>
332+<span class="lbl">commits</span>
333+<span class="k">section</span>
334+</a>
291335 </li>
292-<li data-k="section refs">
336+<li data-k="section branches &amp; tags">
293337 <a href="/files/refs" tabindex="-1">
294-<span class="k">section</span>refs</a>
338+<span class="lbl">branches &amp; tags</span>
339+<span class="k">section</span>
340+</a>
295341 </li>
296342 <li data-k="section merge requests">
297343 <a href="/files/merge-requests" tabindex="-1">
298-<span class="k">section</span>merge requests</a>
344+<span class="lbl">merge requests</span>
345+<span class="k">section</span>
346+</a>
299347 </li>
300348 <li data-k="branch feature/x">
301349 <a href="/files/tree/feature/x" tabindex="-1">
302-<span class="k">branch</span>feature/x</a>
350+<span class="lbl">feature/x</span>
351+<span class="k">branch</span>
352+</a>
303353 </li>
304354 <li data-k="branch main">
305355 <a href="/files/tree/main" tabindex="-1">
306-<span class="k">branch</span>main</a>
356+<span class="lbl">main</span>
357+<span class="k">branch</span>
358+</a>
307359 </li>
308360 <li data-k="tag v1.0">
309361 <a href="/files/tree/v1.0" tabindex="-1">
310-<span class="k">tag</span>v1.0</a>
362+<span class="lbl">v1.0</span>
363+<span class="k">tag</span>
364+</a>
311365 </li>
312366 <li data-k="file docs/readme.md">
313367 <a href="/files/blob/main/docs/README.md" tabindex="-1">
314-<span class="k">file</span>docs/README.md</a>
368+<span class="lbl">docs/README.md</span>
369+<span class="k">file</span>
370+</a>
315371 </li>
316372 <li data-k="file docs/guide.md">
317373 <a href="/files/blob/main/docs/guide.md" tabindex="-1">
318-<span class="k">file</span>docs/guide.md</a>
374+<span class="lbl">docs/guide.md</span>
375+<span class="k">file</span>
376+</a>
319377 </li>
320378 </ul>
379+<p class="panel-foot">
380+<span>
381+<kbd>↑</kbd>
382+<kbd>↓</kbd> move</span>
383+<span>
384+<kbd>enter</kbd> open</span>
385+<span>
386+<kbd>esc</kbd> close</span>
387+</p>
321388 </dialog>
322-<footer class="bar status" aria-label="Status">
323-<span class="mode">tree</span>
324-<span class="st-ctx">tree · 2 entries</span>
325-<button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))">
326-<span class="hi">:</span> go to…</button>
327-<span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''">
328-</span>
329-<span class="st-hints">j/k move · enter open · / filter · b ref · ? help</span>
330-<span class="st-src">
331-<span class="st-ver">ermite devel · </span>
332-<a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
333-</span>
334-<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
335-<span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/2</span>
336-</footer>
337-<div id="keys" hidden hx-on="k-j from:body -> ((d) => {
389+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
338390 if (q('dialog[open]').count) return;
339391 let rows = q('[data-rows] > :not([hidden])').arr();
340392 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
341393 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
342- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
343- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
394+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
395+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
396+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
397+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
344398 })(1);
345- k-k from:body -> ((d) => {
399+ k-k from:body -> (async (d) => {
346400 if (q('dialog[open]').count) return;
347401 let rows = q('[data-rows] > :not([hidden])').arr();
348402 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
349403 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
350- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
351- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
404+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
405+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
406+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
407+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
352408 })(-1);
353409 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
354410 k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
355411 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
356412 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
357- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
358- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus());
359- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
413+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
414+ k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
415+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
360416 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
361- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
362- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
363- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
364- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
365- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
366- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
367- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
368- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
369- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
370- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
417+ k-gh from:body -> document.querySelector('.brand')?.click();
418+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
419+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
420+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
421+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
422+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
423+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
424+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
425+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
426+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
371427 k-next from:body -> {
372428 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
373429 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/web_commits_test.go +6 -6
@@ -122,7 +122,7 @@ func (h *harness) longRepo(name string) {
122122 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/"+name+".git"), "main")
123123 }
124124
125-var reOlder = regexp.MustCompile(`<a href="([^"]+)" rel="next">`)
125+var reOlder = regexp.MustCompile(`<a class="btn" href="([^"]+)" rel="next">`)
126126
127127 // unescape undoes the HTML escaping of an attribute value.
128128 func unescape(s string) string { return strings.ReplaceAll(s, "&amp;", "&") }
@@ -174,12 +174,12 @@ func TestWebCommits(t *testing.T) {
174174 assert.Equal(t, 50, strings.Count(first, `class="c-sha mute"`), "a page lists 50 commits")
175175 assert.Contains(t, first, "commit number 51")
176176 assert.NotContains(t, first, "commit number 1<", "the oldest are on the next page")
177- assert.Contains(t, first, "<span class=\"mute\">← newest</span>")
177+ assert.Contains(t, first, `<span class="btn" aria-disabled="true">← Newest</span>`)
178178 second := unescape(reOlder.FindStringSubmatch(first)[1])
179179 page2 := get(second, 200)
180180 assert.Equal(t, 2, strings.Count(page2, `class="c-sha mute"`))
181- assert.Contains(t, page2, `href="/long/commits/main">← newest</a>`)
182- assert.Contains(t, page2, "<span class=\"mute\">older →</span>")
181+ assert.Contains(t, page2, `href="/long/commits/main">← Newest</a>`)
182+ assert.Contains(t, page2, `<span class="btn" aria-disabled="true">Older →</span>`)
183183 get("/long/commits/main?page=garbage", 404)
184184 })
185185
@@ -196,8 +196,8 @@ func TestWebCommits(t *testing.T) {
196196 assert.Contains(t, get("/diffs/commits/v1.0.0", 200), "modify a file")
197197 assert.Contains(t, get("/diffs/commits/side", 200), "work on the side")
198198 assert.Contains(t, get("/diffs/commits/"+f.modify[:10], 200), "modify a file")
199- assert.Contains(t, get("/diffs/commits/main", 200), `[tag: v1.0.0]`)
200- assert.Contains(t, get("/diffs/commits/main", 200), `(merge)`)
199+ assert.Contains(t, get("/diffs/commits/main", 200), `<span class="pill tag"><span class="sr">tag </span>v1.0.0</span>`)
200+ assert.Contains(t, get("/diffs/commits/main", 200), `<span class="pill">merge</span>`)
201201 get("/diffs/commits/nope", 404)
202202 get("/diffs/commits/mai", 404)
203203 get("/nope/commits/main", 404)
M cmd/ermite/web_mr_test.go +8 -8
@@ -141,7 +141,7 @@ func TestWebMergeRequestPagesRender(t *testing.T) {
141141 assert.Contains(t, mr.body, "Finds things.\nSee ", "line breaks are kept")
142142 assert.NotContains(t, mr.body, "<form", "an anonymous visitor gets no form")
143143 assert.NotContains(t, mr.body, "ermite mr merge", "nor the merge line")
144- assert.NotContains(t, mr.body, "[merge]")
144+ assert.NotContains(t, mr.body, ">Merge</button>")
145145 assert.Contains(t, mr.body, `href="/proj/commit/`+head+`"`, "the commits are in the rail")
146146 assert.NotContains(t, strings.ToLower(mr.body), "proposal")
147147 assertNoInline(t, mr.body)
@@ -303,7 +303,7 @@ func TestWebMergeRequestReviewAndFastForward(t *testing.T) {
303303 assert.Equal(t, base+"#c1", res.hdr.Get("Location"))
304304 own = b.get(base, as(token))
305305 assert.Contains(t, own.body, `data-resolved="true"`)
306- assert.Contains(t, own.body, "[unresolve]")
306+ assert.Contains(t, own.body, ">Unresolve</button>")
307307 assert.Equal(t, http.StatusSeeOther, b.form(base+"/comments/1/resolve", token, url.Values{"resolved": {"false"}}).status)
308308 assert.Equal(t, http.StatusBadRequest, b.form(base+"/comments/1/resolve", token, url.Values{"resolved": {"maybe"}}).status)
309309 assert.Equal(t, 404, b.form(base+"/comments/42/resolve", token, url.Values{"resolved": {"true"}}).status)
@@ -312,7 +312,7 @@ func TestWebMergeRequestReviewAndFastForward(t *testing.T) {
312312 assert.Contains(t, own.body, `action="/proj/merge-requests/1/read"`)
313313 res = b.form(base+"/read", token, url.Values{"head_id": {head1}})
314314 require.Equal(t, http.StatusSeeOther, res.status, res.body)
315- assert.NotContains(t, b.get(base, as(token)).body, "[mark read]", "the head is the one that was read")
315+ assert.NotContains(t, b.get(base, as(token)).body, ">Mark read</button>", "the head is the one that was read")
316316 r.writeFile(r.wt, "extra.txt", []byte("extra\n"))
317317 r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
318318 head2 := r.headOf("feat/search")
@@ -321,7 +321,7 @@ func TestWebMergeRequestReviewAndFastForward(t *testing.T) {
321321 assert.Contains(t, all.body, `href="/proj/merge-requests/1/since"`)
322322 assert.Contains(t, all.body, "since your last read")
323323 assert.Contains(t, all.body, "<li data-new>", "the commit pushed since is marked")
324- assert.Contains(t, all.body, "[mark read]")
324+ assert.Contains(t, all.body, ">Mark read</button>")
325325 assert.Contains(t, all.body, "outdated", "the notes were written on the earlier head")
326326 since := b.get(base+"/since", as(token))
327327 require.Equal(t, 200, since.status, since.body)
@@ -356,14 +356,14 @@ func TestWebMergeRequestReviewAndFastForward(t *testing.T) {
356356 assert.Contains(t, done.body, "fast-forward")
357357 assert.Contains(t, done.body, `href="/proj/commit/`+head2+`"`)
358358 assert.NotContains(t, done.body, "<form")
359- assert.NotContains(t, b.get(base, as(token)).body, "[merge]", "nothing to merge any more")
359+ assert.NotContains(t, b.get(base, as(token)).body, ">Merge</button>", "nothing to merge any more")
360360 // Writes on a merged request are refused with a page.
361361 late := b.form(base+"/comments", token, url.Values{"text": {"too late"}})
362362 assert.Equal(t, http.StatusConflict, late.status)
363363 assert.Equal(t, http.StatusConflict, b.form(base+"/close", token, nil).status)
364364 list := b.get("/proj/merge-requests", nil)
365- assert.Contains(t, list.body, "merged and closed")
366- assert.Regexp(t, `(?s)merged and closed.*Add search`, list.body)
365+ assert.Contains(t, list.body, "Merged and closed")
366+ assert.Regexp(t, `(?s)Merged and closed.*Add search`, list.body)
367367 }
368368
369369 // Squash and close, and the order of the list: open first, then the decided.
@@ -418,7 +418,7 @@ func TestWebMergeRequestSquashCloseAndList(t *testing.T) {
418418 require.True(t, third >= 0 && squash >= 0 && other >= 0, list)
419419 assert.Less(t, third, squash, "the open request comes first")
420420 assert.Less(t, third, other)
421- assert.Less(t, strings.Index(list, ">open<"), strings.Index(list, "merged and closed"))
421+ assert.Less(t, strings.Index(list, ">Open<"), strings.Index(list, "Merged and closed"))
422422 assert.Less(t, other, squash, "newest first among the decided")
423423
424424 // A compare page of a branch that is behind says so.
M cmd/ermite/web_session_test.go +2 -2
@@ -116,7 +116,7 @@ func TestWebLoginEndToEnd(t *testing.T) {
116116 assert.NotContains(t, home.body, "secret")
117117 assert.Equal(t, "no-cache", home.hdr.Get("Cache-Control"))
118118 assert.Empty(t, home.hdr.Values("Vary"))
119- assert.NotContains(t, home.body, "sign out")
119+ assert.NotContains(t, home.body, `action="/-/logout"`)
120120 assert.Equal(t, 404, b.get("/secret", nil).status)
121121
122122 // The CLI asks for a link and prints it; stderr says when it ends.
@@ -198,7 +198,7 @@ func TestWebLoginEndToEnd(t *testing.T) {
198198 }
199199 home = b.get("/", as(token))
200200 assert.Contains(t, home.body, "secret")
201- assert.Contains(t, home.body, `<form class="st-owner" method="post" action="/-/logout">`)
201+ assert.Contains(t, home.body, `<form class="owner" method="post" action="/-/logout">`)
202202 assert.Contains(t, b.get("/secret/blob/main/notes.txt", as(token)).body, "notes.txt")
203203 raw := b.get("/secret/raw/main/notes.txt", as(token))
204204 assert.Equal(t, 200, raw.status)
M cmd/ermite/web_test.go +19 -2
@@ -184,6 +184,7 @@ var (
184184 reStyleAttr = regexp.MustCompile(`(?i)\sstyle\s*=`)
185185 reStyleTag = regexp.MustCompile(`(?i)<style\b`)
186186 reImport = regexp.MustCompile(`(?i)@import|url\(`)
187+ reCSSURL = regexp.MustCompile(`url\(\s*([^)]*?)\s*\)`)
187188 )
188189
189190 // assertNoInline checks the page has no inline script, style element or
@@ -231,14 +232,30 @@ func TestWebContentSecurity(t *testing.T) {
231232 assert.Equal(t, "public, max-age=31536000, immutable", ah.Get("Cache-Control"), m[1])
232233 assert.Equal(t, "nosniff", ah.Get("X-Content-Type-Options"), m[1])
233234 assert.NotEmpty(t, ab)
234- assert.False(t, reImport.MatchString(ab) && strings.HasSuffix(m[1], ".css"), "%s imports or loads a URL", m[1])
235+ if strings.HasSuffix(m[1], ".css") {
236+ // The sheet loads its fonts and icons from here, and nothing else.
237+ assert.NotContains(t, ab, "@import", m[1])
238+ for _, u := range reCSSURL.FindAllStringSubmatch(ab, -1) {
239+ assert.True(t, strings.HasPrefix(u[1], "/-/static/"), "%s loads %s", m[1], u[1])
240+ st, _, _ := h.get(u[1])
241+ assert.Equal(t, 200, st, u[1])
242+ }
243+ }
235244 }
236245 }
237246
238247 // The footer links to the source (AGPL §13).
239- _, _, body := h.get("/")
248+ _, hdr, body := h.get("/")
240249 assert.Contains(t, body, `<a href="http://example.test/ermite"`)
241250
251+ // Pages name their speculation rules (prefetch on hover), a static file.
252+ rules := strings.Trim(hdr.Get("Speculation-Rules"), `"`)
253+ require.True(t, strings.HasPrefix(rules, "/-/static/rules/rules."), rules)
254+ st, rh, rb := h.get(rules)
255+ assert.Equal(t, 200, st)
256+ assert.Equal(t, "application/speculationrules+json", rh.Get("Content-Type"))
257+ assert.Contains(t, rb, `"prefetch"`)
258+
242259 // Unknown, stale or traversing asset names are 404s.
243260 for _, p := range []string{"/-/static/css/ermite.000000000000.css", "/-/static/css/ermite.css", "/-/static/", "/-/static/../x", "/-/static/js/"} {
244261 st, _, _ := h.get(p)
M internal/adapters/web/ASSETS.md +5 -1
@@ -10,7 +10,11 @@ content-hashed names. Nothing is fetched at runtime.
1010 | `static/js/hx-sse.min.js` | htmx 4 `hx-sse` extension | `mockups/assets/htmx/ext/hx-sse.min.js` | `8a834680c4000a9034d79228872372a92e140c810a075cb6d4a76690dfc13085` |
1111 | `static/js/ermite.js` | key sequences to `k-*` events | `mockups/d/js/ermite.js` | |
1212 | `static/css/56-chroma.css` | syntax classes of the code view, mapped to the `--syn-*` tokens (no colour of its own) | generated by `go run scripts/chroma-css.go` from chroma's token classes | |
13-| `static/css/*.css` | native CSS of design D, served as one stylesheet in name order | `mockups/d/css/` (+ `70-footer.css`, the AGPL §13 source link) | |
13+| `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | |
14+| `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` |
15+| `static/fonts/atkinson-mono.woff2` | Atkinson Hyperlegible Mono, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-mono.txt`) | Google Fonts | `2706b1ee4f452e744ea91f7e4908cbde9c5d35521bf5ffffc71a382a2de89613` |
16+| `static/icons/*.svg` | interface icons, used as CSS masks | written here | |
17+| `static/rules.json` | speculation rules: prefetch same-origin pages on hover (`Speculation-Rules` header) | written here | |
1418
1519 The three htmx files are the exact files the approved mockup was built with.
1620 To upgrade, replace the file, update the checksum here, and run the web tests
M internal/adapters/web/assets.go +41 -2
@@ -23,6 +23,10 @@ const StaticPrefix = "/-/static/"
2323 // the content, so they can be cached for ever.
2424 type Assets struct {
2525 CSS, HTMX, Live, SSE, App, Icon string
26+ // Font is the UI font, preloaded by every page.
27+ Font string
28+ // Rules is the speculation rules file named by the Speculation-Rules header.
29+ Rules string
2630 }
2731
2832 // asset is one embedded file under its content-hashed name.
@@ -49,8 +53,16 @@ var scripts = []struct {
4953 {"ermite.js", func(a *Assets, u string) { a.App = u }},
5054 }
5155
56+// sheetAssets are the files the stylesheet references as url(<dir>/<file>):
57+// fonts and icons (pages hold no inline SVG; icons are CSS masks).
58+var sheetAssets = []struct{ glob, typ string }{
59+ {"static/fonts/*.woff2", "font/woff2"},
60+ {"static/icons/*.svg", "image/svg+xml"},
61+}
62+
5263 // loadAssets hashes the embedded files. The stylesheet is the concatenation
53-// of static/css/*.css in name order, as in the mockup.
64+// of static/css/*.css in name order; its url(<dir>/<file>) references become
65+// the hashed URLs of the files of sheetAssets.
5466 func loadAssets() (*assetSet, error) {
5567 s := &assetSet{byName: map[string]asset{}}
5668 put := func(dir, file string, body []byte, typ string) string {
@@ -61,6 +73,26 @@ func loadAssets() (*assetSet, error) {
6173 return StaticPrefix + name
6274 }
6375
76+ var refs []string
77+ for _, sa := range sheetAssets {
78+ files, err := fs.Glob(staticFS, sa.glob)
79+ if err != nil {
80+ return nil, err
81+ }
82+ for _, f := range files {
83+ b, err := staticFS.ReadFile(f)
84+ if err != nil {
85+ return nil, err
86+ }
87+ dir := path.Base(path.Dir(f))
88+ u := put(dir, path.Base(f), b, sa.typ)
89+ refs = append(refs, "url("+dir+"/"+path.Base(f)+")", "url("+u+")")
90+ if f == "static/fonts/atkinson-next.woff2" {
91+ s.urls.Font = u
92+ }
93+ }
94+ }
95+
6496 files, err := fs.Glob(staticFS, "static/css/*.css")
6597 if err != nil {
6698 return nil, err
@@ -75,7 +107,14 @@ func loadAssets() (*assetSet, error) {
75107 css.WriteString("/* " + path.Base(f) + " */\n")
76108 css.Write(b)
77109 }
78- s.urls.CSS = put("css", "ermite.css", []byte(css.String()), "text/css; charset=utf-8")
110+ sheet := strings.NewReplacer(refs...).Replace(css.String())
111+ s.urls.CSS = put("css", "ermite.css", []byte(sheet), "text/css; charset=utf-8")
112+
113+ rules, err := staticFS.ReadFile("static/rules.json")
114+ if err != nil {
115+ return nil, err
116+ }
117+ s.urls.Rules = put("rules", "rules.json", rules, "application/speculationrules+json")
79118
80119 for _, sc := range scripts {
81120 b, err := staticFS.ReadFile("static/js/" + sc.file)
M internal/adapters/web/blob.go +1 -5
@@ -188,11 +188,7 @@ func (s *Server) blob(ctx context.Context, r *http.Request) (templ.Component, in
188188 pinRef(&l, t)
189189 l.Crumbs = pathCrumbs(d.name(), "blob", t)
190190 l.Title = visibleText(t.path) + " · " + d.name() + " · " + s.instance
191- l.Status = Status{
192- Mode: "blob", Context: p.Name,
193- Hints: "click a line number · y permalink · ? help", Blob: p.HasCode,
194- }
195- l.HasRail, l.Read, l.RailSum = true, p.Rendered != nil, "file · last change · selection"
191+ l.HasRail = true
196192 return Blob(l, p), http.StatusOK, nil
197193 }
198194
M internal/adapters/web/blob.templ +6 -6
@@ -12,7 +12,7 @@ templ Blob(l Layout, p BlobPage) {
1212 @Shell(l, blobRail(p)) {
1313 <h1 class="file-h">
1414 <span>{ p.Name }</span>
15- <span class="acts"><a href={ templ.SafeURL(p.RawHref) }>raw</a> <a href={ templ.SafeURL(p.HistHref) }>history</a> <a href={ templ.SafeURL(p.DirHref) }>dir</a></span>
15+ <span class="acts"><a class="btn" href={ templ.SafeURL(p.RawHref) }>Raw</a><a class="btn" href={ templ.SafeURL(p.HistHref) }>History</a><a class="btn" href={ templ.SafeURL(p.DirHref) }>Folder</a></span>
1616 </h1>
1717 if p.Notice != "" {
1818 <p class="notice" role="note">{ p.Notice }</p>
@@ -39,7 +39,7 @@ templ Blob(l Layout, p BlobPage) {
3939 // CodeView is the highlighted lines with a link on each number. A click on a
4040 // number selects the line, shift-click extends the selection to a range; the
4141 // URL hash (#L10 or #L10-L24) is the state, so a page loaded with a hash is
42-// painted the same way. body[data-sel] feeds the status bar and the rail.
42+// painted the same way. body[data-sel] feeds the hint badge and the rail.
4343 templ CodeView(h template.HTML) {
4444 <pre
4545 class="chroma code"
@@ -59,12 +59,12 @@ templ CodeView(h template.HTML) {
5959
6060 templ blobRail(p BlobPage) {
6161 <section aria-labelledby="r-file">
62- <h2 id="r-file">file</h2>
62+ <h2 id="r-file">File</h2>
6363 @Facts(p.Facts)
6464 </section>
6565 if p.Last != nil {
6666 <section aria-labelledby="r-last">
67- <h2 id="r-last">last change <a href={ templ.SafeURL(p.HistHref) }>history</a></h2>
67+ <h2 id="r-last">Last change <a href={ templ.SafeURL(p.HistHref) }>History</a></h2>
6868 <p><a href={ templ.SafeURL(p.Last.CHref) }>{ p.Last.Short }</a> { p.Last.Subject }</p>
6969 <p class="dim">
7070 { p.Last.Author } ·
@@ -76,13 +76,13 @@ templ blobRail(p BlobPage) {
7676 }
7777 if len(p.Outline) > 0 {
7878 <section aria-labelledby="r-outline">
79- <h2 id="r-outline">outline</h2>
79+ <h2 id="r-outline">On this page</h2>
8080 @Outline(p.Outline)
8181 </section>
8282 }
8383 if p.HasCode {
8484 <section aria-labelledby="r-sel">
85- <h2 id="r-sel">selection</h2>
85+ <h2 id="r-sel">Selection</h2>
8686 <p :text="document.body.dataset.sel || 'none · click a line number, shift-click extends'">none · click a line number, shift-click extends</p>
8787 <p class="dim">y copies a permalink pinned to the commit sha</p>
8888 </section>
M internal/adapters/web/blob_templ.go +13 -13 generated file: collapsed
M internal/adapters/web/commit.go +1 -10
@@ -157,19 +157,10 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component,
157157 {Label: "commit", Href: history},
158158 {Label: p.Short, Current: true},
159159 }
160- l.Status = Status{
161- Mode: "commit", Context: p.Short + " · " + p.totals(),
162- Hints: "d split · u unified · x collapse all · ? help",
163- }
164- l.HasRail, l.RailSum = true, plural(len(p.Files), "changed file", "changed files")
160+ l.HasRail = true
165161 return Commit(l, p), http.StatusOK, nil
166162 }
167163
168-// totals is "3 files · +12 -4".
169-func (p CommitPage) totals() string {
170- return plural(p.NFiles, "file", "files") + " · +" + itoa(p.Adds) + " -" + itoa(p.Dels)
171-}
172-
173164 // person converts a signature.
174165 func person(s *pb.Signature, now time.Time) Person {
175166 p := Person{Name: visibleText(s.GetName())}
M internal/adapters/web/commits.go +1 -12
@@ -94,18 +94,7 @@ func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component,
9494 } else {
9595 l.Title = "history of " + visibleText(sel.path) + " · " + d.name() + " · " + s.instance
9696 }
97- what := plural(len(cs), "commit", "commits")
98- if p.Older != "" {
99- what += " · more"
100- }
101- if p.Path != "" {
102- what = "history of " + p.Path + " · " + what
103- }
104- l.Status = Status{
105- Mode: "commits", Context: sel.name + " · " + what,
106- Hints: "j/k move · enter open · / filter · ? help", Cursor: true, Count: len(cs),
107- }
108- l.HasRail, l.RailSum = true, "history · refs on this page"
97+ l.HasRail = true
10998 return Commits(l, p), http.StatusOK, nil
11099 }
111100
M internal/adapters/web/commits.templ +8 -8
@@ -7,12 +7,12 @@ package web
77 templ Commits(l Layout, p CommitsPage) {
88 @Shell(l, commitsRail(p)) {
99 <div class="filter">
10- <label for="commit-filter">filter&gt;</label>
10+ <label for="commit-filter" class="sr">filter</label>
1111 <input
1212 id="commit-filter"
1313 type="search"
1414 data-filter
15- placeholder="subject, sha or author ( / )"
15+ placeholder="Filter by subject, sha or author…"
1616 autocomplete="off"
1717 hx-on:input="let t = this.value.toLowerCase(); q('.days tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t));
1818 q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])'));
@@ -39,14 +39,14 @@ templ Commits(l Layout, p CommitsPage) {
3939 if p.Older != "" || p.Newest != "" {
4040 <nav class="pager" aria-label="Pages">
4141 if p.Newest != "" {
42- <a href={ templ.SafeURL(p.Newest) }>← newest</a>
42+ <a class="btn" href={ templ.SafeURL(p.Newest) }>← Newest</a>
4343 } else {
44- <span class="mute">← newest</span>
44+ <span class="btn" aria-disabled="true">← Newest</span>
4545 }
4646 if p.Older != "" {
47- <a href={ templ.SafeURL(p.Older) } rel="next">older →</a>
47+ <a class="btn" href={ templ.SafeURL(p.Older) } rel="next">Older →</a>
4848 } else {
49- <span class="mute">older →</span>
49+ <span class="btn" aria-disabled="true">Older →</span>
5050 }
5151 </nav>
5252 }
@@ -55,7 +55,7 @@ templ Commits(l Layout, p CommitsPage) {
5555
5656 templ commitsRail(p CommitsPage) {
5757 <section aria-labelledby="r-hist">
58- <h2 id="r-hist">history</h2>
58+ <h2 id="r-hist">History</h2>
5959 <dl class="kv">
6060 <dt>ref</dt>
6161 <dd>{ p.Ref }</dd>
@@ -72,7 +72,7 @@ templ commitsRail(p CommitsPage) {
7272 </dl>
7373 </section>
7474 <section aria-labelledby="r-refs">
75- <h2 id="r-refs">refs on this page</h2>
75+ <h2 id="r-refs">Refs on this page</h2>
7676 if len(p.Refs) > 0 {
7777 <nav class="outline" aria-label="Refs on this page">
7878 for _, r := range p.Refs {
M internal/adapters/web/commits_templ.go +11 -11 generated file: collapsed
M internal/adapters/web/compare.go +1 -5
@@ -132,11 +132,7 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component,
132132 Crumb{Label: "compare", Current: false},
133133 Crumb{Label: p.Source, Current: true},
134134 )
135- l.Status = Status{
136- Mode: "compare", Context: p.Source + " → " + p.Target + " · " + p.AheadText,
137- Hints: "d split · u unified · x collapse all · ? help",
138- }
139- l.HasRail, l.RailSum = true, "commits · files"
135+ l.HasRail = true
140136 return Compare(l, p), http.StatusOK, nil
141137 }
142138
M internal/adapters/web/components.templ +19 -8
@@ -21,11 +21,11 @@ templ CloneBlock(c Clone) {
2121 <span class="mute" aria-hidden="true">$ </span><code>{ line }</code>
2222 <button
2323 type="button"
24- class="copy"
25- data-label="[copy]"
24+ class="btn copy"
25+ data-label="Copy"
2626 aria-label={ "copy: " + line }
27- hx-on:click="navigator.clipboard?.writeText(closest('.clone-line').querySelector('code').textContent); this.textContent = '[copied]'; await timeout('1500ms'); this.textContent = this.dataset.label"
28- >[copy]</button>
27+ hx-on:click="navigator.clipboard?.writeText(closest('.clone-line').querySelector('code').textContent); this.textContent = 'Copied'; await timeout('1500ms'); this.textContent = this.dataset.label"
28+ >Copy</button>
2929 </div>
3030 }
3131 </div>
@@ -38,7 +38,7 @@ templ HomeRowView(r HomeRow) {
3838 <td class="c-name">
3939 <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a>
4040 if r.Fresh {
41- <span class="tagged">● new push</span>
41+ <span class="pill new">new push</span>
4242 }
4343 </td>
4444 <td class="c-desc dim">{ r.Desc }</td>
@@ -62,6 +62,17 @@ templ Outline(headings []Heading) {
6262 </nav>
6363 }
6464
65+// ReadmeCard is a README in a card whose header names the file.
66+templ ReadmeCard(r Readme) {
67+ <section class="card readme" aria-labelledby="readme-h">
68+ <h2 class="card-h" id="readme-h">
69+ <span class="ico i-file" aria-hidden="true"></span>
70+ { r.Name }
71+ </h2>
72+ @ReadmeView(r)
73+ </section>
74+}
75+
6576 // ReadmeView is a rendered README. Its HTML comes from the Markdown
6677 // renderer, which drops raw HTML and filters URLs.
6778 templ ReadmeView(r Readme) {
@@ -91,13 +102,13 @@ templ CommitRows(rows []CommitRow) {
91102 <td class="c-subject">
92103 <a href={ templ.SafeURL(c.CHref) }>{ c.Subject }</a>
93104 if c.Merge {
94- <span class="tagged">(merge)</span>
105+ <span class="pill">merge</span>
95106 }
96107 for _, b := range c.Branches {
97- <span class="tagged">[{ b }]</span>
108+ <span class="pill">{ b }</span>
98109 }
99110 for _, t := range c.Tags {
100- <span class="tagged">[tag: { t }]</span>
111+ <span class="pill tag"><span class="sr">tag </span>{ t }</span>
101112 }
102113 </td>
103114 <td class="c-who dim">{ c.Author }</td>
M internal/adapters/web/components_templ.go +132 -81 generated file: collapsed
M internal/adapters/web/diff.templ +7 -7
@@ -44,7 +44,7 @@ templ Commit(l Layout, p CommitPage) {
4444 <dt>refs</dt>
4545 <dd>
4646 for _, r := range p.Refs {
47- [{ r }]{ " " }
47+ <span class="pill">{ r }</span>
4848 }
4949 </dd>
5050 }
@@ -53,9 +53,9 @@ templ Commit(l Layout, p CommitPage) {
5353 <div class="diff-tools" role="toolbar" aria-label="Diff view">
5454 <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>
5555 <span class="grow"></span>
56- @viewLink(p.DiffView, "unified", "[u]nified", "u", p.UnifiedHref)
57- @viewLink(p.DiffView, "split", "[d] split", "d", p.SplitHref)
58- <button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))">[x] collapse all</button>
56+ @viewLink(p.DiffView, "unified", "Unified", "u", p.UnifiedHref)
57+ @viewLink(p.DiffView, "split", "Split", "d", p.SplitHref)
58+ <button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))" class="btn">Collapse all<kbd>x</kbd></button>
5959 </div>
6060 if p.Invisible {
6161 <p class="notice">
@@ -99,7 +99,7 @@ templ Commit(l Layout, p CommitPage) {
9999 templ viewLink(p DiffView, view, label, key, href string) {
100100 <a
101101 href={ templ.SafeURL(href) }
102- class="vbtn"
102+ class="btn vbtn"
103103 data-view={ view }
104104 aria-keyshortcuts={ key }
105105 if p.View == view {
@@ -109,7 +109,7 @@ templ viewLink(p DiffView, view, label, key, href string) {
109109 hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))"
110110 :aria-current={ "q('#diffs').attr['data-diff'] === '" + view + "' ? 'true' : false" }
111111 }
112- >{ label }</a>
112+ >{ label }<kbd>{ key }</kbd></a>
113113 }
114114
115115 templ personView(pr Person) {
@@ -121,7 +121,7 @@ templ personView(pr Person) {
121121
122122 templ commitRail(p CommitPage) {
123123 <section aria-labelledby="r-files">
124- <h2 id="r-files">files <span>{ itoa(len(p.Files)) }</span></h2>
124+ <h2 id="r-files">Files <span class="count">{ itoa(len(p.Files)) }</span></h2>
125125 @FileList(p.Files)
126126 </section>
127127 }
M internal/adapters/web/diff_templ.go +139 -135 generated file: collapsed
M internal/adapters/web/events.go +1 -1
@@ -19,7 +19,7 @@ import (
1919 )
2020
2121 // Live updates. A page that can change under the eyes of its reader opens
22-// GET /-/events (the Live component of the status bar). The handler follows
22+// GET /-/events (the Live component of the hint badge). The handler follows
2323 // EventService.Watch as the visitor of the request and turns each event into
2424 // the HTML fragments the page shows, sent as Server-Sent Events:
2525 //
M internal/adapters/web/format.go +0 -8
@@ -10,14 +10,6 @@ import (
1010
1111 func itoa(n int) string { return strconv.Itoa(n) }
1212
13-// cursorText is the initial text of the cursor position in the status bar.
14-func cursorText(n int) string {
15- if n == 0 {
16- return "0/0"
17- }
18- return "1/" + itoa(n)
19-}
20-
2113 // ago renders a short relative date: now, 5m ago, 3h ago, 12d ago, 5mo ago,
2214 // 2y ago.
2315 func ago(now, t time.Time) string {
M internal/adapters/web/fragments.templ +8 -11
@@ -76,25 +76,22 @@ templ MRNotice(text, href string) {
7676 </div>
7777 }
7878
79-// Live is the connection state of the event stream, in the status bar.
80-// Without a stream it reads "offline" and does nothing. The events of the
81-// stream are fragments that ermiteLive (ermite.js) swaps in the page.
79+// Live is the connection to the event stream, in the hint badge. It shows
80+// only once a connection was lost: a page that never connected, or that is
81+// live, says nothing. The events of the stream are fragments that ermiteLive
82+// (ermite.js) swaps in the page. A page without a stream renders nothing.
8283 templ Live(l Layout) {
8384 if l.Live && l.LiveURL != "" {
8485 <span
85- class="st-live"
86- data-state="offline"
87- role="status"
86+ class="hint-live"
87+ data-state="connecting"
8888 hx-sse:connect={ l.LiveURL }
8989 hx-swap="none"
90- :text="data.state"
91- :title="data.state === 'live' ? 'receiving updates over SSE' : 'not connected to the event stream'"
90+ :text="data.state === 'offline' ? 'live updates paused' : ''"
9291 hx-on="htmx:sse:after:connection -> data.state = 'live';
9392 htmx:sse:close, htmx:sse:error -> data.state = 'offline';
9493 home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
9594 gap -> ermiteLive.reload()"
96- >offline</span>
97- } else {
98- <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
95+ ></span>
9996 }
10097 }
M internal/adapters/web/fragments_templ.go +6 -10 generated file: collapsed
M internal/adapters/web/home.go +0 -11
@@ -109,17 +109,6 @@ func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, in
109109 l := s.base()
110110 l.Title = s.instance
111111 l.Live, l.LiveURL = true, liveURL("home", "")
112- l.Sections = []Section{{Key: "h", Post: "ome", Href: "/", Current: true}}
113- dir := "▲"
114- if desc {
115- dir = "▼"
116- }
117- l.Status = Status{
118- Mode: "home",
119- Context: plural(len(repos), "repo", "repos") + " · sort " + key + " " + dir,
120- Hints: "j/k move · enter open · / filter · ? help",
121- Cursor: true, Count: len(repos),
122- }
123112 l.Cmd = append([]CmdItem{{Kind: "go", Label: "home", Href: "/"}}, repoCmds(repos, "")...)
124113 return Home(l, p), http.StatusOK, nil
125114 }
M internal/adapters/web/keys.templ +23 -19
@@ -9,40 +9,44 @@ templ Keys() {
99 <div
1010 id="keys"
1111 hidden
12- hx-on="k-j from:body -> ((d) => {
12+ hx-on="k-j from:body -> (async (d) => {
1313 if (q('dialog[open]').count) return;
1414 let rows = q('[data-rows] > :not([hidden])').arr();
1515 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
1616 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
17- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
18- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
17+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
18+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
19+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
20+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
1921 })(1);
20- k-k from:body -> ((d) => {
22+ k-k from:body -> (async (d) => {
2123 if (q('dialog[open]').count) return;
2224 let rows = q('[data-rows] > :not([hidden])').arr();
2325 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
2426 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
25- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];
26- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })
27+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
28+ q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' });
29+ let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark;
30+ await timeout('1500ms'); if (b.nav === mark) b.nav = ''
2731 })(-1);
2832 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
2933 k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9));
3034 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
3135 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());
32- k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show();
33- k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus());
34- k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus();
36+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
37+ k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus());
38+ k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus();
3539 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
36- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();
37- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();
38- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();
39- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();
40- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();
41- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
42- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
43- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
44- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
45- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
40+ k-gh from:body -> document.querySelector('.brand')?.click();
41+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
42+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
43+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
44+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
45+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
46+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
47+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
48+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
49+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
4650 k-next from:body -> {
4751 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
4852 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M internal/adapters/web/keys_templ.go +1 -1 generated file: collapsed
M internal/adapters/web/mr.go +4 -13
@@ -377,12 +377,7 @@ func (s *Server) mrList(ctx context.Context, r *http.Request) (templ.Component,
377377 p.Live = true
378378 l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mrs")
379379 }
380- rows := len(p.Open) + len(p.Done)
381- l.Status = Status{
382- Mode: "merge requests", Context: itoa(len(p.Open)) + " open · " + itoa(len(p.Done)) + " decided",
383- Hints: "j/k move · enter open · / filter · ? help", Cursor: true, Count: rows,
384- }
385- l.HasRail, l.RailSum = true, "states · open one"
380+ l.HasRail = true
386381 return MRList(l, p), http.StatusOK, nil
387382 }
388383
@@ -557,11 +552,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
557552 }
558553 p.Live = true
559554 l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.GetSourceBranch()))
560- l.Status = Status{
561- Mode: "merge request", Context: p.Number + " · " + p.Source + " → " + p.Target + " · " + p.State,
562- Hints: "n next note · d split · u unified · x collapse all · ? help",
563- }
564- l.HasRail, l.RailSum = true, "merge · checks · commits · files"
555+ l.HasRail = true
565556 return MergeRequest(l, p), http.StatusOK, nil
566557 }
567558
@@ -714,9 +705,9 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []*pb.C
714705 }
715706 }
716707 if canWrite {
717- v.Resolve = &ResolveForm{Action: resolveAction(c.GetId()), Value: !c.GetResolved(), Label: "[resolve]"}
708+ v.Resolve = &ResolveForm{Action: resolveAction(c.GetId()), Value: !c.GetResolved(), Label: "Resolve"}
718709 if c.GetResolved() {
719- v.Resolve.Label = "[unresolve]"
710+ v.Resolve.Label = "Unresolve"
720711 }
721712 }
722713 switch {
M internal/adapters/web/mr.templ +32 -32
@@ -12,19 +12,19 @@ templ MRList(l Layout, p MRListPage) {
1212 <div id="mr-notice"></div>
1313 }
1414 <div class="filter">
15- <label for="mr-filter">filter&gt;</label>
15+ <label for="mr-filter" class="sr">filter</label>
1616 <input
1717 id="mr-filter"
1818 type="search"
1919 data-filter
20- placeholder="title, branch, author or state ( / )"
20+ placeholder="Filter by title, branch, author or state…"
2121 autocomplete="off"
2222 hx-on:input="let t = this.value.toLowerCase(); q('.mrs tbody tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t));
2323 let first = q('.mrs tbody tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.mrs tbody tr')"
2424 />
2525 </div>
2626 if p.Newest == "" {
27- <h2 class="rule-h"><span>open</span><span>{ itoa(len(p.Open)) }</span></h2>
27+ <h2 class="rule-h"><span>Open</span><span class="count">{ itoa(len(p.Open)) }</span></h2>
2828 @mrRows(p.Open, "Open merge requests")
2929 if len(p.Open) == 0 {
3030 <p class="lede dim">no open merge requests.</p>
@@ -33,7 +33,7 @@ templ MRList(l Layout, p MRListPage) {
3333 <p class="note">{ p.OpenNote }</p>
3434 }
3535 }
36- <h2 class="rule-h"><span>merged and closed</span><span>{ itoa(len(p.Done)) }</span></h2>
36+ <h2 class="rule-h"><span>Merged and closed</span><span class="count">{ itoa(len(p.Done)) }</span></h2>
3737 @mrRows(p.Done, "Merged and closed merge requests")
3838 if len(p.Done) == 0 {
3939 <p class="lede dim">nothing merged or closed yet.</p>
@@ -41,14 +41,14 @@ templ MRList(l Layout, p MRListPage) {
4141 if p.Older != "" || p.Newest != "" {
4242 <nav class="pager" aria-label="Pages">
4343 if p.Newest != "" {
44- <a href={ templ.SafeURL(p.Newest) }>← newest</a>
44+ <a class="btn" href={ templ.SafeURL(p.Newest) }>← Newest</a>
4545 } else {
46- <span class="mute">← newest</span>
46+ <span class="btn" aria-disabled="true">← Newest</span>
4747 }
4848 if p.Older != "" {
49- <a href={ templ.SafeURL(p.Older) } rel="next">older →</a>
49+ <a class="btn" href={ templ.SafeURL(p.Older) } rel="next">Older →</a>
5050 } else {
51- <span class="mute">older →</span>
51+ <span class="btn" aria-disabled="true">Older →</span>
5252 }
5353 </nav>
5454 }
@@ -80,7 +80,7 @@ templ mrRows(rows []MRRow, label string) {
8080
8181 templ mrListRail(p MRListPage) {
8282 <section aria-labelledby="r-states">
83- <h2 id="r-states">states</h2>
83+ <h2 id="r-states">States</h2>
8484 <dl class="kv">
8585 <dt>open</dt>
8686 <dd>awaits review</dd>
@@ -91,7 +91,7 @@ templ mrListRail(p MRListPage) {
9191 </dl>
9292 </section>
9393 <section aria-labelledby="r-new">
94- <h2 id="r-new">open one</h2>
94+ <h2 id="r-new">Open one</h2>
9595 if len(p.Branches) > 0 {
9696 <p class="dim">compare a branch with { p.Default }:</p>
9797 <nav class="outline" aria-label="Branches">
@@ -166,9 +166,9 @@ templ MergeRequest(l Layout, p MRPage) {
166166 <div class="diff-tools" role="toolbar" aria-label="Diff view">
167167 <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>
168168 <span class="grow"></span>
169- @viewLink(p.DiffView, "unified", "[u]nified", "u", p.UnifiedHref)
170- @viewLink(p.DiffView, "split", "[d] split", "d", p.SplitHref)
171- <button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))">[x] collapse all</button>
169+ @viewLink(p.DiffView, "unified", "Unified", "u", p.UnifiedHref)
170+ @viewLink(p.DiffView, "split", "Split", "d", p.SplitHref)
171+ <button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))" class="btn">Collapse all<kbd>x</kbd></button>
172172 </div>
173173 if p.Invisible {
174174 <p class="notice">
@@ -197,7 +197,7 @@ templ MergeRequest(l Layout, p MRPage) {
197197 <p class="note">no changed files.</p>
198198 }
199199 </div>
200- <h2 class="rule-h" id="comments"><span>comments</span><span>{ itoa(p.NUnresolved) } open / { itoa(p.NComments) }</span></h2>
200+ <h2 class="rule-h" id="comments"><span>Comments</span><span class="count">{ itoa(p.NUnresolved) } open / { itoa(p.NComments) }</span></h2>
201201 for _, c := range p.Loose {
202202 @commentView(c, "")
203203 }
@@ -257,7 +257,7 @@ templ commentView(c CommentView, suffix string) {
257257 if c.Resolve != nil {
258258 <form class="c-act" method="post" action={ templ.SafeURL(c.Resolve.Action) }>
259259 <input type="hidden" name="resolved" value={ boolText(c.Resolve.Value) }/>
260- <button type="submit">{ c.Resolve.Label }</button>
260+ <button type="submit" class="btn">{ c.Resolve.Label }</button>
261261 </form>
262262 }
263263 </div>
@@ -291,9 +291,9 @@ templ commentForm(f CommentForm, suffix string) {
291291 <input type="hidden" name="side" value={ f.Side }/>
292292 }
293293 <div class="f-btns">
294- <button type="submit" class="f-main">[comment]</button>
294+ <button type="submit" class="btn primary">Comment</button>
295295 if f.Cancel != "" {
296- <a href={ templ.SafeURL(f.Cancel) }>[cancel]</a>
296+ <a class="btn" href={ templ.SafeURL(f.Cancel) }>Cancel</a>
297297 }
298298 </div>
299299 </form>
@@ -304,12 +304,12 @@ templ mrRail(p MRPage) {
304304 @mergeBox(p)
305305 }
306306 <section aria-labelledby="r-checks">
307- <h2 id="r-checks">checks <span>0</span></h2>
307+ <h2 id="r-checks">Checks <span class="count">0</span></h2>
308308 <p class="dim">no checks</p>
309309 <p class="mute">there is no CI yet: nothing blocks the merge.</p>
310310 </section>
311311 <section aria-labelledby="r-settings">
312- <h2 id="r-settings">settings</h2>
312+ <h2 id="r-settings">Settings</h2>
313313 <dl class="kv">
314314 <dt>modes</dt>
315315 <dd>{ strings.Join(p.Modes, ", ") }</dd>
@@ -324,7 +324,7 @@ templ mrRail(p MRPage) {
324324 </dl>
325325 </section>
326326 <section aria-labelledby="r-commits">
327- <h2 id="r-commits">commits <span>{ itoa(len(p.Commits)) }</span></h2>
327+ <h2 id="r-commits">Commits <span class="count">{ itoa(len(p.Commits)) }</span></h2>
328328 <ul class="pcommits">
329329 for _, c := range p.Commits {
330330 <li data-new?={ c.New }>
@@ -350,7 +350,7 @@ templ mrRail(p MRPage) {
350350 }
351351 </section>
352352 <section aria-labelledby="r-files">
353- <h2 id="r-files">files <span>{ itoa(len(p.Files)) }</span></h2>
353+ <h2 id="r-files">Files <span class="count">{ itoa(len(p.Files)) }</span></h2>
354354 @FileList(p.Files)
355355 </section>
356356 }
@@ -360,7 +360,7 @@ templ mrRail(p MRPage) {
360360 // the other things one does with the request.
361361 templ mergeBox(p MRPage) {
362362 <section class="decide" aria-labelledby="r-merge" data-state="open">
363- <h2 id="r-merge">merge</h2>
363+ <h2 id="r-merge">Merge</h2>
364364 <dl class="kv">
365365 <dt>head</dt>
366366 <dd>
@@ -386,7 +386,7 @@ templ mergeBox(p MRPage) {
386386 <label class="d-check"><input type="checkbox" name="delete_branch" value="1"/> delete { p.Source } after the merge</label>
387387 }
388388 <div class="d-btns">
389- <button type="submit" class="d-main">[merge]</button>
389+ <button type="submit" class="btn primary">Merge</button>
390390 </div>
391391 </form>
392392 }
@@ -394,11 +394,11 @@ templ mergeBox(p MRPage) {
394394 if p.Box.CanMarkRead {
395395 <form method="post" action={ templ.SafeURL(p.Box.ReadAction) }>
396396 <input type="hidden" name="head_id" value={ p.Box.Head }/>
397- <button type="submit" title="the next visit can show only what changed after this head">[mark read]</button>
397+ <button type="submit" title="the next visit can show only what changed after this head" class="btn">Mark read</button>
398398 </form>
399399 }
400400 <form method="post" action={ templ.SafeURL(p.Box.CloseAction) }>
401- <button type="submit" title="close without merging; the branch is kept">[close]</button>
401+ <button type="submit" title="close without merging; the branch is kept" class="btn danger">Close</button>
402402 </form>
403403 </div>
404404 if !p.Box.HeadGone {
@@ -438,7 +438,7 @@ templ Compare(l Layout, p ComparePage) {
438438 <label for="mr-desc">description</label>
439439 <textarea id="mr-desc" name="description" rows="4" maxlength="8000" placeholder="plain text, optional"></textarea>
440440 <div class="f-btns">
441- <button type="submit" class="f-main">[create merge request]</button>
441+ <button type="submit" class="btn primary">Create merge request</button>
442442 </div>
443443 </form>
444444 }
@@ -446,9 +446,9 @@ templ Compare(l Layout, p ComparePage) {
446446 <div class="diff-tools" role="toolbar" aria-label="Diff view">
447447 <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>
448448 <span class="grow"></span>
449- @viewLink(p.DiffView, "unified", "[u]nified", "u", p.UnifiedHref)
450- @viewLink(p.DiffView, "split", "[d] split", "d", p.SplitHref)
451- <button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))">[x] collapse all</button>
449+ @viewLink(p.DiffView, "unified", "Unified", "u", p.UnifiedHref)
450+ @viewLink(p.DiffView, "split", "Split", "d", p.SplitHref)
451+ <button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))" class="btn">Collapse all<kbd>x</kbd></button>
452452 </div>
453453 if p.Invisible {
454454 <p class="notice">
@@ -477,7 +477,7 @@ templ Compare(l Layout, p ComparePage) {
477477
478478 templ compareRail(p ComparePage) {
479479 <section aria-labelledby="r-ahead">
480- <h2 id="r-ahead">commits <span>{ itoa(len(p.Ahead)) }</span></h2>
480+ <h2 id="r-ahead">Commits <span class="count">{ itoa(len(p.Ahead)) }</span></h2>
481481 <ul class="pcommits">
482482 for _, c := range p.Ahead {
483483 <li>
@@ -498,12 +498,12 @@ templ compareRail(p ComparePage) {
498498 </section>
499499 if p.HasDiff {
500500 <section aria-labelledby="r-files">
501- <h2 id="r-files">files <span>{ itoa(len(p.Files)) }</span></h2>
501+ <h2 id="r-files">Files <span class="count">{ itoa(len(p.Files)) }</span></h2>
502502 @FileList(p.Files)
503503 </section>
504504 }
505505 <section aria-labelledby="r-cli">
506- <h2 id="r-cli">from a terminal</h2>
506+ <h2 id="r-cli">From a terminal</h2>
507507 @CloneBlock(p.CLI)
508508 </section>
509509 }
M internal/adapters/web/mr_post.go +1 -1
@@ -109,7 +109,7 @@ func (s *Server) reject(ctx context.Context, w http.ResponseWriter, r *http.Requ
109109 if back == "" {
110110 back = "/"
111111 }
112- l := s.stateLayout(title, itoa(status)+" · "+title, []Crumb{{Label: "-"}, {Label: title}})
112+ l := s.stateLayout(title, []Crumb{{Label: title, Current: true}})
113113 w.Header().Set("Cache-Control", "no-store")
114114 s.write(ctx, w, r, status, State(l, StatePage{
115115 Code: itoa(status), Title: title,
M internal/adapters/web/mr_templ.go +44 -44
@@ -53,19 +53,19 @@ func MRList(l Layout, p MRListPage) templ.Component {
5353 return templ_7745c5c3_Err
5454 }
5555 }
56- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " <div class=\"filter\"><label for=\"mr-filter\">filter&gt;</label> <input id=\"mr-filter\" type=\"search\" data-filter placeholder=\"title, branch, author or state ( / )\" autocomplete=\"off\" hx-on:input=\"let t = this.value.toLowerCase(); q('.mrs tbody tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t));\n let first = q('.mrs tbody tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.mrs tbody tr')\"></div>")
56+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " <div class=\"filter\"><label for=\"mr-filter\" class=\"sr\">filter</label> <input id=\"mr-filter\" type=\"search\" data-filter placeholder=\"Filter by title, branch, author or state…\" autocomplete=\"off\" hx-on:input=\"let t = this.value.toLowerCase(); q('.mrs tbody tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t));\n let first = q('.mrs tbody tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.mrs tbody tr')\"></div>")
5757 if templ_7745c5c3_Err != nil {
5858 return templ_7745c5c3_Err
5959 }
6060 if p.Newest == "" {
61- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<h2 class=\"rule-h\"><span>open</span><span>")
61+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<h2 class=\"rule-h\"><span>Open</span><span class=\"count\">")
6262 if templ_7745c5c3_Err != nil {
6363 return templ_7745c5c3_Err
6464 }
6565 var templ_7745c5c3_Var3 string
6666 templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Open)))
6767 if templ_7745c5c3_Err != nil {
68- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 27, Col: 64}
68+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 27, Col: 78}
6969 }
7070 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
7171 if templ_7745c5c3_Err != nil {
@@ -113,14 +113,14 @@ func MRList(l Layout, p MRListPage) templ.Component {
113113 }
114114 }
115115 }
116- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " <h2 class=\"rule-h\"><span>merged and closed</span><span>")
116+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " <h2 class=\"rule-h\"><span>Merged and closed</span><span class=\"count\">")
117117 if templ_7745c5c3_Err != nil {
118118 return templ_7745c5c3_Err
119119 }
120120 var templ_7745c5c3_Var5 string
121121 templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Done)))
122122 if templ_7745c5c3_Err != nil {
123- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 36, Col: 76}
123+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 36, Col: 90}
124124 }
125125 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
126126 if templ_7745c5c3_Err != nil {
@@ -154,49 +154,49 @@ func MRList(l Layout, p MRListPage) templ.Component {
154154 return templ_7745c5c3_Err
155155 }
156156 if p.Newest != "" {
157- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<a href=\"")
157+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<a class=\"btn\" href=\"")
158158 if templ_7745c5c3_Err != nil {
159159 return templ_7745c5c3_Err
160160 }
161161 var templ_7745c5c3_Var6 templ.SafeURL
162162 templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Newest))
163163 if templ_7745c5c3_Err != nil {
164- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 44, Col: 38}
164+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 44, Col: 50}
165165 }
166166 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
167167 if templ_7745c5c3_Err != nil {
168168 return templ_7745c5c3_Err
169169 }
170- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\">← newest</a> ")
170+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\">← Newest</a> ")
171171 if templ_7745c5c3_Err != nil {
172172 return templ_7745c5c3_Err
173173 }
174174 } else {
175- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<span class=\"mute\">← newest</span> ")
175+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<span class=\"btn\" aria-disabled=\"true\">← Newest</span> ")
176176 if templ_7745c5c3_Err != nil {
177177 return templ_7745c5c3_Err
178178 }
179179 }
180180 if p.Older != "" {
181- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<a href=\"")
181+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<a class=\"btn\" href=\"")
182182 if templ_7745c5c3_Err != nil {
183183 return templ_7745c5c3_Err
184184 }
185185 var templ_7745c5c3_Var7 templ.SafeURL
186186 templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Older))
187187 if templ_7745c5c3_Err != nil {
188- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 49, Col: 37}
188+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 49, Col: 49}
189189 }
190190 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
191191 if templ_7745c5c3_Err != nil {
192192 return templ_7745c5c3_Err
193193 }
194- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\" rel=\"next\">older →</a>")
194+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\" rel=\"next\">Older →</a>")
195195 if templ_7745c5c3_Err != nil {
196196 return templ_7745c5c3_Err
197197 }
198198 } else {
199- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<span class=\"mute\">older →</span>")
199+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<span class=\"btn\" aria-disabled=\"true\">Older →</span>")
200200 if templ_7745c5c3_Err != nil {
201201 return templ_7745c5c3_Err
202202 }
@@ -441,7 +441,7 @@ func mrListRail(p MRListPage) templ.Component {
441441 templ_7745c5c3_Var20 = templ.NopComponent
442442 }
443443 ctx = templ.ClearChildren(ctx)
444- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<section aria-labelledby=\"r-states\"><h2 id=\"r-states\">states</h2><dl class=\"kv\"><dt>open</dt><dd>awaits review</dd><dt>merged</dt><dd>the target took the branch</dd><dt>closed</dt><dd>refused, branch kept</dd></dl></section><section aria-labelledby=\"r-new\"><h2 id=\"r-new\">open one</h2>")
444+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<section aria-labelledby=\"r-states\"><h2 id=\"r-states\">States</h2><dl class=\"kv\"><dt>open</dt><dd>awaits review</dd><dt>merged</dt><dd>the target took the branch</dd><dt>closed</dt><dd>refused, branch kept</dd></dl></section><section aria-labelledby=\"r-new\"><h2 id=\"r-new\">Open one</h2>")
445445 if templ_7745c5c3_Err != nil {
446446 return templ_7745c5c3_Err
447447 }
@@ -966,15 +966,15 @@ func MergeRequest(l Layout, p MRPage) templ.Component {
966966 if templ_7745c5c3_Err != nil {
967967 return templ_7745c5c3_Err
968968 }
969- templ_7745c5c3_Err = viewLink(p.DiffView, "unified", "[u]nified", "u", p.UnifiedHref).Render(ctx, templ_7745c5c3_Buffer)
969+ templ_7745c5c3_Err = viewLink(p.DiffView, "unified", "Unified", "u", p.UnifiedHref).Render(ctx, templ_7745c5c3_Buffer)
970970 if templ_7745c5c3_Err != nil {
971971 return templ_7745c5c3_Err
972972 }
973- templ_7745c5c3_Err = viewLink(p.DiffView, "split", "[d] split", "d", p.SplitHref).Render(ctx, templ_7745c5c3_Buffer)
973+ templ_7745c5c3_Err = viewLink(p.DiffView, "split", "Split", "d", p.SplitHref).Render(ctx, templ_7745c5c3_Buffer)
974974 if templ_7745c5c3_Err != nil {
975975 return templ_7745c5c3_Err
976976 }
977- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<button type=\"button\" aria-keyshortcuts=\"x\" hx-on:click=\"document.body.dispatchEvent(new CustomEvent('k-collapse'))\">[x] collapse all</button></div>")
977+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<button type=\"button\" aria-keyshortcuts=\"x\" hx-on:click=\"document.body.dispatchEvent(new CustomEvent('k-collapse'))\" class=\"btn\">Collapse all<kbd>x</kbd></button></div>")
978978 if templ_7745c5c3_Err != nil {
979979 return templ_7745c5c3_Err
980980 }
@@ -1070,14 +1070,14 @@ func MergeRequest(l Layout, p MRPage) templ.Component {
10701070 return templ_7745c5c3_Err
10711071 }
10721072 }
1073- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "</div><h2 class=\"rule-h\" id=\"comments\"><span>comments</span><span>")
1073+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "</div><h2 class=\"rule-h\" id=\"comments\"><span>Comments</span><span class=\"count\">")
10741074 if templ_7745c5c3_Err != nil {
10751075 return templ_7745c5c3_Err
10761076 }
10771077 var templ_7745c5c3_Var54 string
10781078 templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.NUnresolved))
10791079 if templ_7745c5c3_Err != nil {
1080- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 200, Col: 83}
1080+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 200, Col: 97}
10811081 }
10821082 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54))
10831083 if templ_7745c5c3_Err != nil {
@@ -1090,7 +1090,7 @@ func MergeRequest(l Layout, p MRPage) templ.Component {
10901090 var templ_7745c5c3_Var55 string
10911091 templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.NComments))
10921092 if templ_7745c5c3_Err != nil {
1093- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 200, Col: 112}
1093+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 200, Col: 126}
10941094 }
10951095 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
10961096 if templ_7745c5c3_Err != nil {
@@ -1467,14 +1467,14 @@ func commentView(c CommentView, suffix string) templ.Component {
14671467 if templ_7745c5c3_Err != nil {
14681468 return templ_7745c5c3_Err
14691469 }
1470- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "\"> <button type=\"submit\">")
1470+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "\"> <button type=\"submit\" class=\"btn\">")
14711471 if templ_7745c5c3_Err != nil {
14721472 return templ_7745c5c3_Err
14731473 }
14741474 var templ_7745c5c3_Var75 string
14751475 templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.JoinStringErrs(c.Resolve.Label)
14761476 if templ_7745c5c3_Err != nil {
1477- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 260, Col: 43}
1477+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 260, Col: 55}
14781478 }
14791479 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var75))
14801480 if templ_7745c5c3_Err != nil {
@@ -1633,25 +1633,25 @@ func commentForm(f CommentForm, suffix string) templ.Component {
16331633 return templ_7745c5c3_Err
16341634 }
16351635 }
1636- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "<div class=\"f-btns\"><button type=\"submit\" class=\"f-main\">[comment]</button> ")
1636+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "<div class=\"f-btns\"><button type=\"submit\" class=\"btn primary\">Comment</button> ")
16371637 if templ_7745c5c3_Err != nil {
16381638 return templ_7745c5c3_Err
16391639 }
16401640 if f.Cancel != "" {
1641- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "<a href=\"")
1641+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "<a class=\"btn\" href=\"")
16421642 if templ_7745c5c3_Err != nil {
16431643 return templ_7745c5c3_Err
16441644 }
16451645 var templ_7745c5c3_Var84 templ.SafeURL
16461646 templ_7745c5c3_Var84, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(f.Cancel))
16471647 if templ_7745c5c3_Err != nil {
1648- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 296, Col: 37}
1648+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 296, Col: 49}
16491649 }
16501650 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var84))
16511651 if templ_7745c5c3_Err != nil {
16521652 return templ_7745c5c3_Err
16531653 }
1654- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "\">[cancel]</a>")
1654+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "\">Cancel</a>")
16551655 if templ_7745c5c3_Err != nil {
16561656 return templ_7745c5c3_Err
16571657 }
@@ -1691,7 +1691,7 @@ func mrRail(p MRPage) templ.Component {
16911691 return templ_7745c5c3_Err
16921692 }
16931693 }
1694- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "<section aria-labelledby=\"r-checks\"><h2 id=\"r-checks\">checks <span>0</span></h2><p class=\"dim\">no checks</p><p class=\"mute\">there is no CI yet: nothing blocks the merge.</p></section><section aria-labelledby=\"r-settings\"><h2 id=\"r-settings\">settings</h2><dl class=\"kv\"><dt>modes</dt><dd>")
1694+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "<section aria-labelledby=\"r-checks\"><h2 id=\"r-checks\">Checks <span class=\"count\">0</span></h2><p class=\"dim\">no checks</p><p class=\"mute\">there is no CI yet: nothing blocks the merge.</p></section><section aria-labelledby=\"r-settings\"><h2 id=\"r-settings\">Settings</h2><dl class=\"kv\"><dt>modes</dt><dd>")
16951695 if templ_7745c5c3_Err != nil {
16961696 return templ_7745c5c3_Err
16971697 }
@@ -1719,14 +1719,14 @@ func mrRail(p MRPage) templ.Component {
17191719 return templ_7745c5c3_Err
17201720 }
17211721 }
1722- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "</dd></dl></section><section aria-labelledby=\"r-commits\"><h2 id=\"r-commits\">commits <span>")
1722+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "</dd></dl></section><section aria-labelledby=\"r-commits\"><h2 id=\"r-commits\">Commits <span class=\"count\">")
17231723 if templ_7745c5c3_Err != nil {
17241724 return templ_7745c5c3_Err
17251725 }
17261726 var templ_7745c5c3_Var87 string
17271727 templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Commits)))
17281728 if templ_7745c5c3_Err != nil {
1729- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 327, Col: 57}
1729+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 327, Col: 71}
17301730 }
17311731 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var87))
17321732 if templ_7745c5c3_Err != nil {
@@ -1889,14 +1889,14 @@ func mrRail(p MRPage) templ.Component {
18891889 return templ_7745c5c3_Err
18901890 }
18911891 }
1892- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 167, "</section><section aria-labelledby=\"r-files\"><h2 id=\"r-files\">files <span>")
1892+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 167, "</section><section aria-labelledby=\"r-files\"><h2 id=\"r-files\">Files <span class=\"count\">")
18931893 if templ_7745c5c3_Err != nil {
18941894 return templ_7745c5c3_Err
18951895 }
18961896 var templ_7745c5c3_Var97 string
18971897 templ_7745c5c3_Var97, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Files)))
18981898 if templ_7745c5c3_Err != nil {
1899- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 353, Col: 51}
1899+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 353, Col: 65}
19001900 }
19011901 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var97))
19021902 if templ_7745c5c3_Err != nil {
@@ -1942,7 +1942,7 @@ func mergeBox(p MRPage) templ.Component {
19421942 templ_7745c5c3_Var98 = templ.NopComponent
19431943 }
19441944 ctx = templ.ClearChildren(ctx)
1945- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 170, "<section class=\"decide\" aria-labelledby=\"r-merge\" data-state=\"open\"><h2 id=\"r-merge\">merge</h2><dl class=\"kv\"><dt>head</dt><dd>")
1945+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 170, "<section class=\"decide\" aria-labelledby=\"r-merge\" data-state=\"open\"><h2 id=\"r-merge\">Merge</h2><dl class=\"kv\"><dt>head</dt><dd>")
19461946 if templ_7745c5c3_Err != nil {
19471947 return templ_7745c5c3_Err
19481948 }
@@ -2065,7 +2065,7 @@ func mergeBox(p MRPage) templ.Component {
20652065 return templ_7745c5c3_Err
20662066 }
20672067 }
2068- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, "<div class=\"d-btns\"><button type=\"submit\" class=\"d-main\">[merge]</button></div></form>")
2068+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, "<div class=\"d-btns\"><button type=\"submit\" class=\"btn primary\">Merge</button></div></form>")
20692069 if templ_7745c5c3_Err != nil {
20702070 return templ_7745c5c3_Err
20712071 }
@@ -2101,7 +2101,7 @@ func mergeBox(p MRPage) templ.Component {
21012101 if templ_7745c5c3_Err != nil {
21022102 return templ_7745c5c3_Err
21032103 }
2104- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 187, "\"> <button type=\"submit\" title=\"the next visit can show only what changed after this head\">[mark read]</button></form>")
2104+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 187, "\"> <button type=\"submit\" title=\"the next visit can show only what changed after this head\" class=\"btn\">Mark read</button></form>")
21052105 if templ_7745c5c3_Err != nil {
21062106 return templ_7745c5c3_Err
21072107 }
@@ -2119,7 +2119,7 @@ func mergeBox(p MRPage) templ.Component {
21192119 if templ_7745c5c3_Err != nil {
21202120 return templ_7745c5c3_Err
21212121 }
2122- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 189, "\"><button type=\"submit\" title=\"close without merging; the branch is kept\">[close]</button></form></div>")
2122+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 189, "\"><button type=\"submit\" title=\"close without merging; the branch is kept\" class=\"btn danger\">Close</button></form></div>")
21232123 if templ_7745c5c3_Err != nil {
21242124 return templ_7745c5c3_Err
21252125 }
@@ -2383,7 +2383,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
23832383 if templ_7745c5c3_Err != nil {
23842384 return templ_7745c5c3_Err
23852385 }
2386- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 211, "\" autocomplete=\"off\"> <label for=\"mr-desc\">description</label> <textarea id=\"mr-desc\" name=\"description\" rows=\"4\" maxlength=\"8000\" placeholder=\"plain text, optional\"></textarea><div class=\"f-btns\"><button type=\"submit\" class=\"f-main\">[create merge request]</button></div></form>")
2386+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 211, "\" autocomplete=\"off\"> <label for=\"mr-desc\">description</label> <textarea id=\"mr-desc\" name=\"description\" rows=\"4\" maxlength=\"8000\" placeholder=\"plain text, optional\"></textarea><div class=\"f-btns\"><button type=\"submit\" class=\"btn primary\">Create merge request</button></div></form>")
23872387 if templ_7745c5c3_Err != nil {
23882388 return templ_7745c5c3_Err
23892389 }
@@ -2436,15 +2436,15 @@ func Compare(l Layout, p ComparePage) templ.Component {
24362436 if templ_7745c5c3_Err != nil {
24372437 return templ_7745c5c3_Err
24382438 }
2439- templ_7745c5c3_Err = viewLink(p.DiffView, "unified", "[u]nified", "u", p.UnifiedHref).Render(ctx, templ_7745c5c3_Buffer)
2439+ templ_7745c5c3_Err = viewLink(p.DiffView, "unified", "Unified", "u", p.UnifiedHref).Render(ctx, templ_7745c5c3_Buffer)
24402440 if templ_7745c5c3_Err != nil {
24412441 return templ_7745c5c3_Err
24422442 }
2443- templ_7745c5c3_Err = viewLink(p.DiffView, "split", "[d] split", "d", p.SplitHref).Render(ctx, templ_7745c5c3_Buffer)
2443+ templ_7745c5c3_Err = viewLink(p.DiffView, "split", "Split", "d", p.SplitHref).Render(ctx, templ_7745c5c3_Buffer)
24442444 if templ_7745c5c3_Err != nil {
24452445 return templ_7745c5c3_Err
24462446 }
2447- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 217, "<button type=\"button\" aria-keyshortcuts=\"x\" hx-on:click=\"document.body.dispatchEvent(new CustomEvent('k-collapse'))\">[x] collapse all</button></div>")
2447+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 217, "<button type=\"button\" aria-keyshortcuts=\"x\" hx-on:click=\"document.body.dispatchEvent(new CustomEvent('k-collapse'))\" class=\"btn\">Collapse all<kbd>x</kbd></button></div>")
24482448 if templ_7745c5c3_Err != nil {
24492449 return templ_7745c5c3_Err
24502450 }
@@ -2547,14 +2547,14 @@ func compareRail(p ComparePage) templ.Component {
25472547 templ_7745c5c3_Var130 = templ.NopComponent
25482548 }
25492549 ctx = templ.ClearChildren(ctx)
2550- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 227, "<section aria-labelledby=\"r-ahead\"><h2 id=\"r-ahead\">commits <span>")
2550+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 227, "<section aria-labelledby=\"r-ahead\"><h2 id=\"r-ahead\">Commits <span class=\"count\">")
25512551 if templ_7745c5c3_Err != nil {
25522552 return templ_7745c5c3_Err
25532553 }
25542554 var templ_7745c5c3_Var131 string
25552555 templ_7745c5c3_Var131, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Ahead)))
25562556 if templ_7745c5c3_Err != nil {
2557- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 480, Col: 53}
2557+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 480, Col: 67}
25582558 }
25592559 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var131))
25602560 if templ_7745c5c3_Err != nil {
@@ -2695,14 +2695,14 @@ func compareRail(p ComparePage) templ.Component {
26952695 return templ_7745c5c3_Err
26962696 }
26972697 if p.HasDiff {
2698- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 242, "<section aria-labelledby=\"r-files\"><h2 id=\"r-files\">files <span>")
2698+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 242, "<section aria-labelledby=\"r-files\"><h2 id=\"r-files\">Files <span class=\"count\">")
26992699 if templ_7745c5c3_Err != nil {
27002700 return templ_7745c5c3_Err
27012701 }
27022702 var templ_7745c5c3_Var140 string
27032703 templ_7745c5c3_Var140, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Files)))
27042704 if templ_7745c5c3_Err != nil {
2705- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 501, Col: 52}
2705+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 501, Col: 66}
27062706 }
27072707 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var140))
27082708 if templ_7745c5c3_Err != nil {
@@ -2721,7 +2721,7 @@ func compareRail(p ComparePage) templ.Component {
27212721 return templ_7745c5c3_Err
27222722 }
27232723 }
2724- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 245, "<section aria-labelledby=\"r-cli\"><h2 id=\"r-cli\">from a terminal</h2>")
2724+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 245, "<section aria-labelledby=\"r-cli\"><h2 id=\"r-cli\">From a terminal</h2>")
27252725 if templ_7745c5c3_Err != nil {
27262726 return templ_7745c5c3_Err
27272727 }
M internal/adapters/web/overlays.templ +26 -26
@@ -6,23 +6,22 @@ import "strings"
66
77 // OverlayHelp is the keyboard map panel.
88 templ OverlayHelp() {
9- <dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
10- <div class="panel-head"><span>keys</span><button type="button" hx-on:click="closest('dialog').close()">[esc] close</button></div>
9+ <dialog id="panel-help" class="panel modal" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
10+ <div class="panel-head"><h2>Keyboard shortcuts</h2><button type="button" class="panel-x" hx-on:click="closest('dialog').close()"><kbd>esc</kbd> close</button></div>
1111 <dl class="keys">
12- <div><dt><kbd>j</kbd> <kbd>k</kbd></dt><dd>move cursor down / up</dd></div>
13- <div><dt><kbd>enter</kbd></dt><dd>open row under cursor</dd></div>
14- <div><dt><kbd>g</kbd> <kbd>g</kbd> / <kbd>G</kbd></dt><dd>first / last row</dd></div>
15- <div><dt><kbd>/</kbd></dt><dd>focus the filter</dd></div>
16- <div><dt><kbd>g h</kbd> <kbd>g t</kbd></dt><dd>go home / tree</dd></div>
17- <div><dt><kbd>g c</kbd> <kbd>g r</kbd></dt><dd>go commits / refs</dd></div>
18- <div><dt><kbd>b</kbd></dt><dd>{ "switch" } ref (branches, tags)</dd></div>
19- <div><dt><kbd>:</kbd> <kbd>f</kbd></dt><dd>go to: section, ref, file, repo</dd></div>
20- <div><dt><kbd>y</kbd></dt><dd>permalink: ref becomes the full sha</dd></div>
21- <div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd></dt><dd>repo sections (when shown)</dd></div>
22- <div><dt><kbd>m</kbd> <kbd>g m</kbd></dt><dd>merge requests (when shown)</dd></div>
12+ <div><dt><kbd>ctrl k</kbd> <kbd>:</kbd></dt><dd>go to a section, branch, tag, file or repository</dd></div>
13+ <div><dt><kbd>?</kbd></dt><dd>this list</dd></div>
14+ <div><dt><kbd>j</kbd> <kbd>k</kbd></dt><dd>move the row cursor down / up</dd></div>
15+ <div><dt><kbd>enter</kbd></dt><dd>open the row under the cursor</dd></div>
16+ <div><dt><kbd>g g</kbd> <kbd>G</kbd></dt><dd>first / last row</dd></div>
17+ <div><dt><kbd>/</kbd></dt><dd>filter the list</dd></div>
18+ <div><dt><kbd>b</kbd></dt><dd>{ "switch" } branch or tag</dd></div>
19+ <div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd> <kbd>m</kbd></dt><dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd></div>
20+ <div><dt><kbd>g h</kbd></dt><dd>home</dd></div>
21+ <div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div>
2322 <div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>diff: split / unified / collapse all</dd></div>
2423 <div><dt><kbd>n</kbd></dt><dd>merge request: next note in the diff</dd></div>
25- <div><dt><kbd>esc</kbd></dt><dd>close panel</dd></div>
24+ <div><dt><kbd>esc</kbd></dt><dd>close a dialog, leave a field</dd></div>
2625 </dl>
2726 </dialog>
2827 }
@@ -30,22 +29,22 @@ templ OverlayHelp() {
3029 // OverlayRefs is the ref switcher. It exists only inside a repository.
3130 templ OverlayRefs(l Layout) {
3231 if l.Ref != "" {
33- <dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
32+ <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
3433 <div class="panel-head">
35- <label for="refs-filter">ref&gt;</label>
34+ <label for="refs-filter" class="sr">filter branches and tags</label>
3635 <input
3736 id="refs-filter"
3837 type="search"
39- placeholder="filter branches and tags"
38+ placeholder="Switch branch or tag…"
4039 autocomplete="off"
4140 data-pick
4241 hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"
4342 />
44- <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
43+ <button type="button" class="panel-x" hx-on:click="closest('dialog').close()"><kbd>esc</kbd></button>
4544 </div>
4645 <div class="panel-cols" data-picklist>
4746 <section aria-label="Branches">
48- <h2>branches <span class="mute">{ itoa(len(l.Heads)) }</span></h2>
47+ <h2>Branches <span class="count">{ itoa(len(l.Heads)) }</span></h2>
4948 <ul>
5049 for _, r := range l.Heads {
5150 @refItem(r)
@@ -53,7 +52,7 @@ templ OverlayRefs(l Layout) {
5352 </ul>
5453 </section>
5554 <section aria-label="Tags">
56- <h2>tags <span class="mute">{ itoa(len(l.Tags)) }</span></h2>
55+ <h2>Tags <span class="count">{ itoa(len(l.Tags)) }</span></h2>
5756 <ul>
5857 for _, r := range l.Tags {
5958 @refItem(r)
@@ -87,13 +86,13 @@ templ refItem(r RefItem) {
8786 // OverlayCmd is the ":" command line: one filterable list mixing sections,
8887 // refs, files of this directory and repos.
8988 templ OverlayCmd(items []CmdItem) {
90- <dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">
89+ <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()">
9190 <div class="panel-head">
92- <label for="cmd-q" class="hi">:</label>
91+ <label for="cmd-q" class="sr">go to</label>
9392 <input
9493 id="cmd-q"
9594 type="search"
96- placeholder="section, ref, file or repo"
95+ placeholder="Go to a section, branch, tag, file or repository…"
9796 autocomplete="off"
9897 spellcheck="false"
9998 aria-controls="cmd-list"
@@ -106,12 +105,13 @@ templ OverlayCmd(items []CmdItem) {
106105 if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) }
107106 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"
108107 />
109- <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>
108+ <button type="button" class="panel-x" hx-on:click="closest('dialog').close()"><kbd>esc</kbd></button>
110109 </div>
111- <ul id="cmd-list" class="cmd-list">
110+ <ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
112111 for i, c := range items {
113- <li data-k={ strings.ToLower(c.Kind + " " + c.Label) } data-cursor?={ i == 0 }><a href={ templ.SafeURL(c.Href) } tabindex="-1"><span class="k">{ c.Kind }</span>{ c.Label }</a></li>
112+ <li data-k={ strings.ToLower(c.Kind + " " + c.Label) } data-cursor?={ i == 0 }><a href={ templ.SafeURL(c.Href) } tabindex="-1"><span class="lbl">{ c.Label }</span><span class="k">{ c.Kind }</span></a></li>
114113 }
115114 </ul>
115+ <p class="panel-foot"><span><kbd>↑</kbd><kbd>↓</kbd> move</span><span><kbd>enter</kbd> open</span><span><kbd>esc</kbd> close</span></p>
116116 </dialog>
117117 }
M internal/adapters/web/overlays_templ.go +19 -19 generated file: collapsed
M internal/adapters/web/pages.templ +16 -20
@@ -6,12 +6,12 @@ package web
66 templ Home(l Layout, p HomePage) {
77 @Shell(l, nil) {
88 <div class="filter">
9- <label for="home-filter">filter&gt;</label>
9+ <label for="home-filter" class="sr">filter</label>
1010 <input
1111 id="home-filter"
1212 type="search"
1313 data-filter
14- placeholder="name or description ( / )"
14+ placeholder="Filter repositories…"
1515 autocomplete="off"
1616 hx-on:input="let t = this.value.toLowerCase(); q('#repo-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#repo-rows > tr:not([hidden])')"
1717 />
@@ -52,23 +52,22 @@ templ Summary(l Layout, p SummaryPage) {
5252 <p class="lede">{ p.Desc }</p>
5353 }
5454 if p.Readme != nil {
55- <h2 class="rule-h"><span>{ p.Readme.Name }</span></h2>
56- @ReadmeView(*p.Readme)
55+ @ReadmeCard(*p.Readme)
5756 }
5857 }
5958 }
6059
6160 templ summaryRail(p SummaryPage) {
62- <section aria-labelledby="r-clone"><h2 id="r-clone">clone</h2>@CloneBlock(p.Clone)
61+ <section aria-labelledby="r-clone"><h2 id="r-clone">Clone</h2>@CloneBlock(p.Clone)
6362 </section>
64- <section aria-labelledby="r-facts"><h2 id="r-facts">facts</h2>@Facts(p.Facts)
63+ <section aria-labelledby="r-facts"><h2 id="r-facts">About</h2>@Facts(p.Facts)
6564 </section>
6665 if len(p.Recent) > 0 {
67- <section aria-labelledby="r-recent"><h2 id="r-recent">recent commits <a href={ templ.SafeURL(p.AllHref) }>all</a></h2>@CommitRows(p.Recent)
66+ <section aria-labelledby="r-recent"><h2 id="r-recent">Recent commits <a href={ templ.SafeURL(p.AllHref) }>View all</a></h2>@CommitRows(p.Recent)
6867 </section>
6968 }
7069 if len(p.Outline) > 0 && p.Readme != nil {
71- <section aria-labelledby="r-outline"><h2 id="r-outline">{ p.Readme.Name }</h2>@Outline(p.Outline)
70+ <section aria-labelledby="r-outline"><h2 id="r-outline">On this page</h2>@Outline(p.Outline)
7271 </section>
7372 }
7473 }
@@ -77,12 +76,12 @@ templ summaryRail(p SummaryPage) {
7776 templ Refs(l Layout, p RefsPage) {
7877 @Shell(l, refsRail(p)) {
7978 <div class="filter">
80- <label for="ref-filter">filter&gt;</label>
79+ <label for="ref-filter" class="sr">filter</label>
8180 <input
8281 id="ref-filter"
8382 type="search"
8483 data-filter
85- placeholder="branch or tag ( / )"
84+ placeholder="Filter branches and tags…"
8685 autocomplete="off"
8786 hx-on:input="let t = this.value.toLowerCase(); q('#ref-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t));
8887 let first = q('#ref-rows > tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#ref-rows > tr')"
@@ -107,7 +106,7 @@ templ refRow(kind string, r RefRow) {
107106 <td class="c-name">
108107 <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a>
109108 if r.Default {
110- <span class="tagged">*</span>
109+ <span class="pill">default</span>
111110 }
112111 </td>
113112 <td class="c-sha">
@@ -128,20 +127,17 @@ templ refRow(kind string, r RefRow) {
128127
129128 templ refsRail(p RefsPage) {
130129 <section aria-labelledby="r-refs">
131- <h2 id="r-refs">refs</h2>
130+ <h2 id="r-refs">Overview</h2>
132131 @RefsKV(p)
133132 </section>
134- <section aria-labelledby="r-legend">
135- <h2 id="r-legend">legend</h2>
136- <dl class="kv"><dt>*</dt><dd>default branch</dd></dl>
137- </section>
138133 }
139134
140135 // State is a 404, 500 or empty-repository page inside the normal shell.
141136 templ State(l Layout, p StatePage) {
142137 @Shell(l, nil) {
143138 <section class="state" aria-labelledby="state-title">
144- <h1 id="state-title"><span class="code">{ p.Code }</span> { p.Title }</h1>
139+ <p class="eyebrow">{ p.Code }</p>
140+ <h1 id="state-title">{ p.Title }</h1>
145141 for _, line := range p.Lines {
146142 <p class="dim">{ line }</p>
147143 }
@@ -162,14 +158,14 @@ templ State(l Layout, p StatePage) {
162158 templ Login(l Layout, p LoginPage) {
163159 @Shell(l, nil) {
164160 <section class="state" aria-labelledby="state-title">
165- <h1 id="state-title"><span class="code">login</span> { p.Title }</h1>
161+ <h1 id="state-title">{ p.Title }</h1>
166162 for _, line := range p.Lines {
167163 <p class="dim">{ line }</p>
168164 }
169165 <form class="state-act" method="post" action={ templ.SafeURL(p.Action) }>
170- <button type="submit">{ p.Button }</button>
166+ <button type="submit" class="btn primary">{ p.Button }</button>
171167 </form>
172- <p class="state-back"><a href="/">home</a></p>
168+ <p class="state-back"><a href="/">Back to home</a></p>
173169 </section>
174170 }
175171 }
M internal/adapters/web/pages_templ.go +159 -189 generated file: collapsed
M internal/adapters/web/refs.go +1 -1
@@ -165,7 +165,7 @@ func hasName(items []RefItem, name string) bool {
165165 return false
166166 }
167167
168-// pathCrumbs is the path in the top bar: repo, kind ("tree", "blob" or
168+// pathCrumbs is the path in the header: repo, kind ("tree", "blob" or
169169 // "commits"), ref, then the segments of the path. The directories link to
170170 // their tree; the last segment links to the file for a blob, and to nothing
171171 // for a history.
M internal/adapters/web/refspage.go +1 -5
@@ -26,11 +26,7 @@ func (s *Server) refs(ctx context.Context, r *http.Request) (templ.Component, in
2626 l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=refs")
2727 l.Crumbs[0].Current = false
2828 l.Crumbs = append(l.Crumbs, Crumb{Label: "refs", Current: true})
29- l.Status = Status{
30- Mode: "refs", Context: plural(len(p.Branches), "branch", "branches") + " · " + plural(len(p.Tags), "tag", "tags"),
31- Hints: "j/k move · enter open · / filter · ? help", Cursor: true, Count: len(p.Branches) + len(p.Tags),
32- }
33- l.HasRail, l.RailSum = true, "refs · legend"
29+ l.HasRail = true
3430 return Refs(l, p), http.StatusOK, nil
3531 }
3632
M internal/adapters/web/repo.go +9 -9
@@ -6,6 +6,7 @@ import (
66 "context"
77 "slices"
88 "sort"
9+ "strings"
910 "sync"
1011 "time"
1112
@@ -98,23 +99,23 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La
9899 l.Ref, l.RefPath = def, def
99100 l.SHA, l.Short = d.head.GetCommitId(), short(d.head.GetCommitId())
100101 }
101- mk := func(key, post, href, id string) Section {
102- return Section{Key: key, Post: post, Href: href, Current: section == id}
102+ mk := func(key, label, href, id string) Section {
103+ return Section{Key: key, Label: label, Href: href, Current: section == id}
103104 }
104105 l.Sections = []Section{
105- mk("s", "ummary", repoHref(d.name()), "summary"),
106- mk("t", "ree", repoHref(d.name(), "tree", def), "tree"),
107- mk("c", "ommits", repoHref(d.name(), "commits", def), "commits"),
108- mk("r", "efs", repoHref(d.name(), "refs"), "refs"),
106+ mk("s", "Summary", repoHref(d.name()), "summary"),
107+ mk("t", "Files", repoHref(d.name(), "tree", def), "tree"),
108+ mk("c", "Commits", repoHref(d.name(), "commits", def), "commits"),
109+ mk("r", "Branches & tags", repoHref(d.name(), "refs"), "refs"),
109110 }
110111 if s.cfg.MergeRequests != nil {
111- l.Sections = append(l.Sections, mk("m", "erge requests", mrBase(d.name()), "merge-requests"))
112+ l.Sections = append(l.Sections, mk("m", "Merge requests", mrBase(d.name()), "merge-requests"))
112113 }
113114 l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}}
114115
115116 l.Cmd = append(l.Cmd, CmdItem{Kind: "go", Label: "home", Href: "/"})
116117 for _, sec := range l.Sections {
117- l.Cmd = append(l.Cmd, CmdItem{Kind: "section", Label: sec.Key + sec.Post, Href: sec.Href})
118+ l.Cmd = append(l.Cmd, CmdItem{Kind: "section", Label: strings.ToLower(sec.Label), Href: sec.Href})
118119 }
119120 if l.Ref != "" {
120121 for _, b := range d.branches {
@@ -233,7 +234,6 @@ func (s *Server) emptyState(ctx context.Context, d *repoData) (Layout, StatePage
233234 l.Title = d.name() + " · " + s.instance
234235 l.Sections = nil
235236 l.Cmd = slices.DeleteFunc(l.Cmd, func(c CmdItem) bool { return c.Kind == "section" })
236- l.Status = Status{Mode: "empty", Context: "empty repository · 0 commits", Hints: "g h home · ? help"}
237237 branch := d.repo.GetDefaultBranch()
238238 return l, StatePage{
239239 Code: "empty", Title: d.name() + " is empty",
M internal/adapters/web/session.go +3 -3
@@ -251,12 +251,12 @@ func (s *Server) loginConfirm(w http.ResponseWriter, r *http.Request) {
251251 s.secretPage(w, r, http.StatusNotFound, s.loginRejectedPage())
252252 return
253253 }
254- l := s.stateLayout("sign in", "sign in · confirm", []Crumb{{Label: "-"}, {Label: "sign in"}})
254+ l := s.stateLayout("sign in", []Crumb{{Label: "sign in", Current: true}})
255255 s.secretPage(w, r, http.StatusOK, Login(l, LoginPage{
256256 Title: "sign in as the owner",
257257 Lines: []string{"this browser will be signed in for 12 hours.", "only confirm if you ran `ermite web login` just now."},
258258 Action: loginPrefix + nonce,
259- Button: "[sign in]",
259+ Button: "Sign in",
260260 }))
261261 }
262262
@@ -310,7 +310,7 @@ func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
310310
311311 // stateOnly is a state page with a title and two lines, for the login flow.
312312 func (s *Server) stateOnly(crumb, code, title string, lines ...string) templ.Component {
313- l := s.stateLayout(title, code+" · "+title, []Crumb{{Label: "-"}, {Label: crumb}})
313+ l := s.stateLayout(title, []Crumb{{Label: crumb, Current: true}})
314314 return State(l, StatePage{Code: code, Title: title, Lines: lines, Back: []Link{{Label: "home", Href: "/"}}})
315315 }
316316
M internal/adapters/web/shell.templ +96 -84
@@ -2,8 +2,9 @@
22
33 package web
44
5-// Shell is the page: head, the two bars, the work area (content and rail),
6-// the overlays and the key map. The content is the children.
5+// Shell is the page: head, the header, the work area (content and rail), the
6+// footer, the hint badge, the overlays and the key map. The content is the
7+// children.
78 templ Shell(l Layout, rail templ.Component) {
89 <!DOCTYPE html>
910 <html lang="en">
@@ -13,132 +14,143 @@ templ Shell(l Layout, rail templ.Component) {
1314 <meta name="color-scheme" content="light dark"/>
1415 <title>{ l.Title }</title>
1516 <link rel="icon" type="image/svg+xml" href={ templ.SafeURL(l.Assets.Icon) }/>
17+ <link rel="preload" href={ templ.SafeURL(l.Assets.Font) } as="font" type="font/woff2" crossorigin/>
1618 <link rel="stylesheet" href={ templ.SafeURL(l.Assets.CSS) }/>
1719 <script src={ l.Assets.HTMX } defer></script>
1820 <script src={ l.Assets.Live } defer></script>
1921 <script src={ l.Assets.SSE } defer></script>
2022 <script src={ l.Assets.App } defer></script>
2123 </head>
22- <body data-ref={ l.RefPath } data-sha={ l.SHA } data-sel="">
24+ <body data-ref={ l.RefPath } data-sha={ l.SHA } data-sel="" data-nav="">
2325 <a class="skip" href="#main">skip to content</a>
24- @Topbar(l)
25- <div class={ "work", templ.KV("has-rail", l.HasRail), templ.KV("read", l.Read) }>
26- <main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">
26+ @Header(l)
27+ <div class={ "work", templ.KV("has-rail", l.HasRail && rail != nil) }>
28+ <main id="main" tabindex="-1">
2729 { children... }
2830 </main>
2931 if l.HasRail && rail != nil {
3032 <aside class="rail" aria-label="Context">
31- <details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }">
32- <summary>{ l.RailSum }</summary>
33- @rail
34- </details>
33+ @rail
3534 </aside>
3635 }
3736 </div>
37+ @Footer(l)
38+ @Hint(l)
3839 @OverlayHelp()
3940 @OverlayRefs(l)
4041 @OverlayCmd(l.Cmd)
41- @Statusbar(l)
4242 @Keys()
4343 </body>
4444 </html>
4545 }
4646
47-// Topbar is the top bar, neferbar style: the one accent block (instance)
48-// fading into the black bar, the path, the repo sections, the ref switcher.
49-templ Topbar(l Layout) {
50- <header class="bar top">
51- <a class="blk" href="/" data-g="h" title="home (g h)">{ l.Instance }</a><span class="fade" aria-hidden="true"></span>
52- <nav class="crumbs" aria-label="Path">
53- <ol>
54- for _, c := range l.Crumbs {
55- <li>
56- if c.Href != "" {
57- <a
58- href={ templ.SafeURL(c.Href) }
59- if c.Current {
60- aria-current="page"
47+// Header is the page header: the instance, the path, the ref switcher, the
48+// "go to" palette and the owner; inside a repository, the tabs below.
49+templ Header(l Layout) {
50+ <header class="hdr">
51+ <div class="hdr-top">
52+ <a class="brand" href="/" data-g="h" title="home (g h)">
53+ <span class="brand-mark" aria-hidden="true"></span>
54+ <span>{ l.Instance }</span>
55+ </a>
56+ if len(l.Crumbs) > 0 {
57+ <nav class="crumbs" aria-label="Path">
58+ <ol>
59+ for _, c := range l.Crumbs {
60+ <li>
61+ if c.Href != "" {
62+ <a
63+ href={ templ.SafeURL(c.Href) }
64+ if c.Current {
65+ aria-current="page"
66+ }
67+ >{ c.Label }</a>
68+ } else {
69+ <span aria-current="page">{ c.Label }</span>
6170 }
62- >{ c.Label }</a>
63- } else {
64- <span aria-current="page">{ c.Label }</span>
71+ </li>
6572 }
66- </li>
73+ </ol>
74+ </nav>
75+ }
76+ <div class="hdr-acts">
77+ if l.Ref != "" {
78+ <button
79+ type="button"
80+ class="btn ref"
81+ aria-haspopup="dialog"
82+ aria-controls="panel-refs"
83+ aria-keyshortcuts="b"
84+ title="switch branch or tag (b)"
85+ hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"
86+ >
87+ <span class="ico i-branch" aria-hidden="true"></span>
88+ <span class="ref-name">{ l.Ref }</span>
89+ <span class="sha">{ l.Short }</span>
90+ </button>
6791 }
68- </ol>
69- </nav>
92+ <button
93+ type="button"
94+ class="btn search"
95+ aria-haspopup="dialog"
96+ aria-controls="panel-cmd"
97+ aria-keyshortcuts="Control+K :"
98+ title="go to a section, ref, file or repo (ctrl k or :)"
99+ hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"
100+ >
101+ <span class="ico i-search" aria-hidden="true"></span>
102+ <span class="search-label">Go to…</span>
103+ <kbd>ctrl k</kbd>
104+ </button>
105+ <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button>
106+ if SignedIn(ctx) {
107+ <form class="owner" method="post" action="/-/logout">
108+ <span class="owner-badge" title="signed in as the owner">owner</span>
109+ <button type="submit" class="btn" title="sign out of this browser">Sign out</button>
110+ </form>
111+ }
112+ </div>
113+ </div>
70114 if len(l.Sections) > 0 {
71- <nav class="secs" aria-label="Sections">
115+ <nav class="tabs" aria-label="Sections">
72116 <ul>
73117 for _, s := range l.Sections {
74118 <li>
75119 <a
76120 href={ templ.SafeURL(s.Href) }
77121 data-key={ s.Key }
78- aria-keyshortcuts={ "g " + s.Key }
122+ aria-keyshortcuts={ s.Key }
79123 if s.Current {
80124 aria-current="page"
81125 }
82- >{ s.Key }{ s.Post }</a>
126+ >{ s.Label }<kbd aria-hidden="true">{ s.Key }</kbd></a>
83127 </li>
84128 }
85129 </ul>
86130 </nav>
87131 }
88- if l.Ref != "" {
89- <button
90- type="button"
91- class="ref"
92- aria-haspopup="dialog"
93- aria-controls="panel-refs"
94- aria-keyshortcuts="b"
95- title="switch ref (b)"
96- hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"
97- >{ l.Ref }<span class="sha">{ "@" + l.Short }</span></button>
98- }
99132 </header>
100133 }
101134
102-// Statusbar is the bottom bar: mode, context, the ":" command prompt, the
103-// source link (AGPL §13), hints, live state and the cursor position. Plain
104-// text only: the top bar carries the accent.
105-templ Statusbar(l Layout) {
106- <footer class="bar status" aria-label="Status">
107- <span class="mode">{ l.Status.Mode }</span>
108- <span class="st-ctx">{ l.Status.Context }</span>
109- <button
110- type="button"
111- class="st-cmd"
112- aria-haspopup="dialog"
113- aria-controls="panel-cmd"
114- aria-keyshortcuts=":"
115- hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"
116- ><span class="hi">:</span> go to…</button>
117- if l.Status.Blob {
118- <span class="st-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : 'click a line number · shift-click extends'">click a line number · shift-click extends</span>
119- }
120- <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"></span>
121- <span class="st-hints">{ l.Status.Hints }</span>
122- if SignedIn(ctx) {
123- <form class="st-owner" method="post" action="/-/logout">
124- <span>owner</span>
125- <button type="submit" title="sign out of this browser">[sign out]</button>
126- </form>
127- }
128- @Footer(l)
129- @Live(l)
130- if l.Status.Cursor {
131- <span
132- class="st-pos num"
133- title="cursor position"
134- :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()"
135- >{ cursorText(l.Status.Count) }</span>
136- }
137- </footer>
138-}
139-
140135 // Footer is the version and the link to the source of the running version,
141136 // which the AGPL §13 asks every user of the service to be offered.
142137 templ Footer(l Layout) {
143- <span class="st-src"><span class="st-ver">ermite { l.Version } · </span><a href={ templ.SafeURL(l.SourceURL) } rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a></span>
138+ <footer class="foot">
139+ <span>ermite { l.Version }</span>
140+ <a href={ templ.SafeURL(l.SourceURL) } rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
141+ <span class="foot-keys"><kbd>?</kbd> keyboard shortcuts</span>
142+ </footer>
143+}
144+
145+// Hint is the floating badge at the bottom right. It shows only what the
146+// keyboard just did or what needs attention: the row position while j/k
147+// move, the selected lines of a file, a flash message (permalink copied) and
148+// a lost event stream. Empty, it takes no room.
149+templ Hint(l Layout) {
150+ <div class="hint" role="status" aria-live="polite">
151+ <span class="hint-nav" :text="document.body.dataset.nav || ''"></span>
152+ <span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''"></span>
153+ <span class="hint-flash" :text="document.body.dataset.flash || ''"></span>
154+ @Live(l)
155+ </div>
144156 }
M internal/adapters/web/shell_templ.go +230 -274 generated file: collapsed
M internal/adapters/web/static/css/00-tokens.css +74 -46
@@ -1,68 +1,96 @@
11 @layer reset, tokens, base, components, utilities;
22
3-/* Palette: the neferbar "mono" theme, black and grays only.
4- Page surfaces follow the OS theme; the two bars stay black in both themes,
5- like neferbar under a light or a dark terminal. */
3+/* Fonts: Atkinson Hyperlegible Next for the interface, Atkinson Hyperlegible
4+ Mono for titles, code, shas, refs and keys. Both are embedded (static/fonts,
5+ SIL OFL 1.1), latin subset, variable weight. The asset loader turns the
6+ relative font and icon references into hashed URLs. */
7+@font-face {
8+ font-family: "Atkinson Hyperlegible Next";
9+ src: url(fonts/atkinson-next.woff2) format("woff2");
10+ font-weight: 200 800; font-style: normal; font-display: swap;
11+}
12+@font-face {
13+ font-family: "Atkinson Hyperlegible Next";
14+ src: url(fonts/atkinson-next-italic.woff2) format("woff2");
15+ font-weight: 200 800; font-style: italic; font-display: swap;
16+}
17+@font-face {
18+ font-family: "Atkinson Hyperlegible Mono";
19+ src: url(fonts/atkinson-mono.woff2) format("woff2");
20+ font-weight: 200 800; font-style: normal; font-display: swap;
21+}
22+
23+/* Palette: grays only, in four surface levels, so that blocks read apart
24+ without hue. Dark: each level is lighter than the one below it; light: each
25+ is whiter. Hue is kept for code, diffs and verdicts. */
626 @layer tokens {
727 :root {
828 color-scheme: light dark;
929
10- /* page */
11- --paper: light-dark(#f2f2f2, #000000);
12- --paper-alt: light-dark(#e8e8e8, #0c0c0c);
13- --row: light-dark(#dddddd, #1a1a1a);
14- --sel: light-dark(#cbcbcb, #303030);
15- --ink: light-dark(#161616, #d0d0d0);
16- --ink-hi: light-dark(#000000, #ffffff);
17- --ink-dim: light-dark(#4a4a4a, #9a9a9a);
18- --ink-mute: light-dark(#5c5c5c, #8a8a8a);
19- --rule: light-dark(#bcbcbc, #303030);
20- --focus: light-dark(#000000, #ffffff);
30+ /* surfaces, bottom to top */
31+ --bg-canvas: light-dark(#f3f3f3, #0c0c0c); /* the page */
32+ --bg-inset: light-dark(#f8f8f8, #080808); /* code, fields: sunk into a surface */
33+ --bg-surface:light-dark(#ffffff, #141414); /* cards, header, rail blocks */
34+ --bg-raised: light-dark(#ececec, #1d1d1d); /* hover, buttons, panel rows */
35+ --bg-strong: light-dark(#dedede, #292929); /* selected row, pressed button */
36+ --bg-overlay:light-dark(#ffffff, #171717); /* dialogs */
37+ --backdrop: light-dark(rgb(0 0 0 / .25), rgb(0 0 0 / .6));
2138
22- /* bars (neferbar mono.conf: background, foreground, color4 accent, color0 ink) */
23- --bar: #000000;
24- --bar-ink: #d0d0d0;
25- --bar-dim: #8a8a8a;
26- --accent: #c8c8c8;
27- --on-accent: #000000;
28- /* lib.sh fade(): four cells from the accent to the bar, each pulled halfway to the bar */
29- --fade-1: #505050;
30- --fade-2: #3c3c3c;
31- --fade-3: #282828;
32- --fade-4: #141414;
39+ --border: light-dark(#dddddd, #242424);
40+ --border-strong: light-dark(#c2c2c2, #383838);
3341
34- /* Colour, sparingly, from the owner's Despair theme: only code and diffs carry hue.
35- Dark values are Despair's own; light values are the same hues darkened to AA on --paper. */
36- --teal: light-dark(#2f6b6b, #7db5b5); /* Despair bright teal: keywords, additions */
37- --teal-2: light-dark(#355f5f, #5a8585); /* slate teal: types, numbers */
38- --red: light-dark(#a8404b, #d96c75); /* soft red: deletions */
39- --rose: light-dark(#6b6570, #a7a2a9); /* rose quartz: strings */
42+ --ink: light-dark(#1b1b1b, #d6d6d6);
43+ --ink-hi: light-dark(#000000, #fafafa);
44+ --ink-dim: light-dark(#4d4d4d, #a3a3a3);
45+ --ink-mute: light-dark(#616161, #8c8c8c);
46+ --focus: light-dark(#000000, #ffffff);
47+ --on-ink: light-dark(#ffffff, #0c0c0c); /* text on a filled ink button */
48+
49+ /* Colour, sparingly, from the owner's Despair theme: only code, diffs and
50+ verdicts carry hue. Light values are the same hues darkened to AA. */
51+ --teal: light-dark(#2f6b6b, #7db5b5);
52+ --teal-2: light-dark(#355f5f, #5a8585);
53+ --red: light-dark(#a8404b, #d96c75);
54+ --rose: light-dark(#6b6570, #a7a2a9);
4055
4156 --syn-plain: var(--ink);
4257 --syn-keyword: var(--teal);
4358 --syn-string: var(--rose);
44- --syn-comment: light-dark(#5c5f63, #6a6e73);
59+ --syn-comment: light-dark(#5c5f63, #6e7277);
4560 --syn-number: var(--teal-2);
4661 --syn-func: var(--ink-hi);
4762 --syn-type: var(--teal-2);
4863 --syn-punct: var(--ink-dim);
4964
50- /* diff rows: the line keeps the normal ink; the hue is in a faint row tint and the marker */
51- --add: light-dark(#d8e5e5, #121919);
52- --del: light-dark(#eedadc, #231113);
65+ --add: light-dark(#dcebeb, #111b1b);
66+ --del: light-dark(#f2dfe1, #231315);
5367
54- /* one family (the neferbar font when installed), three sizes, two weights */
55- --font: "Adwaita Mono", "AdwaitaMono Nerd Font Mono", ui-monospace, "Cascadia Mono", "DejaVu Sans Mono", Menlo, Consolas, monospace;
56- --fs: 14px;
68+ --font: "Atkinson Hyperlegible Next", system-ui, sans-serif;
69+ --mono: "Atkinson Hyperlegible Mono", ui-monospace, "Cascadia Mono", "DejaVu Sans Mono", Menlo, Consolas, monospace;
70+ --fs: 15px;
5771 --fs-code: 13px;
58- --fs-s: 12px;
59- --lh: 20px;
60- --lh-code: 18px;
72+ --fs-s: 13px;
73+ --fs-xs: 12px;
74+ --lh: 24px;
75+ --lh-code: 20px;
76+
77+ /* spacing scale */
78+ --s1: 4px; --s2: 8px; --s3: 12px; --s4: 16px; --s5: 24px; --s6: 32px; --s7: 48px;
79+ --r: 8px; /* cards, dialogs */
80+ --r-s: 6px; /* buttons, fields */
81+
82+ --pad: var(--s5);
83+ --stick: 0px; /* top offset of sticky headers inside the content */
84+ --measure: 80ch; /* reading width of prose */
85+ --rail: 340px;
86+ --page: 1320px;
87+ --shadow: 0 1px 2px rgb(0 0 0 / .06), 0 8px 32px rgb(0 0 0 / .18);
6188
62- --pad: 2ch;
63- --bar-h: 20px; /* one text line, like neferbar: no padding above or below */
64- --stick: var(--bar-h); /* top offset of sticky headers inside the content (0 in the app shell) */
65- --measure: 80ch;
66- --rail: 40ch;
89+ /* names used by the code, diff and merge request sheets */
90+ --paper: var(--bg-canvas);
91+ --paper-alt: var(--bg-raised);
92+ --row: var(--bg-raised);
93+ --sel: var(--bg-strong);
94+ --rule: var(--border);
6795 }
6896 }
M internal/adapters/web/static/css/10-base.css +38 -23
@@ -1,56 +1,71 @@
11 @layer reset {
2- *, *::before, *::after { box-sizing: border-box; border-radius: 0; }
2+ *, *::before, *::after { box-sizing: border-box; }
33 html { -webkit-text-size-adjust: 100%; }
44 body, h1, h2, h3, h4, p, ul, ol, dl, dd, figure, pre { margin: 0; }
55 ul, ol { padding: 0; list-style: none; }
66 table { border-collapse: collapse; border-spacing: 0; }
77 th, td { padding: 0; text-align: left; font-weight: inherit; }
8- button, input, select { font: inherit; color: inherit; letter-spacing: inherit; }
8+ button, input, select, textarea { font: inherit; color: inherit; letter-spacing: inherit; }
99 button { background: none; border: 0; padding: 0; cursor: pointer; text-align: inherit; }
10- input { background: transparent; border: 0; border-radius: 0; }
10+ input { background: transparent; border: 0; }
1111 summary { cursor: pointer; }
1212 dialog { color: inherit; border: 0; padding: 0; max-width: none; max-height: none; }
1313 img { max-width: 100%; }
1414 }
1515
1616 @layer base {
17- html { background: var(--paper); scrollbar-width: thin; scrollbar-color: var(--rule) transparent; }
17+ /* a cross-document fade between pages, where the browser has it */
18+ @view-transition { navigation: auto; }
19+ ::view-transition-old(root), ::view-transition-new(root) { animation-duration: 120ms; }
20+ @media (prefers-reduced-motion: reduce) {
21+ @view-transition { navigation: none; }
22+ }
23+
24+ html { background: var(--bg-canvas); scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; }
1825 body {
1926 min-height: 100dvh;
20- display: grid;
21- grid-template-rows: auto auto 1fr auto;
27+ display: flex; flex-direction: column;
2228 font: var(--fs) / var(--lh) var(--font);
2329 color: var(--ink);
24- background: var(--paper);
25- font-variant-ligatures: none;
30+ background: var(--bg-canvas);
2631 tab-size: 4;
32+ -webkit-font-smoothing: antialiased;
2733 }
28- h1, h2, h3, h4 { font-size: inherit; font-weight: bold; }
29- b, strong { font-weight: bold; }
30- em, i { font-style: normal; text-decoration: underline; text-underline-offset: 3px; }
34+ h1, h2, h3, h4 { font-family: var(--mono); font-weight: 700; color: var(--ink-hi); letter-spacing: -.01em; }
35+ h1 { font-size: 22px; line-height: 30px; }
36+ h2 { font-size: 17px; line-height: 26px; }
37+ h3, h4 { font-size: var(--fs); }
38+ b, strong { font-weight: 700; }
3139 small { font-size: var(--fs-s); }
32- code, pre, kbd, samp { font: inherit; }
33- a { color: inherit; text-decoration: underline; text-decoration-color: var(--rule); text-underline-offset: 3px; }
34- a:hover { text-decoration-color: currentColor; }
35- hr { border: 0; border-top: 1px solid var(--rule); margin: var(--lh) 0; }
36- ::selection { background: var(--ink); color: var(--paper); }
40+ code, pre, kbd, samp { font-family: var(--mono); }
41+ a { color: inherit; text-decoration: underline; text-decoration-color: var(--border-strong); text-underline-offset: 3px; }
42+ a:hover { text-decoration-color: currentColor; color: var(--ink-hi); }
43+ hr { border: 0; border-top: 1px solid var(--border); margin: var(--s5) 0; }
44+ ::selection { background: var(--ink-hi); color: var(--on-ink); }
3745
3846 :focus { outline: none; }
39- :focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
47+ :focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; border-radius: 3px; }
4048
4149 [hidden] { display: none !important; }
4250
51+ kbd {
52+ display: inline-block; min-width: 20px; padding: 0 5px;
53+ font: 600 var(--fs-xs) / 18px var(--mono); text-align: center; color: var(--ink-dim);
54+ background: var(--bg-canvas); border: 1px solid var(--border-strong); border-bottom-width: 2px; border-radius: 4px;
55+ }
56+
4357 .skip {
44- position: absolute; left: 0; top: 0; z-index: 30; padding: 0 1ch;
45- background: var(--ink); color: var(--paper);
46- transform: translateY(-100%);
58+ position: absolute; left: var(--s2); top: var(--s2); z-index: 30; padding: var(--s1) var(--s3);
59+ background: var(--ink-hi); color: var(--on-ink); border-radius: var(--r-s);
60+ transform: translateY(-200%);
4761 }
4862 .skip:focus { transform: none; }
4963
50- main { padding: 0 var(--pad) var(--lh); min-width: 0; }
64+ main { min-width: 0; }
65+ main:focus-visible { outline: none; }
5166
5267 @media (prefers-reduced-motion: no-preference) {
53- a, button, tr, li, summary { transition: background-color 90ms linear, opacity 90ms linear; }
68+ a, button, tr, li, summary, .btn { transition: background-color 100ms ease, border-color 100ms ease, color 100ms ease; }
5469 }
5570 }
5671
@@ -60,5 +75,5 @@
6075 .nowrap { white-space: nowrap; }
6176 .num { text-align: right; font-variant-numeric: tabular-nums; }
6277 .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
63- .rule { border-top: 1px solid var(--rule); }
78+ .rule { border-top: 1px solid var(--border); }
6479 }
M internal/adapters/web/static/css/20-shell.css +170 -110
@@ -1,126 +1,186 @@
1-@layer base {
2- body { grid-template-rows: auto 1fr auto; }
3-}
4-
51 @layer components {
6- /* neferbar: black bars; one accent block (top left) with a 4-cell stepped fade.
7- The fade is four hard-edged 1ch cells (not a blend): it is the bar's own glyph-grid look. */
8- .bar {
9- display: flex; align-items: stretch; column-gap: 1ch;
10- height: var(--bar-h); line-height: var(--bar-h);
11- background: var(--bar); color: var(--bar-ink);
12- white-space: nowrap; overflow: hidden;
13- position: sticky; z-index: 20;
14- & a { text-decoration: none; }
15- & a:hover { text-decoration: underline; }
16- & :focus-visible { outline-color: var(--accent); outline-offset: -2px; }
17- & .blk { background: var(--accent); color: var(--on-accent); font-weight: bold; padding: 0 1ch; }
18- & .fade {
19- flex: none; width: 4ch; margin-left: -1ch;
20- background: linear-gradient(to right,
21- var(--fade-1) 0 25%, var(--fade-2) 25% 50%, var(--fade-3) 50% 75%, var(--fade-4) 75%);
22- }
2+ /* buttons: one shape for links and buttons that act */
3+ .btn {
4+ display: inline-flex; align-items: center; gap: var(--s2); white-space: nowrap;
5+ height: 32px; padding: 0 var(--s3);
6+ font: 600 var(--fs-s) / 1 var(--font); color: var(--ink); text-decoration: none;
7+ background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: var(--r-s);
8+ cursor: pointer;
9+ &:hover { background: var(--bg-strong); color: var(--ink-hi); text-decoration: none; }
10+ & kbd { margin-right: -4px; }
11+ &.primary { background: var(--ink-hi); color: var(--on-ink); border-color: var(--ink-hi); }
12+ &.primary:hover { background: var(--ink); border-color: var(--ink); color: var(--on-ink); }
13+ &.danger:hover { color: var(--red); border-color: var(--red); }
14+ &.icon { width: 32px; justify-content: center; padding: 0; }
15+ &[aria-disabled="true"] { opacity: .45; pointer-events: none; }
16+ &[aria-current="true"], &[aria-pressed="true"] { background: var(--bg-strong); color: var(--ink-hi); border-color: var(--ink-mute); }
2317 }
18+ /* icons: SVG files used as masks, so they take the colour of the text */
19+ .ico { display: inline-block; width: 16px; height: 16px; flex: none; background: currentColor; mask: var(--i) center / contain no-repeat; }
20+ .i-branch { --i: url(icons/branch.svg); }
21+ .i-search { --i: url(icons/search.svg); }
22+ .i-file { --i: url(icons/file.svg); }
2423
25- .top {
26- top: 0;
27- & .crumbs ol { display: flex; }
28- & .crumbs li + li::before { content: "/"; color: var(--bar-dim); padding: 0 .5ch; }
29- & .crumbs [aria-current] { color: #fff; font-weight: bold; }
30- & .secs { margin-left: 2ch; overflow: hidden; }
31- & .secs ul { display: flex; }
32- & .secs li + li::before { content: "/"; color: var(--bar-dim); }
33- & .secs a { display: inline-block; padding: 0 1ch; color: var(--bar-dim); }
34- & .secs a:hover { color: var(--bar-ink); }
35- & .secs a::first-letter { text-decoration: underline; }
36- & .secs a[aria-current] { color: #fff; font-weight: bold; }
37- & .secs a[aria-current]::before { content: "▸ "; }
38- & .ref { flex: none; margin-left: auto; padding-right: 1ch; color: #fff; font-weight: bold; }
39- & .ref .sha { font-weight: normal; color: var(--bar-dim); }
40- & .ref:hover { text-decoration: underline; }
24+ .pill {
25+ display: inline-flex; align-items: center; height: 20px; padding: 0 var(--s2); margin-left: var(--s1);
26+ font: 600 var(--fs-xs) / 1 var(--mono); color: var(--ink-dim); vertical-align: 1px;
27+ background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: 999px; white-space: nowrap;
28+ &.tag::before { content: ""; width: 6px; height: 6px; margin-right: 6px; border: 1.5px solid currentColor; border-radius: 1px; transform: rotate(45deg); }
29+ &.new { color: var(--teal); border-color: currentColor; }
30+ }
31+ .count {
32+ display: inline-block; min-width: 22px; padding: 0 7px; margin-left: var(--s1);
33+ font: 600 var(--fs-xs) / 20px var(--font); text-align: center; color: var(--ink-dim);
34+ background: var(--bg-raised); border-radius: 999px; vertical-align: 1px;
4135 }
4236
43- .status {
44- bottom: 0; font-size: var(--fs-s);
45- padding: 0 1ch;
46- & > span, & .st-cmd { color: var(--bar-dim); }
47- & .mode { color: #fff; font-weight: bold; }
48- & .st-ctx { color: var(--bar-ink); }
49- & .st-cmd { flex: none; }
50- & .st-cmd .hi, & .st-cmd:hover { color: #fff; font-weight: bold; }
51- & .st-sel { color: var(--bar-ink); }
52- & .st-flash:empty { display: none; }
53- & .st-flash { color: #fff; font-weight: bold; }
54- & .st-hints { margin-left: auto; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
55- & .st-live::before { content: "○ "; }
56- & .st-live[data-state="live"] { color: var(--bar-ink); }
57- & .st-live[data-state="live"]::before { content: "● "; }
58- & .st-owner { flex: none; display: flex; gap: 1ch; }
59- & .st-owner span { color: var(--bar-ink); }
60- & .st-owner button { color: var(--bar-dim); }
61- & .st-owner button:hover { color: #fff; text-decoration: underline; }
62- & .st-pos { min-width: 7ch; text-align: right; color: var(--bar-ink); }
37+ /* header: identity, path and actions on top, the repository tabs below.
38+ --hdr-h is its height, the offset of the sticky bars of the diffs. */
39+ body { --hdr-h: 57px; }
40+ body:has(.hdr .tabs) { --hdr-h: 101px; }
41+ .hdr {
42+ position: sticky; top: 0; z-index: 20;
43+ background: var(--bg-surface); border-bottom: 1px solid var(--border);
44+ }
45+ .hdr-top {
46+ display: flex; align-items: center; gap: var(--s4);
47+ max-width: var(--page); margin: 0 auto; height: 56px; padding: 0 var(--pad);
48+ }
49+ .brand {
50+ display: flex; align-items: center; gap: var(--s2); flex: none;
51+ font: 700 var(--fs) / 1 var(--mono); color: var(--ink-hi); text-decoration: none;
52+ & span { max-width: 22ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
53+ &:hover { text-decoration: none; }
54+ }
55+ .brand-mark {
56+ position: relative; width: 28px; height: 28px; flex: none;
57+ background: var(--ink-hi); border-radius: var(--r-s);
58+ &::after { content: ""; position: absolute; inset: 6px; background: var(--on-ink); mask: url(icons/brand.svg) center / contain no-repeat; }
59+ }
60+ .crumbs {
61+ min-width: 0; flex: 1 1 auto; overflow: hidden;
62+ & ol { display: flex; align-items: center; min-width: 0; font-family: var(--mono); font-size: var(--fs); }
63+ & li { display: flex; align-items: center; min-width: 0; white-space: nowrap; }
64+ & li:last-child { overflow: hidden; }
65+ & li::before { content: "/"; padding: 0 var(--s2); color: var(--border-strong); }
66+ & a, & span { overflow: hidden; text-overflow: ellipsis; color: var(--ink-dim); text-decoration: none; }
67+ & a:hover { color: var(--ink-hi); text-decoration: underline; }
68+ & [aria-current] { color: var(--ink-hi); font-weight: 700; }
69+ }
70+ .hdr-acts { display: flex; align-items: center; gap: var(--s2); margin-left: auto; flex: none; }
71+ .ref {
72+ font-family: var(--mono); max-width: 28ch;
73+ & .ref-name { overflow: hidden; text-overflow: ellipsis; }
74+ & .sha { font-weight: 400; color: var(--ink-mute); }
75+ }
76+ .search {
77+ min-width: 220px; justify-content: flex-start; color: var(--ink-mute); font-weight: 400;
78+ background: var(--bg-inset);
79+ & .search-label { flex: 1; }
80+ }
81+ .owner {
82+ display: flex; align-items: center; gap: var(--s2);
83+ & .owner-badge { font: 600 var(--fs-xs) / 1 var(--mono); color: var(--ink-dim); padding: 0 var(--s1); }
6384 }
6485
65- /* work area: content + context rail.
66- Tables and code take the free width and the rail is fixed; on prose pages the text
67- is capped at the reading measure and the rail takes the rest, so no column is idle. */
68- .work { min-width: 0; }
69- .rail { padding: 0 var(--pad) var(--lh); min-width: 0; }
70- @media (min-width: 1100px) {
71- /* app shell: the bars stay put, the content and the rail scroll on their own, so each
72- column has its scrollbar at its own edge (the README's sits next to the README) */
73- body { height: 100dvh; overflow: hidden; }
74- .work { display: grid; grid-template-columns: minmax(0, 1fr); min-height: 0; }
75- .work.has-rail { grid-template-columns: minmax(0, 1fr) var(--rail); }
76- .work.has-rail.read { grid-template-columns: minmax(0, calc(var(--measure) + 2 * var(--pad))) minmax(var(--rail), 1fr); }
77- main, .rail { min-height: 0; overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--rule) transparent; }
78- main:focus { outline: none; }
79- .rail { border-left: 1px solid var(--rule); }
80- :root { --stick: 0px; }
81- }
82- .rail-d > summary { display: none; }
83- @media (max-width: 1099.98px) {
84- /* the rail folds into one line above the content */
85- .work.has-rail { display: flex; flex-direction: column; }
86- .rail { order: -1; border-bottom: 1px solid var(--rule); padding-bottom: 0; }
87- .rail-d > summary {
88- display: block; list-style: none; color: var(--ink-dim);
89- overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
86+ .tabs {
87+ max-width: var(--page); margin: 0 auto; padding: 0 var(--pad);
88+ overflow-x: auto; scrollbar-width: none;
89+ & ul { display: flex; gap: var(--s1); }
90+ & a {
91+ position: relative; display: flex; align-items: center; gap: var(--s2); height: 44px; padding: 0 var(--s3);
92+ color: var(--ink-dim); text-decoration: none; white-space: nowrap; font-weight: 600;
9093 }
91- .rail-d > summary::-webkit-details-marker { display: none; }
92- .rail-d > summary::before { content: "▸ "; color: var(--ink); }
93- .rail-d[open] > summary::before { content: "▾ "; }
94- .rail-d[open] { padding-bottom: var(--lh); }
95- }
96- @media (max-width: 700px) {
97- /* the sections drop to a second bar row instead of being cut off */
98- .top { flex-wrap: wrap; height: auto; }
99- .top .secs { order: 10; flex-basis: 100%; margin: 0; border-top: 1px solid #202020; }
100- .top .crumbs { flex: 1; min-width: 0; overflow: hidden; }
101- .status .st-hints, .status .st-ctx { display: none; }
102- .list .c-who, .list .c-desc, .list .c-sub, .list .c-ref, .list .c-size, .list .c-lang { display: none; }
94+ & a::after { content: ""; position: absolute; left: var(--s2); right: var(--s2); bottom: -1px; height: 2px; border-radius: 2px; }
95+ & a:hover { color: var(--ink-hi); }
96+ & a:hover::after { background: var(--border-strong); }
97+ & a[aria-current] { color: var(--ink-hi); }
98+ & a[aria-current]::after { background: var(--ink-hi); }
99+ & kbd { opacity: .55; }
100+ & a:hover kbd, & a:focus-visible kbd, & a[aria-current] kbd { opacity: 1; }
103101 }
104102
105- /* rail blocks: a dim title rule, then key/value rows on the character grid */
106- .rail section { margin-top: var(--lh); }
107- .rail h2 {
108- display: flex; gap: 1ch; color: var(--ink-dim); font-weight: normal;
109- &::after { content: ""; flex: 1; align-self: center; border-top: 1px solid var(--rule); }
103+ /* work area: content and rail, centred, the page scrolls */
104+ .work {
105+ flex: 1; width: 100%; max-width: var(--page); margin: 0 auto;
106+ padding: var(--s5) var(--pad) var(--s7);
107+ display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--s5); align-items: start;
110108 }
109+ .work.has-rail { grid-template-columns: minmax(0, 1fr) var(--rail); }
110+ .rail { display: flex; flex-direction: column; gap: var(--s4); min-width: 0; }
111+ main > * + * { margin-top: var(--s4); }
112+
113+ /* blocks: a surface with a header; rail sections are blocks too */
114+ .card, .rail section {
115+ background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r);
116+ min-width: 0;
117+ }
118+ .card-h, .rail h2 {
119+ display: flex; align-items: center; gap: var(--s2);
120+ padding: var(--s3) var(--s4); border-bottom: 1px solid var(--border);
121+ font-size: var(--fs); line-height: 20px;
122+ & a { margin-left: auto; font: 600 var(--fs-s) / 1 var(--font); color: var(--ink-dim); text-decoration: none; }
123+ & a:hover { color: var(--ink-hi); text-decoration: underline; }
124+ }
125+ .rail section > :not(h2) { margin: var(--s3) var(--s4); }
126+ .rail section > .list { margin: var(--s2) 0; }
127+ .rail section > p + p { margin-top: calc(-1 * var(--s2)); }
128+
111129 .kv {
112- display: grid; grid-template-columns: 11ch minmax(0, 1fr); column-gap: 1ch;
130+ display: grid; grid-template-columns: max-content minmax(0, 1fr); column-gap: var(--s4); row-gap: var(--s1);
131+ font-size: var(--fs-s);
113132 & dt { color: var(--ink-mute); }
114- & dd { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
133+ & dd { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink); }
115134 }
116135 .outline {
117- & a { display: block; text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
118- & a:hover { text-decoration: underline; }
119- & .l1 { font-weight: bold; }
120- & .l3 { padding-left: 2ch; }
121- & .l4, & .l5, & .l6 { padding-left: 4ch; }
122- & a[aria-current]::before { content: "▸"; margin-left: -1ch; }
123- }
124- .rail .clone { border: 0; padding: 0; margin-top: 0; }
125- .rail .clone-line { flex-wrap: wrap; }
136+ font-size: var(--fs-s);
137+ & a { display: block; padding: 2px var(--s2); margin: 0 calc(-1 * var(--s2)); border-radius: 4px; color: var(--ink-dim); text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
138+ & a:hover { background: var(--bg-raised); color: var(--ink-hi); }
139+ & .l1 { font-weight: 700; color: var(--ink); }
140+ & .l3 { padding-left: var(--s5); }
141+ & .l4, & .l5, & .l6 { padding-left: var(--s6); }
142+ }
143+
144+ /* footer: version and source (AGPL §13) */
145+ .foot {
146+ display: flex; flex-wrap: wrap; gap: var(--s4); align-items: center;
147+ width: 100%; max-width: var(--page); margin: 0 auto; padding: var(--s4) var(--pad) var(--s5);
148+ font-size: var(--fs-s); color: var(--ink-mute);
149+ &::before { content: ""; flex-basis: 100%; height: 1px; margin-bottom: var(--s2); background: var(--border); }
150+ & a { color: var(--ink-dim); }
151+ & .foot-keys { margin-left: auto; }
152+ }
153+
154+ /* hint: bottom right, only while it has something to say */
155+ .hint {
156+ position: fixed; right: var(--s4); bottom: var(--s4); z-index: 25;
157+ display: flex; gap: var(--s3); align-items: center;
158+ padding: var(--s2) var(--s3); font: 600 var(--fs-s) / 18px var(--mono); color: var(--ink-hi);
159+ background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--r); box-shadow: var(--shadow);
160+ pointer-events: none;
161+ & > :empty { display: none; }
162+ & .hint-live { color: var(--red); }
163+ }
164+ .hint:not(:has(> :not(:empty))) { display: none; }
165+
166+ @media (max-width: 1080px) {
167+ /* the rail goes under the content, in two columns while there is room */
168+ .work.has-rail { grid-template-columns: minmax(0, 1fr); }
169+ .rail { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); align-items: start; }
170+ .search { min-width: 0; }
171+ .search .search-label, .search kbd { display: none; }
172+ .search { width: 32px; padding: 0; justify-content: center; }
173+ }
174+ @media (max-width: 720px) {
175+ :root { --pad: var(--s4); }
176+ .hdr-top { gap: var(--s3); height: 52px; }
177+ .brand span { display: none; }
178+ .ref .sha, .owner-badge, .hdr-acts .icon { display: none; }
179+ .ref { max-width: 16ch; }
180+ .work { padding-top: var(--s4); }
181+ .rail { grid-template-columns: minmax(0, 1fr); }
182+ .tabs kbd { display: none; }
183+ .foot .foot-keys { display: none; }
184+ .list .c-who, .list .c-desc, .list .c-sub, .list .c-ref, .list .c-size, .list .c-lang { display: none; }
185+ }
126186 }
M internal/adapters/web/static/css/25-panel.css +58 -33
@@ -1,41 +1,66 @@
11 @layer components {
2- /* bottom panels above the status bar (help, ref switcher, ":" command line) */
3- .panel {
4- position: fixed; inset: auto 0 var(--bar-h) 0; margin: 0; width: 100%; z-index: 15;
5- background: var(--paper); color: var(--ink);
6- border-top: 2px solid var(--ink);
7- max-height: min(46dvh, 28rem); overflow: auto; overscroll-behavior: contain;
8- padding: 0 var(--pad) var(--lh);
9- &:not([open]) { display: none; }
10- & .panel-head {
11- position: sticky; top: 0; background: var(--paper); display: flex; gap: 1ch;
12- align-items: baseline; height: calc(var(--lh) + 6px); padding-top: 4px;
13- border-bottom: 1px solid var(--rule); margin-bottom: 4px;
14- & input { flex: 1; min-width: 0; outline-offset: 2px; }
15- & button { margin-left: auto; color: var(--ink-dim); white-space: nowrap; }
16- & button:hover { color: var(--ink); text-decoration: underline; }
17- & .hi { font-weight: bold; }
2+ /* dialogs: the "go to" palette, the ref switcher and the keys, centred over a backdrop */
3+ .modal {
4+ position: fixed; inset: 0; margin: 12vh auto auto; z-index: 40;
5+ width: min(640px, calc(100vw - 2 * var(--s4))); max-height: min(70dvh, 560px);
6+ display: none; flex-direction: column;
7+ background: var(--bg-overlay); color: var(--ink);
8+ border: 1px solid var(--border-strong); border-radius: var(--r); box-shadow: var(--shadow);
9+ overflow: hidden; overscroll-behavior: contain;
10+ &[open] { display: flex; }
11+ &::backdrop { background: var(--backdrop); }
12+ &#panel-help { width: min(820px, calc(100vw - 2 * var(--s4))); }
13+ }
14+ @media (prefers-reduced-motion: no-preference) {
15+ .modal[open] { animation: modal-in 120ms ease-out; }
16+ @keyframes modal-in { from { opacity: 0; transform: translateY(-6px) scale(.99); } }
17+ }
18+ .panel-head {
19+ display: flex; align-items: center; gap: var(--s3); flex: none;
20+ padding: var(--s3) var(--s4); border-bottom: 1px solid var(--border);
21+ & h2 { font-size: var(--fs); flex: 1; }
22+ & input { flex: 1; min-width: 0; height: 32px; font-size: 16px; }
23+ & input::placeholder { color: var(--ink-mute); opacity: 1; }
24+ & input:focus-visible { outline: none; }
25+ }
26+ .panel-x { color: var(--ink-mute); font-size: var(--fs-s); display: flex; gap: var(--s2); align-items: center; }
27+ .panel-x:hover { color: var(--ink-hi); }
28+ .panel-foot {
29+ display: flex; gap: var(--s4); flex: none; padding: var(--s2) var(--s4);
30+ border-top: 1px solid var(--border); background: var(--bg-surface);
31+ font-size: var(--fs-xs); color: var(--ink-mute);
32+ & span { display: flex; gap: var(--s1); align-items: center; }
33+ }
34+ .modal > ul, .modal > .panel-cols, .modal > .keys { overflow-y: auto; padding: var(--s2); }
35+
36+ .panel-cols {
37+ display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: var(--s2);
38+ & h2 { font: 600 var(--fs-xs) / 1 var(--font); text-transform: uppercase; letter-spacing: .06em; color: var(--ink-mute); padding: var(--s3) var(--s3) var(--s2); }
39+ & li a {
40+ display: flex; align-items: center; gap: var(--s2); padding: 6px var(--s3); border-radius: var(--r-s);
41+ font-family: var(--mono); font-size: var(--fs-s); text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
1842 }
19- & .panel-cols { display: grid; grid-template-columns: repeat(auto-fill, minmax(34ch, 1fr)); column-gap: 3ch; }
20- & .panel-cols h2 { color: var(--ink-dim); font-weight: normal; }
21- & li a { display: block; text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
22- & li a:hover, & li a:focus-visible { background: var(--sel); }
23- & li a[aria-current] { font-weight: bold; }
24- & .mark { display: inline-block; width: 2ch; }
43+ & li a:hover, & li a:focus-visible { background: var(--bg-raised); color: var(--ink-hi); }
44+ & li a[aria-current] { font-weight: 700; color: var(--ink-hi); }
45+ & .mark { display: inline-block; width: 1ch; color: var(--teal); }
2546 }
47+
2648 .cmd-list {
27- columns: 34ch; column-gap: 3ch;
28- & li { break-inside: avoid; }
29- & li[data-cursor] a { background: var(--row); }
30- & li[data-cursor] a::before { content: "▸"; position: absolute; margin-left: -1.5ch; }
31- & a { padding-left: 1.5ch; }
32- & .k { display: inline-block; width: 8ch; color: var(--ink-mute); }
49+ & li a {
50+ display: flex; align-items: center; gap: var(--s3); padding: var(--s2) var(--s3); border-radius: var(--r-s);
51+ text-decoration: none; color: var(--ink);
52+ }
53+ & li a:hover { background: var(--bg-raised); }
54+ & li[data-cursor] a { background: var(--bg-strong); color: var(--ink-hi); }
55+ & .lbl { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); font-size: var(--fs-s); }
56+ & .k { flex: none; font-size: var(--fs-xs); color: var(--ink-mute); }
3357 }
58+
3459 .keys {
35- display: grid; grid-template-columns: repeat(auto-fill, minmax(46ch, 1fr)); column-gap: 3ch;
36- & > div { display: grid; grid-template-columns: 14ch 1fr; }
37- & dt { white-space: nowrap; }
38- & dd { color: var(--ink-dim); }
39- & kbd { border: 1px solid var(--rule); padding: 0 .6ch; }
60+ display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); column-gap: var(--s5); row-gap: var(--s1);
61+ padding: var(--s3) var(--s4) var(--s4) !important;
62+ & > div { display: grid; grid-template-columns: 130px 1fr; gap: var(--s3); align-items: baseline; padding: var(--s1) 0; border-bottom: 1px solid var(--border); }
63+ & dt { white-space: nowrap; display: flex; gap: var(--s1); }
64+ & dd { color: var(--ink-dim); font-size: var(--fs-s); }
4065 }
4166 }
M internal/adapters/web/static/css/30-list.css +76 -45
@@ -1,76 +1,107 @@
11 @layer components {
2- /* plain-text tables with a ▸ row cursor */
2+ /* tables in a card, with a row cursor (j/k) */
33 .list {
44 width: 100%; table-layout: fixed;
5- & th { color: var(--ink-mute); font-weight: normal; white-space: nowrap; border-bottom: 1px solid var(--rule); }
5+ background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r);
6+ border-collapse: separate; overflow: hidden;
7+ & th {
8+ height: 40px; padding: 0 var(--s3); white-space: nowrap;
9+ font: 600 var(--fs-xs) / 1 var(--font); text-transform: uppercase; letter-spacing: .05em; color: var(--ink-mute);
10+ background: var(--bg-raised); border-bottom: 1px solid var(--border);
11+ }
612 & th a { text-decoration: none; }
7- & th a:hover { text-decoration: underline; color: var(--ink); }
8- & th[aria-sort="ascending"], & th[aria-sort="descending"] { color: var(--ink); font-weight: bold; }
9- & td, & th { padding-right: 2ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
10- & .cur { width: 2ch; padding: 0; }
11- & tbody tr[data-cursor] { background: var(--row); }
12- & tbody tr[data-cursor] .cur::before { content: "▸"; font-weight: bold; }
13- & tbody tr:hover { background: var(--row); }
13+ & th a:hover { color: var(--ink-hi); }
14+ & th[aria-sort="ascending"], & th[aria-sort="descending"] { color: var(--ink-hi); }
15+ & td { height: 40px; padding: 0 var(--s3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; border-top: 1px solid var(--border); }
16+ & tbody tr:first-child td { border-top: 0; }
17+ & th.cur, & td.cur { width: 3px; padding: 0; }
18+ & tbody tr:hover { background: var(--bg-raised); }
19+ & tbody tr[data-cursor] { background: var(--bg-raised); }
20+ & tbody tr[data-cursor] .cur { background: var(--ink-hi); }
1421 & tbody tr[hidden] { display: none; }
1522 & td > a { text-decoration: none; }
23+ & td > a:first-child { color: var(--ink-hi); }
1624 & tr:hover td > a:first-child, & tr[data-cursor] td > a:first-child { text-decoration: underline; }
25+ & .c-sha, & .c-ref, & .c-kind, & .c-size, & .c-n { font-family: var(--mono); font-size: var(--fs-s); }
26+ & .c-sha a { color: var(--ink-dim); }
27+ & .c-when, & .c-who { font-size: var(--fs-s); }
1728 & .c-sub { color: var(--ink-dim); }
18- & .c-sub .refs { color: var(--ink); }
1929 }
2030
2131 .home {
22- & .c-name { width: 24ch; font-weight: bold; }
23- & th.c-name { font-weight: normal; }
24- & th.c-name[aria-sort] { font-weight: bold; }
32+ & .c-name { width: 26%; font-family: var(--mono); font-weight: 700; }
33+ & th.c-name { font-family: var(--font); }
2534 & .c-desc { width: auto; }
26- & .c-when { width: 13ch; }
27- & .c-ref { width: 10ch; }
28- & .c-lang { width: 12ch; padding-right: 0; }
35+ & .c-when { width: 130px; }
36+ & .c-ref { width: 120px; }
37+ & .c-lang { width: 120px; }
2938 }
3039 .list.tree {
31- & .c-kind { width: 2ch; color: var(--ink-mute); padding-right: 0; }
32- & .c-name { width: 34ch; }
33- & .c-size { width: 9ch; text-align: right; padding-right: 3ch; }
34- & .c-when { width: 11ch; }
40+ & .c-kind { width: 36px; color: var(--ink-mute); padding-right: 0; text-align: center; }
41+ & .c-name { width: 32%; font-family: var(--mono); font-size: var(--fs-s); }
42+ & .c-size { width: 90px; text-align: right; }
43+ & .c-when { width: 100px; }
3544 & .c-sub { width: auto; }
36- & .c-who { width: 12ch; }
45+ & .c-who { width: 130px; }
3746 }
3847 .list.commits, .list.refs {
39- & .c-sha { width: 8ch; }
40- & .c-who { width: 12ch; }
41- & .c-when { width: 11ch; }
48+ & .c-sha { width: 96px; }
49+ & .c-who { width: 140px; }
50+ & .c-when { width: 100px; }
4251 }
4352
44- /* filter line (home, tree, commits) */
53+ /* filter: a search field above a list */
4554 .filter {
46- display: flex; gap: 1ch; height: var(--lh); margin: var(--lh) 0 0;
47- & label { color: var(--ink-mute); }
48- & input { flex: 1; min-width: 0; }
55+ position: relative; display: flex; max-width: 420px;
56+ &::before {
57+ content: ""; position: absolute; left: 11px; top: 10px; width: 16px; height: 16px; pointer-events: none;
58+ background: var(--ink-mute); mask: url(icons/search.svg) center / contain no-repeat;
59+ }
60+ &::after {
61+ content: "/"; position: absolute; right: 8px; top: 50%; transform: translateY(-50%);
62+ font: 600 var(--fs-xs) / 18px var(--mono); color: var(--ink-mute); padding: 0 6px;
63+ border: 1px solid var(--border-strong); border-bottom-width: 2px; border-radius: 4px; pointer-events: none;
64+ }
65+ & input {
66+ flex: 1; min-width: 0; height: 36px; padding: 0 36px 0 34px;
67+ background: var(--bg-surface); border: 1px solid var(--border-strong); border-radius: var(--r-s);
68+ }
69+ & input:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; border-color: transparent; }
4970 & input::placeholder { color: var(--ink-mute); opacity: 1; }
71+ &:focus-within::after { display: none; }
5072 }
5173
52- /* headings inside pages read as pager section rules */
74+ /* section headings inside a page */
5375 .rule-h {
54- display: flex; gap: 1ch; margin: var(--lh) 0 0; color: var(--ink-dim);
55- &::after { content: ""; flex: 1; align-self: center; border-top: 1px solid var(--rule); }
56- & > :first-child { font-weight: bold; color: var(--ink); }
76+ display: flex; align-items: center; gap: var(--s2); margin-top: var(--s5);
77+ font-size: var(--fs);
78+ & > :first-child { color: var(--ink-hi); }
5779 }
80+ main > .rule-h:first-child { margin-top: 0; }
5881
59- /* empty / 404 / error */
82+ /* empty / 404 / error / sign in: one card in the middle */
6083 .state {
61- margin-top: var(--lh); max-width: var(--measure);
62- & h1 { margin-bottom: var(--lh); }
63- & .code { display: inline-block; min-width: 6ch; }
64- & .state-back { margin-top: var(--lh); display: flex; gap: 2ch; }
65- & .state-act { margin-top: var(--lh); }
66- & .state-act button { color: var(--ink-hi); font-weight: bold; }
67- & .state-act button:hover { text-decoration: underline; }
84+ max-width: 560px; margin: var(--s7) auto 0 !important; padding: var(--s6);
85+ background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r);
86+ & .eyebrow { font: 700 var(--fs-s) / 1 var(--mono); color: var(--ink-mute); text-transform: uppercase; letter-spacing: .08em; margin-bottom: var(--s2); }
87+ & h1 { margin-bottom: var(--s3); }
88+ & p.dim { color: var(--ink-dim); }
89+ & .state-act { margin-top: var(--s5); }
90+ & .state-back { margin-top: var(--s5); display: flex; gap: var(--s4); font-size: var(--fs-s); }
91+ & .clone { margin-top: var(--s4); }
6892 }
93+
94+ /* copyable shell lines */
6995 .clone {
70- margin: var(--lh) 0 0; border: 1px solid var(--rule); padding: 0 1ch;
71- & .clone-line { display: flex; gap: 1ch; align-items: baseline; }
72- & code { flex: 1; min-width: 0; overflow-x: auto; white-space: pre; }
73- & .copy { color: var(--ink-dim); white-space: nowrap; min-width: 9ch; text-align: right; }
74- & .copy:hover { color: var(--ink); text-decoration: underline; }
96+ display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--s2); min-width: 0;
97+ & .clone-line {
98+ min-width: 0;
99+ display: flex; gap: var(--s2); align-items: center; padding: var(--s1) var(--s1) var(--s1) var(--s3);
100+ background: var(--bg-inset); border: 1px solid var(--border); border-radius: var(--r-s);
101+ font-size: var(--fs-code);
102+ }
103+ & .clone-line > .mute { user-select: none; font-family: var(--mono); }
104+ & code { flex: 1; min-width: 0; overflow-x: auto; white-space: pre; scrollbar-width: none; }
105+ & .copy { height: 26px; padding: 0 var(--s2); font-size: var(--fs-xs); }
75106 }
76107 }
M internal/adapters/web/static/css/40-summary.css +34 -23
@@ -1,38 +1,49 @@
11 @layer components {
2- .lede { margin-top: var(--lh); max-width: var(--measure); }
2+ .lede { max-width: var(--measure); color: var(--ink-dim); font-size: 16px; }
33
4- /* key/value facts, aligned on a 12ch key column */
4+ /* key/value facts */
55 .facts {
6- margin-top: var(--lh); display: grid; grid-template-columns: repeat(auto-fill, minmax(38ch, 1fr)); column-gap: 3ch;
7- & > div { display: grid; grid-template-columns: 12ch 1fr; min-width: 0; }
6+ display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); column-gap: var(--s5);
7+ & > div { display: grid; grid-template-columns: 120px 1fr; min-width: 0; }
88 & dt { color: var(--ink-mute); }
99 & dd { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1010 }
1111
12- .tagged { color: var(--ink); font-weight: bold; white-space: nowrap; }
12+ .tagged { color: var(--ink); font-weight: 700; white-space: nowrap; }
1313 .list.commits .c-subject { overflow: hidden; text-overflow: ellipsis; }
1414
15- /* markdown, rendered from the fixture */
15+ .readme .prose { padding: var(--s5) var(--s6) var(--s6); }
16+
17+ /* rendered Markdown */
1618 .prose {
17- max-width: var(--measure); margin-top: var(--lh);
18- & h1, & h2, & h3, & h4 { margin: var(--lh) 0 0; }
19- & h1::before { content: "# "; color: var(--ink-mute); }
20- & h2::before { content: "## "; color: var(--ink-mute); }
21- & h3::before { content: "### "; color: var(--ink-mute); }
22- & h4::before { content: "#### "; color: var(--ink-mute); }
19+ max-width: var(--measure); line-height: 1.65; overflow-wrap: break-word;
20+ & h1, & h2, & h3, & h4 { margin: 1.6em 0 0; }
21+ & h1 { font-size: 26px; line-height: 34px; padding-bottom: var(--s2); border-bottom: 1px solid var(--border); }
22+ & h2 { font-size: 20px; line-height: 28px; padding-bottom: var(--s1); border-bottom: 1px solid var(--border); }
23+ & h3 { font-size: 17px; }
2324 & > :first-child { margin-top: 0; }
24- & p, & ul, & ol, & pre, & table, & blockquote { margin-top: var(--lh); }
25- & ul { list-style: none; & > li::before { content: "- "; color: var(--ink-mute); margin-left: -2ch; } }
26- & ul, & ol { padding-left: 2ch; }
25+ & p, & ul, & ol, & pre, & table, & blockquote { margin-top: var(--s4); }
26+ & ul { list-style: disc; }
27+ & ul, & ol { padding-left: var(--s5); }
2728 & ol { list-style: decimal; }
28- & li > ul, & li > ol, & li > p { margin-top: 0; }
29- & pre { padding: 0 1ch; border-left: 1px solid var(--ink-mute); overflow-x: auto; font-size: var(--fs-code); line-height: var(--lh-code); }
30- & :not(pre) > code { border-bottom: 1px dotted var(--ink-mute); overflow-wrap: anywhere; }
31- & blockquote { padding-left: 2ch; border-left: 1px solid var(--rule); color: var(--ink-dim); }
32- & table { border-collapse: collapse; display: block; overflow-x: auto; }
33- & th, & td { padding: 0 2ch 0 0; white-space: nowrap; vertical-align: top; }
34- & th { border-bottom: 1px solid var(--rule); }
35- & hr { margin: var(--lh) 0; }
29+ & li + li { margin-top: var(--s1); }
30+ & li::marker { color: var(--ink-mute); }
31+ & li > ul, & li > ol, & li > p { margin-top: var(--s1); }
32+ & pre {
33+ padding: var(--s3) var(--s4); overflow-x: auto;
34+ background: var(--bg-inset); border: 1px solid var(--border); border-radius: var(--r-s);
35+ font-size: var(--fs-code); line-height: var(--lh-code);
36+ }
37+ & :not(pre) > code {
38+ padding: 1px 5px; font-size: .88em; overflow-wrap: anywhere;
39+ background: var(--bg-raised); border: 1px solid var(--border); border-radius: 4px;
40+ }
41+ & blockquote { padding-left: var(--s4); border-left: 3px solid var(--border-strong); color: var(--ink-dim); }
42+ & table { display: block; overflow-x: auto; border: 1px solid var(--border); border-radius: var(--r-s); font-size: var(--fs-s); line-height: 20px; }
43+ & th, & td { padding: 6px var(--s3); white-space: nowrap; vertical-align: top; border-top: 1px solid var(--border); }
44+ & th { background: var(--bg-raised); border-top: 0; font-weight: 700; }
45+ & td code { white-space: nowrap; }
46+ & hr { margin: var(--s5) 0; }
3647 & .alt { color: var(--ink-mute); }
3748 & img { display: none; }
3849 }
M internal/adapters/web/static/css/50-code.css +48 -39
@@ -1,17 +1,21 @@
11 @layer components {
22 /* page title for a file: name, then its actions as plain words */
33 .file-h {
4- display: flex; gap: 2ch; align-items: baseline; margin-top: var(--lh);
5- padding-bottom: 2px; border-bottom: 1px solid var(--rule);
6- & .acts { margin-left: auto; font-weight: normal; display: flex; gap: 2ch; }
4+ display: flex; gap: var(--s4); align-items: center; flex-wrap: wrap; font-size: 18px;
5+ & > span:first-child { overflow-wrap: anywhere; }
6+ & .acts { margin-left: auto; display: flex; gap: var(--s2); }
77 }
8- .notice { margin-top: var(--lh); border-left: 2px solid var(--ink); padding-left: 1ch; max-width: var(--measure); }
9- .note { color: var(--ink-dim); }
10- .pad { padding: 0 1ch; }
8+ .notice {
9+ padding: var(--s3) var(--s4); max-width: var(--measure); font-size: var(--fs-s);
10+ background: var(--bg-surface); border: 1px solid var(--border-strong); border-left: 3px solid var(--ink-hi); border-radius: var(--r-s);
11+ }
12+ .note { color: var(--ink-dim); font-size: var(--fs-s); }
13+ .pad { padding: var(--s3) var(--s4); }
1114
1215 /* source view: gutter numbers are links (selectable lines), lines span the full width */
1316 .code {
14- margin: var(--lh) calc(var(--pad) * -1) 0; overflow-x: auto;
17+ overflow-x: auto; padding: var(--s3) 0; font-family: var(--mono);
18+ background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r);
1519 font-size: var(--fs-code); line-height: var(--lh-code);
1620 & code { display: grid; min-width: max-content; }
1721 & .line { padding-right: var(--pad); border-left: 2px solid transparent; }
@@ -23,44 +27,43 @@
2327 }
2428 & .ln:hover, & .sel .ln { color: var(--ink-hi); }
2529 }
26- .source { margin-top: var(--lh); & > summary { color: var(--ink-dim); } }
30+ .source > summary { color: var(--ink-dim); font-size: var(--fs-s); }
31+ .source[open] > summary { margin-bottom: var(--s3); }
32+ .img-view { padding: var(--s4); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); }
2733
2834 /* commit header, like `git show` */
2935 .show {
30- margin-top: var(--lh);
31- & h1 { max-width: var(--measure); }
32- & .msg { margin-top: var(--lh); white-space: pre-wrap; max-width: var(--measure); color: var(--ink-dim); }
33- & .show-kv { margin-top: var(--lh); }
34- & .show-kv dd { white-space: normal; }
36+ padding: var(--s5); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r);
37+ & h1 { max-width: var(--measure); overflow-wrap: anywhere; }
38+ & .msg { margin-top: var(--s3); white-space: pre-wrap; max-width: var(--measure); color: var(--ink-dim); font-size: var(--fs-s); }
39+ & .show-kv { margin-top: var(--s4); }
40+ & .show-kv dd { white-space: normal; overflow-wrap: anywhere; }
3541 }
3642 .diff-tools {
37- display: flex; gap: 2ch; align-items: baseline; margin-top: var(--lh);
38- position: sticky; top: var(--stick); z-index: 5; background: var(--paper);
39- border-bottom: 1px solid var(--rule);
43+ display: flex; gap: var(--s2); align-items: center; flex-wrap: wrap;
44+ position: sticky; top: var(--hdr-h, 0px); z-index: 5; padding: var(--s2) 0; background: var(--bg-canvas);
4045 & .grow { flex: 1; }
41- & button { color: var(--ink-dim); }
42- & button:hover { color: var(--ink); text-decoration: underline; }
43- & button[aria-pressed="true"] { color: var(--ink-hi); font-weight: bold; }
44- & button[aria-pressed="true"]::before { content: "▸"; }
4546 }
4647
4748 /* one file of a diff */
4849 .dfile {
49- margin-top: var(--lh); scroll-margin-top: calc(var(--stick) + var(--lh) + 4px);
50+ margin-top: var(--s3); scroll-margin-top: calc(var(--hdr-h, 0px) + 56px);
5051 & > summary {
51- position: sticky; top: calc(var(--stick) + var(--lh) + 1px); z-index: 4;
52- background: var(--paper-alt); border: 1px solid var(--rule); padding: 0 1ch;
52+ position: sticky; top: calc(var(--hdr-h, 0px) + 48px); z-index: 4;
53+ background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--r) var(--r) 0 0; padding: var(--s2) var(--s3);
54+ font-family: var(--mono); font-size: var(--fs-s);
5355 white-space: nowrap; overflow: hidden; text-overflow: ellipsis; list-style: none;
5456 }
57+ &:not([open]) > summary { border-radius: var(--r); }
5558 & > summary::-webkit-details-marker { display: none; }
5659 & > summary::before { content: "▸ "; }
5760 &[open] > summary::before { content: "▾ "; }
5861 & .st { font-weight: bold; }
5962 & .p { font-weight: bold; }
60- & > .note { border: 1px solid var(--rule); border-top: 0; }
63+ & > .note { border: 1px solid var(--border); border-top: 0; border-radius: 0 0 var(--r) var(--r); background: var(--bg-surface); }
6164 }
6265 .dt {
63- width: max-content; min-width: 100%;
66+ width: max-content; min-width: 100%; font-family: var(--mono);
6467 font-size: var(--fs-code); line-height: var(--lh-code);
6568 & td { padding: 0; vertical-align: top; }
6669 & .ln { width: 6ch; min-width: 6ch; padding-right: 1ch; text-align: right; color: var(--ink-mute); user-select: none; }
@@ -75,13 +78,15 @@
7578 & tr.del .ln, & .ln.del { color: var(--red); }
7679 & .none { background: var(--paper-alt); }
7780 }
78- .dscroll { overflow-x: auto; border: 1px solid var(--rule); border-top: 0; }
81+ .dscroll { overflow-x: auto; background: var(--bg-surface); border: 1px solid var(--border); border-top: 0; border-radius: 0 0 var(--r) var(--r); }
7982 .spl { width: 100%; table-layout: fixed; & .tx { overflow: hidden; text-overflow: ellipsis; } & .ln { width: 6ch; } & .m { width: 2ch; } }
8083 #diffs[data-diff="unified"] .spl, #diffs[data-diff="split"] .uni { display: none; }
8184
8285 /* diff table of contents in the rail */
8386 .files {
84- & a { display: grid; grid-template-columns: 2ch minmax(0, 1fr) auto; column-gap: 1ch; text-decoration: none; }
87+ font-size: var(--fs-s);
88+ & a { display: grid; grid-template-columns: 2ch minmax(0, 1fr) auto; column-gap: var(--s2); padding: 2px var(--s2); margin: 0 calc(-1 * var(--s2)); border-radius: 4px; text-decoration: none; font-family: var(--mono); }
89+ & a:hover { background: var(--bg-raised); }
8590 & a:hover .p { text-decoration: underline; }
8691 & a[aria-current="true"] { background: var(--row); font-weight: bold; }
8792 & .p { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; direction: rtl; text-align: left; }
@@ -90,26 +95,30 @@
9095 }
9196
9297 @media (max-width: 700px) {
93- .diff-tools { flex-wrap: wrap; column-gap: 1ch; }
9498 .diff-tools .grow { flex-basis: 100%; height: 0; }
95- .show-kv dd { overflow-wrap: anywhere; }
96- .file-h .acts { gap: 1ch; }
99+ .file-h .acts { margin-left: 0; }
97100 }
98- .pager { display: flex; gap: 3ch; justify-content: center; margin-top: var(--lh); }
101+ .pager { display: flex; gap: var(--s3); justify-content: center; }
99102 .days section[hidden] { display: none; }
100103 .list.refs {
101- & .c-kind { width: 8ch; }
102- & .c-name { width: 34ch; }
103- & .c-info { width: 11ch; }
104- & .c-sha { width: 9ch; }
104+ & .c-kind { width: 80px; color: var(--ink-mute); }
105+ & .c-name { width: 32%; font-family: var(--mono); font-size: var(--fs-s); }
106+ & .c-sha { width: 96px; }
105107 & .c-sub { width: auto; }
106108 }
109+ /* the commit table inside a rail block: no frame of its own */
107110 .rail .list.commits {
111+ border: 0; border-radius: 0; background: none;
112+ & td { height: 34px; padding: 0 var(--s2); }
113+ & td.cur { width: 3px; padding: 0; }
114+ & td:nth-child(2) { padding-left: var(--s3); }
115+ & td:last-child { padding-right: var(--s4); }
108116 & .c-who { display: none; }
109- & .c-sha { width: 8ch; }
110- & .c-when { width: 9ch; padding-right: 0; text-align: right; }
117+ & .c-sha { width: 72px; }
118+ & .c-subject { font-size: var(--fs-s); }
119+ & .c-subject .pill { display: none; }
120+ & .c-when { width: 70px; text-align: right; }
111121 }
112- .rail p + p { margin-top: 0; }
113122 .rail section > p { overflow-wrap: anywhere; }
114123 @media (max-width: 700px) {
115124 /* the name takes what the fixed columns leave */
@@ -118,7 +127,7 @@
118127 }
119128
120129 @layer components {
121- .gallery section { margin-top: calc(var(--lh) * 2); }
130+ .gallery section { margin-top: var(--s6); }
122131 .gallery .rule-h > :last-child { font-weight: normal; color: var(--ink-mute); order: 2; }
123132 .gallery .rule-h::after { order: 1; }
124133 .gallery .files { max-width: var(--rail); }
M internal/adapters/web/static/css/55-diff.css +1 -6
@@ -2,12 +2,7 @@
22 /* an invisible character in code, shown instead of applied (Trojan Source) */
33 .inv {
44 color: var(--red); font-weight: bold; font-size: var(--fs-s);
5- outline: 1px solid var(--red); outline-offset: -1px; padding: 0 .25ch;
5+ outline: 1px solid var(--red); outline-offset: -1px; padding: 0 2px;
66 }
77 .eol { color: var(--ink-mute); padding-left: 1ch; user-select: none; }
8- /* the layout links of the diff tools read as the buttons of the mockup */
9- .diff-tools a.vbtn { color: var(--ink-dim); text-decoration: none; }
10- .diff-tools a.vbtn:hover { color: var(--ink); text-decoration: underline; }
11- .diff-tools a.vbtn[aria-current="true"] { color: var(--ink-hi); font-weight: bold; }
12- .diff-tools a.vbtn[aria-current="true"]::before { content: "▸"; }
138 }
M internal/adapters/web/static/css/60-mr.css +41 -45
@@ -1,90 +1,86 @@
11 @layer components {
22 /* merge requests: the review page. Hue only where it carries a verdict. */
33 .mr-h .show-kv { margin-top: 0; }
4- .mr-h + * { margin-top: var(--lh); }
4+
55 .mr-state[data-state="open"]::before { content: "● "; color: var(--teal); }
66 .mr-state[data-state="merged"]::before { content: "✓ "; color: var(--teal); }
77 .mr-state[data-state="closed"]::before { content: "✗ "; color: var(--red); }
8- .mr-desc { margin-top: var(--lh); max-width: var(--measure); white-space: pre-wrap; overflow-wrap: anywhere; }
8+ .mr-desc { max-width: var(--measure); white-space: pre-wrap; overflow-wrap: anywhere; padding: var(--s4) var(--s5); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); }
99 .mr-desc a, .c-text a { text-decoration: underline; }
1010
1111 .since {
12- display: flex; flex-wrap: wrap; column-gap: 3ch; margin-top: var(--lh);
13- & a { text-decoration: none; color: var(--ink-dim); }
14- & a:hover { color: var(--ink); text-decoration: underline; }
15- & a[aria-current] { color: var(--ink-hi); font-weight: bold; }
16- & a[aria-current]::before { content: "▸ "; }
17- & .mute { font-weight: normal; }
12+ display: inline-flex; flex-wrap: wrap; gap: var(--s1); padding: var(--s1);
13+ background: var(--bg-raised); border-radius: var(--r-s);
14+ & a { padding: var(--s1) var(--s3); border-radius: 4px; text-decoration: none; color: var(--ink-dim); font-size: var(--fs-s); }
15+ & a:hover { color: var(--ink-hi); }
16+ & a[aria-current] { color: var(--ink-hi); font-weight: 700; background: var(--bg-surface); box-shadow: 0 0 0 1px var(--border-strong); }
17+ & .mute { font-weight: 400; font-family: var(--mono); }
1818 }
1919
2020 /* merge block: first thing in the rail */
2121 .decide {
22- & .d-acts { margin-top: calc(var(--lh) / 2); }
22+ border-color: var(--border-strong) !important;
2323 & select {
24- display: block; width: 100%; font: inherit; color: inherit; background: var(--paper-alt);
25- border: 1px solid var(--rule); border-radius: 0; padding: 0 1ch;
24+ display: block; width: 100%; height: 36px; padding: 0 var(--s3);
25+ font: inherit; color: inherit; background: var(--bg-inset);
26+ border: 1px solid var(--border-strong); border-radius: var(--r-s);
2627 }
27- & .d-check { display: block; margin-top: 4px; color: var(--ink-dim); cursor: pointer; }
28- & .d-check input { accent-color: var(--ink); margin: 0 1ch 0 0; vertical-align: middle; }
29- & .d-btns { display: flex; gap: 2ch; margin-top: 4px; flex-wrap: wrap; }
30- & .d-btns button { color: var(--ink-dim); }
31- & .d-btns button:hover { color: var(--ink); text-decoration: underline; }
32- & .d-btns .d-main { background: var(--ink); color: var(--paper); font-weight: bold; padding: 0 1ch; }
33- & .d-btns .d-main:hover { background: var(--ink-hi); text-decoration: none; }
34- & .d-or { margin-top: calc(var(--lh) / 2); }
28+ & .d-check { display: flex; gap: var(--s2); align-items: center; margin-top: var(--s2); color: var(--ink-dim); font-size: var(--fs-s); cursor: pointer; }
29+ & .d-check input { accent-color: var(--ink-hi); margin: 0; }
30+ & .d-btns { display: flex; gap: var(--s2); margin-top: var(--s3); flex-wrap: wrap; }
31+ & .d-btns .primary { flex: 1; justify-content: center; }
32+ & .d-or { font-size: var(--fs-s); }
3533 & .clone { margin-top: 0; }
3634 & .clone code { white-space: pre-wrap; overflow-wrap: anywhere; }
3735 }
3836
39- .pcommits li { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
40- .pcommits li[data-new] { background: var(--row); }
37+ .pcommits { font-size: var(--fs-s); display: grid; gap: var(--s1); }
38+ .pcommits li { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 2px var(--s2); margin: 0 calc(-1 * var(--s2)); border-radius: 4px; }
39+ .pcommits li a { font-family: var(--mono); }
40+ .pcommits li[data-new] { background: var(--bg-raised); }
4141 .pcommits .s { color: var(--ink); }
42- .rail code { border-bottom: 1px dotted var(--ink-mute); }
42+
4343
4444 /* a note: a ruled block, under its line in the diff and in the list of comments */
4545 .cmt {
46- border-left: 2px solid var(--ink); padding-left: 1ch; margin-top: calc(var(--lh) / 2); max-width: var(--measure);
47- white-space: normal;
46+ padding: var(--s3) var(--s4); margin-top: var(--s3); max-width: var(--measure);
47+ background: var(--bg-surface); border: 1px solid var(--border); border-left: 3px solid var(--ink-hi); border-radius: var(--r-s);
48+ white-space: normal; font-family: var(--font); font-size: var(--fs-s); line-height: 22px;
4849 & p { white-space: normal; }
4950 & .c-who { font-weight: bold; }
5051 & .c-state { color: var(--ink-dim); }
5152 & .c-text { white-space: pre-wrap; overflow-wrap: anywhere; }
5253 & .c-where { font-size: var(--fs-s); overflow-wrap: anywhere; }
53- & .c-act button { color: var(--ink-dim); }
54- & .c-act button:hover { color: var(--ink); text-decoration: underline; }
55- &[data-resolved="true"] { border-left-color: var(--rule); }
54+ & .c-act { margin-top: var(--s2); }
55+ & .c-act button { height: 26px; font-size: var(--fs-xs); }
56+ &[data-resolved="true"] { border-left-color: var(--border-strong); }
5657 &[data-resolved="true"] .c-text { color: var(--ink-dim); }
5758 }
58- .dt .fnote td { background: var(--paper-alt); padding: 2px 0 4px; white-space: normal; }
59- .dt .fnote .cmt, .dt .fnote .cform { margin: 0; font-size: var(--fs); line-height: var(--lh); }
60- .dt .fnote .cmt + .cmt, .dt .fnote .cmt + .cform { margin-top: calc(var(--lh) / 2); }
59+ .dt .fnote td { background: var(--bg-canvas); padding: var(--s2) var(--s3); white-space: normal; }
60+ .dt .fnote .cmt, .dt .fnote .cform { margin: 0; }
61+ .dt .fnote .cmt + .cmt, .dt .fnote .cmt + .cform { margin-top: var(--s2); }
6162 .ln a { color: inherit; text-decoration: none; }
6263 .ln a:hover, .ln a:focus-visible { text-decoration: underline; color: var(--ink-hi); }
6364
6465 /* the forms: a comment, a new merge request */
6566 .cform {
66- display: grid; gap: 2px; margin-top: var(--lh); max-width: var(--measure);
67- & label { color: var(--ink-dim); }
67+ display: grid; gap: var(--s2); margin-top: var(--s3); max-width: var(--measure);
68+ font-family: var(--font); font-size: var(--fs-s);
69+ & label { color: var(--ink-dim); font-weight: 600; }
6870 & textarea, & input[type="text"] {
69- display: block; width: 100%; font: inherit; color: inherit; resize: vertical;
70- background: var(--paper-alt); border: 1px solid var(--rule); border-radius: 0; padding: 0 1ch;
71+ display: block; width: 100%; font: inherit; font-size: var(--fs); color: inherit; resize: vertical;
72+ background: var(--bg-surface); border: 1px solid var(--border-strong); border-radius: var(--r-s); padding: var(--s2) var(--s3);
7173 }
7274 & textarea::placeholder { color: var(--ink-mute); opacity: 1; }
73- & .f-btns { display: flex; gap: 2ch; align-items: baseline; margin-top: 4px; }
74- & .f-btns a { color: var(--ink-dim); }
75- & .f-btns button { color: var(--ink-dim); }
76- & .f-btns button:hover { color: var(--ink); text-decoration: underline; }
77- & .f-btns .f-main { background: var(--ink); color: var(--paper); font-weight: bold; padding: 0 1ch; }
78- & .f-btns .f-main:hover { background: var(--ink-hi); text-decoration: none; }
75+ & .f-btns { display: flex; gap: var(--s2); align-items: center; margin-top: var(--s1); }
7976 }
80- .cform.create { margin-top: var(--lh); }
8177
8278 .list.mrs {
83- & .c-n { width: 5ch; }
84- & .c-state { width: 9ch; }
79+ & .c-n { width: 64px; }
80+ & .c-state { width: 110px; }
8581 & .c-subject { overflow: hidden; text-overflow: ellipsis; }
86- & .c-who { width: 12ch; }
87- & .c-when { width: 11ch; }
82+ & .c-who { width: 140px; }
83+ & .c-when { width: 100px; }
8884 & tr[data-state="open"] .c-state { font-weight: bold; }
8985 & tr[data-state="open"] .c-state::before { content: "● "; color: var(--teal); }
9086 & tr[data-state="merged"] .c-state::before { content: "✓ "; color: var(--teal); }
D internal/adapters/web/static/css/70-footer.css +0 -12
@@ -1,12 +0,0 @@
1-@layer components {
2- /* AGPL §13: the version and the link to the source sit in the status bar, always visible */
3- .status .st-src {
4- flex: none;
5- & a { color: var(--bar-ink); text-decoration: underline; text-decoration-color: var(--bar-dim); text-underline-offset: 3px; }
6- & a:hover { color: #fff; text-decoration-color: currentColor; }
7- }
8- @media (max-width: 700px) {
9- .status .st-ver { display: none; }
10- .status .st-hints { display: none; }
11- }
12-}
M internal/adapters/web/static/css/80-live.css +5 -2
@@ -1,6 +1,9 @@
11 @layer components {
22 /* live updates: the banner of new commits, an empty slot takes no room */
33 #commits-head:empty { display: none; }
4- .live-banner { margin: var(--lh) 0; padding: 0 1ch; background: var(--row); font-weight: bold; }
5- .live-banner a { color: var(--ink); }
4+ .live-banner {
5+ padding: var(--s3) var(--s4); font-weight: 700;
6+ background: var(--bg-surface); border: 1px solid var(--border-strong); border-left: 3px solid var(--teal); border-radius: var(--r-s);
7+ }
8+ .live-banner a { color: var(--ink-hi); }
69 }
A internal/adapters/web/static/fonts/OFL-mono.txt +93 -0
@@ -0,0 +1,93 @@
1+Copyright 2020-2024 The Atkinson Hyperlegible Mono Project Authors (https://github.com/googlefonts/atkinson-hyperlegible-next-mono)
2+
3+This Font Software is licensed under the SIL Open Font License, Version 1.1.
4+This license is copied below, and is also available with a FAQ at:
5+https://openfontlicense.org
6+
7+
8+-----------------------------------------------------------
9+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10+-----------------------------------------------------------
11+
12+PREAMBLE
13+The goals of the Open Font License (OFL) are to stimulate worldwide
14+development of collaborative font projects, to support the font creation
15+efforts of academic and linguistic communities, and to provide a free and
16+open framework in which fonts may be shared and improved in partnership
17+with others.
18+
19+The OFL allows the licensed fonts to be used, studied, modified and
20+redistributed freely as long as they are not sold by themselves. The
21+fonts, including any derivative works, can be bundled, embedded,
22+redistributed and/or sold with any software provided that any reserved
23+names are not used by derivative works. The fonts and derivatives,
24+however, cannot be released under any other type of license. The
25+requirement for fonts to remain under this license does not apply
26+to any document created using the fonts or their derivatives.
27+
28+DEFINITIONS
29+"Font Software" refers to the set of files released by the Copyright
30+Holder(s) under this license and clearly marked as such. This may
31+include source files, build scripts and documentation.
32+
33+"Reserved Font Name" refers to any names specified as such after the
34+copyright statement(s).
35+
36+"Original Version" refers to the collection of Font Software components as
37+distributed by the Copyright Holder(s).
38+
39+"Modified Version" refers to any derivative made by adding to, deleting,
40+or substituting -- in part or in whole -- any of the components of the
41+Original Version, by changing formats or by porting the Font Software to a
42+new environment.
43+
44+"Author" refers to any designer, engineer, programmer, technical
45+writer or other person who contributed to the Font Software.
46+
47+PERMISSION & CONDITIONS
48+Permission is hereby granted, free of charge, to any person obtaining
49+a copy of the Font Software, to use, study, copy, merge, embed, modify,
50+redistribute, and sell modified and unmodified copies of the Font
51+Software, subject to the following conditions:
52+
53+1) Neither the Font Software nor any of its individual components,
54+in Original or Modified Versions, may be sold by itself.
55+
56+2) Original or Modified Versions of the Font Software may be bundled,
57+redistributed and/or sold with any software, provided that each copy
58+contains the above copyright notice and this license. These can be
59+included either as stand-alone text files, human-readable headers or
60+in the appropriate machine-readable metadata fields within text or
61+binary files as long as those fields can be easily viewed by the user.
62+
63+3) No Modified Version of the Font Software may use the Reserved Font
64+Name(s) unless explicit written permission is granted by the corresponding
65+Copyright Holder. This restriction only applies to the primary font name as
66+presented to the users.
67+
68+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69+Software shall not be used to promote, endorse or advertise any
70+Modified Version, except to acknowledge the contribution(s) of the
71+Copyright Holder(s) and the Author(s) or with their explicit written
72+permission.
73+
74+5) The Font Software, modified or unmodified, in part or in whole,
75+must be distributed entirely under this license, and must not be
76+distributed under any other license. The requirement for fonts to
77+remain under this license does not apply to any document created
78+using the Font Software.
79+
80+TERMINATION
81+This license becomes null and void if any of the above conditions are
82+not met.
83+
84+DISCLAIMER
85+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93+OTHER DEALINGS IN THE FONT SOFTWARE.
A internal/adapters/web/static/fonts/OFL-next.txt +93 -0
@@ -0,0 +1,93 @@
1+Copyright 2020-2024 The Atkinson Hyperlegible Next Project Authors (https://github.com/googlefonts/atkinson-hyperlegible-next)
2+
3+This Font Software is licensed under the SIL Open Font License, Version 1.1.
4+This license is copied below, and is also available with a FAQ at:
5+https://openfontlicense.org
6+
7+
8+-----------------------------------------------------------
9+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10+-----------------------------------------------------------
11+
12+PREAMBLE
13+The goals of the Open Font License (OFL) are to stimulate worldwide
14+development of collaborative font projects, to support the font creation
15+efforts of academic and linguistic communities, and to provide a free and
16+open framework in which fonts may be shared and improved in partnership
17+with others.
18+
19+The OFL allows the licensed fonts to be used, studied, modified and
20+redistributed freely as long as they are not sold by themselves. The
21+fonts, including any derivative works, can be bundled, embedded,
22+redistributed and/or sold with any software provided that any reserved
23+names are not used by derivative works. The fonts and derivatives,
24+however, cannot be released under any other type of license. The
25+requirement for fonts to remain under this license does not apply
26+to any document created using the fonts or their derivatives.
27+
28+DEFINITIONS
29+"Font Software" refers to the set of files released by the Copyright
30+Holder(s) under this license and clearly marked as such. This may
31+include source files, build scripts and documentation.
32+
33+"Reserved Font Name" refers to any names specified as such after the
34+copyright statement(s).
35+
36+"Original Version" refers to the collection of Font Software components as
37+distributed by the Copyright Holder(s).
38+
39+"Modified Version" refers to any derivative made by adding to, deleting,
40+or substituting -- in part or in whole -- any of the components of the
41+Original Version, by changing formats or by porting the Font Software to a
42+new environment.
43+
44+"Author" refers to any designer, engineer, programmer, technical
45+writer or other person who contributed to the Font Software.
46+
47+PERMISSION & CONDITIONS
48+Permission is hereby granted, free of charge, to any person obtaining
49+a copy of the Font Software, to use, study, copy, merge, embed, modify,
50+redistribute, and sell modified and unmodified copies of the Font
51+Software, subject to the following conditions:
52+
53+1) Neither the Font Software nor any of its individual components,
54+in Original or Modified Versions, may be sold by itself.
55+
56+2) Original or Modified Versions of the Font Software may be bundled,
57+redistributed and/or sold with any software, provided that each copy
58+contains the above copyright notice and this license. These can be
59+included either as stand-alone text files, human-readable headers or
60+in the appropriate machine-readable metadata fields within text or
61+binary files as long as those fields can be easily viewed by the user.
62+
63+3) No Modified Version of the Font Software may use the Reserved Font
64+Name(s) unless explicit written permission is granted by the corresponding
65+Copyright Holder. This restriction only applies to the primary font name as
66+presented to the users.
67+
68+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69+Software shall not be used to promote, endorse or advertise any
70+Modified Version, except to acknowledge the contribution(s) of the
71+Copyright Holder(s) and the Author(s) or with their explicit written
72+permission.
73+
74+5) The Font Software, modified or unmodified, in part or in whole,
75+must be distributed entirely under this license, and must not be
76+distributed under any other license. The requirement for fonts to
77+remain under this license does not apply to any document created
78+using the Font Software.
79+
80+TERMINATION
81+This license becomes null and void if any of the above conditions are
82+not met.
83+
84+DISCLAIMER
85+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93+OTHER DEALINGS IN THE FONT SOFTWARE.
A internal/adapters/web/static/fonts/atkinson-mono.woff2 +0 -0 binary file: no text diff

binary file: no text diff

A internal/adapters/web/static/fonts/atkinson-next-italic.woff2 +0 -0 binary file: no text diff

binary file: no text diff

A internal/adapters/web/static/fonts/atkinson-next.woff2 +0 -0 binary file: no text diff

binary file: no text diff

A internal/adapters/web/static/icons/branch.svg +1 -0
@@ -0,0 +1,1 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none" stroke="#000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="5" cy="3.5" r="1.5"/><circle cx="5" cy="12.5" r="1.5"/><circle cx="11" cy="4.5" r="1.5"/><path d="M5 5v6M11 6c0 3.5-6 2-6 5"/></svg>⏎̸
A internal/adapters/web/static/icons/brand.svg +1 -0
@@ -0,0 +1,1 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none" stroke="#000" stroke-width="2" stroke-linecap="square"><path d="M4 4h8M4 8h6M4 12h8M4 4v8"/></svg>⏎̸
A internal/adapters/web/static/icons/file.svg +1 -0
@@ -0,0 +1,1 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none" stroke="#000" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"><path d="M3.5 1.5h6l3 3v10h-9zM9.5 1.5v3h3M6 8h4M6 11h4"/></svg>⏎̸
A internal/adapters/web/static/icons/search.svg +1 -0
@@ -0,0 +1,1 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none" stroke="#000" stroke-width="1.5" stroke-linecap="round"><circle cx="7" cy="7" r="4.5"/><path d="m10.5 10.5 3.5 3.5"/></svg>⏎̸
M internal/adapters/web/static/js/ermite.js +2 -0
@@ -9,6 +9,8 @@
99 let pending = 0;
1010 const fire = (name) => document.body.dispatchEvent(new CustomEvent('k-' + name, { bubbles: false }));
1111 addEventListener('keydown', (e) => {
12+ // ctrl k / cmd k opens the palette from anywhere, inputs included.
13+ if ((e.ctrlKey || e.metaKey) && !e.altKey && e.key.toLowerCase() === 'k') { e.preventDefault(); pending = 0; return fire('cmd'); }
1214 if (e.ctrlKey || e.metaKey || e.altKey) return;
1315 const typing = e.target.closest('input, textarea, select, [contenteditable]');
1416 if (e.key === 'Escape') { pending = 0; if (typing) e.target.blur(); return fire('esc'); }
A internal/adapters/web/static/rules.json +16 -0
@@ -0,0 +1,16 @@
1+{
2+ "prefetch": [
3+ {
4+ "source": "document",
5+ "where": {
6+ "and": [
7+ { "href_matches": "/*" },
8+ { "not": { "href_matches": "/-/*" } },
9+ { "not": { "href_matches": "/*/raw/*" } },
10+ { "not": { "selector_matches": "[rel~=nofollow]" } }
11+ ]
12+ },
13+ "eagerness": "moderate"
14+ }
15+ ]
16+}
M internal/adapters/web/summary.go +1 -5
@@ -73,11 +73,7 @@ func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component,
7373 l := s.repoLayout(ctx, d, "summary")
7474 l.Title = d.name() + " · " + s.instance
7575 l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=summary")
76- l.Status = Status{
77- Mode: "summary", Context: plural(len(commits.Msg.GetCommits()), "recent commit", "recent commits"),
78- Hints: "j/k move · enter open · b ref · y permalink · ? help", Cursor: true, Count: len(p.Recent),
79- }
80- l.HasRail, l.Read, l.RailSum = true, true, "clone · facts · recent commits · outline"
76+ l.HasRail = true
8177 return Summary(l, p), http.StatusOK, nil
8278 }
8379
M internal/adapters/web/tree.go +0 -4
@@ -103,9 +103,5 @@ func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, in
103103 } else {
104104 l.Title = visibleText(t.path) + " · " + d.name() + " · " + s.instance
105105 }
106- l.Status = Status{
107- Mode: "tree", Context: "tree · " + plural(len(entries), "entry", "entries"),
108- Hints: "j/k move · enter open · / filter · b ref · ? help", Cursor: true, Count: len(p.Rows),
109- }
110106 return Tree(l, p), http.StatusOK, nil
111107 }
M internal/adapters/web/tree.templ +3 -4
@@ -7,12 +7,12 @@ package web
77 templ Tree(l Layout, p TreePage) {
88 @Shell(l, nil) {
99 <div class="filter">
10- <label for="tree-filter">filter&gt;</label>
10+ <label for="tree-filter" class="sr">filter</label>
1111 <input
1212 id="tree-filter"
1313 type="search"
1414 data-filter
15- placeholder="this directory ( / )"
15+ placeholder="Filter this directory…"
1616 autocomplete="off"
1717 hx-on:input="let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')"
1818 />
@@ -34,8 +34,7 @@ templ Tree(l Layout, p TreePage) {
3434 <p class="note">{ n }</p>
3535 }
3636 if p.Readme != nil {
37- <h2 class="rule-h"><span>{ p.Readme.Name }</span></h2>
38- @ReadmeView(*p.Readme)
37+ @ReadmeCard(*p.Readme)
3938 }
4039 }
4140 }
M internal/adapters/web/tree_templ.go +68 -85 generated file: collapsed
M internal/adapters/web/view.go +5 -18
@@ -7,31 +7,21 @@ import "html/template"
77 // View models handed to the templ components. Components only loop and
88 // branch on these; the handlers build them from the API messages.
99
10-// Crumb is one segment of the path in the top bar.
10+// Crumb is one segment of the path in the header.
1111 type Crumb struct {
1212 Label string
1313 Href string
1414 Current bool
1515 }
1616
17-// Section is a repository section in the top bar, reached by its key.
17+// Section is a repository tab under the header, reached by its key.
1818 type Section struct {
1919 Key string // mnemonic key
20- Post string // rest of the label after the key
20+ Label string
2121 Href string
2222 Current bool
2323 }
2424
25-// Status is the content of the bottom bar.
26-type Status struct {
27- Mode string // "home", "summary", "refs"…
28- Context string // "3 repos · sort push ▼"
29- Hints string // "j/k move · enter open · ? help"
30- Cursor bool // show the row cursor position n/N
31- Blob bool // show the selected line range
32- Count int // number of rows under the cursor
33-}
34-
3525 // RefItem is a branch or tag in the ref switcher.
3626 type RefItem struct {
3727 Name string
@@ -50,10 +40,9 @@ type Layout struct {
5040 SourceURL string
5141 Assets Assets
5242 Crumbs []Crumb
53- Ref string // ref shown in the top bar, "" outside a repo view
43+ Ref string // ref shown in the header, "" outside a repo view
5444 Short string // short sha shown after the ref
5545 Sections []Section
56- Status Status
5746 Repo string
5847 SHA string // full sha of the ref, for the permalink key
5948 RefPath string // the URL segment that `y` rewrites: the ref name
@@ -61,9 +50,7 @@ type Layout struct {
6150 Tags []RefItem
6251 Cmd []CmdItem
6352 HasRail bool
64- RailSum string // the rail as one line when it folds above the content (< 1100px)
65- Read bool // prose page: content capped at the reading measure
66- // Live connects the status bar to the event stream (/-/events).
53+ // Live connects the page to the event stream (/-/events).
6754 Live bool
6855 // LiveURL is the stream the page follows, with its query.
6956 LiveURL string
M internal/adapters/web/web.go +6 -5
@@ -196,6 +196,9 @@ func (s *Server) write(ctx context.Context, w http.ResponseWriter, r *http.Reque
196196 }
197197 h := w.Header()
198198 h.Set("Content-Type", "text/html; charset=utf-8")
199+ // Links on this origin are prefetched on hover, so a click shows the page
200+ // at once. Every GET page is safe to load (nothing is marked read on GET).
201+ h.Set("Speculation-Rules", `"`+s.assets.urls.Rules+`"`)
199202 // Public pages: the browser and any proxy revalidate each time. A page
200203 // rendered for the owner is private and never stored; a handler that set
201204 // its own Cache-Control keeps it.
@@ -253,18 +256,16 @@ func (s *Server) base() Layout {
253256 }
254257
255258 // stateLayout is the layout of a state page: only the way home.
256-func (s *Server) stateLayout(title, context string, crumbs []Crumb) Layout {
259+func (s *Server) stateLayout(title string, crumbs []Crumb) Layout {
257260 l := s.base()
258261 l.Title = title + " · " + s.instance
259262 l.Crumbs = crumbs
260- l.Sections = []Section{{Key: "h", Post: "ome", Href: "/"}}
261- l.Status = Status{Mode: "state", Context: context, Hints: "g h home · ? help"}
262263 l.Cmd = []CmdItem{{Kind: "go", Label: "home", Href: "/"}}
263264 return l
264265 }
265266
266267 func (s *Server) notFoundPage() templ.Component {
267- l := s.stateLayout("404", "404 · not found", []Crumb{{Label: "-"}, {Label: "404"}})
268+ l := s.stateLayout("404", []Crumb{{Label: "not found", Current: true}})
268269 return State(l, StatePage{
269270 Code: "404", Title: "not found",
270271 Lines: []string{"no such page, ref or path.", "check the URL, or start from the repo list."},
@@ -273,7 +274,7 @@ func (s *Server) notFoundPage() templ.Component {
273274 }
274275
275276 func (s *Server) errorPage(id string) templ.Component {
276- l := s.stateLayout("error", "500 · internal error", []Crumb{{Label: "-"}, {Label: "error"}})
277+ l := s.stateLayout("error", []Crumb{{Label: "error", Current: true}})
277278 return State(l, StatePage{
278279 Code: "500", Title: "internal error",
279280 Lines: []string{

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