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 @@
6 <meta name="color-scheme" content="light dark">6 <meta name="color-scheme" content="light dark">
7 <title>data.bin · files · example.test</title>7 <title>data.bin · files · example.test</title>
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <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>
9 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
10 <script src="/-/static/js/htmx.min.HASH.js" defer>11 <script src="/-/static/js/htmx.min.HASH.js" defer>
11 </script>12 </script>
@@ -16,12 +17,15 @@
16 <script src="/-/static/js/ermite.HASH.js" defer>17 <script src="/-/static/js/ermite.HASH.js" defer>
17 </script>18 </script>
18 </head>19 </head>
19-<body data-ref="main" data-sha="SHA40" data-sel="">20+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
20 <a class="skip" href="#main">skip to content</a>21 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">22+<header class="hdr">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>23+<div class="hdr-top">
23-<span class="fade" aria-hidden="true">24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
24 </span>26 </span>
27+<span>example.test</span>
28+</a>
25 <nav class="crumbs" aria-label="Path">29 <nav class="crumbs" aria-label="Path">
26 <ol>30 <ol>
27 <li>31 <li>
@@ -38,45 +42,62 @@
38 </li>42 </li>
39 </ol>43 </ol>
40 </nav>44 </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">
42 <ul>62 <ul>
43 <li>63 <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>
45 </li>66 </li>
46 <li>67 <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>
48 </li>70 </li>
49 <li>71 <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>
51 </li>74 </li>
52 <li>75 <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>
54 </li>78 </li>
55 <li>79 <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>
57 </li>82 </li>
58 </ul>83 </ul>
59 </nav>84 </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>
62 </header>85 </header>
63 <div class="work has-rail">86 <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">
65 <h1 class="file-h">88 <h1 class="file-h">
66 <span>data.bin</span>89 <span>data.bin</span>
67 <span class="acts">90 <span class="acts">
68-<a href="/files/raw/main/data.bin">raw</a>91+<a class="btn" href="/files/raw/main/data.bin">Raw</a>
69-<a href="/files/commits/main/data.bin">history</a>92+<a class="btn" href="/files/commits/main/data.bin">History</a>
70-<a href="/files/tree/main">dir</a>93+<a class="btn" href="/files/tree/main">Folder</a>
71 </span>94 </span>
72 </h1>95 </h1>
73 <p class="notice" role="note">binary file (application/octet-stream, 10 B): not shown. use raw.</p>96 <p class="notice" role="note">binary file (application/octet-stream, 10 B): not shown. use raw.</p>
74 </main>97 </main>
75 <aside class="rail" aria-label="Context">98 <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>
78 <section aria-labelledby="r-file">99 <section aria-labelledby="r-file">
79-<h2 id="r-file">file</h2>100+<h2 id="r-file">File</h2>
80 <dl class="kv">101 <dl class="kv">
81 <dt>size</dt>102 <dt>size</dt>
82 <dd>10 B</dd>103 <dd>10 B</dd>
@@ -85,80 +106,80 @@
85 </dl>106 </dl>
86 </section>107 </section>
87 <section aria-labelledby="r-last">108 <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>
89 </h2>110 </h2>
90 <p>111 <p>
91 <a href="/files/commit/SHA40">SHA7</a> add data.bin</p>112 <a href="/files/commit/SHA40">SHA7</a> add data.bin</p>
92 <p class="dim">T · <time datetime="STAMP" title="STAMP">AGO</time>113 <p class="dim">T · <time datetime="STAMP" title="STAMP">AGO</time>
93 </p>114 </p>
94 </section>115 </section>
95-</details>
96 </aside>116 </aside>
97 </div>117 </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()">
99 <div class="panel-head">133 <div class="panel-head">
100-<span>keys</span>134+<h2>Keyboard shortcuts</h2>
101-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>135+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
136+<kbd>esc</kbd> close</button>
102 </div>137 </div>
103 <dl class="keys">138 <dl class="keys">
104 <div>139 <div>
105 <dt>140 <dt>
106-<kbd>j</kbd>141+<kbd>ctrl k</kbd>
107-<kbd>k</kbd>142+<kbd>:</kbd>
108 </dt>143 </dt>
109-<dd>move cursor down / up</dd>144+<dd>go to a section, branch, tag, file or repository</dd>
110 </div>145 </div>
111 <div>146 <div>
112 <dt>147 <dt>
113-<kbd>enter</kbd>148+<kbd>?</kbd>
114 </dt>149 </dt>
115-<dd>open row under cursor</dd>150+<dd>this list</dd>
116 </div>151 </div>
117 <div>152 <div>
118 <dt>153 <dt>
119-<kbd>g</kbd>154+<kbd>j</kbd>
120-<kbd>g</kbd> / <kbd>G</kbd>155+<kbd>k</kbd>
121 </dt>156 </dt>
122-<dd>first / last row</dd>157+<dd>move the row cursor down / up</dd>
123 </div>158 </div>
124 <div>159 <div>
125 <dt>160 <dt>
126-<kbd>/</kbd>161+<kbd>enter</kbd>
127 </dt>162 </dt>
128-<dd>focus the filter</dd>163+<dd>open the row under the cursor</dd>
129 </div>164 </div>
130 <div>165 <div>
131 <dt>166 <dt>
132-<kbd>g h</kbd>167+<kbd>g g</kbd>
133-<kbd>g t</kbd>168+<kbd>G</kbd>
134 </dt>169 </dt>
135-<dd>go home / tree</dd>170+<dd>first / last row</dd>
136 </div>171 </div>
137 <div>172 <div>
138 <dt>173 <dt>
139-<kbd>g c</kbd>174+<kbd>/</kbd>
140-<kbd>g r</kbd>
141 </dt>175 </dt>
142-<dd>go commits / refs</dd>176+<dd>filter the list</dd>
143 </div>177 </div>
144 <div>178 <div>
145 <dt>179 <dt>
146 <kbd>b</kbd>180 <kbd>b</kbd>
147 </dt>181 </dt>
148-<dd>switch ref (branches, tags)</dd>182+<dd>switch branch or tag</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>
162 </div>183 </div>
163 <div>184 <div>
164 <dt>185 <dt>
@@ -166,15 +187,21 @@
166 <kbd>t</kbd>187 <kbd>t</kbd>
167 <kbd>c</kbd>188 <kbd>c</kbd>
168 <kbd>r</kbd>189 <kbd>r</kbd>
190+<kbd>m</kbd>
169 </dt>191 </dt>
170-<dd>repo sections (when shown)</dd>192+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
171 </div>193 </div>
172 <div>194 <div>
173 <dt>195 <dt>
174-<kbd>m</kbd>196+<kbd>g h</kbd>
175-<kbd>g m</kbd>197+</dt>
198+<dd>home</dd>
199+</div>
200+<div>
201+<dt>
202+<kbd>y</kbd>
176 </dt>203 </dt>
177-<dd>merge requests (when shown)</dd>204+<dd>copy a permalink pinned to the commit</dd>
178 </div>205 </div>
179 <div>206 <div>
180 <dt>207 <dt>
@@ -194,19 +221,21 @@
194 <dt>221 <dt>
195 <kbd>esc</kbd>222 <kbd>esc</kbd>
196 </dt>223 </dt>
197-<dd>close panel</dd>224+<dd>close a dialog, leave a field</dd>
198 </div>225 </div>
199 </dl>226 </dl>
200 </dialog>227 </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()">
202 <div class="panel-head">229 <div class="panel-head">
203-<label for="refs-filter">ref&gt;</label>230+<label for="refs-filter" class="sr">filter branches and tags</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))">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))">
205-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>232+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
233+<kbd>esc</kbd>
234+</button>
206 </div>235 </div>
207 <div class="panel-cols" data-picklist>236 <div class="panel-cols" data-picklist>
208 <section aria-label="Branches">237 <section aria-label="Branches">
209-<h2>branches <span class="mute">2</span>238+<h2>Branches <span class="count">2</span>
210 </h2>239 </h2>
211 <ul>240 <ul>
212 <li>241 <li>
@@ -220,7 +249,7 @@
220 </ul>249 </ul>
221 </section>250 </section>
222 <section aria-label="Tags">251 <section aria-label="Tags">
223-<h2>tags <span class="mute">1</span>252+<h2>Tags <span class="count">1</span>
224 </h2>253 </h2>
225 <ul>254 <ul>
226 <li>255 <li>
@@ -231,105 +260,124 @@
231 </section>260 </section>
232 </div>261 </div>
233 </dialog>262 </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()">
235 <div class="panel-head">264 <div class="panel-head">
236-<label for="cmd-q" class="hi">:</label>265+<label for="cmd-q" class="sr">go to</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();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();
238 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));267 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
239 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();268 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();
240 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));269 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
241 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;270 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
242 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' })) }271 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' })) }
243 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">272 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>
245 </div>276 </div>
246-<ul id="cmd-list" class="cmd-list">277+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
247 <li data-k="go home" data-cursor>278 <li data-k="go home" data-cursor>
248 <a href="/" tabindex="-1">279 <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>
250 </li>283 </li>
251 <li data-k="section summary">284 <li data-k="section summary">
252 <a href="/files" tabindex="-1">285 <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>
254 </li>289 </li>
255-<li data-k="section tree">290+<li data-k="section files">
256 <a href="/files/tree/main" tabindex="-1">291 <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>
258 </li>295 </li>
259 <li data-k="section commits">296 <li data-k="section commits">
260 <a href="/files/commits/main" tabindex="-1">297 <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>
262 </li>301 </li>
263-<li data-k="section refs">302+<li data-k="section branches &amp; tags">
264 <a href="/files/refs" tabindex="-1">303 <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>
266 </li>307 </li>
267 <li data-k="section merge requests">308 <li data-k="section merge requests">
268 <a href="/files/merge-requests" tabindex="-1">309 <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>
270 </li>313 </li>
271 <li data-k="branch feature/x">314 <li data-k="branch feature/x">
272 <a href="/files/tree/feature/x" tabindex="-1">315 <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>
274 </li>319 </li>
275 <li data-k="branch main">320 <li data-k="branch main">
276 <a href="/files/tree/main" tabindex="-1">321 <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>
278 </li>325 </li>
279 <li data-k="tag v1.0">326 <li data-k="tag v1.0">
280 <a href="/files/tree/v1.0" tabindex="-1">327 <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>
282 </li>331 </li>
283 </ul>332 </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>
284 </dialog>342 </dialog>
285-<footer class="bar status" aria-label="Status">343+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
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) => {
300 if (q('dialog[open]').count) return;344 if (q('dialog[open]').count) return;
301 let rows = q('[data-rows] > :not([hidden])').arr();345 let rows = q('[data-rows] > :not([hidden])').arr();
302 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));346 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
303 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));347 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
304- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];348+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
305- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
306 })(1);352 })(1);
307- k-k from:body -> ((d) => {353+ k-k from:body -> (async (d) => {
308 if (q('dialog[open]').count) return;354 if (q('dialog[open]').count) return;
309 let rows = q('[data-rows] > :not([hidden])').arr();355 let rows = q('[data-rows] > :not([hidden])').arr();
310 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));356 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
311 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));357 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
312- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];358+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
313- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
314 })(-1);362 })(-1);
315 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));363 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
316 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));364 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));
317 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();365 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
318 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());366 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();367+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
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());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());
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();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();
322 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();370 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
323- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();371+ k-gh from:body -> document.querySelector('.brand')?.click();
324- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();372+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
325- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();373+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
326- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();374+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
327- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();375+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
328- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();376+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
329- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();377+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
330- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();378+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
331- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();379+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
332- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();380+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
333 k-next from:body -> {381 k-next from:body -> {
334 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;382 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
335 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);383 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/blob-code.html +163 -116
@@ -6,6 +6,7 @@
6 <meta name="color-scheme" content="light dark">6 <meta name="color-scheme" content="light dark">
7 <title>main.go · files · example.test</title>7 <title>main.go · files · example.test</title>
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <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>
9 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
10 <script src="/-/static/js/htmx.min.HASH.js" defer>11 <script src="/-/static/js/htmx.min.HASH.js" defer>
11 </script>12 </script>
@@ -16,12 +17,15 @@
16 <script src="/-/static/js/ermite.HASH.js" defer>17 <script src="/-/static/js/ermite.HASH.js" defer>
17 </script>18 </script>
18 </head>19 </head>
19-<body data-ref="main" data-sha="SHA40" data-sel="">20+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
20 <a class="skip" href="#main">skip to content</a>21 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">22+<header class="hdr">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>23+<div class="hdr-top">
23-<span class="fade" aria-hidden="true">24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
24 </span>26 </span>
27+<span>example.test</span>
28+</a>
25 <nav class="crumbs" aria-label="Path">29 <nav class="crumbs" aria-label="Path">
26 <ol>30 <ol>
27 <li>31 <li>
@@ -38,36 +42,55 @@
38 </li>42 </li>
39 </ol>43 </ol>
40 </nav>44 </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">
42 <ul>62 <ul>
43 <li>63 <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>
45 </li>66 </li>
46 <li>67 <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>
48 </li>70 </li>
49 <li>71 <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>
51 </li>74 </li>
52 <li>75 <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>
54 </li>78 </li>
55 <li>79 <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>
57 </li>82 </li>
58 </ul>83 </ul>
59 </nav>84 </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>
62 </header>85 </header>
63 <div class="work has-rail">86 <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">
65 <h1 class="file-h">88 <h1 class="file-h">
66 <span>main.go</span>89 <span>main.go</span>
67 <span class="acts">90 <span class="acts">
68-<a href="/files/raw/main/main.go">raw</a>91+<a class="btn" href="/files/raw/main/main.go">Raw</a>
69-<a href="/files/commits/main/main.go">history</a>92+<a class="btn" href="/files/commits/main/main.go">History</a>
70-<a href="/files/tree/main">dir</a>93+<a class="btn" href="/files/tree/main">Folder</a>
71 </span>94 </span>
72 </h1>95 </h1>
73 <pre class="chroma code" data-anchor="" hx-on:click="let a = event.target.closest('a.ln'); if (!a) return; event.preventDefault();96 <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 @@
136 </pre>159 </pre>
137 </main>160 </main>
138 <aside class="rail" aria-label="Context">161 <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>
141 <section aria-labelledby="r-file">162 <section aria-labelledby="r-file">
142-<h2 id="r-file">file</h2>163+<h2 id="r-file">File</h2>
143 <dl class="kv">164 <dl class="kv">
144 <dt>size</dt>165 <dt>size</dt>
145 <dd>87 B</dd>166 <dd>87 B</dd>
@@ -150,7 +171,7 @@
150 </dl>171 </dl>
151 </section>172 </section>
152 <section aria-labelledby="r-last">173 <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>
154 </h2>175 </h2>
155 <p>176 <p>
156 <a href="/files/commit/SHA40">SHA7</a> add main.go</p>177 <a href="/files/commit/SHA40">SHA7</a> add main.go</p>
@@ -158,77 +179,77 @@
158 </p>179 </p>
159 </section>180 </section>
160 <section aria-labelledby="r-sel">181 <section aria-labelledby="r-sel">
161-<h2 id="r-sel">selection</h2>182+<h2 id="r-sel">Selection</h2>
162 <p :text="document.body.dataset.sel || 'none · click a line number, shift-click extends'">none · click a line number, shift-click extends</p>183 <p :text="document.body.dataset.sel || 'none · click a line number, shift-click extends'">none · click a line number, shift-click extends</p>
163 <p class="dim">y copies a permalink pinned to the commit sha</p>184 <p class="dim">y copies a permalink pinned to the commit sha</p>
164 </section>185 </section>
165-</details>
166 </aside>186 </aside>
167 </div>187 </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()">
169 <div class="panel-head">203 <div class="panel-head">
170-<span>keys</span>204+<h2>Keyboard shortcuts</h2>
171-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>205+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
206+<kbd>esc</kbd> close</button>
172 </div>207 </div>
173 <dl class="keys">208 <dl class="keys">
174 <div>209 <div>
175 <dt>210 <dt>
176-<kbd>j</kbd>211+<kbd>ctrl k</kbd>
177-<kbd>k</kbd>212+<kbd>:</kbd>
178 </dt>213 </dt>
179-<dd>move cursor down / up</dd>214+<dd>go to a section, branch, tag, file or repository</dd>
180 </div>215 </div>
181 <div>216 <div>
182 <dt>217 <dt>
183-<kbd>enter</kbd>218+<kbd>?</kbd>
184 </dt>219 </dt>
185-<dd>open row under cursor</dd>220+<dd>this list</dd>
186 </div>221 </div>
187 <div>222 <div>
188 <dt>223 <dt>
189-<kbd>g</kbd>224+<kbd>j</kbd>
190-<kbd>g</kbd> / <kbd>G</kbd>225+<kbd>k</kbd>
191 </dt>226 </dt>
192-<dd>first / last row</dd>227+<dd>move the row cursor down / up</dd>
193 </div>228 </div>
194 <div>229 <div>
195 <dt>230 <dt>
196-<kbd>/</kbd>231+<kbd>enter</kbd>
197 </dt>232 </dt>
198-<dd>focus the filter</dd>233+<dd>open the row under the cursor</dd>
199 </div>234 </div>
200 <div>235 <div>
201 <dt>236 <dt>
202-<kbd>g h</kbd>237+<kbd>g g</kbd>
203-<kbd>g t</kbd>238+<kbd>G</kbd>
204 </dt>239 </dt>
205-<dd>go home / tree</dd>240+<dd>first / last row</dd>
206 </div>241 </div>
207 <div>242 <div>
208 <dt>243 <dt>
209-<kbd>g c</kbd>244+<kbd>/</kbd>
210-<kbd>g r</kbd>
211 </dt>245 </dt>
212-<dd>go commits / refs</dd>246+<dd>filter the list</dd>
213 </div>247 </div>
214 <div>248 <div>
215 <dt>249 <dt>
216 <kbd>b</kbd>250 <kbd>b</kbd>
217 </dt>251 </dt>
218-<dd>switch ref (branches, tags)</dd>252+<dd>switch branch or tag</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>
232 </div>253 </div>
233 <div>254 <div>
234 <dt>255 <dt>
@@ -236,15 +257,21 @@
236 <kbd>t</kbd>257 <kbd>t</kbd>
237 <kbd>c</kbd>258 <kbd>c</kbd>
238 <kbd>r</kbd>259 <kbd>r</kbd>
260+<kbd>m</kbd>
239 </dt>261 </dt>
240-<dd>repo sections (when shown)</dd>262+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
241 </div>263 </div>
242 <div>264 <div>
243 <dt>265 <dt>
244-<kbd>m</kbd>266+<kbd>g h</kbd>
245-<kbd>g m</kbd>267+</dt>
268+<dd>home</dd>
269+</div>
270+<div>
271+<dt>
272+<kbd>y</kbd>
246 </dt>273 </dt>
247-<dd>merge requests (when shown)</dd>274+<dd>copy a permalink pinned to the commit</dd>
248 </div>275 </div>
249 <div>276 <div>
250 <dt>277 <dt>
@@ -264,19 +291,21 @@
264 <dt>291 <dt>
265 <kbd>esc</kbd>292 <kbd>esc</kbd>
266 </dt>293 </dt>
267-<dd>close panel</dd>294+<dd>close a dialog, leave a field</dd>
268 </div>295 </div>
269 </dl>296 </dl>
270 </dialog>297 </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()">
272 <div class="panel-head">299 <div class="panel-head">
273-<label for="refs-filter">ref&gt;</label>300+<label for="refs-filter" class="sr">filter branches and tags</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))">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))">
275-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>302+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
303+<kbd>esc</kbd>
304+</button>
276 </div>305 </div>
277 <div class="panel-cols" data-picklist>306 <div class="panel-cols" data-picklist>
278 <section aria-label="Branches">307 <section aria-label="Branches">
279-<h2>branches <span class="mute">2</span>308+<h2>Branches <span class="count">2</span>
280 </h2>309 </h2>
281 <ul>310 <ul>
282 <li>311 <li>
@@ -290,7 +319,7 @@
290 </ul>319 </ul>
291 </section>320 </section>
292 <section aria-label="Tags">321 <section aria-label="Tags">
293-<h2>tags <span class="mute">1</span>322+<h2>Tags <span class="count">1</span>
294 </h2>323 </h2>
295 <ul>324 <ul>
296 <li>325 <li>
@@ -301,106 +330,124 @@
301 </section>330 </section>
302 </div>331 </div>
303 </dialog>332 </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()">
305 <div class="panel-head">334 <div class="panel-head">
306-<label for="cmd-q" class="hi">:</label>335+<label for="cmd-q" class="sr">go to</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();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();
308 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));337 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
309 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();338 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();
310 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));339 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
311 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;340 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
312 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' })) }341 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' })) }
313 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">342 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>
315 </div>346 </div>
316-<ul id="cmd-list" class="cmd-list">347+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
317 <li data-k="go home" data-cursor>348 <li data-k="go home" data-cursor>
318 <a href="/" tabindex="-1">349 <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>
320 </li>353 </li>
321 <li data-k="section summary">354 <li data-k="section summary">
322 <a href="/files" tabindex="-1">355 <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>
324 </li>359 </li>
325-<li data-k="section tree">360+<li data-k="section files">
326 <a href="/files/tree/main" tabindex="-1">361 <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>
328 </li>365 </li>
329 <li data-k="section commits">366 <li data-k="section commits">
330 <a href="/files/commits/main" tabindex="-1">367 <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>
332 </li>371 </li>
333-<li data-k="section refs">372+<li data-k="section branches &amp; tags">
334 <a href="/files/refs" tabindex="-1">373 <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>
336 </li>377 </li>
337 <li data-k="section merge requests">378 <li data-k="section merge requests">
338 <a href="/files/merge-requests" tabindex="-1">379 <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>
340 </li>383 </li>
341 <li data-k="branch feature/x">384 <li data-k="branch feature/x">
342 <a href="/files/tree/feature/x" tabindex="-1">385 <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>
344 </li>389 </li>
345 <li data-k="branch main">390 <li data-k="branch main">
346 <a href="/files/tree/main" tabindex="-1">391 <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>
348 </li>395 </li>
349 <li data-k="tag v1.0">396 <li data-k="tag v1.0">
350 <a href="/files/tree/v1.0" tabindex="-1">397 <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>
352 </li>401 </li>
353 </ul>402 </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>
354 </dialog>412 </dialog>
355-<footer class="bar status" aria-label="Status">413+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
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) => {
371 if (q('dialog[open]').count) return;414 if (q('dialog[open]').count) return;
372 let rows = q('[data-rows] > :not([hidden])').arr();415 let rows = q('[data-rows] > :not([hidden])').arr();
373 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));416 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
374 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));417 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
375- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];418+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
376- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
377 })(1);422 })(1);
378- k-k from:body -> ((d) => {423+ k-k from:body -> (async (d) => {
379 if (q('dialog[open]').count) return;424 if (q('dialog[open]').count) return;
380 let rows = q('[data-rows] > :not([hidden])').arr();425 let rows = q('[data-rows] > :not([hidden])').arr();
381 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));426 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
382 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));427 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
383- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];428+ 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' })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 = ''
385 })(-1);432 })(-1);
386 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));433 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
387 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));434 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));
388 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();435 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
389 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());436 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();437+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
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());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());
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();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();
393 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();440 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
394- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();441+ k-gh from:body -> document.querySelector('.brand')?.click();
395- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();442+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
396- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();443+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
397- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();444+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
398- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();445+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
399- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();446+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
400- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();447+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
401- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();448+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
402- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();449+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
403- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();450+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
404 k-next from:body -> {451 k-next from:body -> {
405 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;452 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
406 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);453 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/blob-empty.html +162 -114
@@ -6,6 +6,7 @@
6 <meta name="color-scheme" content="light dark">6 <meta name="color-scheme" content="light dark">
7 <title>empty.txt · files · example.test</title>7 <title>empty.txt · files · example.test</title>
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <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>
9 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
10 <script src="/-/static/js/htmx.min.HASH.js" defer>11 <script src="/-/static/js/htmx.min.HASH.js" defer>
11 </script>12 </script>
@@ -16,12 +17,15 @@
16 <script src="/-/static/js/ermite.HASH.js" defer>17 <script src="/-/static/js/ermite.HASH.js" defer>
17 </script>18 </script>
18 </head>19 </head>
19-<body data-ref="main" data-sha="SHA40" data-sel="">20+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
20 <a class="skip" href="#main">skip to content</a>21 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">22+<header class="hdr">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>23+<div class="hdr-top">
23-<span class="fade" aria-hidden="true">24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
24 </span>26 </span>
27+<span>example.test</span>
28+</a>
25 <nav class="crumbs" aria-label="Path">29 <nav class="crumbs" aria-label="Path">
26 <ol>30 <ol>
27 <li>31 <li>
@@ -38,45 +42,62 @@
38 </li>42 </li>
39 </ol>43 </ol>
40 </nav>44 </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">
42 <ul>62 <ul>
43 <li>63 <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>
45 </li>66 </li>
46 <li>67 <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>
48 </li>70 </li>
49 <li>71 <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>
51 </li>74 </li>
52 <li>75 <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>
54 </li>78 </li>
55 <li>79 <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>
57 </li>82 </li>
58 </ul>83 </ul>
59 </nav>84 </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>
62 </header>85 </header>
63 <div class="work has-rail">86 <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">
65 <h1 class="file-h">88 <h1 class="file-h">
66 <span>empty.txt</span>89 <span>empty.txt</span>
67 <span class="acts">90 <span class="acts">
68-<a href="/files/raw/main/empty.txt">raw</a>91+<a class="btn" href="/files/raw/main/empty.txt">Raw</a>
69-<a href="/files/commits/main/empty.txt">history</a>92+<a class="btn" href="/files/commits/main/empty.txt">History</a>
70-<a href="/files/tree/main">dir</a>93+<a class="btn" href="/files/tree/main">Folder</a>
71 </span>94 </span>
72 </h1>95 </h1>
73 <p class="notice" role="note">empty file.</p>96 <p class="notice" role="note">empty file.</p>
74 </main>97 </main>
75 <aside class="rail" aria-label="Context">98 <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>
78 <section aria-labelledby="r-file">99 <section aria-labelledby="r-file">
79-<h2 id="r-file">file</h2>100+<h2 id="r-file">File</h2>
80 <dl class="kv">101 <dl class="kv">
81 <dt>size</dt>102 <dt>size</dt>
82 <dd>0 B</dd>103 <dd>0 B</dd>
@@ -85,80 +106,80 @@
85 </dl>106 </dl>
86 </section>107 </section>
87 <section aria-labelledby="r-last">108 <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>
89 </h2>110 </h2>
90 <p>111 <p>
91 <a href="/files/commit/SHA40">SHA7</a> add empty.txt</p>112 <a href="/files/commit/SHA40">SHA7</a> add empty.txt</p>
92 <p class="dim">T · <time datetime="STAMP" title="STAMP">AGO</time>113 <p class="dim">T · <time datetime="STAMP" title="STAMP">AGO</time>
93 </p>114 </p>
94 </section>115 </section>
95-</details>
96 </aside>116 </aside>
97 </div>117 </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()">
99 <div class="panel-head">133 <div class="panel-head">
100-<span>keys</span>134+<h2>Keyboard shortcuts</h2>
101-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>135+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
136+<kbd>esc</kbd> close</button>
102 </div>137 </div>
103 <dl class="keys">138 <dl class="keys">
104 <div>139 <div>
105 <dt>140 <dt>
106-<kbd>j</kbd>141+<kbd>ctrl k</kbd>
107-<kbd>k</kbd>142+<kbd>:</kbd>
108 </dt>143 </dt>
109-<dd>move cursor down / up</dd>144+<dd>go to a section, branch, tag, file or repository</dd>
110 </div>145 </div>
111 <div>146 <div>
112 <dt>147 <dt>
113-<kbd>enter</kbd>148+<kbd>?</kbd>
114 </dt>149 </dt>
115-<dd>open row under cursor</dd>150+<dd>this list</dd>
116 </div>151 </div>
117 <div>152 <div>
118 <dt>153 <dt>
119-<kbd>g</kbd>154+<kbd>j</kbd>
120-<kbd>g</kbd> / <kbd>G</kbd>155+<kbd>k</kbd>
121 </dt>156 </dt>
122-<dd>first / last row</dd>157+<dd>move the row cursor down / up</dd>
123 </div>158 </div>
124 <div>159 <div>
125 <dt>160 <dt>
126-<kbd>/</kbd>161+<kbd>enter</kbd>
127 </dt>162 </dt>
128-<dd>focus the filter</dd>163+<dd>open the row under the cursor</dd>
129 </div>164 </div>
130 <div>165 <div>
131 <dt>166 <dt>
132-<kbd>g h</kbd>167+<kbd>g g</kbd>
133-<kbd>g t</kbd>168+<kbd>G</kbd>
134 </dt>169 </dt>
135-<dd>go home / tree</dd>170+<dd>first / last row</dd>
136 </div>171 </div>
137 <div>172 <div>
138 <dt>173 <dt>
139-<kbd>g c</kbd>174+<kbd>/</kbd>
140-<kbd>g r</kbd>
141 </dt>175 </dt>
142-<dd>go commits / refs</dd>176+<dd>filter the list</dd>
143 </div>177 </div>
144 <div>178 <div>
145 <dt>179 <dt>
146 <kbd>b</kbd>180 <kbd>b</kbd>
147 </dt>181 </dt>
148-<dd>switch ref (branches, tags)</dd>182+<dd>switch branch or tag</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>
162 </div>183 </div>
163 <div>184 <div>
164 <dt>185 <dt>
@@ -166,15 +187,21 @@
166 <kbd>t</kbd>187 <kbd>t</kbd>
167 <kbd>c</kbd>188 <kbd>c</kbd>
168 <kbd>r</kbd>189 <kbd>r</kbd>
190+<kbd>m</kbd>
169 </dt>191 </dt>
170-<dd>repo sections (when shown)</dd>192+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
171 </div>193 </div>
172 <div>194 <div>
173 <dt>195 <dt>
174-<kbd>m</kbd>196+<kbd>g h</kbd>
175-<kbd>g m</kbd>197+</dt>
198+<dd>home</dd>
199+</div>
200+<div>
201+<dt>
202+<kbd>y</kbd>
176 </dt>203 </dt>
177-<dd>merge requests (when shown)</dd>204+<dd>copy a permalink pinned to the commit</dd>
178 </div>205 </div>
179 <div>206 <div>
180 <dt>207 <dt>
@@ -194,19 +221,21 @@
194 <dt>221 <dt>
195 <kbd>esc</kbd>222 <kbd>esc</kbd>
196 </dt>223 </dt>
197-<dd>close panel</dd>224+<dd>close a dialog, leave a field</dd>
198 </div>225 </div>
199 </dl>226 </dl>
200 </dialog>227 </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()">
202 <div class="panel-head">229 <div class="panel-head">
203-<label for="refs-filter">ref&gt;</label>230+<label for="refs-filter" class="sr">filter branches and tags</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))">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))">
205-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>232+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
233+<kbd>esc</kbd>
234+</button>
206 </div>235 </div>
207 <div class="panel-cols" data-picklist>236 <div class="panel-cols" data-picklist>
208 <section aria-label="Branches">237 <section aria-label="Branches">
209-<h2>branches <span class="mute">2</span>238+<h2>Branches <span class="count">2</span>
210 </h2>239 </h2>
211 <ul>240 <ul>
212 <li>241 <li>
@@ -220,7 +249,7 @@
220 </ul>249 </ul>
221 </section>250 </section>
222 <section aria-label="Tags">251 <section aria-label="Tags">
223-<h2>tags <span class="mute">1</span>252+<h2>Tags <span class="count">1</span>
224 </h2>253 </h2>
225 <ul>254 <ul>
226 <li>255 <li>
@@ -231,105 +260,124 @@
231 </section>260 </section>
232 </div>261 </div>
233 </dialog>262 </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()">
235 <div class="panel-head">264 <div class="panel-head">
236-<label for="cmd-q" class="hi">:</label>265+<label for="cmd-q" class="sr">go to</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();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();
238 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));267 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
239 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();268 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();
240 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));269 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
241 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;270 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
242 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' })) }271 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' })) }
243 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">272 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>
245 </div>276 </div>
246-<ul id="cmd-list" class="cmd-list">277+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
247 <li data-k="go home" data-cursor>278 <li data-k="go home" data-cursor>
248 <a href="/" tabindex="-1">279 <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>
250 </li>283 </li>
251 <li data-k="section summary">284 <li data-k="section summary">
252 <a href="/files" tabindex="-1">285 <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>
254 </li>289 </li>
255-<li data-k="section tree">290+<li data-k="section files">
256 <a href="/files/tree/main" tabindex="-1">291 <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>
258 </li>295 </li>
259 <li data-k="section commits">296 <li data-k="section commits">
260 <a href="/files/commits/main" tabindex="-1">297 <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>
262 </li>301 </li>
263-<li data-k="section refs">302+<li data-k="section branches &amp; tags">
264 <a href="/files/refs" tabindex="-1">303 <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>
266 </li>307 </li>
267 <li data-k="section merge requests">308 <li data-k="section merge requests">
268 <a href="/files/merge-requests" tabindex="-1">309 <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>
270 </li>313 </li>
271 <li data-k="branch feature/x">314 <li data-k="branch feature/x">
272 <a href="/files/tree/feature/x" tabindex="-1">315 <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>
274 </li>319 </li>
275 <li data-k="branch main">320 <li data-k="branch main">
276 <a href="/files/tree/main" tabindex="-1">321 <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>
278 </li>325 </li>
279 <li data-k="tag v1.0">326 <li data-k="tag v1.0">
280 <a href="/files/tree/v1.0" tabindex="-1">327 <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>
282 </li>331 </li>
283 </ul>332 </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>
284 </dialog>342 </dialog>
285-<footer class="bar status" aria-label="Status">343+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
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) => {
300 if (q('dialog[open]').count) return;344 if (q('dialog[open]').count) return;
301 let rows = q('[data-rows] > :not([hidden])').arr();345 let rows = q('[data-rows] > :not([hidden])').arr();
302 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));346 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
303 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));347 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
304- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];348+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
305- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
306 })(1);352 })(1);
307- k-k from:body -> ((d) => {353+ k-k from:body -> (async (d) => {
308 if (q('dialog[open]').count) return;354 if (q('dialog[open]').count) return;
309 let rows = q('[data-rows] > :not([hidden])').arr();355 let rows = q('[data-rows] > :not([hidden])').arr();
310 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));356 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
311 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));357 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
312- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];358+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
313- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
314 })(-1);362 })(-1);
315 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));363 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
316 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));364 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));
317 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();365 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
318 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());366 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();367+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
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());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());
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();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();
322 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();370 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
323- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();371+ k-gh from:body -> document.querySelector('.brand')?.click();
324- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();372+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
325- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();373+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
326- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();374+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
327- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();375+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
328- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();376+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
329- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();377+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
330- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();378+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
331- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();379+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
332- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();380+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
333 k-next from:body -> {381 k-next from:body -> {
334 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;382 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
335 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);383 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/blob-image.html +162 -114
@@ -6,6 +6,7 @@
6 <meta name="color-scheme" content="light dark">6 <meta name="color-scheme" content="light dark">
7 <title>pic.png · files · example.test</title>7 <title>pic.png · files · example.test</title>
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <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>
9 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
10 <script src="/-/static/js/htmx.min.HASH.js" defer>11 <script src="/-/static/js/htmx.min.HASH.js" defer>
11 </script>12 </script>
@@ -16,12 +17,15 @@
16 <script src="/-/static/js/ermite.HASH.js" defer>17 <script src="/-/static/js/ermite.HASH.js" defer>
17 </script>18 </script>
18 </head>19 </head>
19-<body data-ref="main" data-sha="SHA40" data-sel="">20+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
20 <a class="skip" href="#main">skip to content</a>21 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">22+<header class="hdr">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>23+<div class="hdr-top">
23-<span class="fade" aria-hidden="true">24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
24 </span>26 </span>
27+<span>example.test</span>
28+</a>
25 <nav class="crumbs" aria-label="Path">29 <nav class="crumbs" aria-label="Path">
26 <ol>30 <ol>
27 <li>31 <li>
@@ -38,36 +42,55 @@
38 </li>42 </li>
39 </ol>43 </ol>
40 </nav>44 </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">
42 <ul>62 <ul>
43 <li>63 <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>
45 </li>66 </li>
46 <li>67 <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>
48 </li>70 </li>
49 <li>71 <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>
51 </li>74 </li>
52 <li>75 <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>
54 </li>78 </li>
55 <li>79 <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>
57 </li>82 </li>
58 </ul>83 </ul>
59 </nav>84 </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>
62 </header>85 </header>
63 <div class="work has-rail">86 <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">
65 <h1 class="file-h">88 <h1 class="file-h">
66 <span>pic.png</span>89 <span>pic.png</span>
67 <span class="acts">90 <span class="acts">
68-<a href="/files/raw/main/pic.png">raw</a>91+<a class="btn" href="/files/raw/main/pic.png">Raw</a>
69-<a href="/files/commits/main/pic.png">history</a>92+<a class="btn" href="/files/commits/main/pic.png">History</a>
70-<a href="/files/tree/main">dir</a>93+<a class="btn" href="/files/tree/main">Folder</a>
71 </span>94 </span>
72 </h1>95 </h1>
73 <p class="img-view">96 <p class="img-view">
@@ -75,10 +98,8 @@
75 </p>98 </p>
76 </main>99 </main>
77 <aside class="rail" aria-label="Context">100 <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>
80 <section aria-labelledby="r-file">101 <section aria-labelledby="r-file">
81-<h2 id="r-file">file</h2>102+<h2 id="r-file">File</h2>
82 <dl class="kv">103 <dl class="kv">
83 <dt>size</dt>104 <dt>size</dt>
84 <dd>88 B</dd>105 <dd>88 B</dd>
@@ -87,80 +108,80 @@
87 </dl>108 </dl>
88 </section>109 </section>
89 <section aria-labelledby="r-last">110 <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>
91 </h2>112 </h2>
92 <p>113 <p>
93 <a href="/files/commit/SHA40">SHA7</a> add pic.png</p>114 <a href="/files/commit/SHA40">SHA7</a> add pic.png</p>
94 <p class="dim">T · <time datetime="STAMP" title="STAMP">AGO</time>115 <p class="dim">T · <time datetime="STAMP" title="STAMP">AGO</time>
95 </p>116 </p>
96 </section>117 </section>
97-</details>
98 </aside>118 </aside>
99 </div>119 </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()">
101 <div class="panel-head">135 <div class="panel-head">
102-<span>keys</span>136+<h2>Keyboard shortcuts</h2>
103-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>137+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
138+<kbd>esc</kbd> close</button>
104 </div>139 </div>
105 <dl class="keys">140 <dl class="keys">
106 <div>141 <div>
107 <dt>142 <dt>
108-<kbd>j</kbd>143+<kbd>ctrl k</kbd>
109-<kbd>k</kbd>144+<kbd>:</kbd>
110 </dt>145 </dt>
111-<dd>move cursor down / up</dd>146+<dd>go to a section, branch, tag, file or repository</dd>
112 </div>147 </div>
113 <div>148 <div>
114 <dt>149 <dt>
115-<kbd>enter</kbd>150+<kbd>?</kbd>
116 </dt>151 </dt>
117-<dd>open row under cursor</dd>152+<dd>this list</dd>
118 </div>153 </div>
119 <div>154 <div>
120 <dt>155 <dt>
121-<kbd>g</kbd>156+<kbd>j</kbd>
122-<kbd>g</kbd> / <kbd>G</kbd>157+<kbd>k</kbd>
123 </dt>158 </dt>
124-<dd>first / last row</dd>159+<dd>move the row cursor down / up</dd>
125 </div>160 </div>
126 <div>161 <div>
127 <dt>162 <dt>
128-<kbd>/</kbd>163+<kbd>enter</kbd>
129 </dt>164 </dt>
130-<dd>focus the filter</dd>165+<dd>open the row under the cursor</dd>
131 </div>166 </div>
132 <div>167 <div>
133 <dt>168 <dt>
134-<kbd>g h</kbd>169+<kbd>g g</kbd>
135-<kbd>g t</kbd>170+<kbd>G</kbd>
136 </dt>171 </dt>
137-<dd>go home / tree</dd>172+<dd>first / last row</dd>
138 </div>173 </div>
139 <div>174 <div>
140 <dt>175 <dt>
141-<kbd>g c</kbd>176+<kbd>/</kbd>
142-<kbd>g r</kbd>
143 </dt>177 </dt>
144-<dd>go commits / refs</dd>178+<dd>filter the list</dd>
145 </div>179 </div>
146 <div>180 <div>
147 <dt>181 <dt>
148 <kbd>b</kbd>182 <kbd>b</kbd>
149 </dt>183 </dt>
150-<dd>switch ref (branches, tags)</dd>184+<dd>switch branch or tag</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>
164 </div>185 </div>
165 <div>186 <div>
166 <dt>187 <dt>
@@ -168,15 +189,21 @@
168 <kbd>t</kbd>189 <kbd>t</kbd>
169 <kbd>c</kbd>190 <kbd>c</kbd>
170 <kbd>r</kbd>191 <kbd>r</kbd>
192+<kbd>m</kbd>
171 </dt>193 </dt>
172-<dd>repo sections (when shown)</dd>194+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
173 </div>195 </div>
174 <div>196 <div>
175 <dt>197 <dt>
176-<kbd>m</kbd>198+<kbd>g h</kbd>
177-<kbd>g m</kbd>199+</dt>
200+<dd>home</dd>
201+</div>
202+<div>
203+<dt>
204+<kbd>y</kbd>
178 </dt>205 </dt>
179-<dd>merge requests (when shown)</dd>206+<dd>copy a permalink pinned to the commit</dd>
180 </div>207 </div>
181 <div>208 <div>
182 <dt>209 <dt>
@@ -196,19 +223,21 @@
196 <dt>223 <dt>
197 <kbd>esc</kbd>224 <kbd>esc</kbd>
198 </dt>225 </dt>
199-<dd>close panel</dd>226+<dd>close a dialog, leave a field</dd>
200 </div>227 </div>
201 </dl>228 </dl>
202 </dialog>229 </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()">
204 <div class="panel-head">231 <div class="panel-head">
205-<label for="refs-filter">ref&gt;</label>232+<label for="refs-filter" class="sr">filter branches and tags</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))">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))">
207-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>234+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
235+<kbd>esc</kbd>
236+</button>
208 </div>237 </div>
209 <div class="panel-cols" data-picklist>238 <div class="panel-cols" data-picklist>
210 <section aria-label="Branches">239 <section aria-label="Branches">
211-<h2>branches <span class="mute">2</span>240+<h2>Branches <span class="count">2</span>
212 </h2>241 </h2>
213 <ul>242 <ul>
214 <li>243 <li>
@@ -222,7 +251,7 @@
222 </ul>251 </ul>
223 </section>252 </section>
224 <section aria-label="Tags">253 <section aria-label="Tags">
225-<h2>tags <span class="mute">1</span>254+<h2>Tags <span class="count">1</span>
226 </h2>255 </h2>
227 <ul>256 <ul>
228 <li>257 <li>
@@ -233,105 +262,124 @@
233 </section>262 </section>
234 </div>263 </div>
235 </dialog>264 </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()">
237 <div class="panel-head">266 <div class="panel-head">
238-<label for="cmd-q" class="hi">:</label>267+<label for="cmd-q" class="sr">go to</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();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();
240 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));269 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
241 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();270 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();
242 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));271 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
243 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;272 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
244 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' })) }273 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' })) }
245 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">274 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>
247 </div>278 </div>
248-<ul id="cmd-list" class="cmd-list">279+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
249 <li data-k="go home" data-cursor>280 <li data-k="go home" data-cursor>
250 <a href="/" tabindex="-1">281 <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>
252 </li>285 </li>
253 <li data-k="section summary">286 <li data-k="section summary">
254 <a href="/files" tabindex="-1">287 <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>
256 </li>291 </li>
257-<li data-k="section tree">292+<li data-k="section files">
258 <a href="/files/tree/main" tabindex="-1">293 <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>
260 </li>297 </li>
261 <li data-k="section commits">298 <li data-k="section commits">
262 <a href="/files/commits/main" tabindex="-1">299 <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>
264 </li>303 </li>
265-<li data-k="section refs">304+<li data-k="section branches &amp; tags">
266 <a href="/files/refs" tabindex="-1">305 <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>
268 </li>309 </li>
269 <li data-k="section merge requests">310 <li data-k="section merge requests">
270 <a href="/files/merge-requests" tabindex="-1">311 <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>
272 </li>315 </li>
273 <li data-k="branch feature/x">316 <li data-k="branch feature/x">
274 <a href="/files/tree/feature/x" tabindex="-1">317 <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>
276 </li>321 </li>
277 <li data-k="branch main">322 <li data-k="branch main">
278 <a href="/files/tree/main" tabindex="-1">323 <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>
280 </li>327 </li>
281 <li data-k="tag v1.0">328 <li data-k="tag v1.0">
282 <a href="/files/tree/v1.0" tabindex="-1">329 <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>
284 </li>333 </li>
285 </ul>334 </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>
286 </dialog>344 </dialog>
287-<footer class="bar status" aria-label="Status">345+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
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) => {
302 if (q('dialog[open]').count) return;346 if (q('dialog[open]').count) return;
303 let rows = q('[data-rows] > :not([hidden])').arr();347 let rows = q('[data-rows] > :not([hidden])').arr();
304 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));348 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
305 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));349 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
306- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];350+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
307- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
308 })(1);354 })(1);
309- k-k from:body -> ((d) => {355+ k-k from:body -> (async (d) => {
310 if (q('dialog[open]').count) return;356 if (q('dialog[open]').count) return;
311 let rows = q('[data-rows] > :not([hidden])').arr();357 let rows = q('[data-rows] > :not([hidden])').arr();
312 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));358 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
313 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));359 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
314- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];360+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
315- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
316 })(-1);364 })(-1);
317 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));365 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
318 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));366 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));
319 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();367 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
320 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());368 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();369+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
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());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());
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();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();
324 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();372 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
325- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();373+ k-gh from:body -> document.querySelector('.brand')?.click();
326- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();374+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
327- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();375+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
328- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();376+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
329- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();377+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
330- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();378+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
331- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();379+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
332- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();380+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
333- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();381+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
334- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();382+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
335 k-next from:body -> {383 k-next from:body -> {
336 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;384 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
337 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);385 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/blob-markdown.html +165 -118
@@ -6,6 +6,7 @@
6 <meta name="color-scheme" content="light dark">6 <meta name="color-scheme" content="light dark">
7 <title>docs/guide.md · files · example.test</title>7 <title>docs/guide.md · files · example.test</title>
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <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>
9 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
10 <script src="/-/static/js/htmx.min.HASH.js" defer>11 <script src="/-/static/js/htmx.min.HASH.js" defer>
11 </script>12 </script>
@@ -16,12 +17,15 @@
16 <script src="/-/static/js/ermite.HASH.js" defer>17 <script src="/-/static/js/ermite.HASH.js" defer>
17 </script>18 </script>
18 </head>19 </head>
19-<body data-ref="main" data-sha="SHA40" data-sel="">20+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
20 <a class="skip" href="#main">skip to content</a>21 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">22+<header class="hdr">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>23+<div class="hdr-top">
23-<span class="fade" aria-hidden="true">24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
24 </span>26 </span>
27+<span>example.test</span>
28+</a>
25 <nav class="crumbs" aria-label="Path">29 <nav class="crumbs" aria-label="Path">
26 <ol>30 <ol>
27 <li>31 <li>
@@ -41,36 +45,55 @@
41 </li>45 </li>
42 </ol>46 </ol>
43 </nav>47 </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">
45 <ul>65 <ul>
46 <li>66 <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>
48 </li>69 </li>
49 <li>70 <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>
51 </li>73 </li>
52 <li>74 <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>
54 </li>77 </li>
55 <li>78 <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>
57 </li>81 </li>
58 <li>82 <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>
60 </li>85 </li>
61 </ul>86 </ul>
62 </nav>87 </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>
65 </header>88 </header>
66-<div class="work has-rail read">89+<div class="work has-rail">
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">
68 <h1 class="file-h">91 <h1 class="file-h">
69 <span>guide.md</span>92 <span>guide.md</span>
70 <span class="acts">93 <span class="acts">
71-<a href="/files/raw/main/docs/guide.md">raw</a>94+<a class="btn" href="/files/raw/main/docs/guide.md">Raw</a>
72-<a href="/files/commits/main/docs/guide.md">history</a>95+<a class="btn" href="/files/commits/main/docs/guide.md">History</a>
73-<a href="/files/tree/main/docs">dir</a>96+<a class="btn" href="/files/tree/main/docs">Folder</a>
74 </span>97 </span>
75 </h1>98 </h1>
76 <article class="prose" aria-label="guide.md">99 <article class="prose" aria-label="guide.md">
@@ -126,10 +149,8 @@
126 </details>149 </details>
127 </main>150 </main>
128 <aside class="rail" aria-label="Context">151 <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>
131 <section aria-labelledby="r-file">152 <section aria-labelledby="r-file">
132-<h2 id="r-file">file</h2>153+<h2 id="r-file">File</h2>
133 <dl class="kv">154 <dl class="kv">
134 <dt>size</dt>155 <dt>size</dt>
135 <dd>47 B</dd>156 <dd>47 B</dd>
@@ -140,7 +161,7 @@
140 </dl>161 </dl>
141 </section>162 </section>
142 <section aria-labelledby="r-last">163 <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>
144 </h2>165 </h2>
145 <p>166 <p>
146 <a href="/files/commit/SHA40">SHA7</a> add docs/guide.md</p>167 <a href="/files/commit/SHA40">SHA7</a> add docs/guide.md</p>
@@ -148,84 +169,84 @@
148 </p>169 </p>
149 </section>170 </section>
150 <section aria-labelledby="r-outline">171 <section aria-labelledby="r-outline">
151-<h2 id="r-outline">outline</h2>172+<h2 id="r-outline">On this page</h2>
152 <nav class="outline" aria-label="Outline">173 <nav class="outline" aria-label="Outline">
153 <a class="l1" href="#md-guide">Guide</a>174 <a class="l1" href="#md-guide">Guide</a>
154 <a class="l2" href="#md-steps">Steps</a>175 <a class="l2" href="#md-steps">Steps</a>
155 </nav>176 </nav>
156 </section>177 </section>
157 <section aria-labelledby="r-sel">178 <section aria-labelledby="r-sel">
158-<h2 id="r-sel">selection</h2>179+<h2 id="r-sel">Selection</h2>
159 <p :text="document.body.dataset.sel || 'none · click a line number, shift-click extends'">none · click a line number, shift-click extends</p>180 <p :text="document.body.dataset.sel || 'none · click a line number, shift-click extends'">none · click a line number, shift-click extends</p>
160 <p class="dim">y copies a permalink pinned to the commit sha</p>181 <p class="dim">y copies a permalink pinned to the commit sha</p>
161 </section>182 </section>
162-</details>
163 </aside>183 </aside>
164 </div>184 </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()">
166 <div class="panel-head">200 <div class="panel-head">
167-<span>keys</span>201+<h2>Keyboard shortcuts</h2>
168-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>202+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
203+<kbd>esc</kbd> close</button>
169 </div>204 </div>
170 <dl class="keys">205 <dl class="keys">
171 <div>206 <div>
172 <dt>207 <dt>
173-<kbd>j</kbd>208+<kbd>ctrl k</kbd>
174-<kbd>k</kbd>209+<kbd>:</kbd>
175 </dt>210 </dt>
176-<dd>move cursor down / up</dd>211+<dd>go to a section, branch, tag, file or repository</dd>
177 </div>212 </div>
178 <div>213 <div>
179 <dt>214 <dt>
180-<kbd>enter</kbd>215+<kbd>?</kbd>
181 </dt>216 </dt>
182-<dd>open row under cursor</dd>217+<dd>this list</dd>
183 </div>218 </div>
184 <div>219 <div>
185 <dt>220 <dt>
186-<kbd>g</kbd>221+<kbd>j</kbd>
187-<kbd>g</kbd> / <kbd>G</kbd>222+<kbd>k</kbd>
188 </dt>223 </dt>
189-<dd>first / last row</dd>224+<dd>move the row cursor down / up</dd>
190 </div>225 </div>
191 <div>226 <div>
192 <dt>227 <dt>
193-<kbd>/</kbd>228+<kbd>enter</kbd>
194 </dt>229 </dt>
195-<dd>focus the filter</dd>230+<dd>open the row under the cursor</dd>
196 </div>231 </div>
197 <div>232 <div>
198 <dt>233 <dt>
199-<kbd>g h</kbd>234+<kbd>g g</kbd>
200-<kbd>g t</kbd>235+<kbd>G</kbd>
201 </dt>236 </dt>
202-<dd>go home / tree</dd>237+<dd>first / last row</dd>
203 </div>238 </div>
204 <div>239 <div>
205 <dt>240 <dt>
206-<kbd>g c</kbd>241+<kbd>/</kbd>
207-<kbd>g r</kbd>
208 </dt>242 </dt>
209-<dd>go commits / refs</dd>243+<dd>filter the list</dd>
210 </div>244 </div>
211 <div>245 <div>
212 <dt>246 <dt>
213 <kbd>b</kbd>247 <kbd>b</kbd>
214 </dt>248 </dt>
215-<dd>switch ref (branches, tags)</dd>249+<dd>switch branch or tag</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>
229 </div>250 </div>
230 <div>251 <div>
231 <dt>252 <dt>
@@ -233,15 +254,21 @@
233 <kbd>t</kbd>254 <kbd>t</kbd>
234 <kbd>c</kbd>255 <kbd>c</kbd>
235 <kbd>r</kbd>256 <kbd>r</kbd>
257+<kbd>m</kbd>
236 </dt>258 </dt>
237-<dd>repo sections (when shown)</dd>259+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
238 </div>260 </div>
239 <div>261 <div>
240 <dt>262 <dt>
241-<kbd>m</kbd>263+<kbd>g h</kbd>
242-<kbd>g m</kbd>264+</dt>
265+<dd>home</dd>
266+</div>
267+<div>
268+<dt>
269+<kbd>y</kbd>
243 </dt>270 </dt>
244-<dd>merge requests (when shown)</dd>271+<dd>copy a permalink pinned to the commit</dd>
245 </div>272 </div>
246 <div>273 <div>
247 <dt>274 <dt>
@@ -261,19 +288,21 @@
261 <dt>288 <dt>
262 <kbd>esc</kbd>289 <kbd>esc</kbd>
263 </dt>290 </dt>
264-<dd>close panel</dd>291+<dd>close a dialog, leave a field</dd>
265 </div>292 </div>
266 </dl>293 </dl>
267 </dialog>294 </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()">
269 <div class="panel-head">296 <div class="panel-head">
270-<label for="refs-filter">ref&gt;</label>297+<label for="refs-filter" class="sr">filter branches and tags</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))">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))">
272-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>299+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
300+<kbd>esc</kbd>
301+</button>
273 </div>302 </div>
274 <div class="panel-cols" data-picklist>303 <div class="panel-cols" data-picklist>
275 <section aria-label="Branches">304 <section aria-label="Branches">
276-<h2>branches <span class="mute">2</span>305+<h2>Branches <span class="count">2</span>
277 </h2>306 </h2>
278 <ul>307 <ul>
279 <li>308 <li>
@@ -287,7 +316,7 @@
287 </ul>316 </ul>
288 </section>317 </section>
289 <section aria-label="Tags">318 <section aria-label="Tags">
290-<h2>tags <span class="mute">1</span>319+<h2>Tags <span class="count">1</span>
291 </h2>320 </h2>
292 <ul>321 <ul>
293 <li>322 <li>
@@ -298,106 +327,124 @@
298 </section>327 </section>
299 </div>328 </div>
300 </dialog>329 </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()">
302 <div class="panel-head">331 <div class="panel-head">
303-<label for="cmd-q" class="hi">:</label>332+<label for="cmd-q" class="sr">go to</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();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();
305 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));334 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
306 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();335 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();
307 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));336 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
308 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;337 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
309 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' })) }338 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' })) }
310 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">339 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>
312 </div>343 </div>
313-<ul id="cmd-list" class="cmd-list">344+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
314 <li data-k="go home" data-cursor>345 <li data-k="go home" data-cursor>
315 <a href="/" tabindex="-1">346 <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>
317 </li>350 </li>
318 <li data-k="section summary">351 <li data-k="section summary">
319 <a href="/files" tabindex="-1">352 <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>
321 </li>356 </li>
322-<li data-k="section tree">357+<li data-k="section files">
323 <a href="/files/tree/main" tabindex="-1">358 <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>
325 </li>362 </li>
326 <li data-k="section commits">363 <li data-k="section commits">
327 <a href="/files/commits/main" tabindex="-1">364 <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>
329 </li>368 </li>
330-<li data-k="section refs">369+<li data-k="section branches &amp; tags">
331 <a href="/files/refs" tabindex="-1">370 <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>
333 </li>374 </li>
334 <li data-k="section merge requests">375 <li data-k="section merge requests">
335 <a href="/files/merge-requests" tabindex="-1">376 <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>
337 </li>380 </li>
338 <li data-k="branch feature/x">381 <li data-k="branch feature/x">
339 <a href="/files/tree/feature/x" tabindex="-1">382 <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>
341 </li>386 </li>
342 <li data-k="branch main">387 <li data-k="branch main">
343 <a href="/files/tree/main" tabindex="-1">388 <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>
345 </li>392 </li>
346 <li data-k="tag v1.0">393 <li data-k="tag v1.0">
347 <a href="/files/tree/v1.0" tabindex="-1">394 <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>
349 </li>398 </li>
350 </ul>399 </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>
351 </dialog>409 </dialog>
352-<footer class="bar status" aria-label="Status">410+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
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) => {
368 if (q('dialog[open]').count) return;411 if (q('dialog[open]').count) return;
369 let rows = q('[data-rows] > :not([hidden])').arr();412 let rows = q('[data-rows] > :not([hidden])').arr();
370 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));413 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
371 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));414 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
372- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];415+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
373- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
374 })(1);419 })(1);
375- k-k from:body -> ((d) => {420+ k-k from:body -> (async (d) => {
376 if (q('dialog[open]').count) return;421 if (q('dialog[open]').count) return;
377 let rows = q('[data-rows] > :not([hidden])').arr();422 let rows = q('[data-rows] > :not([hidden])').arr();
378 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));423 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
379 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));424 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
380- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];425+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
381- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
382 })(-1);429 })(-1);
383 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));430 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
384 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));431 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));
385 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();432 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
386 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());433 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();434+ 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].show(), q('#refs-filter').arr()[0].focus());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());
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();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();
390 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();437 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
391- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();438+ k-gh from:body -> document.querySelector('.brand')?.click();
392- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();439+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
393- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();440+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
394- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();441+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
395- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();442+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
396- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();443+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
397- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();444+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
398- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();445+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
399- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();446+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
400- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();447+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
401 k-next from:body -> {448 k-next from:body -> {
402 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;449 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
403 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);450 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/blob-svg.html +163 -116
@@ -6,6 +6,7 @@
6 <meta name="color-scheme" content="light dark">6 <meta name="color-scheme" content="light dark">
7 <title>logo.svg · files · example.test</title>7 <title>logo.svg · files · example.test</title>
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <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>
9 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
10 <script src="/-/static/js/htmx.min.HASH.js" defer>11 <script src="/-/static/js/htmx.min.HASH.js" defer>
11 </script>12 </script>
@@ -16,12 +17,15 @@
16 <script src="/-/static/js/ermite.HASH.js" defer>17 <script src="/-/static/js/ermite.HASH.js" defer>
17 </script>18 </script>
18 </head>19 </head>
19-<body data-ref="main" data-sha="SHA40" data-sel="">20+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
20 <a class="skip" href="#main">skip to content</a>21 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">22+<header class="hdr">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>23+<div class="hdr-top">
23-<span class="fade" aria-hidden="true">24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
24 </span>26 </span>
27+<span>example.test</span>
28+</a>
25 <nav class="crumbs" aria-label="Path">29 <nav class="crumbs" aria-label="Path">
26 <ol>30 <ol>
27 <li>31 <li>
@@ -38,36 +42,55 @@
38 </li>42 </li>
39 </ol>43 </ol>
40 </nav>44 </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">
42 <ul>62 <ul>
43 <li>63 <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>
45 </li>66 </li>
46 <li>67 <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>
48 </li>70 </li>
49 <li>71 <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>
51 </li>74 </li>
52 <li>75 <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>
54 </li>78 </li>
55 <li>79 <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>
57 </li>82 </li>
58 </ul>83 </ul>
59 </nav>84 </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>
62 </header>85 </header>
63 <div class="work has-rail">86 <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">
65 <h1 class="file-h">88 <h1 class="file-h">
66 <span>logo.svg</span>89 <span>logo.svg</span>
67 <span class="acts">90 <span class="acts">
68-<a href="/files/raw/main/logo.svg">raw</a>91+<a class="btn" href="/files/raw/main/logo.svg">Raw</a>
69-<a href="/files/commits/main/logo.svg">history</a>92+<a class="btn" href="/files/commits/main/logo.svg">History</a>
70-<a href="/files/tree/main">dir</a>93+<a class="btn" href="/files/tree/main">Folder</a>
71 </span>94 </span>
72 </h1>95 </h1>
73 <pre class="chroma code" data-anchor="" hx-on:click="let a = event.target.closest('a.ln'); if (!a) return; event.preventDefault();96 <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 @@
91 </pre>114 </pre>
92 </main>115 </main>
93 <aside class="rail" aria-label="Context">116 <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>
96 <section aria-labelledby="r-file">117 <section aria-labelledby="r-file">
97-<h2 id="r-file">file</h2>118+<h2 id="r-file">File</h2>
98 <dl class="kv">119 <dl class="kv">
99 <dt>size</dt>120 <dt>size</dt>
100 <dd>89 B</dd>121 <dd>89 B</dd>
@@ -105,7 +126,7 @@
105 </dl>126 </dl>
106 </section>127 </section>
107 <section aria-labelledby="r-last">128 <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>
109 </h2>130 </h2>
110 <p>131 <p>
111 <a href="/files/commit/SHA40">SHA7</a> add logo.svg</p>132 <a href="/files/commit/SHA40">SHA7</a> add logo.svg</p>
@@ -113,77 +134,77 @@
113 </p>134 </p>
114 </section>135 </section>
115 <section aria-labelledby="r-sel">136 <section aria-labelledby="r-sel">
116-<h2 id="r-sel">selection</h2>137+<h2 id="r-sel">Selection</h2>
117 <p :text="document.body.dataset.sel || 'none · click a line number, shift-click extends'">none · click a line number, shift-click extends</p>138 <p :text="document.body.dataset.sel || 'none · click a line number, shift-click extends'">none · click a line number, shift-click extends</p>
118 <p class="dim">y copies a permalink pinned to the commit sha</p>139 <p class="dim">y copies a permalink pinned to the commit sha</p>
119 </section>140 </section>
120-</details>
121 </aside>141 </aside>
122 </div>142 </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()">
124 <div class="panel-head">158 <div class="panel-head">
125-<span>keys</span>159+<h2>Keyboard shortcuts</h2>
126-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>160+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
161+<kbd>esc</kbd> close</button>
127 </div>162 </div>
128 <dl class="keys">163 <dl class="keys">
129 <div>164 <div>
130 <dt>165 <dt>
131-<kbd>j</kbd>166+<kbd>ctrl k</kbd>
132-<kbd>k</kbd>167+<kbd>:</kbd>
133 </dt>168 </dt>
134-<dd>move cursor down / up</dd>169+<dd>go to a section, branch, tag, file or repository</dd>
135 </div>170 </div>
136 <div>171 <div>
137 <dt>172 <dt>
138-<kbd>enter</kbd>173+<kbd>?</kbd>
139 </dt>174 </dt>
140-<dd>open row under cursor</dd>175+<dd>this list</dd>
141 </div>176 </div>
142 <div>177 <div>
143 <dt>178 <dt>
144-<kbd>g</kbd>179+<kbd>j</kbd>
145-<kbd>g</kbd> / <kbd>G</kbd>180+<kbd>k</kbd>
146 </dt>181 </dt>
147-<dd>first / last row</dd>182+<dd>move the row cursor down / up</dd>
148 </div>183 </div>
149 <div>184 <div>
150 <dt>185 <dt>
151-<kbd>/</kbd>186+<kbd>enter</kbd>
152 </dt>187 </dt>
153-<dd>focus the filter</dd>188+<dd>open the row under the cursor</dd>
154 </div>189 </div>
155 <div>190 <div>
156 <dt>191 <dt>
157-<kbd>g h</kbd>192+<kbd>g g</kbd>
158-<kbd>g t</kbd>193+<kbd>G</kbd>
159 </dt>194 </dt>
160-<dd>go home / tree</dd>195+<dd>first / last row</dd>
161 </div>196 </div>
162 <div>197 <div>
163 <dt>198 <dt>
164-<kbd>g c</kbd>199+<kbd>/</kbd>
165-<kbd>g r</kbd>
166 </dt>200 </dt>
167-<dd>go commits / refs</dd>201+<dd>filter the list</dd>
168 </div>202 </div>
169 <div>203 <div>
170 <dt>204 <dt>
171 <kbd>b</kbd>205 <kbd>b</kbd>
172 </dt>206 </dt>
173-<dd>switch ref (branches, tags)</dd>207+<dd>switch branch or tag</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>
187 </div>208 </div>
188 <div>209 <div>
189 <dt>210 <dt>
@@ -191,15 +212,21 @@
191 <kbd>t</kbd>212 <kbd>t</kbd>
192 <kbd>c</kbd>213 <kbd>c</kbd>
193 <kbd>r</kbd>214 <kbd>r</kbd>
215+<kbd>m</kbd>
194 </dt>216 </dt>
195-<dd>repo sections (when shown)</dd>217+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
196 </div>218 </div>
197 <div>219 <div>
198 <dt>220 <dt>
199-<kbd>m</kbd>221+<kbd>g h</kbd>
200-<kbd>g m</kbd>222+</dt>
223+<dd>home</dd>
224+</div>
225+<div>
226+<dt>
227+<kbd>y</kbd>
201 </dt>228 </dt>
202-<dd>merge requests (when shown)</dd>229+<dd>copy a permalink pinned to the commit</dd>
203 </div>230 </div>
204 <div>231 <div>
205 <dt>232 <dt>
@@ -219,19 +246,21 @@
219 <dt>246 <dt>
220 <kbd>esc</kbd>247 <kbd>esc</kbd>
221 </dt>248 </dt>
222-<dd>close panel</dd>249+<dd>close a dialog, leave a field</dd>
223 </div>250 </div>
224 </dl>251 </dl>
225 </dialog>252 </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()">
227 <div class="panel-head">254 <div class="panel-head">
228-<label for="refs-filter">ref&gt;</label>255+<label for="refs-filter" class="sr">filter branches and tags</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))">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))">
230-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>257+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
258+<kbd>esc</kbd>
259+</button>
231 </div>260 </div>
232 <div class="panel-cols" data-picklist>261 <div class="panel-cols" data-picklist>
233 <section aria-label="Branches">262 <section aria-label="Branches">
234-<h2>branches <span class="mute">2</span>263+<h2>Branches <span class="count">2</span>
235 </h2>264 </h2>
236 <ul>265 <ul>
237 <li>266 <li>
@@ -245,7 +274,7 @@
245 </ul>274 </ul>
246 </section>275 </section>
247 <section aria-label="Tags">276 <section aria-label="Tags">
248-<h2>tags <span class="mute">1</span>277+<h2>Tags <span class="count">1</span>
249 </h2>278 </h2>
250 <ul>279 <ul>
251 <li>280 <li>
@@ -256,106 +285,124 @@
256 </section>285 </section>
257 </div>286 </div>
258 </dialog>287 </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()">
260 <div class="panel-head">289 <div class="panel-head">
261-<label for="cmd-q" class="hi">:</label>290+<label for="cmd-q" class="sr">go to</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();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();
263 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));292 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
264 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();293 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();
265 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));294 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
266 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;295 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
267 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' })) }296 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' })) }
268 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">297 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>
270 </div>301 </div>
271-<ul id="cmd-list" class="cmd-list">302+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
272 <li data-k="go home" data-cursor>303 <li data-k="go home" data-cursor>
273 <a href="/" tabindex="-1">304 <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>
275 </li>308 </li>
276 <li data-k="section summary">309 <li data-k="section summary">
277 <a href="/files" tabindex="-1">310 <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>
279 </li>314 </li>
280-<li data-k="section tree">315+<li data-k="section files">
281 <a href="/files/tree/main" tabindex="-1">316 <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>
283 </li>320 </li>
284 <li data-k="section commits">321 <li data-k="section commits">
285 <a href="/files/commits/main" tabindex="-1">322 <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>
287 </li>326 </li>
288-<li data-k="section refs">327+<li data-k="section branches &amp; tags">
289 <a href="/files/refs" tabindex="-1">328 <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>
291 </li>332 </li>
292 <li data-k="section merge requests">333 <li data-k="section merge requests">
293 <a href="/files/merge-requests" tabindex="-1">334 <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>
295 </li>338 </li>
296 <li data-k="branch feature/x">339 <li data-k="branch feature/x">
297 <a href="/files/tree/feature/x" tabindex="-1">340 <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>
299 </li>344 </li>
300 <li data-k="branch main">345 <li data-k="branch main">
301 <a href="/files/tree/main" tabindex="-1">346 <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>
303 </li>350 </li>
304 <li data-k="tag v1.0">351 <li data-k="tag v1.0">
305 <a href="/files/tree/v1.0" tabindex="-1">352 <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>
307 </li>356 </li>
308 </ul>357 </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>
309 </dialog>367 </dialog>
310-<footer class="bar status" aria-label="Status">368+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
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) => {
326 if (q('dialog[open]').count) return;369 if (q('dialog[open]').count) return;
327 let rows = q('[data-rows] > :not([hidden])').arr();370 let rows = q('[data-rows] > :not([hidden])').arr();
328 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));371 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
329 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));372 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
330- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];373+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
331- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
332 })(1);377 })(1);
333- k-k from:body -> ((d) => {378+ k-k from:body -> (async (d) => {
334 if (q('dialog[open]').count) return;379 if (q('dialog[open]').count) return;
335 let rows = q('[data-rows] > :not([hidden])').arr();380 let rows = q('[data-rows] > :not([hidden])').arr();
336 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));381 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
337 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));382 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
338- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];383+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
339- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
340 })(-1);387 })(-1);
341 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));388 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
342 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));389 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));
343 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();390 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
344 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());391 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();392+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
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());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());
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();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();
348 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();395 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
349- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();396+ k-gh from:body -> document.querySelector('.brand')?.click();
350- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();397+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
351- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();398+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
352- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();399+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
353- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();400+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
354- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();401+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
355- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();402+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
356- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();403+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
357- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();404+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
358- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();405+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
359 k-next from:body -> {406 k-next from:body -> {
360 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;407 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
361 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);408 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/blob-text.html +163 -116
@@ -6,6 +6,7 @@
6 <meta name="color-scheme" content="light dark">6 <meta name="color-scheme" content="light dark">
7 <title>notes.txt · files · example.test</title>7 <title>notes.txt · files · example.test</title>
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <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>
9 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
10 <script src="/-/static/js/htmx.min.HASH.js" defer>11 <script src="/-/static/js/htmx.min.HASH.js" defer>
11 </script>12 </script>
@@ -16,12 +17,15 @@
16 <script src="/-/static/js/ermite.HASH.js" defer>17 <script src="/-/static/js/ermite.HASH.js" defer>
17 </script>18 </script>
18 </head>19 </head>
19-<body data-ref="main" data-sha="SHA40" data-sel="">20+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
20 <a class="skip" href="#main">skip to content</a>21 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">22+<header class="hdr">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>23+<div class="hdr-top">
23-<span class="fade" aria-hidden="true">24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
24 </span>26 </span>
27+<span>example.test</span>
28+</a>
25 <nav class="crumbs" aria-label="Path">29 <nav class="crumbs" aria-label="Path">
26 <ol>30 <ol>
27 <li>31 <li>
@@ -38,36 +42,55 @@
38 </li>42 </li>
39 </ol>43 </ol>
40 </nav>44 </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">
42 <ul>62 <ul>
43 <li>63 <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>
45 </li>66 </li>
46 <li>67 <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>
48 </li>70 </li>
49 <li>71 <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>
51 </li>74 </li>
52 <li>75 <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>
54 </li>78 </li>
55 <li>79 <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>
57 </li>82 </li>
58 </ul>83 </ul>
59 </nav>84 </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>
62 </header>85 </header>
63 <div class="work has-rail">86 <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">
65 <h1 class="file-h">88 <h1 class="file-h">
66 <span>notes.txt</span>89 <span>notes.txt</span>
67 <span class="acts">90 <span class="acts">
68-<a href="/files/raw/main/notes.txt">raw</a>91+<a class="btn" href="/files/raw/main/notes.txt">Raw</a>
69-<a href="/files/commits/main/notes.txt">history</a>92+<a class="btn" href="/files/commits/main/notes.txt">History</a>
70-<a href="/files/tree/main">dir</a>93+<a class="btn" href="/files/tree/main">Folder</a>
71 </span>94 </span>
72 </h1>95 </h1>
73 <pre class="chroma code" data-anchor="" hx-on:click="let a = event.target.closest('a.ln'); if (!a) return; event.preventDefault();96 <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 @@
86 </pre>109 </pre>
87 </main>110 </main>
88 <aside class="rail" aria-label="Context">111 <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>
91 <section aria-labelledby="r-file">112 <section aria-labelledby="r-file">
92-<h2 id="r-file">file</h2>113+<h2 id="r-file">File</h2>
93 <dl class="kv">114 <dl class="kv">
94 <dt>size</dt>115 <dt>size</dt>
95 <dd>24 B</dd>116 <dd>24 B</dd>
@@ -100,7 +121,7 @@
100 </dl>121 </dl>
101 </section>122 </section>
102 <section aria-labelledby="r-last">123 <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>
104 </h2>125 </h2>
105 <p>126 <p>
106 <a href="/files/commit/SHA40">SHA7</a> add notes.txt</p>127 <a href="/files/commit/SHA40">SHA7</a> add notes.txt</p>
@@ -108,77 +129,77 @@
108 </p>129 </p>
109 </section>130 </section>
110 <section aria-labelledby="r-sel">131 <section aria-labelledby="r-sel">
111-<h2 id="r-sel">selection</h2>132+<h2 id="r-sel">Selection</h2>
112 <p :text="document.body.dataset.sel || 'none · click a line number, shift-click extends'">none · click a line number, shift-click extends</p>133 <p :text="document.body.dataset.sel || 'none · click a line number, shift-click extends'">none · click a line number, shift-click extends</p>
113 <p class="dim">y copies a permalink pinned to the commit sha</p>134 <p class="dim">y copies a permalink pinned to the commit sha</p>
114 </section>135 </section>
115-</details>
116 </aside>136 </aside>
117 </div>137 </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()">
119 <div class="panel-head">153 <div class="panel-head">
120-<span>keys</span>154+<h2>Keyboard shortcuts</h2>
121-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>155+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
156+<kbd>esc</kbd> close</button>
122 </div>157 </div>
123 <dl class="keys">158 <dl class="keys">
124 <div>159 <div>
125 <dt>160 <dt>
126-<kbd>j</kbd>161+<kbd>ctrl k</kbd>
127-<kbd>k</kbd>162+<kbd>:</kbd>
128 </dt>163 </dt>
129-<dd>move cursor down / up</dd>164+<dd>go to a section, branch, tag, file or repository</dd>
130 </div>165 </div>
131 <div>166 <div>
132 <dt>167 <dt>
133-<kbd>enter</kbd>168+<kbd>?</kbd>
134 </dt>169 </dt>
135-<dd>open row under cursor</dd>170+<dd>this list</dd>
136 </div>171 </div>
137 <div>172 <div>
138 <dt>173 <dt>
139-<kbd>g</kbd>174+<kbd>j</kbd>
140-<kbd>g</kbd> / <kbd>G</kbd>175+<kbd>k</kbd>
141 </dt>176 </dt>
142-<dd>first / last row</dd>177+<dd>move the row cursor down / up</dd>
143 </div>178 </div>
144 <div>179 <div>
145 <dt>180 <dt>
146-<kbd>/</kbd>181+<kbd>enter</kbd>
147 </dt>182 </dt>
148-<dd>focus the filter</dd>183+<dd>open the row under the cursor</dd>
149 </div>184 </div>
150 <div>185 <div>
151 <dt>186 <dt>
152-<kbd>g h</kbd>187+<kbd>g g</kbd>
153-<kbd>g t</kbd>188+<kbd>G</kbd>
154 </dt>189 </dt>
155-<dd>go home / tree</dd>190+<dd>first / last row</dd>
156 </div>191 </div>
157 <div>192 <div>
158 <dt>193 <dt>
159-<kbd>g c</kbd>194+<kbd>/</kbd>
160-<kbd>g r</kbd>
161 </dt>195 </dt>
162-<dd>go commits / refs</dd>196+<dd>filter the list</dd>
163 </div>197 </div>
164 <div>198 <div>
165 <dt>199 <dt>
166 <kbd>b</kbd>200 <kbd>b</kbd>
167 </dt>201 </dt>
168-<dd>switch ref (branches, tags)</dd>202+<dd>switch branch or tag</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>
182 </div>203 </div>
183 <div>204 <div>
184 <dt>205 <dt>
@@ -186,15 +207,21 @@
186 <kbd>t</kbd>207 <kbd>t</kbd>
187 <kbd>c</kbd>208 <kbd>c</kbd>
188 <kbd>r</kbd>209 <kbd>r</kbd>
210+<kbd>m</kbd>
189 </dt>211 </dt>
190-<dd>repo sections (when shown)</dd>212+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
191 </div>213 </div>
192 <div>214 <div>
193 <dt>215 <dt>
194-<kbd>m</kbd>216+<kbd>g h</kbd>
195-<kbd>g m</kbd>217+</dt>
218+<dd>home</dd>
219+</div>
220+<div>
221+<dt>
222+<kbd>y</kbd>
196 </dt>223 </dt>
197-<dd>merge requests (when shown)</dd>224+<dd>copy a permalink pinned to the commit</dd>
198 </div>225 </div>
199 <div>226 <div>
200 <dt>227 <dt>
@@ -214,19 +241,21 @@
214 <dt>241 <dt>
215 <kbd>esc</kbd>242 <kbd>esc</kbd>
216 </dt>243 </dt>
217-<dd>close panel</dd>244+<dd>close a dialog, leave a field</dd>
218 </div>245 </div>
219 </dl>246 </dl>
220 </dialog>247 </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()">
222 <div class="panel-head">249 <div class="panel-head">
223-<label for="refs-filter">ref&gt;</label>250+<label for="refs-filter" class="sr">filter branches and tags</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))">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))">
225-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>252+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
253+<kbd>esc</kbd>
254+</button>
226 </div>255 </div>
227 <div class="panel-cols" data-picklist>256 <div class="panel-cols" data-picklist>
228 <section aria-label="Branches">257 <section aria-label="Branches">
229-<h2>branches <span class="mute">2</span>258+<h2>Branches <span class="count">2</span>
230 </h2>259 </h2>
231 <ul>260 <ul>
232 <li>261 <li>
@@ -240,7 +269,7 @@
240 </ul>269 </ul>
241 </section>270 </section>
242 <section aria-label="Tags">271 <section aria-label="Tags">
243-<h2>tags <span class="mute">1</span>272+<h2>Tags <span class="count">1</span>
244 </h2>273 </h2>
245 <ul>274 <ul>
246 <li>275 <li>
@@ -251,106 +280,124 @@
251 </section>280 </section>
252 </div>281 </div>
253 </dialog>282 </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()">
255 <div class="panel-head">284 <div class="panel-head">
256-<label for="cmd-q" class="hi">:</label>285+<label for="cmd-q" class="sr">go to</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();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();
258 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));287 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
259 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();288 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();
260 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));289 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
261 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;290 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
262 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' })) }291 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' })) }
263 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">292 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>
265 </div>296 </div>
266-<ul id="cmd-list" class="cmd-list">297+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
267 <li data-k="go home" data-cursor>298 <li data-k="go home" data-cursor>
268 <a href="/" tabindex="-1">299 <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>
270 </li>303 </li>
271 <li data-k="section summary">304 <li data-k="section summary">
272 <a href="/files" tabindex="-1">305 <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>
274 </li>309 </li>
275-<li data-k="section tree">310+<li data-k="section files">
276 <a href="/files/tree/main" tabindex="-1">311 <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>
278 </li>315 </li>
279 <li data-k="section commits">316 <li data-k="section commits">
280 <a href="/files/commits/main" tabindex="-1">317 <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>
282 </li>321 </li>
283-<li data-k="section refs">322+<li data-k="section branches &amp; tags">
284 <a href="/files/refs" tabindex="-1">323 <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>
286 </li>327 </li>
287 <li data-k="section merge requests">328 <li data-k="section merge requests">
288 <a href="/files/merge-requests" tabindex="-1">329 <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>
290 </li>333 </li>
291 <li data-k="branch feature/x">334 <li data-k="branch feature/x">
292 <a href="/files/tree/feature/x" tabindex="-1">335 <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>
294 </li>339 </li>
295 <li data-k="branch main">340 <li data-k="branch main">
296 <a href="/files/tree/main" tabindex="-1">341 <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>
298 </li>345 </li>
299 <li data-k="tag v1.0">346 <li data-k="tag v1.0">
300 <a href="/files/tree/v1.0" tabindex="-1">347 <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>
302 </li>351 </li>
303 </ul>352 </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>
304 </dialog>362 </dialog>
305-<footer class="bar status" aria-label="Status">363+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
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) => {
321 if (q('dialog[open]').count) return;364 if (q('dialog[open]').count) return;
322 let rows = q('[data-rows] > :not([hidden])').arr();365 let rows = q('[data-rows] > :not([hidden])').arr();
323 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));366 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
324 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));367 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
325- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];368+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
326- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
327 })(1);372 })(1);
328- k-k from:body -> ((d) => {373+ k-k from:body -> (async (d) => {
329 if (q('dialog[open]').count) return;374 if (q('dialog[open]').count) return;
330 let rows = q('[data-rows] > :not([hidden])').arr();375 let rows = q('[data-rows] > :not([hidden])').arr();
331 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));376 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
332 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));377 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
333- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];378+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
334- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
335 })(-1);382 })(-1);
336 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));383 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
337 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));384 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));
338 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();385 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
339 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());386 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();387+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
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());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());
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();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();
343 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();390 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
344- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();391+ k-gh from:body -> document.querySelector('.brand')?.click();
345- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();392+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
346- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();393+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
347- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();394+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
348- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();395+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
349- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();396+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
350- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();397+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
351- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();398+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
352- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();399+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
353- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();400+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
354 k-next from:body -> {401 k-next from:body -> {
355 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;402 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
356 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);403 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 @@
6 <meta name="color-scheme" content="light dark">6 <meta name="color-scheme" content="light dark">
7 <title>big.txt · files · example.test</title>7 <title>big.txt · files · example.test</title>
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <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>
9 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
10 <script src="/-/static/js/htmx.min.HASH.js" defer>11 <script src="/-/static/js/htmx.min.HASH.js" defer>
11 </script>12 </script>
@@ -16,12 +17,15 @@
16 <script src="/-/static/js/ermite.HASH.js" defer>17 <script src="/-/static/js/ermite.HASH.js" defer>
17 </script>18 </script>
18 </head>19 </head>
19-<body data-ref="main" data-sha="SHA40" data-sel="">20+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
20 <a class="skip" href="#main">skip to content</a>21 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">22+<header class="hdr">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>23+<div class="hdr-top">
23-<span class="fade" aria-hidden="true">24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
24 </span>26 </span>
27+<span>example.test</span>
28+</a>
25 <nav class="crumbs" aria-label="Path">29 <nav class="crumbs" aria-label="Path">
26 <ol>30 <ol>
27 <li>31 <li>
@@ -38,45 +42,62 @@
38 </li>42 </li>
39 </ol>43 </ol>
40 </nav>44 </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">
42 <ul>62 <ul>
43 <li>63 <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>
45 </li>66 </li>
46 <li>67 <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>
48 </li>70 </li>
49 <li>71 <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>
51 </li>74 </li>
52 <li>75 <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>
54 </li>78 </li>
55 <li>79 <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>
57 </li>82 </li>
58 </ul>83 </ul>
59 </nav>84 </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>
62 </header>85 </header>
63 <div class="work has-rail">86 <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">
65 <h1 class="file-h">88 <h1 class="file-h">
66 <span>big.txt</span>89 <span>big.txt</span>
67 <span class="acts">90 <span class="acts">
68-<a href="/files/raw/main/big.txt">raw</a>91+<a class="btn" href="/files/raw/main/big.txt">Raw</a>
69-<a href="/files/commits/main/big.txt">history</a>92+<a class="btn" href="/files/commits/main/big.txt">History</a>
70-<a href="/files/tree/main">dir</a>93+<a class="btn" href="/files/tree/main">Folder</a>
71 </span>94 </span>
72 </h1>95 </h1>
73 <p class="notice" role="note">file too large to show (1.1 MiB, the limit is 1.0 MiB). use raw.</p>96 <p class="notice" role="note">file too large to show (1.1 MiB, the limit is 1.0 MiB). use raw.</p>
74 </main>97 </main>
75 <aside class="rail" aria-label="Context">98 <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>
78 <section aria-labelledby="r-file">99 <section aria-labelledby="r-file">
79-<h2 id="r-file">file</h2>100+<h2 id="r-file">File</h2>
80 <dl class="kv">101 <dl class="kv">
81 <dt>size</dt>102 <dt>size</dt>
82 <dd>1.1 MiB</dd>103 <dd>1.1 MiB</dd>
@@ -85,80 +106,80 @@
85 </dl>106 </dl>
86 </section>107 </section>
87 <section aria-labelledby="r-last">108 <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>
89 </h2>110 </h2>
90 <p>111 <p>
91 <a href="/files/commit/SHA40">SHA7</a> add big.txt</p>112 <a href="/files/commit/SHA40">SHA7</a> add big.txt</p>
92 <p class="dim">T · <time datetime="STAMP" title="STAMP">AGO</time>113 <p class="dim">T · <time datetime="STAMP" title="STAMP">AGO</time>
93 </p>114 </p>
94 </section>115 </section>
95-</details>
96 </aside>116 </aside>
97 </div>117 </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()">
99 <div class="panel-head">133 <div class="panel-head">
100-<span>keys</span>134+<h2>Keyboard shortcuts</h2>
101-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>135+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
136+<kbd>esc</kbd> close</button>
102 </div>137 </div>
103 <dl class="keys">138 <dl class="keys">
104 <div>139 <div>
105 <dt>140 <dt>
106-<kbd>j</kbd>141+<kbd>ctrl k</kbd>
107-<kbd>k</kbd>142+<kbd>:</kbd>
108 </dt>143 </dt>
109-<dd>move cursor down / up</dd>144+<dd>go to a section, branch, tag, file or repository</dd>
110 </div>145 </div>
111 <div>146 <div>
112 <dt>147 <dt>
113-<kbd>enter</kbd>148+<kbd>?</kbd>
114 </dt>149 </dt>
115-<dd>open row under cursor</dd>150+<dd>this list</dd>
116 </div>151 </div>
117 <div>152 <div>
118 <dt>153 <dt>
119-<kbd>g</kbd>154+<kbd>j</kbd>
120-<kbd>g</kbd> / <kbd>G</kbd>155+<kbd>k</kbd>
121 </dt>156 </dt>
122-<dd>first / last row</dd>157+<dd>move the row cursor down / up</dd>
123 </div>158 </div>
124 <div>159 <div>
125 <dt>160 <dt>
126-<kbd>/</kbd>161+<kbd>enter</kbd>
127 </dt>162 </dt>
128-<dd>focus the filter</dd>163+<dd>open the row under the cursor</dd>
129 </div>164 </div>
130 <div>165 <div>
131 <dt>166 <dt>
132-<kbd>g h</kbd>167+<kbd>g g</kbd>
133-<kbd>g t</kbd>168+<kbd>G</kbd>
134 </dt>169 </dt>
135-<dd>go home / tree</dd>170+<dd>first / last row</dd>
136 </div>171 </div>
137 <div>172 <div>
138 <dt>173 <dt>
139-<kbd>g c</kbd>174+<kbd>/</kbd>
140-<kbd>g r</kbd>
141 </dt>175 </dt>
142-<dd>go commits / refs</dd>176+<dd>filter the list</dd>
143 </div>177 </div>
144 <div>178 <div>
145 <dt>179 <dt>
146 <kbd>b</kbd>180 <kbd>b</kbd>
147 </dt>181 </dt>
148-<dd>switch ref (branches, tags)</dd>182+<dd>switch branch or tag</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>
162 </div>183 </div>
163 <div>184 <div>
164 <dt>185 <dt>
@@ -166,15 +187,21 @@
166 <kbd>t</kbd>187 <kbd>t</kbd>
167 <kbd>c</kbd>188 <kbd>c</kbd>
168 <kbd>r</kbd>189 <kbd>r</kbd>
190+<kbd>m</kbd>
169 </dt>191 </dt>
170-<dd>repo sections (when shown)</dd>192+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
171 </div>193 </div>
172 <div>194 <div>
173 <dt>195 <dt>
174-<kbd>m</kbd>196+<kbd>g h</kbd>
175-<kbd>g m</kbd>197+</dt>
198+<dd>home</dd>
199+</div>
200+<div>
201+<dt>
202+<kbd>y</kbd>
176 </dt>203 </dt>
177-<dd>merge requests (when shown)</dd>204+<dd>copy a permalink pinned to the commit</dd>
178 </div>205 </div>
179 <div>206 <div>
180 <dt>207 <dt>
@@ -194,19 +221,21 @@
194 <dt>221 <dt>
195 <kbd>esc</kbd>222 <kbd>esc</kbd>
196 </dt>223 </dt>
197-<dd>close panel</dd>224+<dd>close a dialog, leave a field</dd>
198 </div>225 </div>
199 </dl>226 </dl>
200 </dialog>227 </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()">
202 <div class="panel-head">229 <div class="panel-head">
203-<label for="refs-filter">ref&gt;</label>230+<label for="refs-filter" class="sr">filter branches and tags</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))">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))">
205-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>232+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
233+<kbd>esc</kbd>
234+</button>
206 </div>235 </div>
207 <div class="panel-cols" data-picklist>236 <div class="panel-cols" data-picklist>
208 <section aria-label="Branches">237 <section aria-label="Branches">
209-<h2>branches <span class="mute">2</span>238+<h2>Branches <span class="count">2</span>
210 </h2>239 </h2>
211 <ul>240 <ul>
212 <li>241 <li>
@@ -220,7 +249,7 @@
220 </ul>249 </ul>
221 </section>250 </section>
222 <section aria-label="Tags">251 <section aria-label="Tags">
223-<h2>tags <span class="mute">1</span>252+<h2>Tags <span class="count">1</span>
224 </h2>253 </h2>
225 <ul>254 <ul>
226 <li>255 <li>
@@ -231,105 +260,124 @@
231 </section>260 </section>
232 </div>261 </div>
233 </dialog>262 </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()">
235 <div class="panel-head">264 <div class="panel-head">
236-<label for="cmd-q" class="hi">:</label>265+<label for="cmd-q" class="sr">go to</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();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();
238 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));267 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
239 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();268 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();
240 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));269 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
241 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;270 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
242 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' })) }271 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' })) }
243 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">272 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>
245 </div>276 </div>
246-<ul id="cmd-list" class="cmd-list">277+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
247 <li data-k="go home" data-cursor>278 <li data-k="go home" data-cursor>
248 <a href="/" tabindex="-1">279 <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>
250 </li>283 </li>
251 <li data-k="section summary">284 <li data-k="section summary">
252 <a href="/files" tabindex="-1">285 <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>
254 </li>289 </li>
255-<li data-k="section tree">290+<li data-k="section files">
256 <a href="/files/tree/main" tabindex="-1">291 <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>
258 </li>295 </li>
259 <li data-k="section commits">296 <li data-k="section commits">
260 <a href="/files/commits/main" tabindex="-1">297 <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>
262 </li>301 </li>
263-<li data-k="section refs">302+<li data-k="section branches &amp; tags">
264 <a href="/files/refs" tabindex="-1">303 <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>
266 </li>307 </li>
267 <li data-k="section merge requests">308 <li data-k="section merge requests">
268 <a href="/files/merge-requests" tabindex="-1">309 <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>
270 </li>313 </li>
271 <li data-k="branch feature/x">314 <li data-k="branch feature/x">
272 <a href="/files/tree/feature/x" tabindex="-1">315 <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>
274 </li>319 </li>
275 <li data-k="branch main">320 <li data-k="branch main">
276 <a href="/files/tree/main" tabindex="-1">321 <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>
278 </li>325 </li>
279 <li data-k="tag v1.0">326 <li data-k="tag v1.0">
280 <a href="/files/tree/v1.0" tabindex="-1">327 <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>
282 </li>331 </li>
283 </ul>332 </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>
284 </dialog>342 </dialog>
285-<footer class="bar status" aria-label="Status">343+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
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) => {
300 if (q('dialog[open]').count) return;344 if (q('dialog[open]').count) return;
301 let rows = q('[data-rows] > :not([hidden])').arr();345 let rows = q('[data-rows] > :not([hidden])').arr();
302 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));346 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
303 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));347 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
304- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];348+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
305- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
306 })(1);352 })(1);
307- k-k from:body -> ((d) => {353+ k-k from:body -> (async (d) => {
308 if (q('dialog[open]').count) return;354 if (q('dialog[open]').count) return;
309 let rows = q('[data-rows] > :not([hidden])').arr();355 let rows = q('[data-rows] > :not([hidden])').arr();
310 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));356 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
311 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));357 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
312- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];358+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
313- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
314 })(-1);362 })(-1);
315 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));363 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
316 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));364 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));
317 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();365 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
318 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());366 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();367+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
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());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());
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();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();
322 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();370 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
323- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();371+ k-gh from:body -> document.querySelector('.brand')?.click();
324- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();372+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
325- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();373+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
326- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();374+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
327- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();375+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
328- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();376+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
329- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();377+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
330- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();378+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
331- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();379+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
332- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();380+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
333 k-next from:body -> {381 k-next from:body -> {
334 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;382 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
335 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);383 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/blob-trojan.html +163 -116
@@ -6,6 +6,7 @@
6 <meta name="color-scheme" content="light dark">6 <meta name="color-scheme" content="light dark">
7 <title>evil.go · files · example.test</title>7 <title>evil.go · files · example.test</title>
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <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>
9 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
10 <script src="/-/static/js/htmx.min.HASH.js" defer>11 <script src="/-/static/js/htmx.min.HASH.js" defer>
11 </script>12 </script>
@@ -16,12 +17,15 @@
16 <script src="/-/static/js/ermite.HASH.js" defer>17 <script src="/-/static/js/ermite.HASH.js" defer>
17 </script>18 </script>
18 </head>19 </head>
19-<body data-ref="main" data-sha="SHA40" data-sel="">20+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
20 <a class="skip" href="#main">skip to content</a>21 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">22+<header class="hdr">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>23+<div class="hdr-top">
23-<span class="fade" aria-hidden="true">24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
24 </span>26 </span>
27+<span>example.test</span>
28+</a>
25 <nav class="crumbs" aria-label="Path">29 <nav class="crumbs" aria-label="Path">
26 <ol>30 <ol>
27 <li>31 <li>
@@ -38,36 +42,55 @@
38 </li>42 </li>
39 </ol>43 </ol>
40 </nav>44 </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">
42 <ul>62 <ul>
43 <li>63 <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>
45 </li>66 </li>
46 <li>67 <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>
48 </li>70 </li>
49 <li>71 <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>
51 </li>74 </li>
52 <li>75 <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>
54 </li>78 </li>
55 <li>79 <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>
57 </li>82 </li>
58 </ul>83 </ul>
59 </nav>84 </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>
62 </header>85 </header>
63 <div class="work has-rail">86 <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">
65 <h1 class="file-h">88 <h1 class="file-h">
66 <span>evil.go</span>89 <span>evil.go</span>
67 <span class="acts">90 <span class="acts">
68-<a href="/files/raw/main/evil.go">raw</a>91+<a class="btn" href="/files/raw/main/evil.go">Raw</a>
69-<a href="/files/commits/main/evil.go">history</a>92+<a class="btn" href="/files/commits/main/evil.go">History</a>
70-<a href="/files/tree/main">dir</a>93+<a class="btn" href="/files/tree/main">Folder</a>
71 </span>94 </span>
72 </h1>95 </h1>
73 <pre class="chroma code" data-anchor="" hx-on:click="let a = event.target.closest('a.ln'); if (!a) return; event.preventDefault();96 <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 @@
133 </pre>156 </pre>
134 </main>157 </main>
135 <aside class="rail" aria-label="Context">158 <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>
138 <section aria-labelledby="r-file">159 <section aria-labelledby="r-file">
139-<h2 id="r-file">file</h2>160+<h2 id="r-file">File</h2>
140 <dl class="kv">161 <dl class="kv">
141 <dt>size</dt>162 <dt>size</dt>
142 <dd>87 B</dd>163 <dd>87 B</dd>
@@ -147,7 +168,7 @@
147 </dl>168 </dl>
148 </section>169 </section>
149 <section aria-labelledby="r-last">170 <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>
151 </h2>172 </h2>
152 <p>173 <p>
153 <a href="/files/commit/SHA40">SHA7</a> add evil.go</p>174 <a href="/files/commit/SHA40">SHA7</a> add evil.go</p>
@@ -155,77 +176,77 @@
155 </p>176 </p>
156 </section>177 </section>
157 <section aria-labelledby="r-sel">178 <section aria-labelledby="r-sel">
158-<h2 id="r-sel">selection</h2>179+<h2 id="r-sel">Selection</h2>
159 <p :text="document.body.dataset.sel || 'none · click a line number, shift-click extends'">none · click a line number, shift-click extends</p>180 <p :text="document.body.dataset.sel || 'none · click a line number, shift-click extends'">none · click a line number, shift-click extends</p>
160 <p class="dim">y copies a permalink pinned to the commit sha</p>181 <p class="dim">y copies a permalink pinned to the commit sha</p>
161 </section>182 </section>
162-</details>
163 </aside>183 </aside>
164 </div>184 </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()">
166 <div class="panel-head">200 <div class="panel-head">
167-<span>keys</span>201+<h2>Keyboard shortcuts</h2>
168-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>202+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
203+<kbd>esc</kbd> close</button>
169 </div>204 </div>
170 <dl class="keys">205 <dl class="keys">
171 <div>206 <div>
172 <dt>207 <dt>
173-<kbd>j</kbd>208+<kbd>ctrl k</kbd>
174-<kbd>k</kbd>209+<kbd>:</kbd>
175 </dt>210 </dt>
176-<dd>move cursor down / up</dd>211+<dd>go to a section, branch, tag, file or repository</dd>
177 </div>212 </div>
178 <div>213 <div>
179 <dt>214 <dt>
180-<kbd>enter</kbd>215+<kbd>?</kbd>
181 </dt>216 </dt>
182-<dd>open row under cursor</dd>217+<dd>this list</dd>
183 </div>218 </div>
184 <div>219 <div>
185 <dt>220 <dt>
186-<kbd>g</kbd>221+<kbd>j</kbd>
187-<kbd>g</kbd> / <kbd>G</kbd>222+<kbd>k</kbd>
188 </dt>223 </dt>
189-<dd>first / last row</dd>224+<dd>move the row cursor down / up</dd>
190 </div>225 </div>
191 <div>226 <div>
192 <dt>227 <dt>
193-<kbd>/</kbd>228+<kbd>enter</kbd>
194 </dt>229 </dt>
195-<dd>focus the filter</dd>230+<dd>open the row under the cursor</dd>
196 </div>231 </div>
197 <div>232 <div>
198 <dt>233 <dt>
199-<kbd>g h</kbd>234+<kbd>g g</kbd>
200-<kbd>g t</kbd>235+<kbd>G</kbd>
201 </dt>236 </dt>
202-<dd>go home / tree</dd>237+<dd>first / last row</dd>
203 </div>238 </div>
204 <div>239 <div>
205 <dt>240 <dt>
206-<kbd>g c</kbd>241+<kbd>/</kbd>
207-<kbd>g r</kbd>
208 </dt>242 </dt>
209-<dd>go commits / refs</dd>243+<dd>filter the list</dd>
210 </div>244 </div>
211 <div>245 <div>
212 <dt>246 <dt>
213 <kbd>b</kbd>247 <kbd>b</kbd>
214 </dt>248 </dt>
215-<dd>switch ref (branches, tags)</dd>249+<dd>switch branch or tag</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>
229 </div>250 </div>
230 <div>251 <div>
231 <dt>252 <dt>
@@ -233,15 +254,21 @@
233 <kbd>t</kbd>254 <kbd>t</kbd>
234 <kbd>c</kbd>255 <kbd>c</kbd>
235 <kbd>r</kbd>256 <kbd>r</kbd>
257+<kbd>m</kbd>
236 </dt>258 </dt>
237-<dd>repo sections (when shown)</dd>259+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
238 </div>260 </div>
239 <div>261 <div>
240 <dt>262 <dt>
241-<kbd>m</kbd>263+<kbd>g h</kbd>
242-<kbd>g m</kbd>264+</dt>
265+<dd>home</dd>
266+</div>
267+<div>
268+<dt>
269+<kbd>y</kbd>
243 </dt>270 </dt>
244-<dd>merge requests (when shown)</dd>271+<dd>copy a permalink pinned to the commit</dd>
245 </div>272 </div>
246 <div>273 <div>
247 <dt>274 <dt>
@@ -261,19 +288,21 @@
261 <dt>288 <dt>
262 <kbd>esc</kbd>289 <kbd>esc</kbd>
263 </dt>290 </dt>
264-<dd>close panel</dd>291+<dd>close a dialog, leave a field</dd>
265 </div>292 </div>
266 </dl>293 </dl>
267 </dialog>294 </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()">
269 <div class="panel-head">296 <div class="panel-head">
270-<label for="refs-filter">ref&gt;</label>297+<label for="refs-filter" class="sr">filter branches and tags</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))">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))">
272-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>299+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
300+<kbd>esc</kbd>
301+</button>
273 </div>302 </div>
274 <div class="panel-cols" data-picklist>303 <div class="panel-cols" data-picklist>
275 <section aria-label="Branches">304 <section aria-label="Branches">
276-<h2>branches <span class="mute">2</span>305+<h2>Branches <span class="count">2</span>
277 </h2>306 </h2>
278 <ul>307 <ul>
279 <li>308 <li>
@@ -287,7 +316,7 @@
287 </ul>316 </ul>
288 </section>317 </section>
289 <section aria-label="Tags">318 <section aria-label="Tags">
290-<h2>tags <span class="mute">1</span>319+<h2>Tags <span class="count">1</span>
291 </h2>320 </h2>
292 <ul>321 <ul>
293 <li>322 <li>
@@ -298,106 +327,124 @@
298 </section>327 </section>
299 </div>328 </div>
300 </dialog>329 </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()">
302 <div class="panel-head">331 <div class="panel-head">
303-<label for="cmd-q" class="hi">:</label>332+<label for="cmd-q" class="sr">go to</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();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();
305 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));334 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
306 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();335 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();
307 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));336 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
308 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;337 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
309 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' })) }338 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' })) }
310 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">339 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>
312 </div>343 </div>
313-<ul id="cmd-list" class="cmd-list">344+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
314 <li data-k="go home" data-cursor>345 <li data-k="go home" data-cursor>
315 <a href="/" tabindex="-1">346 <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>
317 </li>350 </li>
318 <li data-k="section summary">351 <li data-k="section summary">
319 <a href="/files" tabindex="-1">352 <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>
321 </li>356 </li>
322-<li data-k="section tree">357+<li data-k="section files">
323 <a href="/files/tree/main" tabindex="-1">358 <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>
325 </li>362 </li>
326 <li data-k="section commits">363 <li data-k="section commits">
327 <a href="/files/commits/main" tabindex="-1">364 <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>
329 </li>368 </li>
330-<li data-k="section refs">369+<li data-k="section branches &amp; tags">
331 <a href="/files/refs" tabindex="-1">370 <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>
333 </li>374 </li>
334 <li data-k="section merge requests">375 <li data-k="section merge requests">
335 <a href="/files/merge-requests" tabindex="-1">376 <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>
337 </li>380 </li>
338 <li data-k="branch feature/x">381 <li data-k="branch feature/x">
339 <a href="/files/tree/feature/x" tabindex="-1">382 <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>
341 </li>386 </li>
342 <li data-k="branch main">387 <li data-k="branch main">
343 <a href="/files/tree/main" tabindex="-1">388 <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>
345 </li>392 </li>
346 <li data-k="tag v1.0">393 <li data-k="tag v1.0">
347 <a href="/files/tree/v1.0" tabindex="-1">394 <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>
349 </li>398 </li>
350 </ul>399 </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>
351 </dialog>409 </dialog>
352-<footer class="bar status" aria-label="Status">410+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
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) => {
368 if (q('dialog[open]').count) return;411 if (q('dialog[open]').count) return;
369 let rows = q('[data-rows] > :not([hidden])').arr();412 let rows = q('[data-rows] > :not([hidden])').arr();
370 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));413 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
371 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));414 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
372- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];415+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
373- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
374 })(1);419 })(1);
375- k-k from:body -> ((d) => {420+ k-k from:body -> (async (d) => {
376 if (q('dialog[open]').count) return;421 if (q('dialog[open]').count) return;
377 let rows = q('[data-rows] > :not([hidden])').arr();422 let rows = q('[data-rows] > :not([hidden])').arr();
378 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));423 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
379 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));424 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
380- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];425+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
381- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
382 })(-1);429 })(-1);
383 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));430 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
384 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));431 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));
385 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();432 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
386 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());433 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();434+ 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].show(), q('#refs-filter').arr()[0].focus());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());
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();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();
390 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();437 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
391- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();438+ k-gh from:body -> document.querySelector('.brand')?.click();
392- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();439+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
393- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();440+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
394- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();441+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
395- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();442+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
396- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();443+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
397- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();444+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
398- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();445+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
399- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();446+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
400- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();447+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
401 k-next from:body -> {448 k-next from:body -> {
402 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;449 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
403 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);450 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/commit-big.html +167 -114
@@ -6,6 +6,7 @@
6 <meta name="color-scheme" content="light dark">6 <meta name="color-scheme" content="light dark">
7 <title>SHA7 add a very large file · diffs · example.test</title>7 <title>SHA7 add a very large file · diffs · example.test</title>
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <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>
9 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
10 <script src="/-/static/js/htmx.min.HASH.js" defer>11 <script src="/-/static/js/htmx.min.HASH.js" defer>
11 </script>12 </script>
@@ -16,12 +17,15 @@
16 <script src="/-/static/js/ermite.HASH.js" defer>17 <script src="/-/static/js/ermite.HASH.js" defer>
17 </script>18 </script>
18 </head>19 </head>
19-<body data-ref="" data-sha="SHA40" data-sel="">20+<body data-ref="" data-sha="SHA40" data-sel="" data-nav="">
20 <a class="skip" href="#main">skip to content</a>21 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">22+<header class="hdr">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>23+<div class="hdr-top">
23-<span class="fade" aria-hidden="true">24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
24 </span>26 </span>
27+<span>example.test</span>
28+</a>
25 <nav class="crumbs" aria-label="Path">29 <nav class="crumbs" aria-label="Path">
26 <ol>30 <ol>
27 <li>31 <li>
@@ -35,30 +39,49 @@
35 </li>39 </li>
36 </ol>40 </ol>
37 </nav>41 </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">
39 <ul>59 <ul>
40 <li>60 <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>
42 </li>63 </li>
43 <li>64 <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>
45 </li>67 </li>
46 <li>68 <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>
48 </li>71 </li>
49 <li>72 <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>
51 </li>75 </li>
52 <li>76 <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>
54 </li>79 </li>
55 </ul>80 </ul>
56 </nav>81 </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>
59 </header>82 </header>
60 <div class="work has-rail">83 <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">
62 <header class="show">85 <header class="show">
63 <h1>add a very large file</h1>86 <h1>add a very large file</h1>
64 <dl class="kv show-kv">87 <dl class="kv show-kv">
@@ -71,7 +94,9 @@
71 <a href="/diffs/commit/SHA40">SHA7</a>94 <a href="/diffs/commit/SHA40">SHA7</a>
72 </dd>95 </dd>
73 <dt>refs</dt>96 <dt>refs</dt>
74-<dd>[main] </dd>97+<dd>
98+<span class="pill">main</span>
99+</dd>
75 </dl>100 </dl>
76 </header>101 </header>
77 <div class="diff-tools" role="toolbar" aria-label="Diff view">102 <div class="diff-tools" role="toolbar" aria-label="Diff view">
@@ -80,9 +105,12 @@
80 </span>105 </span>
81 <span class="grow">106 <span class="grow">
82 </span>107 </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>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>
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>109+</a>
85-<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=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>
86 </div>114 </div>
87 <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>115 <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>
88 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">116 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">
@@ -99,10 +127,8 @@
99 </div>127 </div>
100 </main>128 </main>
101 <aside class="rail" aria-label="Context">129 <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>
104 <section aria-labelledby="r-files">130 <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>
106 </h2>132 </h2>
107 <ol class="files" aria-label="Changed files">133 <ol class="files" aria-label="Changed files">
108 <li>134 <li>
@@ -122,73 +148,73 @@
122 </li>148 </li>
123 </ol>149 </ol>
124 </section>150 </section>
125-</details>
126 </aside>151 </aside>
127 </div>152 </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()">
129 <div class="panel-head">168 <div class="panel-head">
130-<span>keys</span>169+<h2>Keyboard shortcuts</h2>
131-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>170+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
171+<kbd>esc</kbd> close</button>
132 </div>172 </div>
133 <dl class="keys">173 <dl class="keys">
134 <div>174 <div>
135 <dt>175 <dt>
136-<kbd>j</kbd>176+<kbd>ctrl k</kbd>
137-<kbd>k</kbd>177+<kbd>:</kbd>
138 </dt>178 </dt>
139-<dd>move cursor down / up</dd>179+<dd>go to a section, branch, tag, file or repository</dd>
140 </div>180 </div>
141 <div>181 <div>
142 <dt>182 <dt>
143-<kbd>enter</kbd>183+<kbd>?</kbd>
144 </dt>184 </dt>
145-<dd>open row under cursor</dd>185+<dd>this list</dd>
146 </div>186 </div>
147 <div>187 <div>
148 <dt>188 <dt>
149-<kbd>g</kbd>189+<kbd>j</kbd>
150-<kbd>g</kbd> / <kbd>G</kbd>190+<kbd>k</kbd>
151 </dt>191 </dt>
152-<dd>first / last row</dd>192+<dd>move the row cursor down / up</dd>
153 </div>193 </div>
154 <div>194 <div>
155 <dt>195 <dt>
156-<kbd>/</kbd>196+<kbd>enter</kbd>
157 </dt>197 </dt>
158-<dd>focus the filter</dd>198+<dd>open the row under the cursor</dd>
159 </div>199 </div>
160 <div>200 <div>
161 <dt>201 <dt>
162-<kbd>g h</kbd>202+<kbd>g g</kbd>
163-<kbd>g t</kbd>203+<kbd>G</kbd>
164 </dt>204 </dt>
165-<dd>go home / tree</dd>205+<dd>first / last row</dd>
166 </div>206 </div>
167 <div>207 <div>
168 <dt>208 <dt>
169-<kbd>g c</kbd>209+<kbd>/</kbd>
170-<kbd>g r</kbd>
171 </dt>210 </dt>
172-<dd>go commits / refs</dd>211+<dd>filter the list</dd>
173 </div>212 </div>
174 <div>213 <div>
175 <dt>214 <dt>
176 <kbd>b</kbd>215 <kbd>b</kbd>
177 </dt>216 </dt>
178-<dd>switch ref (branches, tags)</dd>217+<dd>switch branch or tag</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>
192 </div>218 </div>
193 <div>219 <div>
194 <dt>220 <dt>
@@ -196,15 +222,21 @@
196 <kbd>t</kbd>222 <kbd>t</kbd>
197 <kbd>c</kbd>223 <kbd>c</kbd>
198 <kbd>r</kbd>224 <kbd>r</kbd>
225+<kbd>m</kbd>
199 </dt>226 </dt>
200-<dd>repo sections (when shown)</dd>227+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
201 </div>228 </div>
202 <div>229 <div>
203 <dt>230 <dt>
204-<kbd>m</kbd>231+<kbd>g h</kbd>
205-<kbd>g m</kbd>232+</dt>
233+<dd>home</dd>
234+</div>
235+<div>
236+<dt>
237+<kbd>y</kbd>
206 </dt>238 </dt>
207-<dd>merge requests (when shown)</dd>239+<dd>copy a permalink pinned to the commit</dd>
208 </div>240 </div>
209 <div>241 <div>
210 <dt>242 <dt>
@@ -224,19 +256,21 @@
224 <dt>256 <dt>
225 <kbd>esc</kbd>257 <kbd>esc</kbd>
226 </dt>258 </dt>
227-<dd>close panel</dd>259+<dd>close a dialog, leave a field</dd>
228 </div>260 </div>
229 </dl>261 </dl>
230 </dialog>262 </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()">
232 <div class="panel-head">264 <div class="panel-head">
233-<label for="refs-filter">ref&gt;</label>265+<label for="refs-filter" class="sr">filter branches and tags</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))">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))">
235-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>267+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
268+<kbd>esc</kbd>
269+</button>
236 </div>270 </div>
237 <div class="panel-cols" data-picklist>271 <div class="panel-cols" data-picklist>
238 <section aria-label="Branches">272 <section aria-label="Branches">
239-<h2>branches <span class="mute">2</span>273+<h2>Branches <span class="count">2</span>
240 </h2>274 </h2>
241 <ul>275 <ul>
242 <li>276 <li>
@@ -250,7 +284,7 @@
250 </ul>284 </ul>
251 </section>285 </section>
252 <section aria-label="Tags">286 <section aria-label="Tags">
253-<h2>tags <span class="mute">1</span>287+<h2>Tags <span class="count">1</span>
254 </h2>288 </h2>
255 <ul>289 <ul>
256 <li>290 <li>
@@ -261,105 +295,124 @@
261 </section>295 </section>
262 </div>296 </div>
263 </dialog>297 </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()">
265 <div class="panel-head">299 <div class="panel-head">
266-<label for="cmd-q" class="hi">:</label>300+<label for="cmd-q" class="sr">go to</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();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();
268 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));302 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
269 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();303 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();
270 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));304 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
271 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;305 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
272 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' })) }306 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' })) }
273 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">307 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>
275 </div>311 </div>
276-<ul id="cmd-list" class="cmd-list">312+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
277 <li data-k="go home" data-cursor>313 <li data-k="go home" data-cursor>
278 <a href="/" tabindex="-1">314 <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>
280 </li>318 </li>
281 <li data-k="section summary">319 <li data-k="section summary">
282 <a href="/diffs" tabindex="-1">320 <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>
284 </li>324 </li>
285-<li data-k="section tree">325+<li data-k="section files">
286 <a href="/diffs/tree/main" tabindex="-1">326 <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>
288 </li>330 </li>
289 <li data-k="section commits">331 <li data-k="section commits">
290 <a href="/diffs/commits/main" tabindex="-1">332 <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>
292 </li>336 </li>
293-<li data-k="section refs">337+<li data-k="section branches &amp; tags">
294 <a href="/diffs/refs" tabindex="-1">338 <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>
296 </li>342 </li>
297 <li data-k="section merge requests">343 <li data-k="section merge requests">
298 <a href="/diffs/merge-requests" tabindex="-1">344 <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>
300 </li>348 </li>
301 <li data-k="branch main">349 <li data-k="branch main">
302 <a href="/diffs/tree/main" tabindex="-1">350 <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>
304 </li>354 </li>
305 <li data-k="branch side">355 <li data-k="branch side">
306 <a href="/diffs/tree/side" tabindex="-1">356 <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>
308 </li>360 </li>
309 <li data-k="tag v1.0.0">361 <li data-k="tag v1.0.0">
310 <a href="/diffs/tree/v1.0.0" tabindex="-1">362 <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>
312 </li>366 </li>
313 </ul>367 </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>
314 </dialog>377 </dialog>
315-<footer class="bar status" aria-label="Status">378+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
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) => {
330 if (q('dialog[open]').count) return;379 if (q('dialog[open]').count) return;
331 let rows = q('[data-rows] > :not([hidden])').arr();380 let rows = q('[data-rows] > :not([hidden])').arr();
332 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));381 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
333 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));382 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
334- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];383+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
335- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
336 })(1);387 })(1);
337- k-k from:body -> ((d) => {388+ k-k from:body -> (async (d) => {
338 if (q('dialog[open]').count) return;389 if (q('dialog[open]').count) return;
339 let rows = q('[data-rows] > :not([hidden])').arr();390 let rows = q('[data-rows] > :not([hidden])').arr();
340 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));391 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
341 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));392 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
342- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];393+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
343- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
344 })(-1);397 })(-1);
345 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));398 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
346 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));399 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));
347 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();400 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
348 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());401 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();402+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
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());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());
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();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();
352 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();405 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
353- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();406+ k-gh from:body -> document.querySelector('.brand')?.click();
354- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();407+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
355- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();408+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
356- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();409+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
357- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();410+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
358- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();411+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
359- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();412+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
360- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();413+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
361- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();414+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
362- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();415+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
363 k-next from:body -> {416 k-next from:body -> {
364 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;417 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
365 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);418 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/commit-binary.html +164 -113
@@ -6,6 +6,7 @@
6 <meta name="color-scheme" content="light dark">6 <meta name="color-scheme" content="light dark">
7 <title>SHA7 change a binary file · diffs · example.test</title>7 <title>SHA7 change a binary file · diffs · example.test</title>
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <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>
9 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
10 <script src="/-/static/js/htmx.min.HASH.js" defer>11 <script src="/-/static/js/htmx.min.HASH.js" defer>
11 </script>12 </script>
@@ -16,12 +17,15 @@
16 <script src="/-/static/js/ermite.HASH.js" defer>17 <script src="/-/static/js/ermite.HASH.js" defer>
17 </script>18 </script>
18 </head>19 </head>
19-<body data-ref="" data-sha="SHA40" data-sel="">20+<body data-ref="" data-sha="SHA40" data-sel="" data-nav="">
20 <a class="skip" href="#main">skip to content</a>21 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">22+<header class="hdr">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>23+<div class="hdr-top">
23-<span class="fade" aria-hidden="true">24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
24 </span>26 </span>
27+<span>example.test</span>
28+</a>
25 <nav class="crumbs" aria-label="Path">29 <nav class="crumbs" aria-label="Path">
26 <ol>30 <ol>
27 <li>31 <li>
@@ -35,30 +39,49 @@
35 </li>39 </li>
36 </ol>40 </ol>
37 </nav>41 </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">
39 <ul>59 <ul>
40 <li>60 <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>
42 </li>63 </li>
43 <li>64 <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>
45 </li>67 </li>
46 <li>68 <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>
48 </li>71 </li>
49 <li>72 <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>
51 </li>75 </li>
52 <li>76 <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>
54 </li>79 </li>
55 </ul>80 </ul>
56 </nav>81 </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>
59 </header>82 </header>
60 <div class="work has-rail">83 <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">
62 <header class="show">85 <header class="show">
63 <h1>change a binary file</h1>86 <h1>change a binary file</h1>
64 <dl class="kv show-kv">87 <dl class="kv show-kv">
@@ -78,9 +101,12 @@
78 </span>101 </span>
79 <span class="grow">102 <span class="grow">
80 </span>103 </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>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>
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>105+</a>
83-<button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))">[x] collapse all</button>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>
84 </div>110 </div>
85 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">111 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">
86 <details class="dfile" id="f1" data-file>112 <details class="dfile" id="f1" data-file>
@@ -96,10 +122,8 @@
96 </div>122 </div>
97 </main>123 </main>
98 <aside class="rail" aria-label="Context">124 <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>
101 <section aria-labelledby="r-files">125 <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>
103 </h2>127 </h2>
104 <ol class="files" aria-label="Changed files">128 <ol class="files" aria-label="Changed files">
105 <li>129 <li>
@@ -119,73 +143,73 @@
119 </li>143 </li>
120 </ol>144 </ol>
121 </section>145 </section>
122-</details>
123 </aside>146 </aside>
124 </div>147 </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()">
126 <div class="panel-head">163 <div class="panel-head">
127-<span>keys</span>164+<h2>Keyboard shortcuts</h2>
128-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>165+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
166+<kbd>esc</kbd> close</button>
129 </div>167 </div>
130 <dl class="keys">168 <dl class="keys">
131 <div>169 <div>
132 <dt>170 <dt>
133-<kbd>j</kbd>171+<kbd>ctrl k</kbd>
134-<kbd>k</kbd>172+<kbd>:</kbd>
135 </dt>173 </dt>
136-<dd>move cursor down / up</dd>174+<dd>go to a section, branch, tag, file or repository</dd>
137 </div>175 </div>
138 <div>176 <div>
139 <dt>177 <dt>
140-<kbd>enter</kbd>178+<kbd>?</kbd>
141 </dt>179 </dt>
142-<dd>open row under cursor</dd>180+<dd>this list</dd>
143 </div>181 </div>
144 <div>182 <div>
145 <dt>183 <dt>
146-<kbd>g</kbd>184+<kbd>j</kbd>
147-<kbd>g</kbd> / <kbd>G</kbd>185+<kbd>k</kbd>
148 </dt>186 </dt>
149-<dd>first / last row</dd>187+<dd>move the row cursor down / up</dd>
150 </div>188 </div>
151 <div>189 <div>
152 <dt>190 <dt>
153-<kbd>/</kbd>191+<kbd>enter</kbd>
154 </dt>192 </dt>
155-<dd>focus the filter</dd>193+<dd>open the row under the cursor</dd>
156 </div>194 </div>
157 <div>195 <div>
158 <dt>196 <dt>
159-<kbd>g h</kbd>197+<kbd>g g</kbd>
160-<kbd>g t</kbd>198+<kbd>G</kbd>
161 </dt>199 </dt>
162-<dd>go home / tree</dd>200+<dd>first / last row</dd>
163 </div>201 </div>
164 <div>202 <div>
165 <dt>203 <dt>
166-<kbd>g c</kbd>204+<kbd>/</kbd>
167-<kbd>g r</kbd>
168 </dt>205 </dt>
169-<dd>go commits / refs</dd>206+<dd>filter the list</dd>
170 </div>207 </div>
171 <div>208 <div>
172 <dt>209 <dt>
173 <kbd>b</kbd>210 <kbd>b</kbd>
174 </dt>211 </dt>
175-<dd>switch ref (branches, tags)</dd>212+<dd>switch branch or tag</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>
189 </div>213 </div>
190 <div>214 <div>
191 <dt>215 <dt>
@@ -193,15 +217,21 @@
193 <kbd>t</kbd>217 <kbd>t</kbd>
194 <kbd>c</kbd>218 <kbd>c</kbd>
195 <kbd>r</kbd>219 <kbd>r</kbd>
220+<kbd>m</kbd>
196 </dt>221 </dt>
197-<dd>repo sections (when shown)</dd>222+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
198 </div>223 </div>
199 <div>224 <div>
200 <dt>225 <dt>
201-<kbd>m</kbd>226+<kbd>g h</kbd>
202-<kbd>g m</kbd>227+</dt>
228+<dd>home</dd>
229+</div>
230+<div>
231+<dt>
232+<kbd>y</kbd>
203 </dt>233 </dt>
204-<dd>merge requests (when shown)</dd>234+<dd>copy a permalink pinned to the commit</dd>
205 </div>235 </div>
206 <div>236 <div>
207 <dt>237 <dt>
@@ -221,19 +251,21 @@
221 <dt>251 <dt>
222 <kbd>esc</kbd>252 <kbd>esc</kbd>
223 </dt>253 </dt>
224-<dd>close panel</dd>254+<dd>close a dialog, leave a field</dd>
225 </div>255 </div>
226 </dl>256 </dl>
227 </dialog>257 </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()">
229 <div class="panel-head">259 <div class="panel-head">
230-<label for="refs-filter">ref&gt;</label>260+<label for="refs-filter" class="sr">filter branches and tags</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))">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))">
232-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>262+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
263+<kbd>esc</kbd>
264+</button>
233 </div>265 </div>
234 <div class="panel-cols" data-picklist>266 <div class="panel-cols" data-picklist>
235 <section aria-label="Branches">267 <section aria-label="Branches">
236-<h2>branches <span class="mute">2</span>268+<h2>Branches <span class="count">2</span>
237 </h2>269 </h2>
238 <ul>270 <ul>
239 <li>271 <li>
@@ -247,7 +279,7 @@
247 </ul>279 </ul>
248 </section>280 </section>
249 <section aria-label="Tags">281 <section aria-label="Tags">
250-<h2>tags <span class="mute">1</span>282+<h2>Tags <span class="count">1</span>
251 </h2>283 </h2>
252 <ul>284 <ul>
253 <li>285 <li>
@@ -258,105 +290,124 @@
258 </section>290 </section>
259 </div>291 </div>
260 </dialog>292 </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()">
262 <div class="panel-head">294 <div class="panel-head">
263-<label for="cmd-q" class="hi">:</label>295+<label for="cmd-q" class="sr">go to</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();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();
265 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));297 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
266 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();298 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();
267 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));299 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
268 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;300 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
269 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' })) }301 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' })) }
270 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">302 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>
272 </div>306 </div>
273-<ul id="cmd-list" class="cmd-list">307+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
274 <li data-k="go home" data-cursor>308 <li data-k="go home" data-cursor>
275 <a href="/" tabindex="-1">309 <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>
277 </li>313 </li>
278 <li data-k="section summary">314 <li data-k="section summary">
279 <a href="/diffs" tabindex="-1">315 <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>
281 </li>319 </li>
282-<li data-k="section tree">320+<li data-k="section files">
283 <a href="/diffs/tree/main" tabindex="-1">321 <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>
285 </li>325 </li>
286 <li data-k="section commits">326 <li data-k="section commits">
287 <a href="/diffs/commits/main" tabindex="-1">327 <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>
289 </li>331 </li>
290-<li data-k="section refs">332+<li data-k="section branches &amp; tags">
291 <a href="/diffs/refs" tabindex="-1">333 <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>
293 </li>337 </li>
294 <li data-k="section merge requests">338 <li data-k="section merge requests">
295 <a href="/diffs/merge-requests" tabindex="-1">339 <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>
297 </li>343 </li>
298 <li data-k="branch main">344 <li data-k="branch main">
299 <a href="/diffs/tree/main" tabindex="-1">345 <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>
301 </li>349 </li>
302 <li data-k="branch side">350 <li data-k="branch side">
303 <a href="/diffs/tree/side" tabindex="-1">351 <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>
305 </li>355 </li>
306 <li data-k="tag v1.0.0">356 <li data-k="tag v1.0.0">
307 <a href="/diffs/tree/v1.0.0" tabindex="-1">357 <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>
309 </li>361 </li>
310 </ul>362 </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>
311 </dialog>372 </dialog>
312-<footer class="bar status" aria-label="Status">373+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
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) => {
327 if (q('dialog[open]').count) return;374 if (q('dialog[open]').count) return;
328 let rows = q('[data-rows] > :not([hidden])').arr();375 let rows = q('[data-rows] > :not([hidden])').arr();
329 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));376 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
330 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));377 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
331- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];378+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
332- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
333 })(1);382 })(1);
334- k-k from:body -> ((d) => {383+ k-k from:body -> (async (d) => {
335 if (q('dialog[open]').count) return;384 if (q('dialog[open]').count) return;
336 let rows = q('[data-rows] > :not([hidden])').arr();385 let rows = q('[data-rows] > :not([hidden])').arr();
337 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));386 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
338 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));387 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
339- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];388+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
340- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
341 })(-1);392 })(-1);
342 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));393 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
343 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));394 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));
344 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();395 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
345 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());396 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();397+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
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());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());
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();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();
349 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();400 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
350- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();401+ k-gh from:body -> document.querySelector('.brand')?.click();
351- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();402+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
352- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();403+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
353- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();404+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
354- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();405+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
355- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();406+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
356- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();407+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
357- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();408+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
358- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();409+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
359- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();410+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
360 k-next from:body -> {411 k-next from:body -> {
361 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;412 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
362 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);413 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/commit-merge.html +164 -113
@@ -6,6 +6,7 @@
6 <meta name="color-scheme" content="light dark">6 <meta name="color-scheme" content="light dark">
7 <title>SHA7 merge the side branch · diffs · example.test</title>7 <title>SHA7 merge the side branch · diffs · example.test</title>
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <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>
9 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
10 <script src="/-/static/js/htmx.min.HASH.js" defer>11 <script src="/-/static/js/htmx.min.HASH.js" defer>
11 </script>12 </script>
@@ -16,12 +17,15 @@
16 <script src="/-/static/js/ermite.HASH.js" defer>17 <script src="/-/static/js/ermite.HASH.js" defer>
17 </script>18 </script>
18 </head>19 </head>
19-<body data-ref="" data-sha="SHA40" data-sel="">20+<body data-ref="" data-sha="SHA40" data-sel="" data-nav="">
20 <a class="skip" href="#main">skip to content</a>21 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">22+<header class="hdr">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>23+<div class="hdr-top">
23-<span class="fade" aria-hidden="true">24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
24 </span>26 </span>
27+<span>example.test</span>
28+</a>
25 <nav class="crumbs" aria-label="Path">29 <nav class="crumbs" aria-label="Path">
26 <ol>30 <ol>
27 <li>31 <li>
@@ -35,30 +39,49 @@
35 </li>39 </li>
36 </ol>40 </ol>
37 </nav>41 </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">
39 <ul>59 <ul>
40 <li>60 <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>
42 </li>63 </li>
43 <li>64 <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>
45 </li>67 </li>
46 <li>68 <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>
48 </li>71 </li>
49 <li>72 <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>
51 </li>75 </li>
52 <li>76 <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>
54 </li>79 </li>
55 </ul>80 </ul>
56 </nav>81 </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>
59 </header>82 </header>
60 <div class="work has-rail">83 <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">
62 <header class="show">85 <header class="show">
63 <h1>merge the side branch</h1>86 <h1>merge the side branch</h1>
64 <dl class="kv show-kv">87 <dl class="kv show-kv">
@@ -77,9 +100,12 @@
77 </span>100 </span>
78 <span class="grow">101 <span class="grow">
79 </span>102 </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>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>
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>104+</a>
82-<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=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>
83 </div>109 </div>
84 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">110 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">
85 <details class="dfile" id="f1" data-file open>111 <details class="dfile" id="f1" data-file open>
@@ -127,10 +153,8 @@
127 </div>153 </div>
128 </main>154 </main>
129 <aside class="rail" aria-label="Context">155 <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>
132 <section aria-labelledby="r-files">156 <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>
134 </h2>158 </h2>
135 <ol class="files" aria-label="Changed files">159 <ol class="files" aria-label="Changed files">
136 <li>160 <li>
@@ -150,73 +174,73 @@
150 </li>174 </li>
151 </ol>175 </ol>
152 </section>176 </section>
153-</details>
154 </aside>177 </aside>
155 </div>178 </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()">
157 <div class="panel-head">194 <div class="panel-head">
158-<span>keys</span>195+<h2>Keyboard shortcuts</h2>
159-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>196+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
197+<kbd>esc</kbd> close</button>
160 </div>198 </div>
161 <dl class="keys">199 <dl class="keys">
162 <div>200 <div>
163 <dt>201 <dt>
164-<kbd>j</kbd>202+<kbd>ctrl k</kbd>
165-<kbd>k</kbd>203+<kbd>:</kbd>
166 </dt>204 </dt>
167-<dd>move cursor down / up</dd>205+<dd>go to a section, branch, tag, file or repository</dd>
168 </div>206 </div>
169 <div>207 <div>
170 <dt>208 <dt>
171-<kbd>enter</kbd>209+<kbd>?</kbd>
172 </dt>210 </dt>
173-<dd>open row under cursor</dd>211+<dd>this list</dd>
174 </div>212 </div>
175 <div>213 <div>
176 <dt>214 <dt>
177-<kbd>g</kbd>215+<kbd>j</kbd>
178-<kbd>g</kbd> / <kbd>G</kbd>216+<kbd>k</kbd>
179 </dt>217 </dt>
180-<dd>first / last row</dd>218+<dd>move the row cursor down / up</dd>
181 </div>219 </div>
182 <div>220 <div>
183 <dt>221 <dt>
184-<kbd>/</kbd>222+<kbd>enter</kbd>
185 </dt>223 </dt>
186-<dd>focus the filter</dd>224+<dd>open the row under the cursor</dd>
187 </div>225 </div>
188 <div>226 <div>
189 <dt>227 <dt>
190-<kbd>g h</kbd>228+<kbd>g g</kbd>
191-<kbd>g t</kbd>229+<kbd>G</kbd>
192 </dt>230 </dt>
193-<dd>go home / tree</dd>231+<dd>first / last row</dd>
194 </div>232 </div>
195 <div>233 <div>
196 <dt>234 <dt>
197-<kbd>g c</kbd>235+<kbd>/</kbd>
198-<kbd>g r</kbd>
199 </dt>236 </dt>
200-<dd>go commits / refs</dd>237+<dd>filter the list</dd>
201 </div>238 </div>
202 <div>239 <div>
203 <dt>240 <dt>
204 <kbd>b</kbd>241 <kbd>b</kbd>
205 </dt>242 </dt>
206-<dd>switch ref (branches, tags)</dd>243+<dd>switch branch or tag</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>
220 </div>244 </div>
221 <div>245 <div>
222 <dt>246 <dt>
@@ -224,15 +248,21 @@
224 <kbd>t</kbd>248 <kbd>t</kbd>
225 <kbd>c</kbd>249 <kbd>c</kbd>
226 <kbd>r</kbd>250 <kbd>r</kbd>
251+<kbd>m</kbd>
227 </dt>252 </dt>
228-<dd>repo sections (when shown)</dd>253+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
229 </div>254 </div>
230 <div>255 <div>
231 <dt>256 <dt>
232-<kbd>m</kbd>257+<kbd>g h</kbd>
233-<kbd>g m</kbd>258+</dt>
259+<dd>home</dd>
260+</div>
261+<div>
262+<dt>
263+<kbd>y</kbd>
234 </dt>264 </dt>
235-<dd>merge requests (when shown)</dd>265+<dd>copy a permalink pinned to the commit</dd>
236 </div>266 </div>
237 <div>267 <div>
238 <dt>268 <dt>
@@ -252,19 +282,21 @@
252 <dt>282 <dt>
253 <kbd>esc</kbd>283 <kbd>esc</kbd>
254 </dt>284 </dt>
255-<dd>close panel</dd>285+<dd>close a dialog, leave a field</dd>
256 </div>286 </div>
257 </dl>287 </dl>
258 </dialog>288 </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()">
260 <div class="panel-head">290 <div class="panel-head">
261-<label for="refs-filter">ref&gt;</label>291+<label for="refs-filter" class="sr">filter branches and tags</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))">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))">
263-<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>
264 </div>296 </div>
265 <div class="panel-cols" data-picklist>297 <div class="panel-cols" data-picklist>
266 <section aria-label="Branches">298 <section aria-label="Branches">
267-<h2>branches <span class="mute">2</span>299+<h2>Branches <span class="count">2</span>
268 </h2>300 </h2>
269 <ul>301 <ul>
270 <li>302 <li>
@@ -278,7 +310,7 @@
278 </ul>310 </ul>
279 </section>311 </section>
280 <section aria-label="Tags">312 <section aria-label="Tags">
281-<h2>tags <span class="mute">1</span>313+<h2>Tags <span class="count">1</span>
282 </h2>314 </h2>
283 <ul>315 <ul>
284 <li>316 <li>
@@ -289,105 +321,124 @@
289 </section>321 </section>
290 </div>322 </div>
291 </dialog>323 </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()">
293 <div class="panel-head">325 <div class="panel-head">
294-<label for="cmd-q" class="hi">:</label>326+<label for="cmd-q" class="sr">go to</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();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();
296 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));328 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
297 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();329 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();
298 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));330 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
299 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;331 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
300 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' })) }332 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' })) }
301 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">333 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>
303 </div>337 </div>
304-<ul id="cmd-list" class="cmd-list">338+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
305 <li data-k="go home" data-cursor>339 <li data-k="go home" data-cursor>
306 <a href="/" tabindex="-1">340 <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>
308 </li>344 </li>
309 <li data-k="section summary">345 <li data-k="section summary">
310 <a href="/diffs" tabindex="-1">346 <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>
312 </li>350 </li>
313-<li data-k="section tree">351+<li data-k="section files">
314 <a href="/diffs/tree/main" tabindex="-1">352 <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>
316 </li>356 </li>
317 <li data-k="section commits">357 <li data-k="section commits">
318 <a href="/diffs/commits/main" tabindex="-1">358 <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>
320 </li>362 </li>
321-<li data-k="section refs">363+<li data-k="section branches &amp; tags">
322 <a href="/diffs/refs" tabindex="-1">364 <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>
324 </li>368 </li>
325 <li data-k="section merge requests">369 <li data-k="section merge requests">
326 <a href="/diffs/merge-requests" tabindex="-1">370 <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>
328 </li>374 </li>
329 <li data-k="branch main">375 <li data-k="branch main">
330 <a href="/diffs/tree/main" tabindex="-1">376 <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>
332 </li>380 </li>
333 <li data-k="branch side">381 <li data-k="branch side">
334 <a href="/diffs/tree/side" tabindex="-1">382 <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>
336 </li>386 </li>
337 <li data-k="tag v1.0.0">387 <li data-k="tag v1.0.0">
338 <a href="/diffs/tree/v1.0.0" tabindex="-1">388 <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>
340 </li>392 </li>
341 </ul>393 </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>
342 </dialog>403 </dialog>
343-<footer class="bar status" aria-label="Status">404+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
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) => {
358 if (q('dialog[open]').count) return;405 if (q('dialog[open]').count) return;
359 let rows = q('[data-rows] > :not([hidden])').arr();406 let rows = q('[data-rows] > :not([hidden])').arr();
360 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));407 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
361 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));408 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
362- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];409+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
363- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
364 })(1);413 })(1);
365- k-k from:body -> ((d) => {414+ k-k from:body -> (async (d) => {
366 if (q('dialog[open]').count) return;415 if (q('dialog[open]').count) return;
367 let rows = q('[data-rows] > :not([hidden])').arr();416 let rows = q('[data-rows] > :not([hidden])').arr();
368 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));417 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
369 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));418 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
370- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];419+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
371- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
372 })(-1);423 })(-1);
373 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));424 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
374 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));425 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));
375 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();426 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
376 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());427 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();428+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
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());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());
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();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();
380 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();431 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
381- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();432+ k-gh from:body -> document.querySelector('.brand')?.click();
382- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();433+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
383- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();434+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
384- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();435+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
385- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();436+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
386- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();437+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
387- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();438+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
388- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();439+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
389- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();440+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
390- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();441+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
391 k-next from:body -> {442 k-next from:body -> {
392 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;443 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
393 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);444 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 @@
6 <meta name="color-scheme" content="light dark">6 <meta name="color-scheme" content="light dark">
7 <title>SHA7 modify a file and make a script executable · diffs · example.test</title>7 <title>SHA7 modify a file and make a script executable · diffs · example.test</title>
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <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>
9 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
10 <script src="/-/static/js/htmx.min.HASH.js" defer>11 <script src="/-/static/js/htmx.min.HASH.js" defer>
11 </script>12 </script>
@@ -16,12 +17,15 @@
16 <script src="/-/static/js/ermite.HASH.js" defer>17 <script src="/-/static/js/ermite.HASH.js" defer>
17 </script>18 </script>
18 </head>19 </head>
19-<body data-ref="" data-sha="SHA40" data-sel="">20+<body data-ref="" data-sha="SHA40" data-sel="" data-nav="">
20 <a class="skip" href="#main">skip to content</a>21 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">22+<header class="hdr">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>23+<div class="hdr-top">
23-<span class="fade" aria-hidden="true">24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
24 </span>26 </span>
27+<span>example.test</span>
28+</a>
25 <nav class="crumbs" aria-label="Path">29 <nav class="crumbs" aria-label="Path">
26 <ol>30 <ol>
27 <li>31 <li>
@@ -35,30 +39,49 @@
35 </li>39 </li>
36 </ol>40 </ol>
37 </nav>41 </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">
39 <ul>59 <ul>
40 <li>60 <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>
42 </li>63 </li>
43 <li>64 <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>
45 </li>67 </li>
46 <li>68 <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>
48 </li>71 </li>
49 <li>72 <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>
51 </li>75 </li>
52 <li>76 <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>
54 </li>79 </li>
55 </ul>80 </ul>
56 </nav>81 </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>
59 </header>82 </header>
60 <div class="work has-rail">83 <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">
62 <header class="show">85 <header class="show">
63 <h1>modify a file and make a script executable</h1>86 <h1>modify a file and make a script executable</h1>
64 <pre class="msg">The body has a second paragraph.87 <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>
73 <a href="/diffs/commit/SHA40">SHA7</a>96 <a href="/diffs/commit/SHA40">SHA7</a>
74 </dd>97 </dd>
75 <dt>refs</dt>98 <dt>refs</dt>
76-<dd>[tag: v1.0.0] </dd>99+<dd>
100+<span class="pill">tag: v1.0.0</span>
101+</dd>
77 </dl>102 </dl>
78 </header>103 </header>
79 <div class="diff-tools" role="toolbar" aria-label="Diff view">104 <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>
82 </span>107 </span>
83 <span class="grow">108 <span class="grow">
84 </span>109 </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>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>
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>111+</a>
87-<button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))">[x] collapse all</button>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>
88 </div>116 </div>
89 <div id="diffs" data-diff="split">117 <div id="diffs" data-diff="split">
90 <details class="dfile" id="f1" data-file open>118 <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>
232 </div>260 </div>
233 </main>261 </main>
234 <aside class="rail" aria-label="Context">262 <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>
237 <section aria-labelledby="r-files">263 <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>
239 </h2>265 </h2>
240 <ol class="files" aria-label="Changed files">266 <ol class="files" aria-label="Changed files">
241 <li>267 <li>
@@ -269,73 +295,73 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
269 </li>295 </li>
270 </ol>296 </ol>
271 </section>297 </section>
272-</details>
273 </aside>298 </aside>
274 </div>299 </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()">
276 <div class="panel-head">315 <div class="panel-head">
277-<span>keys</span>316+<h2>Keyboard shortcuts</h2>
278-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>317+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
318+<kbd>esc</kbd> close</button>
279 </div>319 </div>
280 <dl class="keys">320 <dl class="keys">
281 <div>321 <div>
282 <dt>322 <dt>
283-<kbd>j</kbd>323+<kbd>ctrl k</kbd>
284-<kbd>k</kbd>324+<kbd>:</kbd>
285 </dt>325 </dt>
286-<dd>move cursor down / up</dd>326+<dd>go to a section, branch, tag, file or repository</dd>
287 </div>327 </div>
288 <div>328 <div>
289 <dt>329 <dt>
290-<kbd>enter</kbd>330+<kbd>?</kbd>
291 </dt>331 </dt>
292-<dd>open row under cursor</dd>332+<dd>this list</dd>
293 </div>333 </div>
294 <div>334 <div>
295 <dt>335 <dt>
296-<kbd>g</kbd>336+<kbd>j</kbd>
297-<kbd>g</kbd> / <kbd>G</kbd>337+<kbd>k</kbd>
298 </dt>338 </dt>
299-<dd>first / last row</dd>339+<dd>move the row cursor down / up</dd>
300 </div>340 </div>
301 <div>341 <div>
302 <dt>342 <dt>
303-<kbd>/</kbd>343+<kbd>enter</kbd>
304 </dt>344 </dt>
305-<dd>focus the filter</dd>345+<dd>open the row under the cursor</dd>
306 </div>346 </div>
307 <div>347 <div>
308 <dt>348 <dt>
309-<kbd>g h</kbd>349+<kbd>g g</kbd>
310-<kbd>g t</kbd>350+<kbd>G</kbd>
311 </dt>351 </dt>
312-<dd>go home / tree</dd>352+<dd>first / last row</dd>
313 </div>353 </div>
314 <div>354 <div>
315 <dt>355 <dt>
316-<kbd>g c</kbd>356+<kbd>/</kbd>
317-<kbd>g r</kbd>
318 </dt>357 </dt>
319-<dd>go commits / refs</dd>358+<dd>filter the list</dd>
320 </div>359 </div>
321 <div>360 <div>
322 <dt>361 <dt>
323 <kbd>b</kbd>362 <kbd>b</kbd>
324 </dt>363 </dt>
325-<dd>switch ref (branches, tags)</dd>364+<dd>switch branch or tag</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>
339 </div>365 </div>
340 <div>366 <div>
341 <dt>367 <dt>
@@ -343,15 +369,21 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
343 <kbd>t</kbd>369 <kbd>t</kbd>
344 <kbd>c</kbd>370 <kbd>c</kbd>
345 <kbd>r</kbd>371 <kbd>r</kbd>
372+<kbd>m</kbd>
346 </dt>373 </dt>
347-<dd>repo sections (when shown)</dd>374+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
348 </div>375 </div>
349 <div>376 <div>
350 <dt>377 <dt>
351-<kbd>m</kbd>378+<kbd>g h</kbd>
352-<kbd>g m</kbd>379+</dt>
380+<dd>home</dd>
381+</div>
382+<div>
383+<dt>
384+<kbd>y</kbd>
353 </dt>385 </dt>
354-<dd>merge requests (when shown)</dd>386+<dd>copy a permalink pinned to the commit</dd>
355 </div>387 </div>
356 <div>388 <div>
357 <dt>389 <dt>
@@ -371,19 +403,21 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
371 <dt>403 <dt>
372 <kbd>esc</kbd>404 <kbd>esc</kbd>
373 </dt>405 </dt>
374-<dd>close panel</dd>406+<dd>close a dialog, leave a field</dd>
375 </div>407 </div>
376 </dl>408 </dl>
377 </dialog>409 </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()">
379 <div class="panel-head">411 <div class="panel-head">
380-<label for="refs-filter">ref&gt;</label>412+<label for="refs-filter" class="sr">filter branches and tags</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))">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))">
382-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>414+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
415+<kbd>esc</kbd>
416+</button>
383 </div>417 </div>
384 <div class="panel-cols" data-picklist>418 <div class="panel-cols" data-picklist>
385 <section aria-label="Branches">419 <section aria-label="Branches">
386-<h2>branches <span class="mute">2</span>420+<h2>Branches <span class="count">2</span>
387 </h2>421 </h2>
388 <ul>422 <ul>
389 <li>423 <li>
@@ -397,7 +431,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
397 </ul>431 </ul>
398 </section>432 </section>
399 <section aria-label="Tags">433 <section aria-label="Tags">
400-<h2>tags <span class="mute">1</span>434+<h2>Tags <span class="count">1</span>
401 </h2>435 </h2>
402 <ul>436 <ul>
403 <li>437 <li>
@@ -408,105 +442,124 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
408 </section>442 </section>
409 </div>443 </div>
410 </dialog>444 </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()">
412 <div class="panel-head">446 <div class="panel-head">
413-<label for="cmd-q" class="hi">:</label>447+<label for="cmd-q" class="sr">go to</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();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();
415 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));449 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
416 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();450 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();
417 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));451 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
418 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;452 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
419 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' })) }453 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' })) }
420 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">454 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>
422 </div>458 </div>
423-<ul id="cmd-list" class="cmd-list">459+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
424 <li data-k="go home" data-cursor>460 <li data-k="go home" data-cursor>
425 <a href="/" tabindex="-1">461 <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>
427 </li>465 </li>
428 <li data-k="section summary">466 <li data-k="section summary">
429 <a href="/diffs" tabindex="-1">467 <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>
431 </li>471 </li>
432-<li data-k="section tree">472+<li data-k="section files">
433 <a href="/diffs/tree/main" tabindex="-1">473 <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>
435 </li>477 </li>
436 <li data-k="section commits">478 <li data-k="section commits">
437 <a href="/diffs/commits/main" tabindex="-1">479 <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>
439 </li>483 </li>
440-<li data-k="section refs">484+<li data-k="section branches &amp; tags">
441 <a href="/diffs/refs" tabindex="-1">485 <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>
443 </li>489 </li>
444 <li data-k="section merge requests">490 <li data-k="section merge requests">
445 <a href="/diffs/merge-requests" tabindex="-1">491 <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>
447 </li>495 </li>
448 <li data-k="branch main">496 <li data-k="branch main">
449 <a href="/diffs/tree/main" tabindex="-1">497 <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>
451 </li>501 </li>
452 <li data-k="branch side">502 <li data-k="branch side">
453 <a href="/diffs/tree/side" tabindex="-1">503 <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>
455 </li>507 </li>
456 <li data-k="tag v1.0.0">508 <li data-k="tag v1.0.0">
457 <a href="/diffs/tree/v1.0.0" tabindex="-1">509 <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>
459 </li>513 </li>
460 </ul>514 </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>
461 </dialog>524 </dialog>
462-<footer class="bar status" aria-label="Status">525+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
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) => {
477 if (q('dialog[open]').count) return;526 if (q('dialog[open]').count) return;
478 let rows = q('[data-rows] > :not([hidden])').arr();527 let rows = q('[data-rows] > :not([hidden])').arr();
479 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));528 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
480 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));529 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
481- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];530+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
482- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
483 })(1);534 })(1);
484- k-k from:body -> ((d) => {535+ k-k from:body -> (async (d) => {
485 if (q('dialog[open]').count) return;536 if (q('dialog[open]').count) return;
486 let rows = q('[data-rows] > :not([hidden])').arr();537 let rows = q('[data-rows] > :not([hidden])').arr();
487 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));538 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
488 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));539 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
489- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];540+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
490- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
491 })(-1);544 })(-1);
492 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));545 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
493 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));546 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));
494 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();547 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
495 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());548 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();549+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
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());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());
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();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();
499 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();552 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
500- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();553+ k-gh from:body -> document.querySelector('.brand')?.click();
501- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();554+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
502- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();555+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
503- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();556+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
504- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();557+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
505- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();558+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
506- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();559+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
507- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();560+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
508- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();561+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
509- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();562+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
510 k-next from:body -> {563 k-next from:body -> {
511 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;564 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
512 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);565 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/commit-modify.html +167 -114
@@ -6,6 +6,7 @@
6 <meta name="color-scheme" content="light dark">6 <meta name="color-scheme" content="light dark">
7 <title>SHA7 modify a file and make a script executable · diffs · example.test</title>7 <title>SHA7 modify a file and make a script executable · diffs · example.test</title>
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <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>
9 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
10 <script src="/-/static/js/htmx.min.HASH.js" defer>11 <script src="/-/static/js/htmx.min.HASH.js" defer>
11 </script>12 </script>
@@ -16,12 +17,15 @@
16 <script src="/-/static/js/ermite.HASH.js" defer>17 <script src="/-/static/js/ermite.HASH.js" defer>
17 </script>18 </script>
18 </head>19 </head>
19-<body data-ref="" data-sha="SHA40" data-sel="">20+<body data-ref="" data-sha="SHA40" data-sel="" data-nav="">
20 <a class="skip" href="#main">skip to content</a>21 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">22+<header class="hdr">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>23+<div class="hdr-top">
23-<span class="fade" aria-hidden="true">24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
24 </span>26 </span>
27+<span>example.test</span>
28+</a>
25 <nav class="crumbs" aria-label="Path">29 <nav class="crumbs" aria-label="Path">
26 <ol>30 <ol>
27 <li>31 <li>
@@ -35,30 +39,49 @@
35 </li>39 </li>
36 </ol>40 </ol>
37 </nav>41 </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">
39 <ul>59 <ul>
40 <li>60 <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>
42 </li>63 </li>
43 <li>64 <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>
45 </li>67 </li>
46 <li>68 <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>
48 </li>71 </li>
49 <li>72 <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>
51 </li>75 </li>
52 <li>76 <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>
54 </li>79 </li>
55 </ul>80 </ul>
56 </nav>81 </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>
59 </header>82 </header>
60 <div class="work has-rail">83 <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">
62 <header class="show">85 <header class="show">
63 <h1>modify a file and make a script executable</h1>86 <h1>modify a file and make a script executable</h1>
64 <pre class="msg">The body has a second paragraph.87 <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>
73 <a href="/diffs/commit/SHA40">SHA7</a>96 <a href="/diffs/commit/SHA40">SHA7</a>
74 </dd>97 </dd>
75 <dt>refs</dt>98 <dt>refs</dt>
76-<dd>[tag: v1.0.0] </dd>99+<dd>
100+<span class="pill">tag: v1.0.0</span>
101+</dd>
77 </dl>102 </dl>
78 </header>103 </header>
79 <div class="diff-tools" role="toolbar" aria-label="Diff view">104 <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>
82 </span>107 </span>
83 <span class="grow">108 <span class="grow">
84 </span>109 </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>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>
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>111+</a>
87-<button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))">[x] collapse all</button>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>
88 </div>116 </div>
89 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">117 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">
90 <details class="dfile" id="f1" data-file open>118 <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>
232 </div>260 </div>
233 </main>261 </main>
234 <aside class="rail" aria-label="Context">262 <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>
237 <section aria-labelledby="r-files">263 <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>
239 </h2>265 </h2>
240 <ol class="files" aria-label="Changed files">266 <ol class="files" aria-label="Changed files">
241 <li>267 <li>
@@ -269,73 +295,73 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
269 </li>295 </li>
270 </ol>296 </ol>
271 </section>297 </section>
272-</details>
273 </aside>298 </aside>
274 </div>299 </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()">
276 <div class="panel-head">315 <div class="panel-head">
277-<span>keys</span>316+<h2>Keyboard shortcuts</h2>
278-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>317+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
318+<kbd>esc</kbd> close</button>
279 </div>319 </div>
280 <dl class="keys">320 <dl class="keys">
281 <div>321 <div>
282 <dt>322 <dt>
283-<kbd>j</kbd>323+<kbd>ctrl k</kbd>
284-<kbd>k</kbd>324+<kbd>:</kbd>
285 </dt>325 </dt>
286-<dd>move cursor down / up</dd>326+<dd>go to a section, branch, tag, file or repository</dd>
287 </div>327 </div>
288 <div>328 <div>
289 <dt>329 <dt>
290-<kbd>enter</kbd>330+<kbd>?</kbd>
291 </dt>331 </dt>
292-<dd>open row under cursor</dd>332+<dd>this list</dd>
293 </div>333 </div>
294 <div>334 <div>
295 <dt>335 <dt>
296-<kbd>g</kbd>336+<kbd>j</kbd>
297-<kbd>g</kbd> / <kbd>G</kbd>337+<kbd>k</kbd>
298 </dt>338 </dt>
299-<dd>first / last row</dd>339+<dd>move the row cursor down / up</dd>
300 </div>340 </div>
301 <div>341 <div>
302 <dt>342 <dt>
303-<kbd>/</kbd>343+<kbd>enter</kbd>
304 </dt>344 </dt>
305-<dd>focus the filter</dd>345+<dd>open the row under the cursor</dd>
306 </div>346 </div>
307 <div>347 <div>
308 <dt>348 <dt>
309-<kbd>g h</kbd>349+<kbd>g g</kbd>
310-<kbd>g t</kbd>350+<kbd>G</kbd>
311 </dt>351 </dt>
312-<dd>go home / tree</dd>352+<dd>first / last row</dd>
313 </div>353 </div>
314 <div>354 <div>
315 <dt>355 <dt>
316-<kbd>g c</kbd>356+<kbd>/</kbd>
317-<kbd>g r</kbd>
318 </dt>357 </dt>
319-<dd>go commits / refs</dd>358+<dd>filter the list</dd>
320 </div>359 </div>
321 <div>360 <div>
322 <dt>361 <dt>
323 <kbd>b</kbd>362 <kbd>b</kbd>
324 </dt>363 </dt>
325-<dd>switch ref (branches, tags)</dd>364+<dd>switch branch or tag</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>
339 </div>365 </div>
340 <div>366 <div>
341 <dt>367 <dt>
@@ -343,15 +369,21 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
343 <kbd>t</kbd>369 <kbd>t</kbd>
344 <kbd>c</kbd>370 <kbd>c</kbd>
345 <kbd>r</kbd>371 <kbd>r</kbd>
372+<kbd>m</kbd>
346 </dt>373 </dt>
347-<dd>repo sections (when shown)</dd>374+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
348 </div>375 </div>
349 <div>376 <div>
350 <dt>377 <dt>
351-<kbd>m</kbd>378+<kbd>g h</kbd>
352-<kbd>g m</kbd>379+</dt>
380+<dd>home</dd>
381+</div>
382+<div>
383+<dt>
384+<kbd>y</kbd>
353 </dt>385 </dt>
354-<dd>merge requests (when shown)</dd>386+<dd>copy a permalink pinned to the commit</dd>
355 </div>387 </div>
356 <div>388 <div>
357 <dt>389 <dt>
@@ -371,19 +403,21 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
371 <dt>403 <dt>
372 <kbd>esc</kbd>404 <kbd>esc</kbd>
373 </dt>405 </dt>
374-<dd>close panel</dd>406+<dd>close a dialog, leave a field</dd>
375 </div>407 </div>
376 </dl>408 </dl>
377 </dialog>409 </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()">
379 <div class="panel-head">411 <div class="panel-head">
380-<label for="refs-filter">ref&gt;</label>412+<label for="refs-filter" class="sr">filter branches and tags</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))">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))">
382-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>414+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
415+<kbd>esc</kbd>
416+</button>
383 </div>417 </div>
384 <div class="panel-cols" data-picklist>418 <div class="panel-cols" data-picklist>
385 <section aria-label="Branches">419 <section aria-label="Branches">
386-<h2>branches <span class="mute">2</span>420+<h2>Branches <span class="count">2</span>
387 </h2>421 </h2>
388 <ul>422 <ul>
389 <li>423 <li>
@@ -397,7 +431,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
397 </ul>431 </ul>
398 </section>432 </section>
399 <section aria-label="Tags">433 <section aria-label="Tags">
400-<h2>tags <span class="mute">1</span>434+<h2>Tags <span class="count">1</span>
401 </h2>435 </h2>
402 <ul>436 <ul>
403 <li>437 <li>
@@ -408,105 +442,124 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
408 </section>442 </section>
409 </div>443 </div>
410 </dialog>444 </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()">
412 <div class="panel-head">446 <div class="panel-head">
413-<label for="cmd-q" class="hi">:</label>447+<label for="cmd-q" class="sr">go to</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();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();
415 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));449 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
416 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();450 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();
417 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));451 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
418 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;452 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
419 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' })) }453 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' })) }
420 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">454 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>
422 </div>458 </div>
423-<ul id="cmd-list" class="cmd-list">459+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
424 <li data-k="go home" data-cursor>460 <li data-k="go home" data-cursor>
425 <a href="/" tabindex="-1">461 <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>
427 </li>465 </li>
428 <li data-k="section summary">466 <li data-k="section summary">
429 <a href="/diffs" tabindex="-1">467 <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>
431 </li>471 </li>
432-<li data-k="section tree">472+<li data-k="section files">
433 <a href="/diffs/tree/main" tabindex="-1">473 <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>
435 </li>477 </li>
436 <li data-k="section commits">478 <li data-k="section commits">
437 <a href="/diffs/commits/main" tabindex="-1">479 <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>
439 </li>483 </li>
440-<li data-k="section refs">484+<li data-k="section branches &amp; tags">
441 <a href="/diffs/refs" tabindex="-1">485 <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>
443 </li>489 </li>
444 <li data-k="section merge requests">490 <li data-k="section merge requests">
445 <a href="/diffs/merge-requests" tabindex="-1">491 <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>
447 </li>495 </li>
448 <li data-k="branch main">496 <li data-k="branch main">
449 <a href="/diffs/tree/main" tabindex="-1">497 <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>
451 </li>501 </li>
452 <li data-k="branch side">502 <li data-k="branch side">
453 <a href="/diffs/tree/side" tabindex="-1">503 <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>
455 </li>507 </li>
456 <li data-k="tag v1.0.0">508 <li data-k="tag v1.0.0">
457 <a href="/diffs/tree/v1.0.0" tabindex="-1">509 <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>
459 </li>513 </li>
460 </ul>514 </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>
461 </dialog>524 </dialog>
462-<footer class="bar status" aria-label="Status">525+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
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) => {
477 if (q('dialog[open]').count) return;526 if (q('dialog[open]').count) return;
478 let rows = q('[data-rows] > :not([hidden])').arr();527 let rows = q('[data-rows] > :not([hidden])').arr();
479 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));528 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
480 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));529 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
481- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];530+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
482- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
483 })(1);534 })(1);
484- k-k from:body -> ((d) => {535+ k-k from:body -> (async (d) => {
485 if (q('dialog[open]').count) return;536 if (q('dialog[open]').count) return;
486 let rows = q('[data-rows] > :not([hidden])').arr();537 let rows = q('[data-rows] > :not([hidden])').arr();
487 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));538 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
488 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));539 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
489- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];540+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
490- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
491 })(-1);544 })(-1);
492 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));545 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
493 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));546 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));
494 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();547 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
495 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());548 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();549+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
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());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());
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();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();
499 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();552 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
500- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();553+ k-gh from:body -> document.querySelector('.brand')?.click();
501- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();554+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
502- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();555+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
503- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();556+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
504- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();557+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
505- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();558+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
506- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();559+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
507- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();560+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
508- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();561+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
509- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();562+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
510 k-next from:body -> {563 k-next from:body -> {
511 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;564 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
512 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);565 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/commit-rename.html +164 -113
@@ -6,6 +6,7 @@
6 <meta name="color-scheme" content="light dark">6 <meta name="color-scheme" content="light dark">
7 <title>SHA7 rename a file and delete another · diffs · example.test</title>7 <title>SHA7 rename a file and delete another · diffs · example.test</title>
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <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>
9 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
10 <script src="/-/static/js/htmx.min.HASH.js" defer>11 <script src="/-/static/js/htmx.min.HASH.js" defer>
11 </script>12 </script>
@@ -16,12 +17,15 @@
16 <script src="/-/static/js/ermite.HASH.js" defer>17 <script src="/-/static/js/ermite.HASH.js" defer>
17 </script>18 </script>
18 </head>19 </head>
19-<body data-ref="" data-sha="SHA40" data-sel="">20+<body data-ref="" data-sha="SHA40" data-sel="" data-nav="">
20 <a class="skip" href="#main">skip to content</a>21 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">22+<header class="hdr">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>23+<div class="hdr-top">
23-<span class="fade" aria-hidden="true">24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
24 </span>26 </span>
27+<span>example.test</span>
28+</a>
25 <nav class="crumbs" aria-label="Path">29 <nav class="crumbs" aria-label="Path">
26 <ol>30 <ol>
27 <li>31 <li>
@@ -35,30 +39,49 @@
35 </li>39 </li>
36 </ol>40 </ol>
37 </nav>41 </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">
39 <ul>59 <ul>
40 <li>60 <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>
42 </li>63 </li>
43 <li>64 <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>
45 </li>67 </li>
46 <li>68 <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>
48 </li>71 </li>
49 <li>72 <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>
51 </li>75 </li>
52 <li>76 <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>
54 </li>79 </li>
55 </ul>80 </ul>
56 </nav>81 </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>
59 </header>82 </header>
60 <div class="work has-rail">83 <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">
62 <header class="show">85 <header class="show">
63 <h1>rename a file and delete another</h1>86 <h1>rename a file and delete another</h1>
64 <dl class="kv show-kv">87 <dl class="kv show-kv">
@@ -78,9 +101,12 @@
78 </span>101 </span>
79 <span class="grow">102 <span class="grow">
80 </span>103 </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>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>
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>105+</a>
83-<button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))">[x] collapse all</button>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>
84 </div>110 </div>
85 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">111 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">
86 <details class="dfile" id="f1" data-file>112 <details class="dfile" id="f1" data-file>
@@ -138,10 +164,8 @@
138 </div>164 </div>
139 </main>165 </main>
140 <aside class="rail" aria-label="Context">166 <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>
143 <section aria-labelledby="r-files">167 <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>
145 </h2>169 </h2>
146 <ol class="files" aria-label="Changed files">170 <ol class="files" aria-label="Changed files">
147 <li>171 <li>
@@ -176,73 +200,73 @@
176 </li>200 </li>
177 </ol>201 </ol>
178 </section>202 </section>
179-</details>
180 </aside>203 </aside>
181 </div>204 </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()">
183 <div class="panel-head">220 <div class="panel-head">
184-<span>keys</span>221+<h2>Keyboard shortcuts</h2>
185-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>222+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
223+<kbd>esc</kbd> close</button>
186 </div>224 </div>
187 <dl class="keys">225 <dl class="keys">
188 <div>226 <div>
189 <dt>227 <dt>
190-<kbd>j</kbd>228+<kbd>ctrl k</kbd>
191-<kbd>k</kbd>229+<kbd>:</kbd>
192 </dt>230 </dt>
193-<dd>move cursor down / up</dd>231+<dd>go to a section, branch, tag, file or repository</dd>
194 </div>232 </div>
195 <div>233 <div>
196 <dt>234 <dt>
197-<kbd>enter</kbd>235+<kbd>?</kbd>
198 </dt>236 </dt>
199-<dd>open row under cursor</dd>237+<dd>this list</dd>
200 </div>238 </div>
201 <div>239 <div>
202 <dt>240 <dt>
203-<kbd>g</kbd>241+<kbd>j</kbd>
204-<kbd>g</kbd> / <kbd>G</kbd>242+<kbd>k</kbd>
205 </dt>243 </dt>
206-<dd>first / last row</dd>244+<dd>move the row cursor down / up</dd>
207 </div>245 </div>
208 <div>246 <div>
209 <dt>247 <dt>
210-<kbd>/</kbd>248+<kbd>enter</kbd>
211 </dt>249 </dt>
212-<dd>focus the filter</dd>250+<dd>open the row under the cursor</dd>
213 </div>251 </div>
214 <div>252 <div>
215 <dt>253 <dt>
216-<kbd>g h</kbd>254+<kbd>g g</kbd>
217-<kbd>g t</kbd>255+<kbd>G</kbd>
218 </dt>256 </dt>
219-<dd>go home / tree</dd>257+<dd>first / last row</dd>
220 </div>258 </div>
221 <div>259 <div>
222 <dt>260 <dt>
223-<kbd>g c</kbd>261+<kbd>/</kbd>
224-<kbd>g r</kbd>
225 </dt>262 </dt>
226-<dd>go commits / refs</dd>263+<dd>filter the list</dd>
227 </div>264 </div>
228 <div>265 <div>
229 <dt>266 <dt>
230 <kbd>b</kbd>267 <kbd>b</kbd>
231 </dt>268 </dt>
232-<dd>switch ref (branches, tags)</dd>269+<dd>switch branch or tag</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>
246 </div>270 </div>
247 <div>271 <div>
248 <dt>272 <dt>
@@ -250,15 +274,21 @@
250 <kbd>t</kbd>274 <kbd>t</kbd>
251 <kbd>c</kbd>275 <kbd>c</kbd>
252 <kbd>r</kbd>276 <kbd>r</kbd>
277+<kbd>m</kbd>
253 </dt>278 </dt>
254-<dd>repo sections (when shown)</dd>279+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
255 </div>280 </div>
256 <div>281 <div>
257 <dt>282 <dt>
258-<kbd>m</kbd>283+<kbd>g h</kbd>
259-<kbd>g m</kbd>284+</dt>
285+<dd>home</dd>
286+</div>
287+<div>
288+<dt>
289+<kbd>y</kbd>
260 </dt>290 </dt>
261-<dd>merge requests (when shown)</dd>291+<dd>copy a permalink pinned to the commit</dd>
262 </div>292 </div>
263 <div>293 <div>
264 <dt>294 <dt>
@@ -278,19 +308,21 @@
278 <dt>308 <dt>
279 <kbd>esc</kbd>309 <kbd>esc</kbd>
280 </dt>310 </dt>
281-<dd>close panel</dd>311+<dd>close a dialog, leave a field</dd>
282 </div>312 </div>
283 </dl>313 </dl>
284 </dialog>314 </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()">
286 <div class="panel-head">316 <div class="panel-head">
287-<label for="refs-filter">ref&gt;</label>317+<label for="refs-filter" class="sr">filter branches and tags</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))">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))">
289-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>319+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
320+<kbd>esc</kbd>
321+</button>
290 </div>322 </div>
291 <div class="panel-cols" data-picklist>323 <div class="panel-cols" data-picklist>
292 <section aria-label="Branches">324 <section aria-label="Branches">
293-<h2>branches <span class="mute">2</span>325+<h2>Branches <span class="count">2</span>
294 </h2>326 </h2>
295 <ul>327 <ul>
296 <li>328 <li>
@@ -304,7 +336,7 @@
304 </ul>336 </ul>
305 </section>337 </section>
306 <section aria-label="Tags">338 <section aria-label="Tags">
307-<h2>tags <span class="mute">1</span>339+<h2>Tags <span class="count">1</span>
308 </h2>340 </h2>
309 <ul>341 <ul>
310 <li>342 <li>
@@ -315,105 +347,124 @@
315 </section>347 </section>
316 </div>348 </div>
317 </dialog>349 </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()">
319 <div class="panel-head">351 <div class="panel-head">
320-<label for="cmd-q" class="hi">:</label>352+<label for="cmd-q" class="sr">go to</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();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();
322 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));354 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
323 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();355 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();
324 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));356 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
325 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;357 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
326 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' })) }358 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' })) }
327 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">359 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>
329 </div>363 </div>
330-<ul id="cmd-list" class="cmd-list">364+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
331 <li data-k="go home" data-cursor>365 <li data-k="go home" data-cursor>
332 <a href="/" tabindex="-1">366 <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>
334 </li>370 </li>
335 <li data-k="section summary">371 <li data-k="section summary">
336 <a href="/diffs" tabindex="-1">372 <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>
338 </li>376 </li>
339-<li data-k="section tree">377+<li data-k="section files">
340 <a href="/diffs/tree/main" tabindex="-1">378 <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>
342 </li>382 </li>
343 <li data-k="section commits">383 <li data-k="section commits">
344 <a href="/diffs/commits/main" tabindex="-1">384 <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>
346 </li>388 </li>
347-<li data-k="section refs">389+<li data-k="section branches &amp; tags">
348 <a href="/diffs/refs" tabindex="-1">390 <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>
350 </li>394 </li>
351 <li data-k="section merge requests">395 <li data-k="section merge requests">
352 <a href="/diffs/merge-requests" tabindex="-1">396 <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>
354 </li>400 </li>
355 <li data-k="branch main">401 <li data-k="branch main">
356 <a href="/diffs/tree/main" tabindex="-1">402 <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>
358 </li>406 </li>
359 <li data-k="branch side">407 <li data-k="branch side">
360 <a href="/diffs/tree/side" tabindex="-1">408 <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>
362 </li>412 </li>
363 <li data-k="tag v1.0.0">413 <li data-k="tag v1.0.0">
364 <a href="/diffs/tree/v1.0.0" tabindex="-1">414 <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>
366 </li>418 </li>
367 </ul>419 </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>
368 </dialog>429 </dialog>
369-<footer class="bar status" aria-label="Status">430+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
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) => {
384 if (q('dialog[open]').count) return;431 if (q('dialog[open]').count) return;
385 let rows = q('[data-rows] > :not([hidden])').arr();432 let rows = q('[data-rows] > :not([hidden])').arr();
386 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));433 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
387 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));434 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
388- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];435+ 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' })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 = ''
390 })(1);439 })(1);
391- k-k from:body -> ((d) => {440+ k-k from:body -> (async (d) => {
392 if (q('dialog[open]').count) return;441 if (q('dialog[open]').count) return;
393 let rows = q('[data-rows] > :not([hidden])').arr();442 let rows = q('[data-rows] > :not([hidden])').arr();
394 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));443 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
395 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));444 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
396- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];445+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
397- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
398 })(-1);449 })(-1);
399 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));450 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
400 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));451 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));
401 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();452 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
402 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());453 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();454+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
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());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());
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();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();
406 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();457 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
407- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();458+ k-gh from:body -> document.querySelector('.brand')?.click();
408- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();459+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
409- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();460+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
410- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();461+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
411- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();462+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
412- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();463+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
413- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();464+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
414- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();465+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
415- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();466+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
416- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();467+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
417 k-next from:body -> {468 k-next from:body -> {
418 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;469 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
419 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);470 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/commit-root.html +164 -113
@@ -6,6 +6,7 @@
6 <meta name="color-scheme" content="light dark">6 <meta name="color-scheme" content="light dark">
7 <title>SHA7 root: the first files · diffs · example.test</title>7 <title>SHA7 root: the first files · diffs · example.test</title>
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <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>
9 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
10 <script src="/-/static/js/htmx.min.HASH.js" defer>11 <script src="/-/static/js/htmx.min.HASH.js" defer>
11 </script>12 </script>
@@ -16,12 +17,15 @@
16 <script src="/-/static/js/ermite.HASH.js" defer>17 <script src="/-/static/js/ermite.HASH.js" defer>
17 </script>18 </script>
18 </head>19 </head>
19-<body data-ref="" data-sha="SHA40" data-sel="">20+<body data-ref="" data-sha="SHA40" data-sel="" data-nav="">
20 <a class="skip" href="#main">skip to content</a>21 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">22+<header class="hdr">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>23+<div class="hdr-top">
23-<span class="fade" aria-hidden="true">24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
24 </span>26 </span>
27+<span>example.test</span>
28+</a>
25 <nav class="crumbs" aria-label="Path">29 <nav class="crumbs" aria-label="Path">
26 <ol>30 <ol>
27 <li>31 <li>
@@ -35,30 +39,49 @@
35 </li>39 </li>
36 </ol>40 </ol>
37 </nav>41 </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">
39 <ul>59 <ul>
40 <li>60 <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>
42 </li>63 </li>
43 <li>64 <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>
45 </li>67 </li>
46 <li>68 <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>
48 </li>71 </li>
49 <li>72 <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>
51 </li>75 </li>
52 <li>76 <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>
54 </li>79 </li>
55 </ul>80 </ul>
56 </nav>81 </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>
59 </header>82 </header>
60 <div class="work has-rail">83 <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">
62 <header class="show">85 <header class="show">
63 <h1>root: the first files</h1>86 <h1>root: the first files</h1>
64 <dl class="kv show-kv">87 <dl class="kv show-kv">
@@ -76,9 +99,12 @@
76 </span>99 </span>
77 <span class="grow">100 <span class="grow">
78 </span>101 </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>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>
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>103+</a>
81-<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=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>
82 </div>108 </div>
83 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">109 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">
84 <details class="dfile" id="f1" data-file open>110 <details class="dfile" id="f1" data-file open>
@@ -388,10 +414,8 @@
388 </div>414 </div>
389 </main>415 </main>
390 <aside class="rail" aria-label="Context">416 <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>
393 <section aria-labelledby="r-files">417 <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>
395 </h2>419 </h2>
396 <ol class="files" aria-label="Changed files">420 <ol class="files" aria-label="Changed files">
397 <li>421 <li>
@@ -468,73 +492,73 @@
468 </li>492 </li>
469 </ol>493 </ol>
470 </section>494 </section>
471-</details>
472 </aside>495 </aside>
473 </div>496 </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()">
475 <div class="panel-head">512 <div class="panel-head">
476-<span>keys</span>513+<h2>Keyboard shortcuts</h2>
477-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>514+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
515+<kbd>esc</kbd> close</button>
478 </div>516 </div>
479 <dl class="keys">517 <dl class="keys">
480 <div>518 <div>
481 <dt>519 <dt>
482-<kbd>j</kbd>520+<kbd>ctrl k</kbd>
483-<kbd>k</kbd>521+<kbd>:</kbd>
484 </dt>522 </dt>
485-<dd>move cursor down / up</dd>523+<dd>go to a section, branch, tag, file or repository</dd>
486 </div>524 </div>
487 <div>525 <div>
488 <dt>526 <dt>
489-<kbd>enter</kbd>527+<kbd>?</kbd>
490 </dt>528 </dt>
491-<dd>open row under cursor</dd>529+<dd>this list</dd>
492 </div>530 </div>
493 <div>531 <div>
494 <dt>532 <dt>
495-<kbd>g</kbd>533+<kbd>j</kbd>
496-<kbd>g</kbd> / <kbd>G</kbd>534+<kbd>k</kbd>
497 </dt>535 </dt>
498-<dd>first / last row</dd>536+<dd>move the row cursor down / up</dd>
499 </div>537 </div>
500 <div>538 <div>
501 <dt>539 <dt>
502-<kbd>/</kbd>540+<kbd>enter</kbd>
503 </dt>541 </dt>
504-<dd>focus the filter</dd>542+<dd>open the row under the cursor</dd>
505 </div>543 </div>
506 <div>544 <div>
507 <dt>545 <dt>
508-<kbd>g h</kbd>546+<kbd>g g</kbd>
509-<kbd>g t</kbd>547+<kbd>G</kbd>
510 </dt>548 </dt>
511-<dd>go home / tree</dd>549+<dd>first / last row</dd>
512 </div>550 </div>
513 <div>551 <div>
514 <dt>552 <dt>
515-<kbd>g c</kbd>553+<kbd>/</kbd>
516-<kbd>g r</kbd>
517 </dt>554 </dt>
518-<dd>go commits / refs</dd>555+<dd>filter the list</dd>
519 </div>556 </div>
520 <div>557 <div>
521 <dt>558 <dt>
522 <kbd>b</kbd>559 <kbd>b</kbd>
523 </dt>560 </dt>
524-<dd>switch ref (branches, tags)</dd>561+<dd>switch branch or tag</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>
538 </div>562 </div>
539 <div>563 <div>
540 <dt>564 <dt>
@@ -542,15 +566,21 @@
542 <kbd>t</kbd>566 <kbd>t</kbd>
543 <kbd>c</kbd>567 <kbd>c</kbd>
544 <kbd>r</kbd>568 <kbd>r</kbd>
569+<kbd>m</kbd>
545 </dt>570 </dt>
546-<dd>repo sections (when shown)</dd>571+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
547 </div>572 </div>
548 <div>573 <div>
549 <dt>574 <dt>
550-<kbd>m</kbd>575+<kbd>g h</kbd>
551-<kbd>g m</kbd>576+</dt>
577+<dd>home</dd>
578+</div>
579+<div>
580+<dt>
581+<kbd>y</kbd>
552 </dt>582 </dt>
553-<dd>merge requests (when shown)</dd>583+<dd>copy a permalink pinned to the commit</dd>
554 </div>584 </div>
555 <div>585 <div>
556 <dt>586 <dt>
@@ -570,19 +600,21 @@
570 <dt>600 <dt>
571 <kbd>esc</kbd>601 <kbd>esc</kbd>
572 </dt>602 </dt>
573-<dd>close panel</dd>603+<dd>close a dialog, leave a field</dd>
574 </div>604 </div>
575 </dl>605 </dl>
576 </dialog>606 </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()">
578 <div class="panel-head">608 <div class="panel-head">
579-<label for="refs-filter">ref&gt;</label>609+<label for="refs-filter" class="sr">filter branches and tags</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))">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))">
581-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>611+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
612+<kbd>esc</kbd>
613+</button>
582 </div>614 </div>
583 <div class="panel-cols" data-picklist>615 <div class="panel-cols" data-picklist>
584 <section aria-label="Branches">616 <section aria-label="Branches">
585-<h2>branches <span class="mute">2</span>617+<h2>Branches <span class="count">2</span>
586 </h2>618 </h2>
587 <ul>619 <ul>
588 <li>620 <li>
@@ -596,7 +628,7 @@
596 </ul>628 </ul>
597 </section>629 </section>
598 <section aria-label="Tags">630 <section aria-label="Tags">
599-<h2>tags <span class="mute">1</span>631+<h2>Tags <span class="count">1</span>
600 </h2>632 </h2>
601 <ul>633 <ul>
602 <li>634 <li>
@@ -607,105 +639,124 @@
607 </section>639 </section>
608 </div>640 </div>
609 </dialog>641 </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()">
611 <div class="panel-head">643 <div class="panel-head">
612-<label for="cmd-q" class="hi">:</label>644+<label for="cmd-q" class="sr">go to</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();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();
614 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));646 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
615 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();647 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();
616 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));648 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
617 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;649 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
618 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' })) }650 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' })) }
619 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">651 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>
621 </div>655 </div>
622-<ul id="cmd-list" class="cmd-list">656+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
623 <li data-k="go home" data-cursor>657 <li data-k="go home" data-cursor>
624 <a href="/" tabindex="-1">658 <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>
626 </li>662 </li>
627 <li data-k="section summary">663 <li data-k="section summary">
628 <a href="/diffs" tabindex="-1">664 <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>
630 </li>668 </li>
631-<li data-k="section tree">669+<li data-k="section files">
632 <a href="/diffs/tree/main" tabindex="-1">670 <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>
634 </li>674 </li>
635 <li data-k="section commits">675 <li data-k="section commits">
636 <a href="/diffs/commits/main" tabindex="-1">676 <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>
638 </li>680 </li>
639-<li data-k="section refs">681+<li data-k="section branches &amp; tags">
640 <a href="/diffs/refs" tabindex="-1">682 <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>
642 </li>686 </li>
643 <li data-k="section merge requests">687 <li data-k="section merge requests">
644 <a href="/diffs/merge-requests" tabindex="-1">688 <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>
646 </li>692 </li>
647 <li data-k="branch main">693 <li data-k="branch main">
648 <a href="/diffs/tree/main" tabindex="-1">694 <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>
650 </li>698 </li>
651 <li data-k="branch side">699 <li data-k="branch side">
652 <a href="/diffs/tree/side" tabindex="-1">700 <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>
654 </li>704 </li>
655 <li data-k="tag v1.0.0">705 <li data-k="tag v1.0.0">
656 <a href="/diffs/tree/v1.0.0" tabindex="-1">706 <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>
658 </li>710 </li>
659 </ul>711 </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>
660 </dialog>721 </dialog>
661-<footer class="bar status" aria-label="Status">722+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
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) => {
676 if (q('dialog[open]').count) return;723 if (q('dialog[open]').count) return;
677 let rows = q('[data-rows] > :not([hidden])').arr();724 let rows = q('[data-rows] > :not([hidden])').arr();
678 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));725 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
679 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));726 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
680- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];727+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
681- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
682 })(1);731 })(1);
683- k-k from:body -> ((d) => {732+ k-k from:body -> (async (d) => {
684 if (q('dialog[open]').count) return;733 if (q('dialog[open]').count) return;
685 let rows = q('[data-rows] > :not([hidden])').arr();734 let rows = q('[data-rows] > :not([hidden])').arr();
686 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));735 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
687 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));736 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
688- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];737+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
689- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
690 })(-1);741 })(-1);
691 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));742 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
692 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));743 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));
693 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();744 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
694 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());745 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();746+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
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());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());
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();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();
698 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();749 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
699- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();750+ k-gh from:body -> document.querySelector('.brand')?.click();
700- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();751+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
701- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();752+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
702- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();753+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
703- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();754+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
704- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();755+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
705- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();756+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
706- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();757+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
707- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();758+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
708- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();759+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
709 k-next from:body -> {760 k-next from:body -> {
710 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;761 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
711 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);762 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/commit-trojan.html +164 -113
@@ -6,6 +6,7 @@
6 <meta name="color-scheme" content="light dark">6 <meta name="color-scheme" content="light dark">
7 <title>SHA7 add evil.go · diffs · example.test</title>7 <title>SHA7 add evil.go · diffs · example.test</title>
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <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>
9 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
10 <script src="/-/static/js/htmx.min.HASH.js" defer>11 <script src="/-/static/js/htmx.min.HASH.js" defer>
11 </script>12 </script>
@@ -16,12 +17,15 @@
16 <script src="/-/static/js/ermite.HASH.js" defer>17 <script src="/-/static/js/ermite.HASH.js" defer>
17 </script>18 </script>
18 </head>19 </head>
19-<body data-ref="" data-sha="SHA40" data-sel="">20+<body data-ref="" data-sha="SHA40" data-sel="" data-nav="">
20 <a class="skip" href="#main">skip to content</a>21 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">22+<header class="hdr">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>23+<div class="hdr-top">
23-<span class="fade" aria-hidden="true">24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
24 </span>26 </span>
27+<span>example.test</span>
28+</a>
25 <nav class="crumbs" aria-label="Path">29 <nav class="crumbs" aria-label="Path">
26 <ol>30 <ol>
27 <li>31 <li>
@@ -35,30 +39,49 @@
35 </li>39 </li>
36 </ol>40 </ol>
37 </nav>41 </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">
39 <ul>59 <ul>
40 <li>60 <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>
42 </li>63 </li>
43 <li>64 <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>
45 </li>67 </li>
46 <li>68 <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>
48 </li>71 </li>
49 <li>72 <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>
51 </li>75 </li>
52 <li>76 <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>
54 </li>79 </li>
55 </ul>80 </ul>
56 </nav>81 </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>
59 </header>82 </header>
60 <div class="work has-rail">83 <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">
62 <header class="show">85 <header class="show">
63 <h1>add evil.go</h1>86 <h1>add evil.go</h1>
64 <pre class="msg">looks &lt;U+202E&gt;fine&lt;U+202C&gt;</pre>87 <pre class="msg">looks &lt;U+202E&gt;fine&lt;U+202C&gt;</pre>
@@ -79,9 +102,12 @@
79 </span>102 </span>
80 <span class="grow">103 <span class="grow">
81 </span>104 </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>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>
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>106+</a>
84-<button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))">[x] collapse all</button>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>
85 </div>111 </div>
86 <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>112 <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>
87 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">113 <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff">
@@ -262,10 +288,8 @@
262 </div>288 </div>
263 </main>289 </main>
264 <aside class="rail" aria-label="Context">290 <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>
267 <section aria-labelledby="r-files">291 <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>
269 </h2>293 </h2>
270 <ol class="files" aria-label="Changed files">294 <ol class="files" aria-label="Changed files">
271 <li>295 <li>
@@ -285,73 +309,73 @@
285 </li>309 </li>
286 </ol>310 </ol>
287 </section>311 </section>
288-</details>
289 </aside>312 </aside>
290 </div>313 </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()">
292 <div class="panel-head">329 <div class="panel-head">
293-<span>keys</span>330+<h2>Keyboard shortcuts</h2>
294-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>331+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
332+<kbd>esc</kbd> close</button>
295 </div>333 </div>
296 <dl class="keys">334 <dl class="keys">
297 <div>335 <div>
298 <dt>336 <dt>
299-<kbd>j</kbd>337+<kbd>ctrl k</kbd>
300-<kbd>k</kbd>338+<kbd>:</kbd>
301 </dt>339 </dt>
302-<dd>move cursor down / up</dd>340+<dd>go to a section, branch, tag, file or repository</dd>
303 </div>341 </div>
304 <div>342 <div>
305 <dt>343 <dt>
306-<kbd>enter</kbd>344+<kbd>?</kbd>
307 </dt>345 </dt>
308-<dd>open row under cursor</dd>346+<dd>this list</dd>
309 </div>347 </div>
310 <div>348 <div>
311 <dt>349 <dt>
312-<kbd>g</kbd>350+<kbd>j</kbd>
313-<kbd>g</kbd> / <kbd>G</kbd>351+<kbd>k</kbd>
314 </dt>352 </dt>
315-<dd>first / last row</dd>353+<dd>move the row cursor down / up</dd>
316 </div>354 </div>
317 <div>355 <div>
318 <dt>356 <dt>
319-<kbd>/</kbd>357+<kbd>enter</kbd>
320 </dt>358 </dt>
321-<dd>focus the filter</dd>359+<dd>open the row under the cursor</dd>
322 </div>360 </div>
323 <div>361 <div>
324 <dt>362 <dt>
325-<kbd>g h</kbd>363+<kbd>g g</kbd>
326-<kbd>g t</kbd>364+<kbd>G</kbd>
327 </dt>365 </dt>
328-<dd>go home / tree</dd>366+<dd>first / last row</dd>
329 </div>367 </div>
330 <div>368 <div>
331 <dt>369 <dt>
332-<kbd>g c</kbd>370+<kbd>/</kbd>
333-<kbd>g r</kbd>
334 </dt>371 </dt>
335-<dd>go commits / refs</dd>372+<dd>filter the list</dd>
336 </div>373 </div>
337 <div>374 <div>
338 <dt>375 <dt>
339 <kbd>b</kbd>376 <kbd>b</kbd>
340 </dt>377 </dt>
341-<dd>switch ref (branches, tags)</dd>378+<dd>switch branch or tag</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>
355 </div>379 </div>
356 <div>380 <div>
357 <dt>381 <dt>
@@ -359,15 +383,21 @@
359 <kbd>t</kbd>383 <kbd>t</kbd>
360 <kbd>c</kbd>384 <kbd>c</kbd>
361 <kbd>r</kbd>385 <kbd>r</kbd>
386+<kbd>m</kbd>
362 </dt>387 </dt>
363-<dd>repo sections (when shown)</dd>388+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
364 </div>389 </div>
365 <div>390 <div>
366 <dt>391 <dt>
367-<kbd>m</kbd>392+<kbd>g h</kbd>
368-<kbd>g m</kbd>393+</dt>
394+<dd>home</dd>
395+</div>
396+<div>
397+<dt>
398+<kbd>y</kbd>
369 </dt>399 </dt>
370-<dd>merge requests (when shown)</dd>400+<dd>copy a permalink pinned to the commit</dd>
371 </div>401 </div>
372 <div>402 <div>
373 <dt>403 <dt>
@@ -387,19 +417,21 @@
387 <dt>417 <dt>
388 <kbd>esc</kbd>418 <kbd>esc</kbd>
389 </dt>419 </dt>
390-<dd>close panel</dd>420+<dd>close a dialog, leave a field</dd>
391 </div>421 </div>
392 </dl>422 </dl>
393 </dialog>423 </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()">
395 <div class="panel-head">425 <div class="panel-head">
396-<label for="refs-filter">ref&gt;</label>426+<label for="refs-filter" class="sr">filter branches and tags</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))">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))">
398-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>428+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
429+<kbd>esc</kbd>
430+</button>
399 </div>431 </div>
400 <div class="panel-cols" data-picklist>432 <div class="panel-cols" data-picklist>
401 <section aria-label="Branches">433 <section aria-label="Branches">
402-<h2>branches <span class="mute">2</span>434+<h2>Branches <span class="count">2</span>
403 </h2>435 </h2>
404 <ul>436 <ul>
405 <li>437 <li>
@@ -413,7 +445,7 @@
413 </ul>445 </ul>
414 </section>446 </section>
415 <section aria-label="Tags">447 <section aria-label="Tags">
416-<h2>tags <span class="mute">1</span>448+<h2>Tags <span class="count">1</span>
417 </h2>449 </h2>
418 <ul>450 <ul>
419 <li>451 <li>
@@ -424,105 +456,124 @@
424 </section>456 </section>
425 </div>457 </div>
426 </dialog>458 </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()">
428 <div class="panel-head">460 <div class="panel-head">
429-<label for="cmd-q" class="hi">:</label>461+<label for="cmd-q" class="sr">go to</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();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();
431 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));463 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
432 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();464 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();
433 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));465 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
434 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;466 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
435 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' })) }467 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' })) }
436 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">468 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>
438 </div>472 </div>
439-<ul id="cmd-list" class="cmd-list">473+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
440 <li data-k="go home" data-cursor>474 <li data-k="go home" data-cursor>
441 <a href="/" tabindex="-1">475 <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>
443 </li>479 </li>
444 <li data-k="section summary">480 <li data-k="section summary">
445 <a href="/diffs" tabindex="-1">481 <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>
447 </li>485 </li>
448-<li data-k="section tree">486+<li data-k="section files">
449 <a href="/diffs/tree/main" tabindex="-1">487 <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>
451 </li>491 </li>
452 <li data-k="section commits">492 <li data-k="section commits">
453 <a href="/diffs/commits/main" tabindex="-1">493 <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>
455 </li>497 </li>
456-<li data-k="section refs">498+<li data-k="section branches &amp; tags">
457 <a href="/diffs/refs" tabindex="-1">499 <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>
459 </li>503 </li>
460 <li data-k="section merge requests">504 <li data-k="section merge requests">
461 <a href="/diffs/merge-requests" tabindex="-1">505 <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>
463 </li>509 </li>
464 <li data-k="branch main">510 <li data-k="branch main">
465 <a href="/diffs/tree/main" tabindex="-1">511 <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>
467 </li>515 </li>
468 <li data-k="branch side">516 <li data-k="branch side">
469 <a href="/diffs/tree/side" tabindex="-1">517 <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>
471 </li>521 </li>
472 <li data-k="tag v1.0.0">522 <li data-k="tag v1.0.0">
473 <a href="/diffs/tree/v1.0.0" tabindex="-1">523 <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>
475 </li>527 </li>
476 </ul>528 </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>
477 </dialog>538 </dialog>
478-<footer class="bar status" aria-label="Status">539+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
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) => {
493 if (q('dialog[open]').count) return;540 if (q('dialog[open]').count) return;
494 let rows = q('[data-rows] > :not([hidden])').arr();541 let rows = q('[data-rows] > :not([hidden])').arr();
495 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));542 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
496 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));543 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
497- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];544+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
498- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
499 })(1);548 })(1);
500- k-k from:body -> ((d) => {549+ k-k from:body -> (async (d) => {
501 if (q('dialog[open]').count) return;550 if (q('dialog[open]').count) return;
502 let rows = q('[data-rows] > :not([hidden])').arr();551 let rows = q('[data-rows] > :not([hidden])').arr();
503 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));552 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
504 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));553 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
505- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];554+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
506- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
507 })(-1);558 })(-1);
508 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));559 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
509 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));560 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));
510 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();561 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
511 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());562 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();563+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
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());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());
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();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();
515 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();566 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
516- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();567+ k-gh from:body -> document.querySelector('.brand')?.click();
517- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();568+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
518- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();569+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
519- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();570+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
520- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();571+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
521- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();572+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
522- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();573+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
523- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();574+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
524- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();575+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
525- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();576+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
526 k-next from:body -> {577 k-next from:body -> {
527 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;578 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
528 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);579 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/commits-page2.html +160 -115
@@ -6,6 +6,7 @@
6 <meta name="color-scheme" content="light dark">6 <meta name="color-scheme" content="light dark">
7 <title>commits · long · example.test</title>7 <title>commits · long · example.test</title>
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <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>
9 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
10 <script src="/-/static/js/htmx.min.HASH.js" defer>11 <script src="/-/static/js/htmx.min.HASH.js" defer>
11 </script>12 </script>
@@ -16,12 +17,15 @@
16 <script src="/-/static/js/ermite.HASH.js" defer>17 <script src="/-/static/js/ermite.HASH.js" defer>
17 </script>18 </script>
18 </head>19 </head>
19-<body data-ref="main" data-sha="SHA40" data-sel="">20+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
20 <a class="skip" href="#main">skip to content</a>21 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">22+<header class="hdr">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>23+<div class="hdr-top">
23-<span class="fade" aria-hidden="true">24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
24 </span>26 </span>
27+<span>example.test</span>
28+</a>
25 <nav class="crumbs" aria-label="Path">29 <nav class="crumbs" aria-label="Path">
26 <ol>30 <ol>
27 <li>31 <li>
@@ -35,33 +39,52 @@
35 </li>39 </li>
36 </ol>40 </ol>
37 </nav>41 </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">
39 <ul>59 <ul>
40 <li>60 <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>
42 </li>63 </li>
43 <li>64 <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>
45 </li>67 </li>
46 <li>68 <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>
48 </li>71 </li>
49 <li>72 <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>
51 </li>75 </li>
52 <li>76 <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>
54 </li>79 </li>
55 </ul>80 </ul>
56 </nav>81 </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>
59 </header>82 </header>
60 <div class="work has-rail">83 <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">
62 <div class="filter">85 <div class="filter">
63-<label for="commit-filter">filter&gt;</label>86+<label for="commit-filter" class="sr">filter</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));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));
65 q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])'));88 q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])'));
66 let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')">89 let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')">
67 </div>90 </div>
@@ -101,15 +124,13 @@
101 </section>124 </section>
102 </div>125 </div>
103 <nav class="pager" aria-label="Pages">126 <nav class="pager" aria-label="Pages">
104-<a href="/long/commits/main">← newest</a>127+<a class="btn" href="/long/commits/main">← Newest</a>
105-<span class="mute">older →</span>128+<span class="btn" aria-disabled="true">Older →</span>
106 </nav>129 </nav>
107 </main>130 </main>
108 <aside class="rail" aria-label="Context">131 <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>
111 <section aria-labelledby="r-hist">132 <section aria-labelledby="r-hist">
112-<h2 id="r-hist">history</h2>133+<h2 id="r-hist">History</h2>
113 <dl class="kv">134 <dl class="kv">
114 <dt>ref</dt>135 <dt>ref</dt>
115 <dd>main</dd>136 <dd>main</dd>
@@ -118,76 +139,76 @@
118 </dl>139 </dl>
119 </section>140 </section>
120 <section aria-labelledby="r-refs">141 <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>
122 <p class="dim">no tag or branch head here</p>143 <p class="dim">no tag or branch head here</p>
123 </section>144 </section>
124-</details>
125 </aside>145 </aside>
126 </div>146 </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()">
128 <div class="panel-head">162 <div class="panel-head">
129-<span>keys</span>163+<h2>Keyboard shortcuts</h2>
130-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>164+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
165+<kbd>esc</kbd> close</button>
131 </div>166 </div>
132 <dl class="keys">167 <dl class="keys">
133 <div>168 <div>
134 <dt>169 <dt>
135-<kbd>j</kbd>170+<kbd>ctrl k</kbd>
136-<kbd>k</kbd>171+<kbd>:</kbd>
137 </dt>172 </dt>
138-<dd>move cursor down / up</dd>173+<dd>go to a section, branch, tag, file or repository</dd>
139 </div>174 </div>
140 <div>175 <div>
141 <dt>176 <dt>
142-<kbd>enter</kbd>177+<kbd>?</kbd>
143 </dt>178 </dt>
144-<dd>open row under cursor</dd>179+<dd>this list</dd>
145 </div>180 </div>
146 <div>181 <div>
147 <dt>182 <dt>
148-<kbd>g</kbd>183+<kbd>j</kbd>
149-<kbd>g</kbd> / <kbd>G</kbd>184+<kbd>k</kbd>
150 </dt>185 </dt>
151-<dd>first / last row</dd>186+<dd>move the row cursor down / up</dd>
152 </div>187 </div>
153 <div>188 <div>
154 <dt>189 <dt>
155-<kbd>/</kbd>190+<kbd>enter</kbd>
156 </dt>191 </dt>
157-<dd>focus the filter</dd>192+<dd>open the row under the cursor</dd>
158 </div>193 </div>
159 <div>194 <div>
160 <dt>195 <dt>
161-<kbd>g h</kbd>196+<kbd>g g</kbd>
162-<kbd>g t</kbd>197+<kbd>G</kbd>
163 </dt>198 </dt>
164-<dd>go home / tree</dd>199+<dd>first / last row</dd>
165 </div>200 </div>
166 <div>201 <div>
167 <dt>202 <dt>
168-<kbd>g c</kbd>203+<kbd>/</kbd>
169-<kbd>g r</kbd>
170 </dt>204 </dt>
171-<dd>go commits / refs</dd>205+<dd>filter the list</dd>
172 </div>206 </div>
173 <div>207 <div>
174 <dt>208 <dt>
175 <kbd>b</kbd>209 <kbd>b</kbd>
176 </dt>210 </dt>
177-<dd>switch ref (branches, tags)</dd>211+<dd>switch branch or tag</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>
191 </div>212 </div>
192 <div>213 <div>
193 <dt>214 <dt>
@@ -195,15 +216,21 @@
195 <kbd>t</kbd>216 <kbd>t</kbd>
196 <kbd>c</kbd>217 <kbd>c</kbd>
197 <kbd>r</kbd>218 <kbd>r</kbd>
219+<kbd>m</kbd>
198 </dt>220 </dt>
199-<dd>repo sections (when shown)</dd>221+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
200 </div>222 </div>
201 <div>223 <div>
202 <dt>224 <dt>
203-<kbd>m</kbd>225+<kbd>g h</kbd>
204-<kbd>g m</kbd>
205 </dt>226 </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>
207 </div>234 </div>
208 <div>235 <div>
209 <dt>236 <dt>
@@ -223,19 +250,21 @@
223 <dt>250 <dt>
224 <kbd>esc</kbd>251 <kbd>esc</kbd>
225 </dt>252 </dt>
226-<dd>close panel</dd>253+<dd>close a dialog, leave a field</dd>
227 </div>254 </div>
228 </dl>255 </dl>
229 </dialog>256 </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()">
231 <div class="panel-head">258 <div class="panel-head">
232-<label for="refs-filter">ref&gt;</label>259+<label for="refs-filter" class="sr">filter branches and tags</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))">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))">
234-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>261+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
262+<kbd>esc</kbd>
263+</button>
235 </div>264 </div>
236 <div class="panel-cols" data-picklist>265 <div class="panel-cols" data-picklist>
237 <section aria-label="Branches">266 <section aria-label="Branches">
238-<h2>branches <span class="mute">1</span>267+<h2>Branches <span class="count">1</span>
239 </h2>268 </h2>
240 <ul>269 <ul>
241 <li>270 <li>
@@ -245,109 +274,125 @@
245 </ul>274 </ul>
246 </section>275 </section>
247 <section aria-label="Tags">276 <section aria-label="Tags">
248-<h2>tags <span class="mute">0</span>277+<h2>Tags <span class="count">0</span>
249 </h2>278 </h2>
250 <ul>279 <ul>
251 </ul>280 </ul>
252 </section>281 </section>
253 </div>282 </div>
254 </dialog>283 </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()">
256 <div class="panel-head">285 <div class="panel-head">
257-<label for="cmd-q" class="hi">:</label>286+<label for="cmd-q" class="sr">go to</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();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();
259 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));288 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
260 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();289 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();
261 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));290 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
262 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;291 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
263 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' })) }292 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' })) }
264 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">293 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>
266 </div>297 </div>
267-<ul id="cmd-list" class="cmd-list">298+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
268 <li data-k="go home" data-cursor>299 <li data-k="go home" data-cursor>
269 <a href="/" tabindex="-1">300 <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>
271 </li>304 </li>
272 <li data-k="section summary">305 <li data-k="section summary">
273 <a href="/long" tabindex="-1">306 <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>
275 </li>310 </li>
276-<li data-k="section tree">311+<li data-k="section files">
277 <a href="/long/tree/main" tabindex="-1">312 <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>
279 </li>316 </li>
280 <li data-k="section commits">317 <li data-k="section commits">
281 <a href="/long/commits/main" tabindex="-1">318 <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>
283 </li>322 </li>
284-<li data-k="section refs">323+<li data-k="section branches &amp; tags">
285 <a href="/long/refs" tabindex="-1">324 <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>
287 </li>328 </li>
288 <li data-k="section merge requests">329 <li data-k="section merge requests">
289 <a href="/long/merge-requests" tabindex="-1">330 <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>
291 </li>334 </li>
292 <li data-k="branch main">335 <li data-k="branch main">
293 <a href="/long/tree/main" tabindex="-1">336 <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>
295 </li>340 </li>
296 <li data-k="repo diffs">341 <li data-k="repo diffs">
297 <a href="/diffs" tabindex="-1">342 <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>
299 </li>346 </li>
300 </ul>347 </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>
301 </dialog>357 </dialog>
302-<footer class="bar status" aria-label="Status">358+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
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) => {
318 if (q('dialog[open]').count) return;359 if (q('dialog[open]').count) return;
319 let rows = q('[data-rows] > :not([hidden])').arr();360 let rows = q('[data-rows] > :not([hidden])').arr();
320 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));361 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
321 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));362 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
322- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];363+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
323- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
324 })(1);367 })(1);
325- k-k from:body -> ((d) => {368+ k-k from:body -> (async (d) => {
326 if (q('dialog[open]').count) return;369 if (q('dialog[open]').count) return;
327 let rows = q('[data-rows] > :not([hidden])').arr();370 let rows = q('[data-rows] > :not([hidden])').arr();
328 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));371 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
329 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));372 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
330- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];373+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
331- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
332 })(-1);377 })(-1);
333 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));378 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
334 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));379 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));
335 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();380 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
336 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());381 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();382+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
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());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());
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();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();
340 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();385 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
341- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();386+ k-gh from:body -> document.querySelector('.brand')?.click();
342- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();387+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
343- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();388+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
344- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();389+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
345- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();390+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
346- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();391+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
347- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();392+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
348- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();393+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
349- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();394+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
350- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();395+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
351 k-next from:body -> {396 k-next from:body -> {
352 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;397 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
353 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);398 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/commits-path.html +158 -113
@@ -6,6 +6,7 @@
6 <meta name="color-scheme" content="light dark">6 <meta name="color-scheme" content="light dark">
7 <title>history of a.txt · long · example.test</title>7 <title>history of a.txt · long · example.test</title>
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <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>
9 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
10 <script src="/-/static/js/htmx.min.HASH.js" defer>11 <script src="/-/static/js/htmx.min.HASH.js" defer>
11 </script>12 </script>
@@ -16,12 +17,15 @@
16 <script src="/-/static/js/ermite.HASH.js" defer>17 <script src="/-/static/js/ermite.HASH.js" defer>
17 </script>18 </script>
18 </head>19 </head>
19-<body data-ref="main" data-sha="SHA40" data-sel="">20+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
20 <a class="skip" href="#main">skip to content</a>21 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">22+<header class="hdr">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>23+<div class="hdr-top">
23-<span class="fade" aria-hidden="true">24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
24 </span>26 </span>
27+<span>example.test</span>
28+</a>
25 <nav class="crumbs" aria-label="Path">29 <nav class="crumbs" aria-label="Path">
26 <ol>30 <ol>
27 <li>31 <li>
@@ -38,33 +42,52 @@
38 </li>42 </li>
39 </ol>43 </ol>
40 </nav>44 </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">
42 <ul>62 <ul>
43 <li>63 <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>
45 </li>66 </li>
46 <li>67 <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>
48 </li>70 </li>
49 <li>71 <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>
51 </li>74 </li>
52 <li>75 <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>
54 </li>78 </li>
55 <li>79 <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>
57 </li>82 </li>
58 </ul>83 </ul>
59 </nav>84 </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>
62 </header>85 </header>
63 <div class="work has-rail">86 <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">
65 <div class="filter">88 <div class="filter">
66-<label for="commit-filter">filter&gt;</label>89+<label for="commit-filter" class="sr">filter</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));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));
68 q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])'));91 q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])'));
69 let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')">92 let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')">
70 </div>93 </div>
@@ -115,10 +138,8 @@
115 </div>138 </div>
116 </main>139 </main>
117 <aside class="rail" aria-label="Context">140 <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>
120 <section aria-labelledby="r-hist">141 <section aria-labelledby="r-hist">
121-<h2 id="r-hist">history</h2>142+<h2 id="r-hist">History</h2>
122 <dl class="kv">143 <dl class="kv">
123 <dt>ref</dt>144 <dt>ref</dt>
124 <dd>main</dd>145 <dd>main</dd>
@@ -133,76 +154,76 @@
133 </dl>154 </dl>
134 </section>155 </section>
135 <section aria-labelledby="r-refs">156 <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>
137 <p class="dim">no tag or branch head here</p>158 <p class="dim">no tag or branch head here</p>
138 </section>159 </section>
139-</details>
140 </aside>160 </aside>
141 </div>161 </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()">
143 <div class="panel-head">177 <div class="panel-head">
144-<span>keys</span>178+<h2>Keyboard shortcuts</h2>
145-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>179+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
180+<kbd>esc</kbd> close</button>
146 </div>181 </div>
147 <dl class="keys">182 <dl class="keys">
148 <div>183 <div>
149 <dt>184 <dt>
150-<kbd>j</kbd>185+<kbd>ctrl k</kbd>
151-<kbd>k</kbd>186+<kbd>:</kbd>
152 </dt>187 </dt>
153-<dd>move cursor down / up</dd>188+<dd>go to a section, branch, tag, file or repository</dd>
154 </div>189 </div>
155 <div>190 <div>
156 <dt>191 <dt>
157-<kbd>enter</kbd>192+<kbd>?</kbd>
158 </dt>193 </dt>
159-<dd>open row under cursor</dd>194+<dd>this list</dd>
160 </div>195 </div>
161 <div>196 <div>
162 <dt>197 <dt>
163-<kbd>g</kbd>198+<kbd>j</kbd>
164-<kbd>g</kbd> / <kbd>G</kbd>199+<kbd>k</kbd>
165 </dt>200 </dt>
166-<dd>first / last row</dd>201+<dd>move the row cursor down / up</dd>
167 </div>202 </div>
168 <div>203 <div>
169 <dt>204 <dt>
170-<kbd>/</kbd>205+<kbd>enter</kbd>
171 </dt>206 </dt>
172-<dd>focus the filter</dd>207+<dd>open the row under the cursor</dd>
173 </div>208 </div>
174 <div>209 <div>
175 <dt>210 <dt>
176-<kbd>g h</kbd>211+<kbd>g g</kbd>
177-<kbd>g t</kbd>212+<kbd>G</kbd>
178 </dt>213 </dt>
179-<dd>go home / tree</dd>214+<dd>first / last row</dd>
180 </div>215 </div>
181 <div>216 <div>
182 <dt>217 <dt>
183-<kbd>g c</kbd>218+<kbd>/</kbd>
184-<kbd>g r</kbd>
185 </dt>219 </dt>
186-<dd>go commits / refs</dd>220+<dd>filter the list</dd>
187 </div>221 </div>
188 <div>222 <div>
189 <dt>223 <dt>
190 <kbd>b</kbd>224 <kbd>b</kbd>
191 </dt>225 </dt>
192-<dd>switch ref (branches, tags)</dd>226+<dd>switch branch or tag</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>
206 </div>227 </div>
207 <div>228 <div>
208 <dt>229 <dt>
@@ -210,15 +231,21 @@
210 <kbd>t</kbd>231 <kbd>t</kbd>
211 <kbd>c</kbd>232 <kbd>c</kbd>
212 <kbd>r</kbd>233 <kbd>r</kbd>
234+<kbd>m</kbd>
213 </dt>235 </dt>
214-<dd>repo sections (when shown)</dd>236+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
215 </div>237 </div>
216 <div>238 <div>
217 <dt>239 <dt>
218-<kbd>m</kbd>240+<kbd>g h</kbd>
219-<kbd>g m</kbd>
220 </dt>241 </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>
222 </div>249 </div>
223 <div>250 <div>
224 <dt>251 <dt>
@@ -238,19 +265,21 @@
238 <dt>265 <dt>
239 <kbd>esc</kbd>266 <kbd>esc</kbd>
240 </dt>267 </dt>
241-<dd>close panel</dd>268+<dd>close a dialog, leave a field</dd>
242 </div>269 </div>
243 </dl>270 </dl>
244 </dialog>271 </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()">
246 <div class="panel-head">273 <div class="panel-head">
247-<label for="refs-filter">ref&gt;</label>274+<label for="refs-filter" class="sr">filter branches and tags</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))">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))">
249-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>276+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
277+<kbd>esc</kbd>
278+</button>
250 </div>279 </div>
251 <div class="panel-cols" data-picklist>280 <div class="panel-cols" data-picklist>
252 <section aria-label="Branches">281 <section aria-label="Branches">
253-<h2>branches <span class="mute">1</span>282+<h2>Branches <span class="count">1</span>
254 </h2>283 </h2>
255 <ul>284 <ul>
256 <li>285 <li>
@@ -260,109 +289,125 @@
260 </ul>289 </ul>
261 </section>290 </section>
262 <section aria-label="Tags">291 <section aria-label="Tags">
263-<h2>tags <span class="mute">0</span>292+<h2>Tags <span class="count">0</span>
264 </h2>293 </h2>
265 <ul>294 <ul>
266 </ul>295 </ul>
267 </section>296 </section>
268 </div>297 </div>
269 </dialog>298 </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()">
271 <div class="panel-head">300 <div class="panel-head">
272-<label for="cmd-q" class="hi">:</label>301+<label for="cmd-q" class="sr">go to</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();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();
274 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));303 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
275 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();304 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();
276 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));305 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
277 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;306 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
278 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' })) }307 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' })) }
279 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">308 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>
281 </div>312 </div>
282-<ul id="cmd-list" class="cmd-list">313+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
283 <li data-k="go home" data-cursor>314 <li data-k="go home" data-cursor>
284 <a href="/" tabindex="-1">315 <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>
286 </li>319 </li>
287 <li data-k="section summary">320 <li data-k="section summary">
288 <a href="/long" tabindex="-1">321 <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>
290 </li>325 </li>
291-<li data-k="section tree">326+<li data-k="section files">
292 <a href="/long/tree/main" tabindex="-1">327 <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>
294 </li>331 </li>
295 <li data-k="section commits">332 <li data-k="section commits">
296 <a href="/long/commits/main" tabindex="-1">333 <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>
298 </li>337 </li>
299-<li data-k="section refs">338+<li data-k="section branches &amp; tags">
300 <a href="/long/refs" tabindex="-1">339 <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>
302 </li>343 </li>
303 <li data-k="section merge requests">344 <li data-k="section merge requests">
304 <a href="/long/merge-requests" tabindex="-1">345 <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>
306 </li>349 </li>
307 <li data-k="branch main">350 <li data-k="branch main">
308 <a href="/long/tree/main" tabindex="-1">351 <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>
310 </li>355 </li>
311 <li data-k="repo diffs">356 <li data-k="repo diffs">
312 <a href="/diffs" tabindex="-1">357 <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>
314 </li>361 </li>
315 </ul>362 </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>
316 </dialog>372 </dialog>
317-<footer class="bar status" aria-label="Status">373+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
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) => {
333 if (q('dialog[open]').count) return;374 if (q('dialog[open]').count) return;
334 let rows = q('[data-rows] > :not([hidden])').arr();375 let rows = q('[data-rows] > :not([hidden])').arr();
335 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));376 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
336 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));377 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
337- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];378+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
338- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
339 })(1);382 })(1);
340- k-k from:body -> ((d) => {383+ k-k from:body -> (async (d) => {
341 if (q('dialog[open]').count) return;384 if (q('dialog[open]').count) return;
342 let rows = q('[data-rows] > :not([hidden])').arr();385 let rows = q('[data-rows] > :not([hidden])').arr();
343 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));386 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
344 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));387 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
345- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];388+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
346- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
347 })(-1);392 })(-1);
348 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));393 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
349 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));394 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));
350 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();395 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
351 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());396 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();397+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
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());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());
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();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();
355 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();400 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
356- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();401+ k-gh from:body -> document.querySelector('.brand')?.click();
357- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();402+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
358- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();403+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
359- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();404+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
360- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();405+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
361- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();406+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
362- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();407+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
363- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();408+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
364- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();409+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
365- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();410+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
366 k-next from:body -> {411 k-next from:body -> {
367 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;412 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
368 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);413 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/commits-root.html +166 -119
@@ -6,6 +6,7 @@
6 <meta name="color-scheme" content="light dark">6 <meta name="color-scheme" content="light dark">
7 <title>commits · long · example.test</title>7 <title>commits · long · example.test</title>
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <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>
9 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
10 <script src="/-/static/js/htmx.min.HASH.js" defer>11 <script src="/-/static/js/htmx.min.HASH.js" defer>
11 </script>12 </script>
@@ -16,12 +17,15 @@
16 <script src="/-/static/js/ermite.HASH.js" defer>17 <script src="/-/static/js/ermite.HASH.js" defer>
17 </script>18 </script>
18 </head>19 </head>
19-<body data-ref="main" data-sha="SHA40" data-sel="">20+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
20 <a class="skip" href="#main">skip to content</a>21 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">22+<header class="hdr">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>23+<div class="hdr-top">
23-<span class="fade" aria-hidden="true">24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
24 </span>26 </span>
27+<span>example.test</span>
28+</a>
25 <nav class="crumbs" aria-label="Path">29 <nav class="crumbs" aria-label="Path">
26 <ol>30 <ol>
27 <li>31 <li>
@@ -35,33 +39,52 @@
35 </li>39 </li>
36 </ol>40 </ol>
37 </nav>41 </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">
39 <ul>59 <ul>
40 <li>60 <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>
42 </li>63 </li>
43 <li>64 <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>
45 </li>67 </li>
46 <li>68 <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>
48 </li>71 </li>
49 <li>72 <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>
51 </li>75 </li>
52 <li>76 <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>
54 </li>79 </li>
55 </ul>80 </ul>
56 </nav>81 </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>
59 </header>82 </header>
60 <div class="work has-rail">83 <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">
62 <div class="filter">85 <div class="filter">
63-<label for="commit-filter">filter&gt;</label>86+<label for="commit-filter" class="sr">filter</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));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));
65 q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])'));88 q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])'));
66 let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')">89 let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')">
67 </div>90 </div>
@@ -80,7 +103,7 @@
80 <td class="c-sha mute">SHA7</td>103 <td class="c-sha mute">SHA7</td>
81 <td class="c-subject">104 <td class="c-subject">
82 <a href="/long/commit/SHA40">commit number 51</a>105 <a href="/long/commit/SHA40">commit number 51</a>
83-<span class="tagged">[main]</span>106+<span class="pill">main</span>
84 </td>107 </td>
85 <td class="c-who dim">T</td>108 <td class="c-who dim">T</td>
86 <td class="c-when nowrap dim">109 <td class="c-when nowrap dim">
@@ -896,15 +919,13 @@
896 </section>919 </section>
897 </div>920 </div>
898 <nav class="pager" aria-label="Pages">921 <nav class="pager" aria-label="Pages">
899-<span class="mute">← newest</span>922+<span class="btn" aria-disabled="true">← Newest</span>
900-<a href="/long/commits/main?page=YzE6MzMzZGFjMWY5YmY4MjIzMmQxOTAzNGVlZTliZmI2NzFmM2QxMWEzNDo1MDplM2IwYzQ0Mjk4ZmM" rel="next">older →</a>923+<a class="btn" href="/long/commits/main?page=YzE6MzMzZGFjMWY5YmY4MjIzMmQxOTAzNGVlZTliZmI2NzFmM2QxMWEzNDo1MDplM2IwYzQ0Mjk4ZmM" rel="next">Older →</a>
901 </nav>924 </nav>
902 </main>925 </main>
903 <aside class="rail" aria-label="Context">926 <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>
906 <section aria-labelledby="r-hist">927 <section aria-labelledby="r-hist">
907-<h2 id="r-hist">history</h2>928+<h2 id="r-hist">History</h2>
908 <dl class="kv">929 <dl class="kv">
909 <dt>ref</dt>930 <dt>ref</dt>
910 <dd>main</dd>931 <dd>main</dd>
@@ -913,78 +934,83 @@
913 </dl>934 </dl>
914 </section>935 </section>
915 <section aria-labelledby="r-refs">936 <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>
917 <nav class="outline" aria-label="Refs on this page">938 <nav class="outline" aria-label="Refs on this page">
918 <a href="#c-SHA7">branch main</a>939 <a href="#c-SHA7">branch main</a>
919 </nav>940 </nav>
920 </section>941 </section>
921-</details>
922 </aside>942 </aside>
923 </div>943 </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()">
925 <div class="panel-head">964 <div class="panel-head">
926-<span>keys</span>965+<h2>Keyboard shortcuts</h2>
927-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>966+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
967+<kbd>esc</kbd> close</button>
928 </div>968 </div>
929 <dl class="keys">969 <dl class="keys">
930 <div>970 <div>
931 <dt>971 <dt>
932-<kbd>j</kbd>972+<kbd>ctrl k</kbd>
933-<kbd>k</kbd>973+<kbd>:</kbd>
934 </dt>974 </dt>
935-<dd>move cursor down / up</dd>975+<dd>go to a section, branch, tag, file or repository</dd>
936 </div>976 </div>
937 <div>977 <div>
938 <dt>978 <dt>
939-<kbd>enter</kbd>979+<kbd>?</kbd>
940 </dt>980 </dt>
941-<dd>open row under cursor</dd>981+<dd>this list</dd>
942 </div>982 </div>
943 <div>983 <div>
944 <dt>984 <dt>
945-<kbd>g</kbd>985+<kbd>j</kbd>
946-<kbd>g</kbd> / <kbd>G</kbd>986+<kbd>k</kbd>
947 </dt>987 </dt>
948-<dd>first / last row</dd>988+<dd>move the row cursor down / up</dd>
949 </div>989 </div>
950 <div>990 <div>
951 <dt>991 <dt>
952-<kbd>/</kbd>992+<kbd>enter</kbd>
953 </dt>993 </dt>
954-<dd>focus the filter</dd>994+<dd>open the row under the cursor</dd>
955 </div>995 </div>
956 <div>996 <div>
957 <dt>997 <dt>
958-<kbd>g h</kbd>998+<kbd>g g</kbd>
959-<kbd>g t</kbd>999+<kbd>G</kbd>
960 </dt>1000 </dt>
961-<dd>go home / tree</dd>1001+<dd>first / last row</dd>
962 </div>1002 </div>
963 <div>1003 <div>
964 <dt>1004 <dt>
965-<kbd>g c</kbd>1005+<kbd>/</kbd>
966-<kbd>g r</kbd>
967 </dt>1006 </dt>
968-<dd>go commits / refs</dd>1007+<dd>filter the list</dd>
969 </div>1008 </div>
970 <div>1009 <div>
971 <dt>1010 <dt>
972 <kbd>b</kbd>1011 <kbd>b</kbd>
973 </dt>1012 </dt>
974-<dd>switch ref (branches, tags)</dd>1013+<dd>switch branch or tag</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>
988 </div>1014 </div>
989 <div>1015 <div>
990 <dt>1016 <dt>
@@ -992,15 +1018,21 @@
992 <kbd>t</kbd>1018 <kbd>t</kbd>
993 <kbd>c</kbd>1019 <kbd>c</kbd>
994 <kbd>r</kbd>1020 <kbd>r</kbd>
1021+<kbd>m</kbd>
995 </dt>1022 </dt>
996-<dd>repo sections (when shown)</dd>1023+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
997 </div>1024 </div>
998 <div>1025 <div>
999 <dt>1026 <dt>
1000-<kbd>m</kbd>1027+<kbd>g h</kbd>
1001-<kbd>g m</kbd>1028+</dt>
1029+<dd>home</dd>
1030+</div>
1031+<div>
1032+<dt>
1033+<kbd>y</kbd>
1002 </dt>1034 </dt>
1003-<dd>merge requests (when shown)</dd>1035+<dd>copy a permalink pinned to the commit</dd>
1004 </div>1036 </div>
1005 <div>1037 <div>
1006 <dt>1038 <dt>
@@ -1020,19 +1052,21 @@
1020 <dt>1052 <dt>
1021 <kbd>esc</kbd>1053 <kbd>esc</kbd>
1022 </dt>1054 </dt>
1023-<dd>close panel</dd>1055+<dd>close a dialog, leave a field</dd>
1024 </div>1056 </div>
1025 </dl>1057 </dl>
1026 </dialog>1058 </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()">
1028 <div class="panel-head">1060 <div class="panel-head">
1029-<label for="refs-filter">ref&gt;</label>1061+<label for="refs-filter" class="sr">filter branches and tags</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))">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))">
1031-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>1063+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
1064+<kbd>esc</kbd>
1065+</button>
1032 </div>1066 </div>
1033 <div class="panel-cols" data-picklist>1067 <div class="panel-cols" data-picklist>
1034 <section aria-label="Branches">1068 <section aria-label="Branches">
1035-<h2>branches <span class="mute">1</span>1069+<h2>Branches <span class="count">1</span>
1036 </h2>1070 </h2>
1037 <ul>1071 <ul>
1038 <li>1072 <li>
@@ -1042,112 +1076,125 @@
1042 </ul>1076 </ul>
1043 </section>1077 </section>
1044 <section aria-label="Tags">1078 <section aria-label="Tags">
1045-<h2>tags <span class="mute">0</span>1079+<h2>Tags <span class="count">0</span>
1046 </h2>1080 </h2>
1047 <ul>1081 <ul>
1048 </ul>1082 </ul>
1049 </section>1083 </section>
1050 </div>1084 </div>
1051 </dialog>1085 </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()">
1053 <div class="panel-head">1087 <div class="panel-head">
1054-<label for="cmd-q" class="hi">:</label>1088+<label for="cmd-q" class="sr">go to</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();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();
1056 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));1090 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
1057 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();1091 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();
1058 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));1092 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
1059 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;1093 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
1060 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' })) }1094 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' })) }
1061 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">1095 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>
1063 </div>1099 </div>
1064-<ul id="cmd-list" class="cmd-list">1100+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
1065 <li data-k="go home" data-cursor>1101 <li data-k="go home" data-cursor>
1066 <a href="/" tabindex="-1">1102 <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>
1068 </li>1106 </li>
1069 <li data-k="section summary">1107 <li data-k="section summary">
1070 <a href="/long" tabindex="-1">1108 <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>
1072 </li>1112 </li>
1073-<li data-k="section tree">1113+<li data-k="section files">
1074 <a href="/long/tree/main" tabindex="-1">1114 <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>
1076 </li>1118 </li>
1077 <li data-k="section commits">1119 <li data-k="section commits">
1078 <a href="/long/commits/main" tabindex="-1">1120 <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>
1080 </li>1124 </li>
1081-<li data-k="section refs">1125+<li data-k="section branches &amp; tags">
1082 <a href="/long/refs" tabindex="-1">1126 <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>
1084 </li>1130 </li>
1085 <li data-k="section merge requests">1131 <li data-k="section merge requests">
1086 <a href="/long/merge-requests" tabindex="-1">1132 <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>
1088 </li>1136 </li>
1089 <li data-k="branch main">1137 <li data-k="branch main">
1090 <a href="/long/tree/main" tabindex="-1">1138 <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>
1092 </li>1142 </li>
1093 <li data-k="repo diffs">1143 <li data-k="repo diffs">
1094 <a href="/diffs" tabindex="-1">1144 <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>
1096 </li>1148 </li>
1097 </ul>1149 </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>
1098 </dialog>1159 </dialog>
1099-<footer class="bar status" aria-label="Status">1160+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
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) => {
1118 if (q('dialog[open]').count) return;1161 if (q('dialog[open]').count) return;
1119 let rows = q('[data-rows] > :not([hidden])').arr();1162 let rows = q('[data-rows] > :not([hidden])').arr();
1120 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));1163 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
1121 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));1164 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
1122- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];1165+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
1123- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
1124 })(1);1169 })(1);
1125- k-k from:body -> ((d) => {1170+ k-k from:body -> (async (d) => {
1126 if (q('dialog[open]').count) return;1171 if (q('dialog[open]').count) return;
1127 let rows = q('[data-rows] > :not([hidden])').arr();1172 let rows = q('[data-rows] > :not([hidden])').arr();
1128 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));1173 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
1129 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));1174 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
1130- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];1175+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
1131- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
1132 })(-1);1179 })(-1);
1133 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));1180 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
1134 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));1181 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));
1135 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();1182 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
1136 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());1183 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();1184+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
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());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());
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();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();
1140 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();1187 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
1141- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();1188+ k-gh from:body -> document.querySelector('.brand')?.click();
1142- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();1189+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
1143- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();1190+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
1144- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();1191+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
1145- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();1192+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
1146- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();1193+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
1147- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();1194+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
1148- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();1195+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
1149- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();1196+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
1150- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();1197+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
1151 k-next from:body -> {1198 k-next from:body -> {
1152 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;1199 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
1153 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);1200 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/commits-small.html +174 -122
@@ -6,6 +6,7 @@
6 <meta name="color-scheme" content="light dark">6 <meta name="color-scheme" content="light dark">
7 <title>commits · diffs · example.test</title>7 <title>commits · diffs · example.test</title>
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <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>
9 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
10 <script src="/-/static/js/htmx.min.HASH.js" defer>11 <script src="/-/static/js/htmx.min.HASH.js" defer>
11 </script>12 </script>
@@ -16,12 +17,15 @@
16 <script src="/-/static/js/ermite.HASH.js" defer>17 <script src="/-/static/js/ermite.HASH.js" defer>
17 </script>18 </script>
18 </head>19 </head>
19-<body data-ref="main" data-sha="SHA40" data-sel="">20+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
20 <a class="skip" href="#main">skip to content</a>21 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">22+<header class="hdr">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>23+<div class="hdr-top">
23-<span class="fade" aria-hidden="true">24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
24 </span>26 </span>
27+<span>example.test</span>
28+</a>
25 <nav class="crumbs" aria-label="Path">29 <nav class="crumbs" aria-label="Path">
26 <ol>30 <ol>
27 <li>31 <li>
@@ -35,33 +39,52 @@
35 </li>39 </li>
36 </ol>40 </ol>
37 </nav>41 </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">
39 <ul>59 <ul>
40 <li>60 <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>
42 </li>63 </li>
43 <li>64 <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>
45 </li>67 </li>
46 <li>68 <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>
48 </li>71 </li>
49 <li>72 <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>
51 </li>75 </li>
52 <li>76 <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>
54 </li>79 </li>
55 </ul>80 </ul>
56 </nav>81 </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>
59 </header>82 </header>
60 <div class="work has-rail">83 <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">
62 <div class="filter">85 <div class="filter">
63-<label for="commit-filter">filter&gt;</label>86+<label for="commit-filter" class="sr">filter</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));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));
65 q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])'));88 q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])'));
66 let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')">89 let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')">
67 </div>90 </div>
@@ -80,7 +103,7 @@
80 <td class="c-sha mute">SHA7</td>103 <td class="c-sha mute">SHA7</td>
81 <td class="c-subject">104 <td class="c-subject">
82 <a href="/diffs/commit/SHA40">add a very large file</a>105 <a href="/diffs/commit/SHA40">add a very large file</a>
83-<span class="tagged">[main]</span>106+<span class="pill">main</span>
84 </td>107 </td>
85 <td class="c-who dim">T</td>108 <td class="c-who dim">T</td>
86 <td class="c-when nowrap dim">109 <td class="c-when nowrap dim">
@@ -123,7 +146,7 @@
123 <td class="c-sha mute">SHA7</td>146 <td class="c-sha mute">SHA7</td>
124 <td class="c-subject">147 <td class="c-subject">
125 <a href="/diffs/commit/SHA40">merge the side branch</a>148 <a href="/diffs/commit/SHA40">merge the side branch</a>
126-<span class="tagged">(merge)</span>149+<span class="pill">merge</span>
127 </td>150 </td>
128 <td class="c-who dim">T</td>151 <td class="c-who dim">T</td>
129 <td class="c-when nowrap dim">152 <td class="c-when nowrap dim">
@@ -145,7 +168,7 @@
145 <td class="c-sha mute">SHA7</td>168 <td class="c-sha mute">SHA7</td>
146 <td class="c-subject">169 <td class="c-subject">
147 <a href="/diffs/commit/SHA40">work on the side</a>170 <a href="/diffs/commit/SHA40">work on the side</a>
148-<span class="tagged">[side]</span>171+<span class="pill">side</span>
149 </td>172 </td>
150 <td class="c-who dim">T</td>173 <td class="c-who dim">T</td>
151 <td class="c-when nowrap dim">174 <td class="c-when nowrap dim">
@@ -209,7 +232,8 @@
209 <td class="c-sha mute">SHA7</td>232 <td class="c-sha mute">SHA7</td>
210 <td class="c-subject">233 <td class="c-subject">
211 <a href="/diffs/commit/SHA40">modify a file and make a script executable</a>234 <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>
213 </td>237 </td>
214 <td class="c-who dim">T</td>238 <td class="c-who dim">T</td>
215 <td class="c-when nowrap dim">239 <td class="c-when nowrap dim">
@@ -243,10 +267,8 @@
243 </div>267 </div>
244 </main>268 </main>
245 <aside class="rail" aria-label="Context">269 <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>
248 <section aria-labelledby="r-hist">270 <section aria-labelledby="r-hist">
249-<h2 id="r-hist">history</h2>271+<h2 id="r-hist">History</h2>
250 <dl class="kv">272 <dl class="kv">
251 <dt>ref</dt>273 <dt>ref</dt>
252 <dd>main</dd>274 <dd>main</dd>
@@ -255,80 +277,85 @@
255 </dl>277 </dl>
256 </section>278 </section>
257 <section aria-labelledby="r-refs">279 <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>
259 <nav class="outline" aria-label="Refs on this page">281 <nav class="outline" aria-label="Refs on this page">
260 <a href="#c-SHA7">branch main</a>282 <a href="#c-SHA7">branch main</a>
261 <a href="#c-SHA7">branch side</a>283 <a href="#c-SHA7">branch side</a>
262 <a href="#c-SHA7">tag v1.0.0</a>284 <a href="#c-SHA7">tag v1.0.0</a>
263 </nav>285 </nav>
264 </section>286 </section>
265-</details>
266 </aside>287 </aside>
267 </div>288 </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()">
269 <div class="panel-head">309 <div class="panel-head">
270-<span>keys</span>310+<h2>Keyboard shortcuts</h2>
271-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>311+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
312+<kbd>esc</kbd> close</button>
272 </div>313 </div>
273 <dl class="keys">314 <dl class="keys">
274 <div>315 <div>
275 <dt>316 <dt>
276-<kbd>j</kbd>317+<kbd>ctrl k</kbd>
277-<kbd>k</kbd>318+<kbd>:</kbd>
278 </dt>319 </dt>
279-<dd>move cursor down / up</dd>320+<dd>go to a section, branch, tag, file or repository</dd>
280 </div>321 </div>
281 <div>322 <div>
282 <dt>323 <dt>
283-<kbd>enter</kbd>324+<kbd>?</kbd>
284 </dt>325 </dt>
285-<dd>open row under cursor</dd>326+<dd>this list</dd>
286 </div>327 </div>
287 <div>328 <div>
288 <dt>329 <dt>
289-<kbd>g</kbd>330+<kbd>j</kbd>
290-<kbd>g</kbd> / <kbd>G</kbd>331+<kbd>k</kbd>
291 </dt>332 </dt>
292-<dd>first / last row</dd>333+<dd>move the row cursor down / up</dd>
293 </div>334 </div>
294 <div>335 <div>
295 <dt>336 <dt>
296-<kbd>/</kbd>337+<kbd>enter</kbd>
297 </dt>338 </dt>
298-<dd>focus the filter</dd>339+<dd>open the row under the cursor</dd>
299 </div>340 </div>
300 <div>341 <div>
301 <dt>342 <dt>
302-<kbd>g h</kbd>343+<kbd>g g</kbd>
303-<kbd>g t</kbd>344+<kbd>G</kbd>
304 </dt>345 </dt>
305-<dd>go home / tree</dd>346+<dd>first / last row</dd>
306 </div>347 </div>
307 <div>348 <div>
308 <dt>349 <dt>
309-<kbd>g c</kbd>350+<kbd>/</kbd>
310-<kbd>g r</kbd>
311 </dt>351 </dt>
312-<dd>go commits / refs</dd>352+<dd>filter the list</dd>
313 </div>353 </div>
314 <div>354 <div>
315 <dt>355 <dt>
316 <kbd>b</kbd>356 <kbd>b</kbd>
317 </dt>357 </dt>
318-<dd>switch ref (branches, tags)</dd>358+<dd>switch branch or tag</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>
332 </div>359 </div>
333 <div>360 <div>
334 <dt>361 <dt>
@@ -336,15 +363,21 @@
336 <kbd>t</kbd>363 <kbd>t</kbd>
337 <kbd>c</kbd>364 <kbd>c</kbd>
338 <kbd>r</kbd>365 <kbd>r</kbd>
366+<kbd>m</kbd>
339 </dt>367 </dt>
340-<dd>repo sections (when shown)</dd>368+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
341 </div>369 </div>
342 <div>370 <div>
343 <dt>371 <dt>
344-<kbd>m</kbd>372+<kbd>g h</kbd>
345-<kbd>g m</kbd>373+</dt>
374+<dd>home</dd>
375+</div>
376+<div>
377+<dt>
378+<kbd>y</kbd>
346 </dt>379 </dt>
347-<dd>merge requests (when shown)</dd>380+<dd>copy a permalink pinned to the commit</dd>
348 </div>381 </div>
349 <div>382 <div>
350 <dt>383 <dt>
@@ -364,19 +397,21 @@
364 <dt>397 <dt>
365 <kbd>esc</kbd>398 <kbd>esc</kbd>
366 </dt>399 </dt>
367-<dd>close panel</dd>400+<dd>close a dialog, leave a field</dd>
368 </div>401 </div>
369 </dl>402 </dl>
370 </dialog>403 </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()">
372 <div class="panel-head">405 <div class="panel-head">
373-<label for="refs-filter">ref&gt;</label>406+<label for="refs-filter" class="sr">filter branches and tags</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))">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))">
375-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>408+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
409+<kbd>esc</kbd>
410+</button>
376 </div>411 </div>
377 <div class="panel-cols" data-picklist>412 <div class="panel-cols" data-picklist>
378 <section aria-label="Branches">413 <section aria-label="Branches">
379-<h2>branches <span class="mute">2</span>414+<h2>Branches <span class="count">2</span>
380 </h2>415 </h2>
381 <ul>416 <ul>
382 <li>417 <li>
@@ -390,7 +425,7 @@
390 </ul>425 </ul>
391 </section>426 </section>
392 <section aria-label="Tags">427 <section aria-label="Tags">
393-<h2>tags <span class="mute">1</span>428+<h2>Tags <span class="count">1</span>
394 </h2>429 </h2>
395 <ul>430 <ul>
396 <li>431 <li>
@@ -401,113 +436,130 @@
401 </section>436 </section>
402 </div>437 </div>
403 </dialog>438 </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()">
405 <div class="panel-head">440 <div class="panel-head">
406-<label for="cmd-q" class="hi">:</label>441+<label for="cmd-q" class="sr">go to</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();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();
408 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));443 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
409 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();444 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();
410 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));445 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
411 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;446 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
412 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' })) }447 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' })) }
413 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">448 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>
415 </div>452 </div>
416-<ul id="cmd-list" class="cmd-list">453+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
417 <li data-k="go home" data-cursor>454 <li data-k="go home" data-cursor>
418 <a href="/" tabindex="-1">455 <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>
420 </li>459 </li>
421 <li data-k="section summary">460 <li data-k="section summary">
422 <a href="/diffs" tabindex="-1">461 <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>
424 </li>465 </li>
425-<li data-k="section tree">466+<li data-k="section files">
426 <a href="/diffs/tree/main" tabindex="-1">467 <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>
428 </li>471 </li>
429 <li data-k="section commits">472 <li data-k="section commits">
430 <a href="/diffs/commits/main" tabindex="-1">473 <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>
432 </li>477 </li>
433-<li data-k="section refs">478+<li data-k="section branches &amp; tags">
434 <a href="/diffs/refs" tabindex="-1">479 <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>
436 </li>483 </li>
437 <li data-k="section merge requests">484 <li data-k="section merge requests">
438 <a href="/diffs/merge-requests" tabindex="-1">485 <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>
440 </li>489 </li>
441 <li data-k="branch main">490 <li data-k="branch main">
442 <a href="/diffs/tree/main" tabindex="-1">491 <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>
444 </li>495 </li>
445 <li data-k="branch side">496 <li data-k="branch side">
446 <a href="/diffs/tree/side" tabindex="-1">497 <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>
448 </li>501 </li>
449 <li data-k="tag v1.0.0">502 <li data-k="tag v1.0.0">
450 <a href="/diffs/tree/v1.0.0" tabindex="-1">503 <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>
452 </li>507 </li>
453 <li data-k="repo long">508 <li data-k="repo long">
454 <a href="/long" tabindex="-1">509 <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>
456 </li>513 </li>
457 </ul>514 </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>
458 </dialog>524 </dialog>
459-<footer class="bar status" aria-label="Status">525+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
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) => {
478 if (q('dialog[open]').count) return;526 if (q('dialog[open]').count) return;
479 let rows = q('[data-rows] > :not([hidden])').arr();527 let rows = q('[data-rows] > :not([hidden])').arr();
480 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));528 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
481 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));529 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
482- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];530+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
483- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
484 })(1);534 })(1);
485- k-k from:body -> ((d) => {535+ k-k from:body -> (async (d) => {
486 if (q('dialog[open]').count) return;536 if (q('dialog[open]').count) return;
487 let rows = q('[data-rows] > :not([hidden])').arr();537 let rows = q('[data-rows] > :not([hidden])').arr();
488 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));538 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
489 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));539 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
490- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];540+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
491- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
492 })(-1);544 })(-1);
493 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));545 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
494 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));546 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));
495 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();547 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
496 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());548 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();549+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
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());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());
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();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();
500 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();552 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
501- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();553+ k-gh from:body -> document.querySelector('.brand')?.click();
502- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();554+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
503- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();555+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
504- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();556+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
505- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();557+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
506- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();558+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
507- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();559+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
508- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();560+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
509- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();561+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
510- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();562+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
511 k-next from:body -> {563 k-next from:body -> {
512 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;564 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
513 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);565 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/empty.html +113 -87
@@ -6,6 +6,7 @@
6 <meta name="color-scheme" content="light dark">6 <meta name="color-scheme" content="light dark">
7 <title>quiet · example.test</title>7 <title>quiet · example.test</title>
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <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>
9 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
10 <script src="/-/static/js/htmx.min.HASH.js" defer>11 <script src="/-/static/js/htmx.min.HASH.js" defer>
11 </script>12 </script>
@@ -16,12 +17,15 @@
16 <script src="/-/static/js/ermite.HASH.js" defer>17 <script src="/-/static/js/ermite.HASH.js" defer>
17 </script>18 </script>
18 </head>19 </head>
19-<body data-ref="" data-sha="" data-sel="">20+<body data-ref="" data-sha="" data-sel="" data-nav="">
20 <a class="skip" href="#main">skip to content</a>21 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">22+<header class="hdr">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>23+<div class="hdr-top">
23-<span class="fade" aria-hidden="true">24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
24 </span>26 </span>
27+<span>example.test</span>
28+</a>
25 <nav class="crumbs" aria-label="Path">29 <nav class="crumbs" aria-label="Path">
26 <ol>30 <ol>
27 <li>31 <li>
@@ -29,23 +33,33 @@
29 </li>33 </li>
30 </ol>34 </ol>
31 </nav>35 </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>
32 </header>46 </header>
33 <div class="work">47 <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">
35 <section class="state" aria-labelledby="state-title">49 <section class="state" aria-labelledby="state-title">
36-<h1 id="state-title">50+<p class="eyebrow">empty</p>
37-<span class="code">empty</span> quiet is empty</h1>51+<h1 id="state-title">quiet is empty</h1>
38 <p class="dim">no commits yet. push a branch to create one.</p>52 <p class="dim">no commits yet. push a branch to create one.</p>
39 <div class="clone" id="clone-quiet">53 <div class="clone" id="clone-quiet">
40 <div class="clone-line">54 <div class="clone-line">
41 <span class="mute" aria-hidden="true">$ </span>55 <span class="mute" aria-hidden="true">$ </span>
42 <code>git remote add origin http://example.test/quiet.git</code>56 <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>
44 </div>58 </div>
45 <div class="clone-line">59 <div class="clone-line">
46 <span class="mute" aria-hidden="true">$ </span>60 <span class="mute" aria-hidden="true">$ </span>
47 <code>git push -u origin main</code>61 <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>
49 </div>63 </div>
50 </div>64 </div>
51 <p class="state-back">65 <p class="state-back">
@@ -54,70 +68,71 @@
54 </section>68 </section>
55 </main>69 </main>
56 </div>70 </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()">
58 <div class="panel-head">86 <div class="panel-head">
59-<span>keys</span>87+<h2>Keyboard shortcuts</h2>
60-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>88+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
89+<kbd>esc</kbd> close</button>
61 </div>90 </div>
62 <dl class="keys">91 <dl class="keys">
63 <div>92 <div>
64 <dt>93 <dt>
65-<kbd>j</kbd>94+<kbd>ctrl k</kbd>
66-<kbd>k</kbd>95+<kbd>:</kbd>
67 </dt>96 </dt>
68-<dd>move cursor down / up</dd>97+<dd>go to a section, branch, tag, file or repository</dd>
69 </div>98 </div>
70 <div>99 <div>
71 <dt>100 <dt>
72-<kbd>enter</kbd>101+<kbd>?</kbd>
73 </dt>102 </dt>
74-<dd>open row under cursor</dd>103+<dd>this list</dd>
75 </div>104 </div>
76 <div>105 <div>
77 <dt>106 <dt>
78-<kbd>g</kbd>107+<kbd>j</kbd>
79-<kbd>g</kbd> / <kbd>G</kbd>108+<kbd>k</kbd>
80 </dt>109 </dt>
81-<dd>first / last row</dd>110+<dd>move the row cursor down / up</dd>
82 </div>111 </div>
83 <div>112 <div>
84 <dt>113 <dt>
85-<kbd>/</kbd>114+<kbd>enter</kbd>
86 </dt>115 </dt>
87-<dd>focus the filter</dd>116+<dd>open the row under the cursor</dd>
88 </div>117 </div>
89 <div>118 <div>
90 <dt>119 <dt>
91-<kbd>g h</kbd>120+<kbd>g g</kbd>
92-<kbd>g t</kbd>121+<kbd>G</kbd>
93 </dt>122 </dt>
94-<dd>go home / tree</dd>123+<dd>first / last row</dd>
95 </div>124 </div>
96 <div>125 <div>
97 <dt>126 <dt>
98-<kbd>g c</kbd>127+<kbd>/</kbd>
99-<kbd>g r</kbd>
100 </dt>128 </dt>
101-<dd>go commits / refs</dd>129+<dd>filter the list</dd>
102 </div>130 </div>
103 <div>131 <div>
104 <dt>132 <dt>
105 <kbd>b</kbd>133 <kbd>b</kbd>
106 </dt>134 </dt>
107-<dd>switch ref (branches, tags)</dd>135+<dd>switch branch or tag</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>
121 </div>136 </div>
122 <div>137 <div>
123 <dt>138 <dt>
@@ -125,15 +140,21 @@
125 <kbd>t</kbd>140 <kbd>t</kbd>
126 <kbd>c</kbd>141 <kbd>c</kbd>
127 <kbd>r</kbd>142 <kbd>r</kbd>
143+<kbd>m</kbd>
128 </dt>144 </dt>
129-<dd>repo sections (when shown)</dd>145+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
130 </div>146 </div>
131 <div>147 <div>
132 <dt>148 <dt>
133-<kbd>m</kbd>149+<kbd>g h</kbd>
134-<kbd>g m</kbd>
135 </dt>150 </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>
137 </div>158 </div>
138 <div>159 <div>
139 <dt>160 <dt>
@@ -153,81 +174,86 @@
153 <dt>174 <dt>
154 <kbd>esc</kbd>175 <kbd>esc</kbd>
155 </dt>176 </dt>
156-<dd>close panel</dd>177+<dd>close a dialog, leave a field</dd>
157 </div>178 </div>
158 </dl>179 </dl>
159 </dialog>180 </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()">
161 <div class="panel-head">182 <div class="panel-head">
162-<label for="cmd-q" class="hi">:</label>183+<label for="cmd-q" class="sr">go to</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();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();
164 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));185 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
165 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();186 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();
166 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));187 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
167 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;188 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
168 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' })) }189 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' })) }
169 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">190 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>
171 </div>194 </div>
172-<ul id="cmd-list" class="cmd-list">195+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
173 <li data-k="go home" data-cursor>196 <li data-k="go home" data-cursor>
174 <a href="/" tabindex="-1">197 <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>
176 </li>201 </li>
177 <li data-k="repo demo">202 <li data-k="repo demo">
178 <a href="/demo" tabindex="-1">203 <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>
180 </li>207 </li>
181 </ul>208 </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>
182 </dialog>218 </dialog>
183-<footer class="bar status" aria-label="Status">219+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
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) => {
198 if (q('dialog[open]').count) return;220 if (q('dialog[open]').count) return;
199 let rows = q('[data-rows] > :not([hidden])').arr();221 let rows = q('[data-rows] > :not([hidden])').arr();
200 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));222 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
201 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));223 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
202- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];224+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
203- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
204 })(1);228 })(1);
205- k-k from:body -> ((d) => {229+ k-k from:body -> (async (d) => {
206 if (q('dialog[open]').count) return;230 if (q('dialog[open]').count) return;
207 let rows = q('[data-rows] > :not([hidden])').arr();231 let rows = q('[data-rows] > :not([hidden])').arr();
208 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));232 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
209 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));233 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
210- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];234+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
211- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
212 })(-1);238 })(-1);
213 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));239 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
214 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));240 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));
215 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();241 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
216 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());242 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();243+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
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());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());
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();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();
220 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();246 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
221- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();247+ k-gh from:body -> document.querySelector('.brand')?.click();
222- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();248+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
223- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();249+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
224- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();250+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
225- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();251+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
226- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();252+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
227- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();253+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
228- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();254+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
229- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();255+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
230- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();256+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
231 k-next from:body -> {257 k-next from:body -> {
232 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;258 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
233 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);259 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/home.html +119 -101
@@ -6,6 +6,7 @@
6 <meta name="color-scheme" content="light dark">6 <meta name="color-scheme" content="light dark">
7 <title>example.test</title>7 <title>example.test</title>
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <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>
9 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
10 <script src="/-/static/js/htmx.min.HASH.js" defer>11 <script src="/-/static/js/htmx.min.HASH.js" defer>
11 </script>12 </script>
@@ -16,29 +17,31 @@
16 <script src="/-/static/js/ermite.HASH.js" defer>17 <script src="/-/static/js/ermite.HASH.js" defer>
17 </script>18 </script>
18 </head>19 </head>
19-<body data-ref="" data-sha="" data-sel="">20+<body data-ref="" data-sha="" data-sel="" data-nav="">
20 <a class="skip" href="#main">skip to content</a>21 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">22+<header class="hdr">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>23+<div class="hdr-top">
23-<span class="fade" aria-hidden="true">24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
24 </span>26 </span>
25-<nav class="crumbs" aria-label="Path">27+<span>example.test</span>
26-<ol>28+</a>
27-</ol>29+<div class="hdr-acts">
28-</nav>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'))">
29-<nav class="secs" aria-label="Sections">31+<span class="ico i-search" aria-hidden="true">
30-<ul>32+</span>
31-<li>33+<span class="search-label">Go to…</span>
32-<a href="/" data-key="h" aria-keyshortcuts="g h" aria-current="page">home</a>34+<kbd>ctrl k</kbd>
33-</li>35+</button>
34-</ul>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>
35-</nav>37+</div>
38+</div>
36 </header>39 </header>
37 <div class="work">40 <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">
39 <div class="filter">42 <div class="filter">
40-<label for="home-filter">filter&gt;</label>43+<label for="home-filter" class="sr">filter</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])')">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])')">
42 </div>45 </div>
43 <table class="list home" aria-label="Repositories">46 <table class="list home" aria-label="Repositories">
44 <thead>47 <thead>
@@ -90,70 +93,76 @@
90 </table>93 </table>
91 </main>94 </main>
92 </div>95 </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()">
94 <div class="panel-head">116 <div class="panel-head">
95-<span>keys</span>117+<h2>Keyboard shortcuts</h2>
96-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>118+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
119+<kbd>esc</kbd> close</button>
97 </div>120 </div>
98 <dl class="keys">121 <dl class="keys">
99 <div>122 <div>
100 <dt>123 <dt>
101-<kbd>j</kbd>124+<kbd>ctrl k</kbd>
102-<kbd>k</kbd>125+<kbd>:</kbd>
103 </dt>126 </dt>
104-<dd>move cursor down / up</dd>127+<dd>go to a section, branch, tag, file or repository</dd>
105 </div>128 </div>
106 <div>129 <div>
107 <dt>130 <dt>
108-<kbd>enter</kbd>131+<kbd>?</kbd>
109 </dt>132 </dt>
110-<dd>open row under cursor</dd>133+<dd>this list</dd>
111 </div>134 </div>
112 <div>135 <div>
113 <dt>136 <dt>
114-<kbd>g</kbd>137+<kbd>j</kbd>
115-<kbd>g</kbd> / <kbd>G</kbd>138+<kbd>k</kbd>
116 </dt>139 </dt>
117-<dd>first / last row</dd>140+<dd>move the row cursor down / up</dd>
118 </div>141 </div>
119 <div>142 <div>
120 <dt>143 <dt>
121-<kbd>/</kbd>144+<kbd>enter</kbd>
122 </dt>145 </dt>
123-<dd>focus the filter</dd>146+<dd>open the row under the cursor</dd>
124 </div>147 </div>
125 <div>148 <div>
126 <dt>149 <dt>
127-<kbd>g h</kbd>150+<kbd>g g</kbd>
128-<kbd>g t</kbd>151+<kbd>G</kbd>
129 </dt>152 </dt>
130-<dd>go home / tree</dd>153+<dd>first / last row</dd>
131 </div>154 </div>
132 <div>155 <div>
133 <dt>156 <dt>
134-<kbd>g c</kbd>157+<kbd>/</kbd>
135-<kbd>g r</kbd>
136 </dt>158 </dt>
137-<dd>go commits / refs</dd>159+<dd>filter the list</dd>
138 </div>160 </div>
139 <div>161 <div>
140 <dt>162 <dt>
141 <kbd>b</kbd>163 <kbd>b</kbd>
142 </dt>164 </dt>
143-<dd>switch ref (branches, tags)</dd>165+<dd>switch branch or tag</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>
157 </div>166 </div>
158 <div>167 <div>
159 <dt>168 <dt>
@@ -161,15 +170,21 @@
161 <kbd>t</kbd>170 <kbd>t</kbd>
162 <kbd>c</kbd>171 <kbd>c</kbd>
163 <kbd>r</kbd>172 <kbd>r</kbd>
173+<kbd>m</kbd>
164 </dt>174 </dt>
165-<dd>repo sections (when shown)</dd>175+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
166 </div>176 </div>
167 <div>177 <div>
168 <dt>178 <dt>
169-<kbd>m</kbd>179+<kbd>g h</kbd>
170-<kbd>g m</kbd>
171 </dt>180 </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>
173 </div>188 </div>
174 <div>189 <div>
175 <dt>190 <dt>
@@ -189,89 +204,92 @@
189 <dt>204 <dt>
190 <kbd>esc</kbd>205 <kbd>esc</kbd>
191 </dt>206 </dt>
192-<dd>close panel</dd>207+<dd>close a dialog, leave a field</dd>
193 </div>208 </div>
194 </dl>209 </dl>
195 </dialog>210 </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()">
197 <div class="panel-head">212 <div class="panel-head">
198-<label for="cmd-q" class="hi">:</label>213+<label for="cmd-q" class="sr">go to</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();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();
200 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));215 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
201 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();216 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();
202 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));217 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
203 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;218 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
204 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' })) }219 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' })) }
205 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">220 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>
207 </div>224 </div>
208-<ul id="cmd-list" class="cmd-list">225+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
209 <li data-k="go home" data-cursor>226 <li data-k="go home" data-cursor>
210 <a href="/" tabindex="-1">227 <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>
212 </li>231 </li>
213 <li data-k="repo demo">232 <li data-k="repo demo">
214 <a href="/demo" tabindex="-1">233 <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>
216 </li>237 </li>
217 <li data-k="repo quiet">238 <li data-k="repo quiet">
218 <a href="/quiet" tabindex="-1">239 <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>
220 </li>243 </li>
221 </ul>244 </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>
222 </dialog>254 </dialog>
223-<footer class="bar status" aria-label="Status">255+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
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) => {
242 if (q('dialog[open]').count) return;256 if (q('dialog[open]').count) return;
243 let rows = q('[data-rows] > :not([hidden])').arr();257 let rows = q('[data-rows] > :not([hidden])').arr();
244 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));258 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
245 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));259 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
246- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];260+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
247- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
248 })(1);264 })(1);
249- k-k from:body -> ((d) => {265+ k-k from:body -> (async (d) => {
250 if (q('dialog[open]').count) return;266 if (q('dialog[open]').count) return;
251 let rows = q('[data-rows] > :not([hidden])').arr();267 let rows = q('[data-rows] > :not([hidden])').arr();
252 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));268 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
253 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));269 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
254- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];270+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
255- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
256 })(-1);274 })(-1);
257 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));275 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
258 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));276 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));
259 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();277 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
260 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());278 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();279+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
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());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());
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();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();
264 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();282 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
265- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();283+ k-gh from:body -> document.querySelector('.brand')?.click();
266- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();284+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
267- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();285+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
268- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();286+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
269- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();287+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
270- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();288+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
271- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();289+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
272- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();290+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
273- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();291+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
274- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();292+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
275 k-next from:body -> {293 k-next from:body -> {
276 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;294 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
277 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);295 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 @@
6 <meta name="color-scheme" content="light dark">6 <meta name="color-scheme" content="light dark">
7 <title>404 · example.test</title>7 <title>404 · example.test</title>
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <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>
9 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
10 <script src="/-/static/js/htmx.min.HASH.js" defer>11 <script src="/-/static/js/htmx.min.HASH.js" defer>
11 </script>12 </script>
@@ -16,35 +17,38 @@
16 <script src="/-/static/js/ermite.HASH.js" defer>17 <script src="/-/static/js/ermite.HASH.js" defer>
17 </script>18 </script>
18 </head>19 </head>
19-<body data-ref="" data-sha="" data-sel="">20+<body data-ref="" data-sha="" data-sel="" data-nav="">
20 <a class="skip" href="#main">skip to content</a>21 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">22+<header class="hdr">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>23+<div class="hdr-top">
23-<span class="fade" aria-hidden="true">24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
24 </span>26 </span>
27+<span>example.test</span>
28+</a>
25 <nav class="crumbs" aria-label="Path">29 <nav class="crumbs" aria-label="Path">
26 <ol>30 <ol>
27 <li>31 <li>
28-<span aria-current="page">-</span>32+<span aria-current="page">not found</span>
29-</li>
30-<li>
31-<span aria-current="page">404</span>
32 </li>33 </li>
33 </ol>34 </ol>
34 </nav>35 </nav>
35-<nav class="secs" aria-label="Sections">36+<div class="hdr-acts">
36-<ul>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'))">
37-<li>38+<span class="ico i-search" aria-hidden="true">
38-<a href="/" data-key="h" aria-keyshortcuts="g h">home</a>39+</span>
39-</li>40+<span class="search-label">Go to…</span>
40-</ul>41+<kbd>ctrl k</kbd>
41-</nav>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>
42 </header>46 </header>
43 <div class="work">47 <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">
45 <section class="state" aria-labelledby="state-title">49 <section class="state" aria-labelledby="state-title">
46-<h1 id="state-title">50+<p class="eyebrow">404</p>
47-<span class="code">404</span> not found</h1>51+<h1 id="state-title">not found</h1>
48 <p class="dim">no such page, ref or path.</p>52 <p class="dim">no such page, ref or path.</p>
49 <p class="dim">check the URL, or start from the repo list.</p>53 <p class="dim">check the URL, or start from the repo list.</p>
50 <p class="state-back">54 <p class="state-back">
@@ -53,70 +57,71 @@
53 </section>57 </section>
54 </main>58 </main>
55 </div>59 </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()">
57 <div class="panel-head">75 <div class="panel-head">
58-<span>keys</span>76+<h2>Keyboard shortcuts</h2>
59-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>77+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
78+<kbd>esc</kbd> close</button>
60 </div>79 </div>
61 <dl class="keys">80 <dl class="keys">
62 <div>81 <div>
63 <dt>82 <dt>
64-<kbd>j</kbd>83+<kbd>ctrl k</kbd>
65-<kbd>k</kbd>84+<kbd>:</kbd>
66 </dt>85 </dt>
67-<dd>move cursor down / up</dd>86+<dd>go to a section, branch, tag, file or repository</dd>
68 </div>87 </div>
69 <div>88 <div>
70 <dt>89 <dt>
71-<kbd>enter</kbd>90+<kbd>?</kbd>
72 </dt>91 </dt>
73-<dd>open row under cursor</dd>92+<dd>this list</dd>
74 </div>93 </div>
75 <div>94 <div>
76 <dt>95 <dt>
77-<kbd>g</kbd>96+<kbd>j</kbd>
78-<kbd>g</kbd> / <kbd>G</kbd>97+<kbd>k</kbd>
79 </dt>98 </dt>
80-<dd>first / last row</dd>99+<dd>move the row cursor down / up</dd>
81 </div>100 </div>
82 <div>101 <div>
83 <dt>102 <dt>
84-<kbd>/</kbd>103+<kbd>enter</kbd>
85 </dt>104 </dt>
86-<dd>focus the filter</dd>105+<dd>open the row under the cursor</dd>
87 </div>106 </div>
88 <div>107 <div>
89 <dt>108 <dt>
90-<kbd>g h</kbd>109+<kbd>g g</kbd>
91-<kbd>g t</kbd>110+<kbd>G</kbd>
92 </dt>111 </dt>
93-<dd>go home / tree</dd>112+<dd>first / last row</dd>
94 </div>113 </div>
95 <div>114 <div>
96 <dt>115 <dt>
97-<kbd>g c</kbd>116+<kbd>/</kbd>
98-<kbd>g r</kbd>
99 </dt>117 </dt>
100-<dd>go commits / refs</dd>118+<dd>filter the list</dd>
101 </div>119 </div>
102 <div>120 <div>
103 <dt>121 <dt>
104 <kbd>b</kbd>122 <kbd>b</kbd>
105 </dt>123 </dt>
106-<dd>switch ref (branches, tags)</dd>124+<dd>switch branch or tag</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>
120 </div>125 </div>
121 <div>126 <div>
122 <dt>127 <dt>
@@ -124,15 +129,21 @@
124 <kbd>t</kbd>129 <kbd>t</kbd>
125 <kbd>c</kbd>130 <kbd>c</kbd>
126 <kbd>r</kbd>131 <kbd>r</kbd>
132+<kbd>m</kbd>
127 </dt>133 </dt>
128-<dd>repo sections (when shown)</dd>134+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
129 </div>135 </div>
130 <div>136 <div>
131 <dt>137 <dt>
132-<kbd>m</kbd>138+<kbd>g h</kbd>
133-<kbd>g m</kbd>139+</dt>
140+<dd>home</dd>
141+</div>
142+<div>
143+<dt>
144+<kbd>y</kbd>
134 </dt>145 </dt>
135-<dd>merge requests (when shown)</dd>146+<dd>copy a permalink pinned to the commit</dd>
136 </div>147 </div>
137 <div>148 <div>
138 <dt>149 <dt>
@@ -152,77 +163,80 @@
152 <dt>163 <dt>
153 <kbd>esc</kbd>164 <kbd>esc</kbd>
154 </dt>165 </dt>
155-<dd>close panel</dd>166+<dd>close a dialog, leave a field</dd>
156 </div>167 </div>
157 </dl>168 </dl>
158 </dialog>169 </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()">
160 <div class="panel-head">171 <div class="panel-head">
161-<label for="cmd-q" class="hi">:</label>172+<label for="cmd-q" class="sr">go to</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();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();
163 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));174 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
164 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();175 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();
165 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));176 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
166 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;177 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
167 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' })) }178 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' })) }
168 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">179 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>
170 </div>183 </div>
171-<ul id="cmd-list" class="cmd-list">184+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
172 <li data-k="go home" data-cursor>185 <li data-k="go home" data-cursor>
173 <a href="/" tabindex="-1">186 <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>
175 </li>190 </li>
176 </ul>191 </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>
177 </dialog>201 </dialog>
178-<footer class="bar status" aria-label="Status">202+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
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) => {
193 if (q('dialog[open]').count) return;203 if (q('dialog[open]').count) return;
194 let rows = q('[data-rows] > :not([hidden])').arr();204 let rows = q('[data-rows] > :not([hidden])').arr();
195 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));205 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
196 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));206 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
197- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];207+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
198- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
199 })(1);211 })(1);
200- k-k from:body -> ((d) => {212+ k-k from:body -> (async (d) => {
201 if (q('dialog[open]').count) return;213 if (q('dialog[open]').count) return;
202 let rows = q('[data-rows] > :not([hidden])').arr();214 let rows = q('[data-rows] > :not([hidden])').arr();
203 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));215 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
204 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));216 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
205- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];217+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
206- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
207 })(-1);221 })(-1);
208 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));222 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
209 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));223 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));
210 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();224 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
211 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());225 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();226+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
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());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());
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();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();
215 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();229 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
216- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();230+ k-gh from:body -> document.querySelector('.brand')?.click();
217- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();231+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
218- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();232+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
219- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();233+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
220- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();234+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
221- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();235+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
222- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();236+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
223- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();237+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
224- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();238+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
225- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();239+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
226 k-next from:body -> {240 k-next from:body -> {
227 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;241 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
228 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);242 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/not-found.html +109 -95
@@ -6,6 +6,7 @@
6 <meta name="color-scheme" content="light dark">6 <meta name="color-scheme" content="light dark">
7 <title>404 · example.test</title>7 <title>404 · example.test</title>
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <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>
9 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
10 <script src="/-/static/js/htmx.min.HASH.js" defer>11 <script src="/-/static/js/htmx.min.HASH.js" defer>
11 </script>12 </script>
@@ -16,35 +17,38 @@
16 <script src="/-/static/js/ermite.HASH.js" defer>17 <script src="/-/static/js/ermite.HASH.js" defer>
17 </script>18 </script>
18 </head>19 </head>
19-<body data-ref="" data-sha="" data-sel="">20+<body data-ref="" data-sha="" data-sel="" data-nav="">
20 <a class="skip" href="#main">skip to content</a>21 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">22+<header class="hdr">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>23+<div class="hdr-top">
23-<span class="fade" aria-hidden="true">24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
24 </span>26 </span>
27+<span>example.test</span>
28+</a>
25 <nav class="crumbs" aria-label="Path">29 <nav class="crumbs" aria-label="Path">
26 <ol>30 <ol>
27 <li>31 <li>
28-<span aria-current="page">-</span>32+<span aria-current="page">not found</span>
29-</li>
30-<li>
31-<span aria-current="page">404</span>
32 </li>33 </li>
33 </ol>34 </ol>
34 </nav>35 </nav>
35-<nav class="secs" aria-label="Sections">36+<div class="hdr-acts">
36-<ul>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'))">
37-<li>38+<span class="ico i-search" aria-hidden="true">
38-<a href="/" data-key="h" aria-keyshortcuts="g h">home</a>39+</span>
39-</li>40+<span class="search-label">Go to…</span>
40-</ul>41+<kbd>ctrl k</kbd>
41-</nav>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>
42 </header>46 </header>
43 <div class="work">47 <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">
45 <section class="state" aria-labelledby="state-title">49 <section class="state" aria-labelledby="state-title">
46-<h1 id="state-title">50+<p class="eyebrow">404</p>
47-<span class="code">404</span> not found</h1>51+<h1 id="state-title">not found</h1>
48 <p class="dim">no such page, ref or path.</p>52 <p class="dim">no such page, ref or path.</p>
49 <p class="dim">check the URL, or start from the repo list.</p>53 <p class="dim">check the URL, or start from the repo list.</p>
50 <p class="state-back">54 <p class="state-back">
@@ -53,70 +57,71 @@
53 </section>57 </section>
54 </main>58 </main>
55 </div>59 </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()">
57 <div class="panel-head">75 <div class="panel-head">
58-<span>keys</span>76+<h2>Keyboard shortcuts</h2>
59-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>77+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
78+<kbd>esc</kbd> close</button>
60 </div>79 </div>
61 <dl class="keys">80 <dl class="keys">
62 <div>81 <div>
63 <dt>82 <dt>
64-<kbd>j</kbd>83+<kbd>ctrl k</kbd>
65-<kbd>k</kbd>84+<kbd>:</kbd>
66 </dt>85 </dt>
67-<dd>move cursor down / up</dd>86+<dd>go to a section, branch, tag, file or repository</dd>
68 </div>87 </div>
69 <div>88 <div>
70 <dt>89 <dt>
71-<kbd>enter</kbd>90+<kbd>?</kbd>
72 </dt>91 </dt>
73-<dd>open row under cursor</dd>92+<dd>this list</dd>
74 </div>93 </div>
75 <div>94 <div>
76 <dt>95 <dt>
77-<kbd>g</kbd>96+<kbd>j</kbd>
78-<kbd>g</kbd> / <kbd>G</kbd>97+<kbd>k</kbd>
79 </dt>98 </dt>
80-<dd>first / last row</dd>99+<dd>move the row cursor down / up</dd>
81 </div>100 </div>
82 <div>101 <div>
83 <dt>102 <dt>
84-<kbd>/</kbd>103+<kbd>enter</kbd>
85 </dt>104 </dt>
86-<dd>focus the filter</dd>105+<dd>open the row under the cursor</dd>
87 </div>106 </div>
88 <div>107 <div>
89 <dt>108 <dt>
90-<kbd>g h</kbd>109+<kbd>g g</kbd>
91-<kbd>g t</kbd>110+<kbd>G</kbd>
92 </dt>111 </dt>
93-<dd>go home / tree</dd>112+<dd>first / last row</dd>
94 </div>113 </div>
95 <div>114 <div>
96 <dt>115 <dt>
97-<kbd>g c</kbd>116+<kbd>/</kbd>
98-<kbd>g r</kbd>
99 </dt>117 </dt>
100-<dd>go commits / refs</dd>118+<dd>filter the list</dd>
101 </div>119 </div>
102 <div>120 <div>
103 <dt>121 <dt>
104 <kbd>b</kbd>122 <kbd>b</kbd>
105 </dt>123 </dt>
106-<dd>switch ref (branches, tags)</dd>124+<dd>switch branch or tag</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>
120 </div>125 </div>
121 <div>126 <div>
122 <dt>127 <dt>
@@ -124,15 +129,21 @@
124 <kbd>t</kbd>129 <kbd>t</kbd>
125 <kbd>c</kbd>130 <kbd>c</kbd>
126 <kbd>r</kbd>131 <kbd>r</kbd>
132+<kbd>m</kbd>
127 </dt>133 </dt>
128-<dd>repo sections (when shown)</dd>134+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
129 </div>135 </div>
130 <div>136 <div>
131 <dt>137 <dt>
132-<kbd>m</kbd>138+<kbd>g h</kbd>
133-<kbd>g m</kbd>139+</dt>
140+<dd>home</dd>
141+</div>
142+<div>
143+<dt>
144+<kbd>y</kbd>
134 </dt>145 </dt>
135-<dd>merge requests (when shown)</dd>146+<dd>copy a permalink pinned to the commit</dd>
136 </div>147 </div>
137 <div>148 <div>
138 <dt>149 <dt>
@@ -152,77 +163,80 @@
152 <dt>163 <dt>
153 <kbd>esc</kbd>164 <kbd>esc</kbd>
154 </dt>165 </dt>
155-<dd>close panel</dd>166+<dd>close a dialog, leave a field</dd>
156 </div>167 </div>
157 </dl>168 </dl>
158 </dialog>169 </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()">
160 <div class="panel-head">171 <div class="panel-head">
161-<label for="cmd-q" class="hi">:</label>172+<label for="cmd-q" class="sr">go to</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();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();
163 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));174 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
164 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();175 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();
165 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));176 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
166 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;177 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
167 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' })) }178 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' })) }
168 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">179 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>
170 </div>183 </div>
171-<ul id="cmd-list" class="cmd-list">184+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
172 <li data-k="go home" data-cursor>185 <li data-k="go home" data-cursor>
173 <a href="/" tabindex="-1">186 <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>
175 </li>190 </li>
176 </ul>191 </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>
177 </dialog>201 </dialog>
178-<footer class="bar status" aria-label="Status">202+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
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) => {
193 if (q('dialog[open]').count) return;203 if (q('dialog[open]').count) return;
194 let rows = q('[data-rows] > :not([hidden])').arr();204 let rows = q('[data-rows] > :not([hidden])').arr();
195 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));205 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
196 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));206 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
197- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];207+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
198- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
199 })(1);211 })(1);
200- k-k from:body -> ((d) => {212+ k-k from:body -> (async (d) => {
201 if (q('dialog[open]').count) return;213 if (q('dialog[open]').count) return;
202 let rows = q('[data-rows] > :not([hidden])').arr();214 let rows = q('[data-rows] > :not([hidden])').arr();
203 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));215 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
204 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));216 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
205- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];217+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
206- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
207 })(-1);221 })(-1);
208 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));222 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
209 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));223 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));
210 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();224 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
211 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());225 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();226+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
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());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());
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();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();
215 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();229 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
216- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();230+ k-gh from:body -> document.querySelector('.brand')?.click();
217- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();231+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
218- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();232+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
219- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();233+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
220- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();234+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
221- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();235+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
222- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();236+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
223- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();237+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
224- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();238+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
225- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();239+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
226 k-next from:body -> {240 k-next from:body -> {
227 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;241 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
228 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);242 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/refs.html +172 -126
@@ -6,6 +6,7 @@
6 <meta name="color-scheme" content="light dark">6 <meta name="color-scheme" content="light dark">
7 <title>refs · demo · example.test</title>7 <title>refs · demo · example.test</title>
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <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>
9 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
10 <script src="/-/static/js/htmx.min.HASH.js" defer>11 <script src="/-/static/js/htmx.min.HASH.js" defer>
11 </script>12 </script>
@@ -16,12 +17,15 @@
16 <script src="/-/static/js/ermite.HASH.js" defer>17 <script src="/-/static/js/ermite.HASH.js" defer>
17 </script>18 </script>
18 </head>19 </head>
19-<body data-ref="main" data-sha="SHA40" data-sel="">20+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
20 <a class="skip" href="#main">skip to content</a>21 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">22+<header class="hdr">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>23+<div class="hdr-top">
23-<span class="fade" aria-hidden="true">24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
24 </span>26 </span>
27+<span>example.test</span>
28+</a>
25 <nav class="crumbs" aria-label="Path">29 <nav class="crumbs" aria-label="Path">
26 <ol>30 <ol>
27 <li>31 <li>
@@ -32,33 +36,52 @@
32 </li>36 </li>
33 </ol>37 </ol>
34 </nav>38 </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">
36 <ul>56 <ul>
37 <li>57 <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>
39 </li>60 </li>
40 <li>61 <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>
42 </li>64 </li>
43 <li>65 <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>
45 </li>68 </li>
46 <li>69 <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>
48 </li>72 </li>
49 <li>73 <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>
51 </li>76 </li>
52 </ul>77 </ul>
53 </nav>78 </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>
56 </header>79 </header>
57 <div class="work has-rail">80 <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">
59 <div class="filter">82 <div class="filter">
60-<label for="ref-filter">filter&gt;</label>83+<label for="ref-filter" class="sr">filter</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));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));
62 let first = q('#ref-rows > tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#ref-rows > tr')">85 let first = q('#ref-rows > tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#ref-rows > tr')">
63 </div>86 </div>
64 <table class="list refs" aria-label="Branches and tags">87 <table class="list refs" aria-label="Branches and tags">
@@ -80,7 +103,7 @@
80 <td class="c-kind mute">branch</td>103 <td class="c-kind mute">branch</td>
81 <td class="c-name">104 <td class="c-name">
82 <a href="/demo/tree/main">main</a>105 <a href="/demo/tree/main">main</a>
83-<span class="tagged">*</span>106+<span class="pill">default</span>
84 </td>107 </td>
85 <td class="c-sha">108 <td class="c-sha">
86 <a href="/demo/commit/SHA40">SHA7</a>109 <a href="/demo/commit/SHA40">SHA7</a>
@@ -139,10 +162,8 @@
139 </table>162 </table>
140 </main>163 </main>
141 <aside class="rail" aria-label="Context">164 <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>
144 <section aria-labelledby="r-refs">165 <section aria-labelledby="r-refs">
145-<h2 id="r-refs">refs</h2>166+<h2 id="r-refs">Overview</h2>
146 <dl class="kv" id="refs-kv">167 <dl class="kv" id="refs-kv">
147 <dt>default</dt>168 <dt>default</dt>
148 <dd>main</dd>169 <dd>main</dd>
@@ -154,80 +175,78 @@
154 <dd>v0.2.0</dd>175 <dd>v0.2.0</dd>
155 </dl>176 </dl>
156 </section>177 </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>
165 </aside>178 </aside>
166 </div>179 </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()">
168 <div class="panel-head">200 <div class="panel-head">
169-<span>keys</span>201+<h2>Keyboard shortcuts</h2>
170-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>202+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
203+<kbd>esc</kbd> close</button>
171 </div>204 </div>
172 <dl class="keys">205 <dl class="keys">
173 <div>206 <div>
174 <dt>207 <dt>
175-<kbd>j</kbd>208+<kbd>ctrl k</kbd>
176-<kbd>k</kbd>209+<kbd>:</kbd>
177 </dt>210 </dt>
178-<dd>move cursor down / up</dd>211+<dd>go to a section, branch, tag, file or repository</dd>
179 </div>212 </div>
180 <div>213 <div>
181 <dt>214 <dt>
182-<kbd>enter</kbd>215+<kbd>?</kbd>
183 </dt>216 </dt>
184-<dd>open row under cursor</dd>217+<dd>this list</dd>
185 </div>218 </div>
186 <div>219 <div>
187 <dt>220 <dt>
188-<kbd>g</kbd>221+<kbd>j</kbd>
189-<kbd>g</kbd> / <kbd>G</kbd>222+<kbd>k</kbd>
190 </dt>223 </dt>
191-<dd>first / last row</dd>224+<dd>move the row cursor down / up</dd>
192 </div>225 </div>
193 <div>226 <div>
194 <dt>227 <dt>
195-<kbd>/</kbd>228+<kbd>enter</kbd>
196 </dt>229 </dt>
197-<dd>focus the filter</dd>230+<dd>open the row under the cursor</dd>
198 </div>231 </div>
199 <div>232 <div>
200 <dt>233 <dt>
201-<kbd>g h</kbd>234+<kbd>g g</kbd>
202-<kbd>g t</kbd>235+<kbd>G</kbd>
203 </dt>236 </dt>
204-<dd>go home / tree</dd>237+<dd>first / last row</dd>
205 </div>238 </div>
206 <div>239 <div>
207 <dt>240 <dt>
208-<kbd>g c</kbd>241+<kbd>/</kbd>
209-<kbd>g r</kbd>
210 </dt>242 </dt>
211-<dd>go commits / refs</dd>243+<dd>filter the list</dd>
212 </div>244 </div>
213 <div>245 <div>
214 <dt>246 <dt>
215 <kbd>b</kbd>247 <kbd>b</kbd>
216 </dt>248 </dt>
217-<dd>switch ref (branches, tags)</dd>249+<dd>switch branch or tag</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>
231 </div>250 </div>
232 <div>251 <div>
233 <dt>252 <dt>
@@ -235,15 +254,21 @@
235 <kbd>t</kbd>254 <kbd>t</kbd>
236 <kbd>c</kbd>255 <kbd>c</kbd>
237 <kbd>r</kbd>256 <kbd>r</kbd>
257+<kbd>m</kbd>
238 </dt>258 </dt>
239-<dd>repo sections (when shown)</dd>259+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
240 </div>260 </div>
241 <div>261 <div>
242 <dt>262 <dt>
243-<kbd>m</kbd>263+<kbd>g h</kbd>
244-<kbd>g m</kbd>
245 </dt>264 </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>
247 </div>272 </div>
248 <div>273 <div>
249 <dt>274 <dt>
@@ -263,19 +288,21 @@
263 <dt>288 <dt>
264 <kbd>esc</kbd>289 <kbd>esc</kbd>
265 </dt>290 </dt>
266-<dd>close panel</dd>291+<dd>close a dialog, leave a field</dd>
267 </div>292 </div>
268 </dl>293 </dl>
269 </dialog>294 </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()">
271 <div class="panel-head">296 <div class="panel-head">
272-<label for="refs-filter">ref&gt;</label>297+<label for="refs-filter" class="sr">filter branches and tags</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))">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))">
274-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>299+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
300+<kbd>esc</kbd>
301+</button>
275 </div>302 </div>
276 <div class="panel-cols" data-picklist>303 <div class="panel-cols" data-picklist>
277 <section aria-label="Branches">304 <section aria-label="Branches">
278-<h2>branches <span class="mute">2</span>305+<h2>Branches <span class="count">2</span>
279 </h2>306 </h2>
280 <ul>307 <ul>
281 <li>308 <li>
@@ -289,7 +316,7 @@
289 </ul>316 </ul>
290 </section>317 </section>
291 <section aria-label="Tags">318 <section aria-label="Tags">
292-<h2>tags <span class="mute">2</span>319+<h2>Tags <span class="count">2</span>
293 </h2>320 </h2>
294 <ul>321 <ul>
295 <li>322 <li>
@@ -304,117 +331,136 @@
304 </section>331 </section>
305 </div>332 </div>
306 </dialog>333 </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()">
308 <div class="panel-head">335 <div class="panel-head">
309-<label for="cmd-q" class="hi">:</label>336+<label for="cmd-q" class="sr">go to</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();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();
311 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));338 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
312 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();339 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();
313 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));340 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
314 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;341 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
315 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' })) }342 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' })) }
316 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">343 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>
318 </div>347 </div>
319-<ul id="cmd-list" class="cmd-list">348+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
320 <li data-k="go home" data-cursor>349 <li data-k="go home" data-cursor>
321 <a href="/" tabindex="-1">350 <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>
323 </li>354 </li>
324 <li data-k="section summary">355 <li data-k="section summary">
325 <a href="/demo" tabindex="-1">356 <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>
327 </li>360 </li>
328-<li data-k="section tree">361+<li data-k="section files">
329 <a href="/demo/tree/main" tabindex="-1">362 <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>
331 </li>366 </li>
332 <li data-k="section commits">367 <li data-k="section commits">
333 <a href="/demo/commits/main" tabindex="-1">368 <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>
335 </li>372 </li>
336-<li data-k="section refs">373+<li data-k="section branches &amp; tags">
337 <a href="/demo/refs" tabindex="-1">374 <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>
339 </li>378 </li>
340 <li data-k="section merge requests">379 <li data-k="section merge requests">
341 <a href="/demo/merge-requests" tabindex="-1">380 <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>
343 </li>384 </li>
344 <li data-k="branch feature/x">385 <li data-k="branch feature/x">
345 <a href="/demo/tree/feature/x" tabindex="-1">386 <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>
347 </li>390 </li>
348 <li data-k="branch main">391 <li data-k="branch main">
349 <a href="/demo/tree/main" tabindex="-1">392 <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>
351 </li>396 </li>
352 <li data-k="tag v0.1.0">397 <li data-k="tag v0.1.0">
353 <a href="/demo/tree/v0.1.0" tabindex="-1">398 <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>
355 </li>402 </li>
356 <li data-k="tag v0.2.0">403 <li data-k="tag v0.2.0">
357 <a href="/demo/tree/v0.2.0" tabindex="-1">404 <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>
359 </li>408 </li>
360 <li data-k="repo quiet">409 <li data-k="repo quiet">
361 <a href="/quiet" tabindex="-1">410 <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>
363 </li>414 </li>
364 </ul>415 </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>
365 </dialog>425 </dialog>
366-<footer class="bar status" aria-label="Status">426+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
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) => {
385 if (q('dialog[open]').count) return;427 if (q('dialog[open]').count) return;
386 let rows = q('[data-rows] > :not([hidden])').arr();428 let rows = q('[data-rows] > :not([hidden])').arr();
387 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));429 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
388 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));430 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
389- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];431+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
390- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
391 })(1);435 })(1);
392- k-k from:body -> ((d) => {436+ k-k from:body -> (async (d) => {
393 if (q('dialog[open]').count) return;437 if (q('dialog[open]').count) return;
394 let rows = q('[data-rows] > :not([hidden])').arr();438 let rows = q('[data-rows] > :not([hidden])').arr();
395 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));439 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
396 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));440 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
397- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];441+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
398- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
399 })(-1);445 })(-1);
400 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));446 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
401 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));447 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));
402 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();448 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
403 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());449 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();450+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
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());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());
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();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();
407 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();453 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
408- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();454+ k-gh from:body -> document.querySelector('.brand')?.click();
409- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();455+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
410- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();456+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
411- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();457+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
412- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();458+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
413- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();459+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
414- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();460+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
415- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();461+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
416- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();462+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
417- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();463+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
418 k-next from:body -> {464 k-next from:body -> {
419 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;465 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
420 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);466 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/summary.html +184 -127
@@ -6,6 +6,7 @@
6 <meta name="color-scheme" content="light dark">6 <meta name="color-scheme" content="light dark">
7 <title>demo · example.test</title>7 <title>demo · example.test</title>
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <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>
9 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
10 <script src="/-/static/js/htmx.min.HASH.js" defer>11 <script src="/-/static/js/htmx.min.HASH.js" defer>
11 </script>12 </script>
@@ -16,12 +17,15 @@
16 <script src="/-/static/js/ermite.HASH.js" defer>17 <script src="/-/static/js/ermite.HASH.js" defer>
17 </script>18 </script>
18 </head>19 </head>
19-<body data-ref="main" data-sha="SHA40" data-sel="">20+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
20 <a class="skip" href="#main">skip to content</a>21 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">22+<header class="hdr">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>23+<div class="hdr-top">
23-<span class="fade" aria-hidden="true">24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
24 </span>26 </span>
27+<span>example.test</span>
28+</a>
25 <nav class="crumbs" aria-label="Path">29 <nav class="crumbs" aria-label="Path">
26 <ol>30 <ol>
27 <li>31 <li>
@@ -29,34 +33,54 @@
29 </li>33 </li>
30 </ol>34 </ol>
31 </nav>35 </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">
33 <ul>53 <ul>
34 <li>54 <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>
36 </li>57 </li>
37 <li>58 <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>
39 </li>61 </li>
40 <li>62 <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>
42 </li>65 </li>
43 <li>66 <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>
45 </li>69 </li>
46 <li>70 <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>
48 </li>73 </li>
49 </ul>74 </ul>
50 </nav>75 </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>
53 </header>76 </header>
54-<div class="work has-rail read">77+<div class="work has-rail">
55-<main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">78+<main id="main" tabindex="-1">
56 <p class="lede">A demo repository</p>79 <p class="lede">A demo repository</p>
57-<h2 class="rule-h">80+<section class="card readme" aria-labelledby="readme-h">
58-<span>README.md</span>81+<h2 class="card-h" id="readme-h">
59-</h2>82+<span class="ico i-file" aria-hidden="true">
83+</span> README.md</h2>
60 <article class="prose" aria-label="README.md">84 <article class="prose" aria-label="README.md">
61 <h1 id="md-demo">Demo</h1>85 <h1 id="md-demo">Demo</h1>
62 <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>86 <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 @@
81 </tbody>105 </tbody>
82 </table>106 </table>
83 </article>107 </article>
108+</section>
84 </main>109 </main>
85 <aside class="rail" aria-label="Context">110 <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>
88 <section aria-labelledby="r-clone">111 <section aria-labelledby="r-clone">
89-<h2 id="r-clone">clone</h2>112+<h2 id="r-clone">Clone</h2>
90 <div class="clone" id="clone-demo">113 <div class="clone" id="clone-demo">
91 <div class="clone-line">114 <div class="clone-line">
92 <span class="mute" aria-hidden="true">$ </span>115 <span class="mute" aria-hidden="true">$ </span>
93 <code>git clone http://example.test/demo.git</code>116 <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>
95 </div>118 </div>
96 </div>119 </div>
97 </section>120 </section>
98 <section aria-labelledby="r-facts">121 <section aria-labelledby="r-facts">
99-<h2 id="r-facts">facts</h2>122+<h2 id="r-facts">About</h2>
100 <dl class="kv">123 <dl class="kv">
101 <dt>last push</dt>124 <dt>last push</dt>
102 <dd id="last-push-demo">125 <dd id="last-push-demo">
@@ -113,7 +136,7 @@
113 </dl>136 </dl>
114 </section>137 </section>
115 <section aria-labelledby="r-recent">138 <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>
117 </h2>140 </h2>
118 <table class="list commits" aria-label="Commits">141 <table class="list commits" aria-label="Commits">
119 <tbody data-rows>142 <tbody data-rows>
@@ -123,8 +146,9 @@
123 <td class="c-sha mute">SHA7</td>146 <td class="c-sha mute">SHA7</td>
124 <td class="c-subject">147 <td class="c-subject">
125 <a href="/demo/commit/SHA40">add main.go</a>148 <a href="/demo/commit/SHA40">add main.go</a>
126-<span class="tagged">[main]</span>149+<span class="pill">main</span>
127-<span class="tagged">[tag: v0.2.0]</span>150+<span class="pill tag">
151+<span class="sr">tag </span>v0.2.0</span>
128 </td>152 </td>
129 <td class="c-who dim">T</td>153 <td class="c-who dim">T</td>
130 <td class="c-when nowrap dim">154 <td class="c-when nowrap dim">
@@ -137,7 +161,8 @@
137 <td class="c-sha mute">SHA7</td>161 <td class="c-sha mute">SHA7</td>
138 <td class="c-subject">162 <td class="c-subject">
139 <a href="/demo/commit/SHA40">first commit</a>163 <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>
141 </td>166 </td>
142 <td class="c-who dim">T</td>167 <td class="c-who dim">T</td>
143 <td class="c-when nowrap dim">168 <td class="c-when nowrap dim">
@@ -148,80 +173,85 @@
148 </table>173 </table>
149 </section>174 </section>
150 <section aria-labelledby="r-outline">175 <section aria-labelledby="r-outline">
151-<h2 id="r-outline">README.md</h2>176+<h2 id="r-outline">On this page</h2>
152 <nav class="outline" aria-label="Outline">177 <nav class="outline" aria-label="Outline">
153 <a class="l1" href="#md-demo">Demo</a>178 <a class="l1" href="#md-demo">Demo</a>
154 <a class="l2" href="#md-install">Install</a>179 <a class="l2" href="#md-install">Install</a>
155 <a class="l2" href="#md-usage">Usage</a>180 <a class="l2" href="#md-usage">Usage</a>
156 </nav>181 </nav>
157 </section>182 </section>
158-</details>
159 </aside>183 </aside>
160 </div>184 </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()">
162 <div class="panel-head">205 <div class="panel-head">
163-<span>keys</span>206+<h2>Keyboard shortcuts</h2>
164-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>207+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
208+<kbd>esc</kbd> close</button>
165 </div>209 </div>
166 <dl class="keys">210 <dl class="keys">
167 <div>211 <div>
168 <dt>212 <dt>
169-<kbd>j</kbd>213+<kbd>ctrl k</kbd>
170-<kbd>k</kbd>214+<kbd>:</kbd>
171 </dt>215 </dt>
172-<dd>move cursor down / up</dd>216+<dd>go to a section, branch, tag, file or repository</dd>
173 </div>217 </div>
174 <div>218 <div>
175 <dt>219 <dt>
176-<kbd>enter</kbd>220+<kbd>?</kbd>
177 </dt>221 </dt>
178-<dd>open row under cursor</dd>222+<dd>this list</dd>
179 </div>223 </div>
180 <div>224 <div>
181 <dt>225 <dt>
182-<kbd>g</kbd>226+<kbd>j</kbd>
183-<kbd>g</kbd> / <kbd>G</kbd>227+<kbd>k</kbd>
184 </dt>228 </dt>
185-<dd>first / last row</dd>229+<dd>move the row cursor down / up</dd>
186 </div>230 </div>
187 <div>231 <div>
188 <dt>232 <dt>
189-<kbd>/</kbd>233+<kbd>enter</kbd>
190 </dt>234 </dt>
191-<dd>focus the filter</dd>235+<dd>open the row under the cursor</dd>
192 </div>236 </div>
193 <div>237 <div>
194 <dt>238 <dt>
195-<kbd>g h</kbd>239+<kbd>g g</kbd>
196-<kbd>g t</kbd>240+<kbd>G</kbd>
197 </dt>241 </dt>
198-<dd>go home / tree</dd>242+<dd>first / last row</dd>
199 </div>243 </div>
200 <div>244 <div>
201 <dt>245 <dt>
202-<kbd>g c</kbd>246+<kbd>/</kbd>
203-<kbd>g r</kbd>
204 </dt>247 </dt>
205-<dd>go commits / refs</dd>248+<dd>filter the list</dd>
206 </div>249 </div>
207 <div>250 <div>
208 <dt>251 <dt>
209 <kbd>b</kbd>252 <kbd>b</kbd>
210 </dt>253 </dt>
211-<dd>switch ref (branches, tags)</dd>254+<dd>switch branch or tag</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>
225 </div>255 </div>
226 <div>256 <div>
227 <dt>257 <dt>
@@ -229,15 +259,21 @@
229 <kbd>t</kbd>259 <kbd>t</kbd>
230 <kbd>c</kbd>260 <kbd>c</kbd>
231 <kbd>r</kbd>261 <kbd>r</kbd>
262+<kbd>m</kbd>
232 </dt>263 </dt>
233-<dd>repo sections (when shown)</dd>264+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
234 </div>265 </div>
235 <div>266 <div>
236 <dt>267 <dt>
237-<kbd>m</kbd>268+<kbd>g h</kbd>
238-<kbd>g m</kbd>
239 </dt>269 </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>
241 </div>277 </div>
242 <div>278 <div>
243 <dt>279 <dt>
@@ -257,19 +293,21 @@
257 <dt>293 <dt>
258 <kbd>esc</kbd>294 <kbd>esc</kbd>
259 </dt>295 </dt>
260-<dd>close panel</dd>296+<dd>close a dialog, leave a field</dd>
261 </div>297 </div>
262 </dl>298 </dl>
263 </dialog>299 </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()">
265 <div class="panel-head">301 <div class="panel-head">
266-<label for="refs-filter">ref&gt;</label>302+<label for="refs-filter" class="sr">filter branches and tags</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))">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))">
268-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>304+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
305+<kbd>esc</kbd>
306+</button>
269 </div>307 </div>
270 <div class="panel-cols" data-picklist>308 <div class="panel-cols" data-picklist>
271 <section aria-label="Branches">309 <section aria-label="Branches">
272-<h2>branches <span class="mute">2</span>310+<h2>Branches <span class="count">2</span>
273 </h2>311 </h2>
274 <ul>312 <ul>
275 <li>313 <li>
@@ -283,7 +321,7 @@
283 </ul>321 </ul>
284 </section>322 </section>
285 <section aria-label="Tags">323 <section aria-label="Tags">
286-<h2>tags <span class="mute">2</span>324+<h2>Tags <span class="count">2</span>
287 </h2>325 </h2>
288 <ul>326 <ul>
289 <li>327 <li>
@@ -298,117 +336,136 @@
298 </section>336 </section>
299 </div>337 </div>
300 </dialog>338 </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()">
302 <div class="panel-head">340 <div class="panel-head">
303-<label for="cmd-q" class="hi">:</label>341+<label for="cmd-q" class="sr">go to</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();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();
305 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));343 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
306 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();344 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();
307 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));345 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
308 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;346 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
309 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' })) }347 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' })) }
310 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">348 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>
312 </div>352 </div>
313-<ul id="cmd-list" class="cmd-list">353+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
314 <li data-k="go home" data-cursor>354 <li data-k="go home" data-cursor>
315 <a href="/" tabindex="-1">355 <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>
317 </li>359 </li>
318 <li data-k="section summary">360 <li data-k="section summary">
319 <a href="/demo" tabindex="-1">361 <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>
321 </li>365 </li>
322-<li data-k="section tree">366+<li data-k="section files">
323 <a href="/demo/tree/main" tabindex="-1">367 <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>
325 </li>371 </li>
326 <li data-k="section commits">372 <li data-k="section commits">
327 <a href="/demo/commits/main" tabindex="-1">373 <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>
329 </li>377 </li>
330-<li data-k="section refs">378+<li data-k="section branches &amp; tags">
331 <a href="/demo/refs" tabindex="-1">379 <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>
333 </li>383 </li>
334 <li data-k="section merge requests">384 <li data-k="section merge requests">
335 <a href="/demo/merge-requests" tabindex="-1">385 <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>
337 </li>389 </li>
338 <li data-k="branch feature/x">390 <li data-k="branch feature/x">
339 <a href="/demo/tree/feature/x" tabindex="-1">391 <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>
341 </li>395 </li>
342 <li data-k="branch main">396 <li data-k="branch main">
343 <a href="/demo/tree/main" tabindex="-1">397 <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>
345 </li>401 </li>
346 <li data-k="tag v0.1.0">402 <li data-k="tag v0.1.0">
347 <a href="/demo/tree/v0.1.0" tabindex="-1">403 <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>
349 </li>407 </li>
350 <li data-k="tag v0.2.0">408 <li data-k="tag v0.2.0">
351 <a href="/demo/tree/v0.2.0" tabindex="-1">409 <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>
353 </li>413 </li>
354 <li data-k="repo quiet">414 <li data-k="repo quiet">
355 <a href="/quiet" tabindex="-1">415 <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>
357 </li>419 </li>
358 </ul>420 </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>
359 </dialog>430 </dialog>
360-<footer class="bar status" aria-label="Status">431+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
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) => {
379 if (q('dialog[open]').count) return;432 if (q('dialog[open]').count) return;
380 let rows = q('[data-rows] > :not([hidden])').arr();433 let rows = q('[data-rows] > :not([hidden])').arr();
381 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));434 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
382 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));435 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
383- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];436+ 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' })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 = ''
385 })(1);440 })(1);
386- k-k from:body -> ((d) => {441+ k-k from:body -> (async (d) => {
387 if (q('dialog[open]').count) return;442 if (q('dialog[open]').count) return;
388 let rows = q('[data-rows] > :not([hidden])').arr();443 let rows = q('[data-rows] > :not([hidden])').arr();
389 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));444 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
390 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));445 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
391- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];446+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
392- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
393 })(-1);450 })(-1);
394 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));451 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
395 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));452 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));
396 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();453 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
397 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());454 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();455+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
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());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());
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();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();
401 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();458 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
402- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();459+ k-gh from:body -> document.querySelector('.brand')?.click();
403- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();460+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
404- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();461+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
405- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();462+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
406- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();463+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
407- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();464+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
408- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();465+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
409- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();466+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
410- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();467+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
411- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();468+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
412 k-next from:body -> {469 k-next from:body -> {
413 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;470 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
414 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);471 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/tree-nested.html +162 -110
@@ -6,6 +6,7 @@
6 <meta name="color-scheme" content="light dark">6 <meta name="color-scheme" content="light dark">
7 <title>src/lib · files · example.test</title>7 <title>src/lib · files · example.test</title>
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <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>
9 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
10 <script src="/-/static/js/htmx.min.HASH.js" defer>11 <script src="/-/static/js/htmx.min.HASH.js" defer>
11 </script>12 </script>
@@ -16,12 +17,15 @@
16 <script src="/-/static/js/ermite.HASH.js" defer>17 <script src="/-/static/js/ermite.HASH.js" defer>
17 </script>18 </script>
18 </head>19 </head>
19-<body data-ref="main" data-sha="SHA40" data-sel="">20+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
20 <a class="skip" href="#main">skip to content</a>21 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">22+<header class="hdr">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>23+<div class="hdr-top">
23-<span class="fade" aria-hidden="true">24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
24 </span>26 </span>
27+<span>example.test</span>
28+</a>
25 <nav class="crumbs" aria-label="Path">29 <nav class="crumbs" aria-label="Path">
26 <ol>30 <ol>
27 <li>31 <li>
@@ -41,33 +45,52 @@
41 </li>45 </li>
42 </ol>46 </ol>
43 </nav>47 </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">
45 <ul>65 <ul>
46 <li>66 <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>
48 </li>69 </li>
49 <li>70 <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>
51 </li>73 </li>
52 <li>74 <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>
54 </li>77 </li>
55 <li>78 <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>
57 </li>81 </li>
58 <li>82 <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>
60 </li>85 </li>
61 </ul>86 </ul>
62 </nav>87 </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>
65 </header>88 </header>
66 <div class="work">89 <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">
68 <div class="filter">91 <div class="filter">
69-<label for="tree-filter">filter&gt;</label>92+<label for="tree-filter" class="sr">filter</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])')">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])')">
71 </div>94 </div>
72 <table class="list tree" aria-label="Entries">95 <table class="list tree" aria-label="Entries">
73 <thead>96 <thead>
@@ -104,70 +127,71 @@
104 </table>127 </table>
105 </main>128 </main>
106 </div>129 </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()">
108 <div class="panel-head">145 <div class="panel-head">
109-<span>keys</span>146+<h2>Keyboard shortcuts</h2>
110-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>147+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
148+<kbd>esc</kbd> close</button>
111 </div>149 </div>
112 <dl class="keys">150 <dl class="keys">
113 <div>151 <div>
114 <dt>152 <dt>
115-<kbd>j</kbd>153+<kbd>ctrl k</kbd>
116-<kbd>k</kbd>154+<kbd>:</kbd>
117 </dt>155 </dt>
118-<dd>move cursor down / up</dd>156+<dd>go to a section, branch, tag, file or repository</dd>
119 </div>157 </div>
120 <div>158 <div>
121 <dt>159 <dt>
122-<kbd>enter</kbd>160+<kbd>?</kbd>
123 </dt>161 </dt>
124-<dd>open row under cursor</dd>162+<dd>this list</dd>
125 </div>163 </div>
126 <div>164 <div>
127 <dt>165 <dt>
128-<kbd>g</kbd>166+<kbd>j</kbd>
129-<kbd>g</kbd> / <kbd>G</kbd>167+<kbd>k</kbd>
130 </dt>168 </dt>
131-<dd>first / last row</dd>169+<dd>move the row cursor down / up</dd>
132 </div>170 </div>
133 <div>171 <div>
134 <dt>172 <dt>
135-<kbd>/</kbd>173+<kbd>enter</kbd>
136 </dt>174 </dt>
137-<dd>focus the filter</dd>175+<dd>open the row under the cursor</dd>
138 </div>176 </div>
139 <div>177 <div>
140 <dt>178 <dt>
141-<kbd>g h</kbd>179+<kbd>g g</kbd>
142-<kbd>g t</kbd>180+<kbd>G</kbd>
143 </dt>181 </dt>
144-<dd>go home / tree</dd>182+<dd>first / last row</dd>
145 </div>183 </div>
146 <div>184 <div>
147 <dt>185 <dt>
148-<kbd>g c</kbd>186+<kbd>/</kbd>
149-<kbd>g r</kbd>
150 </dt>187 </dt>
151-<dd>go commits / refs</dd>188+<dd>filter the list</dd>
152 </div>189 </div>
153 <div>190 <div>
154 <dt>191 <dt>
155 <kbd>b</kbd>192 <kbd>b</kbd>
156 </dt>193 </dt>
157-<dd>switch ref (branches, tags)</dd>194+<dd>switch branch or tag</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>
171 </div>195 </div>
172 <div>196 <div>
173 <dt>197 <dt>
@@ -175,15 +199,21 @@
175 <kbd>t</kbd>199 <kbd>t</kbd>
176 <kbd>c</kbd>200 <kbd>c</kbd>
177 <kbd>r</kbd>201 <kbd>r</kbd>
202+<kbd>m</kbd>
178 </dt>203 </dt>
179-<dd>repo sections (when shown)</dd>204+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
180 </div>205 </div>
181 <div>206 <div>
182 <dt>207 <dt>
183-<kbd>m</kbd>208+<kbd>g h</kbd>
184-<kbd>g m</kbd>
185 </dt>209 </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>
187 </div>217 </div>
188 <div>218 <div>
189 <dt>219 <dt>
@@ -203,19 +233,21 @@
203 <dt>233 <dt>
204 <kbd>esc</kbd>234 <kbd>esc</kbd>
205 </dt>235 </dt>
206-<dd>close panel</dd>236+<dd>close a dialog, leave a field</dd>
207 </div>237 </div>
208 </dl>238 </dl>
209 </dialog>239 </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()">
211 <div class="panel-head">241 <div class="panel-head">
212-<label for="refs-filter">ref&gt;</label>242+<label for="refs-filter" class="sr">filter branches and tags</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))">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))">
214-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>244+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
245+<kbd>esc</kbd>
246+</button>
215 </div>247 </div>
216 <div class="panel-cols" data-picklist>248 <div class="panel-cols" data-picklist>
217 <section aria-label="Branches">249 <section aria-label="Branches">
218-<h2>branches <span class="mute">2</span>250+<h2>Branches <span class="count">2</span>
219 </h2>251 </h2>
220 <ul>252 <ul>
221 <li>253 <li>
@@ -229,7 +261,7 @@
229 </ul>261 </ul>
230 </section>262 </section>
231 <section aria-label="Tags">263 <section aria-label="Tags">
232-<h2>tags <span class="mute">1</span>264+<h2>Tags <span class="count">1</span>
233 </h2>265 </h2>
234 <ul>266 <ul>
235 <li>267 <li>
@@ -240,110 +272,130 @@
240 </section>272 </section>
241 </div>273 </div>
242 </dialog>274 </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()">
244 <div class="panel-head">276 <div class="panel-head">
245-<label for="cmd-q" class="hi">:</label>277+<label for="cmd-q" class="sr">go to</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();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();
247 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));279 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
248 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();280 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();
249 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));281 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
250 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;282 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
251 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' })) }283 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' })) }
252 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">284 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>
254 </div>288 </div>
255-<ul id="cmd-list" class="cmd-list">289+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
256 <li data-k="go home" data-cursor>290 <li data-k="go home" data-cursor>
257 <a href="/" tabindex="-1">291 <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>
259 </li>295 </li>
260 <li data-k="section summary">296 <li data-k="section summary">
261 <a href="/files" tabindex="-1">297 <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>
263 </li>301 </li>
264-<li data-k="section tree">302+<li data-k="section files">
265 <a href="/files/tree/main" tabindex="-1">303 <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>
267 </li>307 </li>
268 <li data-k="section commits">308 <li data-k="section commits">
269 <a href="/files/commits/main" tabindex="-1">309 <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>
271 </li>313 </li>
272-<li data-k="section refs">314+<li data-k="section branches &amp; tags">
273 <a href="/files/refs" tabindex="-1">315 <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>
275 </li>319 </li>
276 <li data-k="section merge requests">320 <li data-k="section merge requests">
277 <a href="/files/merge-requests" tabindex="-1">321 <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>
279 </li>325 </li>
280 <li data-k="branch feature/x">326 <li data-k="branch feature/x">
281 <a href="/files/tree/feature/x" tabindex="-1">327 <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>
283 </li>331 </li>
284 <li data-k="branch main">332 <li data-k="branch main">
285 <a href="/files/tree/main" tabindex="-1">333 <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>
287 </li>337 </li>
288 <li data-k="tag v1.0">338 <li data-k="tag v1.0">
289 <a href="/files/tree/v1.0" tabindex="-1">339 <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>
291 </li>343 </li>
292 <li data-k="file src/lib/util.go">344 <li data-k="file src/lib/util.go">
293 <a href="/files/blob/main/src/lib/util.go" tabindex="-1">345 <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>
295 </li>349 </li>
296 </ul>350 </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>
297 </dialog>360 </dialog>
298-<footer class="bar status" aria-label="Status">361+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
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) => {
314 if (q('dialog[open]').count) return;362 if (q('dialog[open]').count) return;
315 let rows = q('[data-rows] > :not([hidden])').arr();363 let rows = q('[data-rows] > :not([hidden])').arr();
316 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));364 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
317 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));365 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
318- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];366+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
319- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
320 })(1);370 })(1);
321- k-k from:body -> ((d) => {371+ k-k from:body -> (async (d) => {
322 if (q('dialog[open]').count) return;372 if (q('dialog[open]').count) return;
323 let rows = q('[data-rows] > :not([hidden])').arr();373 let rows = q('[data-rows] > :not([hidden])').arr();
324 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));374 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
325 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));375 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
326- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];376+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
327- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
328 })(-1);380 })(-1);
329 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));381 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
330 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));382 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));
331 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();383 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
332 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());384 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();385+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
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());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());
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();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();
336 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();388 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
337- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();389+ k-gh from:body -> document.querySelector('.brand')?.click();
338- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();390+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
339- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();391+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
340- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();392+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
341- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();393+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
342- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();394+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
343- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();395+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
344- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();396+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
345- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();397+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
346- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();398+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
347 k-next from:body -> {399 k-next from:body -> {
348 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;400 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
349 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);401 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/tree-root.html +203 -125
@@ -6,6 +6,7 @@
6 <meta name="color-scheme" content="light dark">6 <meta name="color-scheme" content="light dark">
7 <title>tree · files · example.test</title>7 <title>tree · files · example.test</title>
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <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>
9 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
10 <script src="/-/static/js/htmx.min.HASH.js" defer>11 <script src="/-/static/js/htmx.min.HASH.js" defer>
11 </script>12 </script>
@@ -16,12 +17,15 @@
16 <script src="/-/static/js/ermite.HASH.js" defer>17 <script src="/-/static/js/ermite.HASH.js" defer>
17 </script>18 </script>
18 </head>19 </head>
19-<body data-ref="main" data-sha="SHA40" data-sel="">20+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
20 <a class="skip" href="#main">skip to content</a>21 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">22+<header class="hdr">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>23+<div class="hdr-top">
23-<span class="fade" aria-hidden="true">24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
24 </span>26 </span>
27+<span>example.test</span>
28+</a>
25 <nav class="crumbs" aria-label="Path">29 <nav class="crumbs" aria-label="Path">
26 <ol>30 <ol>
27 <li>31 <li>
@@ -35,33 +39,52 @@
35 </li>39 </li>
36 </ol>40 </ol>
37 </nav>41 </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">
39 <ul>59 <ul>
40 <li>60 <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>
42 </li>63 </li>
43 <li>64 <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>
45 </li>67 </li>
46 <li>68 <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>
48 </li>71 </li>
49 <li>72 <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>
51 </li>75 </li>
52 <li>76 <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>
54 </li>79 </li>
55 </ul>80 </ul>
56 </nav>81 </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>
59 </header>82 </header>
60 <div class="work">83 <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">
62 <div class="filter">85 <div class="filter">
63-<label for="tree-filter">filter&gt;</label>86+<label for="tree-filter" class="sr">filter</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])')">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])')">
65 </div>88 </div>
66 <table class="list tree" aria-label="Entries">89 <table class="list tree" aria-label="Entries">
67 <thead>90 <thead>
@@ -320,79 +343,82 @@
320 </tr>343 </tr>
321 </tbody>344 </tbody>
322 </table>345 </table>
323-<h2 class="rule-h">346+<section class="card readme" aria-labelledby="readme-h">
324-<span>README.md</span>347+<h2 class="card-h" id="readme-h">
325-</h2>348+<span class="ico i-file" aria-hidden="true">
349+</span> README.md</h2>
326 <article class="prose" aria-label="README.md">350 <article class="prose" aria-label="README.md">
327 <h1 id="md-files">Files</h1>351 <h1 id="md-files">Files</h1>
328 <p>See <a href="/files/blob/main/docs/guide.md">the guide</a>.</p>352 <p>See <a href="/files/blob/main/docs/guide.md">the guide</a>.</p>
329 </article>353 </article>
354+</section>
330 </main>355 </main>
331 </div>356 </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()">
333 <div class="panel-head">372 <div class="panel-head">
334-<span>keys</span>373+<h2>Keyboard shortcuts</h2>
335-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>374+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
375+<kbd>esc</kbd> close</button>
336 </div>376 </div>
337 <dl class="keys">377 <dl class="keys">
338 <div>378 <div>
339 <dt>379 <dt>
340-<kbd>j</kbd>380+<kbd>ctrl k</kbd>
341-<kbd>k</kbd>381+<kbd>:</kbd>
342 </dt>382 </dt>
343-<dd>move cursor down / up</dd>383+<dd>go to a section, branch, tag, file or repository</dd>
344 </div>384 </div>
345 <div>385 <div>
346 <dt>386 <dt>
347-<kbd>enter</kbd>387+<kbd>?</kbd>
348 </dt>388 </dt>
349-<dd>open row under cursor</dd>389+<dd>this list</dd>
350 </div>390 </div>
351 <div>391 <div>
352 <dt>392 <dt>
353-<kbd>g</kbd>393+<kbd>j</kbd>
354-<kbd>g</kbd> / <kbd>G</kbd>394+<kbd>k</kbd>
355 </dt>395 </dt>
356-<dd>first / last row</dd>396+<dd>move the row cursor down / up</dd>
357 </div>397 </div>
358 <div>398 <div>
359 <dt>399 <dt>
360-<kbd>/</kbd>400+<kbd>enter</kbd>
361 </dt>401 </dt>
362-<dd>focus the filter</dd>402+<dd>open the row under the cursor</dd>
363 </div>403 </div>
364 <div>404 <div>
365 <dt>405 <dt>
366-<kbd>g h</kbd>406+<kbd>g g</kbd>
367-<kbd>g t</kbd>407+<kbd>G</kbd>
368 </dt>408 </dt>
369-<dd>go home / tree</dd>409+<dd>first / last row</dd>
370 </div>410 </div>
371 <div>411 <div>
372 <dt>412 <dt>
373-<kbd>g c</kbd>413+<kbd>/</kbd>
374-<kbd>g r</kbd>
375 </dt>414 </dt>
376-<dd>go commits / refs</dd>415+<dd>filter the list</dd>
377 </div>416 </div>
378 <div>417 <div>
379 <dt>418 <dt>
380 <kbd>b</kbd>419 <kbd>b</kbd>
381 </dt>420 </dt>
382-<dd>switch ref (branches, tags)</dd>421+<dd>switch branch or tag</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>
396 </div>422 </div>
397 <div>423 <div>
398 <dt>424 <dt>
@@ -400,15 +426,21 @@
400 <kbd>t</kbd>426 <kbd>t</kbd>
401 <kbd>c</kbd>427 <kbd>c</kbd>
402 <kbd>r</kbd>428 <kbd>r</kbd>
429+<kbd>m</kbd>
403 </dt>430 </dt>
404-<dd>repo sections (when shown)</dd>431+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
405 </div>432 </div>
406 <div>433 <div>
407 <dt>434 <dt>
408-<kbd>m</kbd>435+<kbd>g h</kbd>
409-<kbd>g m</kbd>
410 </dt>436 </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>
412 </div>444 </div>
413 <div>445 <div>
414 <dt>446 <dt>
@@ -428,19 +460,21 @@
428 <dt>460 <dt>
429 <kbd>esc</kbd>461 <kbd>esc</kbd>
430 </dt>462 </dt>
431-<dd>close panel</dd>463+<dd>close a dialog, leave a field</dd>
432 </div>464 </div>
433 </dl>465 </dl>
434 </dialog>466 </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()">
436 <div class="panel-head">468 <div class="panel-head">
437-<label for="refs-filter">ref&gt;</label>469+<label for="refs-filter" class="sr">filter branches and tags</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))">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))">
439-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>471+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
472+<kbd>esc</kbd>
473+</button>
440 </div>474 </div>
441 <div class="panel-cols" data-picklist>475 <div class="panel-cols" data-picklist>
442 <section aria-label="Branches">476 <section aria-label="Branches">
443-<h2>branches <span class="mute">2</span>477+<h2>Branches <span class="count">2</span>
444 </h2>478 </h2>
445 <ul>479 <ul>
446 <li>480 <li>
@@ -454,7 +488,7 @@
454 </ul>488 </ul>
455 </section>489 </section>
456 <section aria-label="Tags">490 <section aria-label="Tags">
457-<h2>tags <span class="mute">1</span>491+<h2>Tags <span class="count">1</span>
458 </h2>492 </h2>
459 <ul>493 <ul>
460 <li>494 <li>
@@ -465,158 +499,202 @@
465 </section>499 </section>
466 </div>500 </div>
467 </dialog>501 </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()">
469 <div class="panel-head">503 <div class="panel-head">
470-<label for="cmd-q" class="hi">:</label>504+<label for="cmd-q" class="sr">go to</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();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();
472 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));506 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
473 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();507 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();
474 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));508 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
475 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;509 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
476 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' })) }510 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' })) }
477 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">511 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>
479 </div>515 </div>
480-<ul id="cmd-list" class="cmd-list">516+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
481 <li data-k="go home" data-cursor>517 <li data-k="go home" data-cursor>
482 <a href="/" tabindex="-1">518 <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>
484 </li>522 </li>
485 <li data-k="section summary">523 <li data-k="section summary">
486 <a href="/files" tabindex="-1">524 <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>
488 </li>528 </li>
489-<li data-k="section tree">529+<li data-k="section files">
490 <a href="/files/tree/main" tabindex="-1">530 <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>
492 </li>534 </li>
493 <li data-k="section commits">535 <li data-k="section commits">
494 <a href="/files/commits/main" tabindex="-1">536 <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>
496 </li>540 </li>
497-<li data-k="section refs">541+<li data-k="section branches &amp; tags">
498 <a href="/files/refs" tabindex="-1">542 <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>
500 </li>546 </li>
501 <li data-k="section merge requests">547 <li data-k="section merge requests">
502 <a href="/files/merge-requests" tabindex="-1">548 <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>
504 </li>552 </li>
505 <li data-k="branch feature/x">553 <li data-k="branch feature/x">
506 <a href="/files/tree/feature/x" tabindex="-1">554 <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>
508 </li>558 </li>
509 <li data-k="branch main">559 <li data-k="branch main">
510 <a href="/files/tree/main" tabindex="-1">560 <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>
512 </li>564 </li>
513 <li data-k="tag v1.0">565 <li data-k="tag v1.0">
514 <a href="/files/tree/v1.0" tabindex="-1">566 <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>
516 </li>570 </li>
517 <li data-k="file readme.md">571 <li data-k="file readme.md">
518 <a href="/files/blob/main/README.md" tabindex="-1">572 <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>
520 </li>576 </li>
521 <li data-k="file big.txt">577 <li data-k="file big.txt">
522 <a href="/files/blob/main/big.txt" tabindex="-1">578 <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>
524 </li>582 </li>
525 <li data-k="file data.bin">583 <li data-k="file data.bin">
526 <a href="/files/blob/main/data.bin" tabindex="-1">584 <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>
528 </li>588 </li>
529 <li data-k="file empty.txt">589 <li data-k="file empty.txt">
530 <a href="/files/blob/main/empty.txt" tabindex="-1">590 <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>
532 </li>594 </li>
533 <li data-k="file evil.go">595 <li data-k="file evil.go">
534 <a href="/files/blob/main/evil.go" tabindex="-1">596 <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>
536 </li>600 </li>
537 <li data-k="file fake.jpg">601 <li data-k="file fake.jpg">
538 <a href="/files/blob/main/fake.jpg" tabindex="-1">602 <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>
540 </li>606 </li>
541 <li data-k="file fake.png">607 <li data-k="file fake.png">
542 <a href="/files/blob/main/fake.png" tabindex="-1">608 <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>
544 </li>612 </li>
545 <li data-k="file lines.txt">613 <li data-k="file lines.txt">
546 <a href="/files/blob/main/lines.txt" tabindex="-1">614 <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>
548 </li>618 </li>
549 <li data-k="file logo.svg">619 <li data-k="file logo.svg">
550 <a href="/files/blob/main/logo.svg" tabindex="-1">620 <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>
552 </li>624 </li>
553 <li data-k="file main.go">625 <li data-k="file main.go">
554 <a href="/files/blob/main/main.go" tabindex="-1">626 <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>
556 </li>630 </li>
557 <li data-k="file notes.txt">631 <li data-k="file notes.txt">
558 <a href="/files/blob/main/notes.txt" tabindex="-1">632 <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>
560 </li>636 </li>
561 <li data-k="file page.html">637 <li data-k="file page.html">
562 <a href="/files/blob/main/page.html" tabindex="-1">638 <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>
564 </li>642 </li>
565 <li data-k="file pic.png">643 <li data-k="file pic.png">
566 <a href="/files/blob/main/pic.png" tabindex="-1">644 <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>
568 </li>648 </li>
569 </ul>649 </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>
570 </dialog>659 </dialog>
571-<footer class="bar status" aria-label="Status">660+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
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) => {
587 if (q('dialog[open]').count) return;661 if (q('dialog[open]').count) return;
588 let rows = q('[data-rows] > :not([hidden])').arr();662 let rows = q('[data-rows] > :not([hidden])').arr();
589 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));663 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
590 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));664 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
591- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];665+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
592- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
593 })(1);669 })(1);
594- k-k from:body -> ((d) => {670+ k-k from:body -> (async (d) => {
595 if (q('dialog[open]').count) return;671 if (q('dialog[open]').count) return;
596 let rows = q('[data-rows] > :not([hidden])').arr();672 let rows = q('[data-rows] > :not([hidden])').arr();
597 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));673 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
598 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));674 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
599- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];675+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
600- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
601 })(-1);679 })(-1);
602 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));680 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
603 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));681 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));
604 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();682 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
605 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());683 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();684+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
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());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());
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();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();
609 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();687 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
610- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();688+ k-gh from:body -> document.querySelector('.brand')?.click();
611- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();689+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
612- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();690+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
613- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();691+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
614- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();692+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
615- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();693+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
616- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();694+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
617- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();695+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
618- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();696+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
619- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();697+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
620 k-next from:body -> {698 k-next from:body -> {
621 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;699 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
622 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);700 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 @@
6 <meta name="color-scheme" content="light dark">6 <meta name="color-scheme" content="light dark">
7 <title>x · files · example.test</title>7 <title>x · files · example.test</title>
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <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>
9 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
10 <script src="/-/static/js/htmx.min.HASH.js" defer>11 <script src="/-/static/js/htmx.min.HASH.js" defer>
11 </script>12 </script>
@@ -16,12 +17,15 @@
16 <script src="/-/static/js/ermite.HASH.js" defer>17 <script src="/-/static/js/ermite.HASH.js" defer>
17 </script>18 </script>
18 </head>19 </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="">
20 <a class="skip" href="#main">skip to content</a>21 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">22+<header class="hdr">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>23+<div class="hdr-top">
23-<span class="fade" aria-hidden="true">24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
24 </span>26 </span>
27+<span>example.test</span>
28+</a>
25 <nav class="crumbs" aria-label="Path">29 <nav class="crumbs" aria-label="Path">
26 <ol>30 <ol>
27 <li>31 <li>
@@ -38,33 +42,52 @@
38 </li>42 </li>
39 </ol>43 </ol>
40 </nav>44 </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">
42 <ul>62 <ul>
43 <li>63 <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>
45 </li>66 </li>
46 <li>67 <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>
48 </li>70 </li>
49 <li>71 <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>
51 </li>74 </li>
52 <li>75 <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>
54 </li>78 </li>
55 <li>79 <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>
57 </li>82 </li>
58 </ul>83 </ul>
59 </nav>84 </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>
62 </header>85 </header>
63 <div class="work">86 <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">
65 <div class="filter">88 <div class="filter">
66-<label for="tree-filter">filter&gt;</label>89+<label for="tree-filter" class="sr">filter</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])')">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])')">
68 </div>91 </div>
69 <table class="list tree" aria-label="Entries">92 <table class="list tree" aria-label="Entries">
70 <thead>93 <thead>
@@ -101,70 +124,71 @@
101 </table>124 </table>
102 </main>125 </main>
103 </div>126 </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()">
105 <div class="panel-head">142 <div class="panel-head">
106-<span>keys</span>143+<h2>Keyboard shortcuts</h2>
107-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>144+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
145+<kbd>esc</kbd> close</button>
108 </div>146 </div>
109 <dl class="keys">147 <dl class="keys">
110 <div>148 <div>
111 <dt>149 <dt>
112-<kbd>j</kbd>150+<kbd>ctrl k</kbd>
113-<kbd>k</kbd>151+<kbd>:</kbd>
114 </dt>152 </dt>
115-<dd>move cursor down / up</dd>153+<dd>go to a section, branch, tag, file or repository</dd>
116 </div>154 </div>
117 <div>155 <div>
118 <dt>156 <dt>
119-<kbd>enter</kbd>157+<kbd>?</kbd>
120 </dt>158 </dt>
121-<dd>open row under cursor</dd>159+<dd>this list</dd>
122 </div>160 </div>
123 <div>161 <div>
124 <dt>162 <dt>
125-<kbd>g</kbd>163+<kbd>j</kbd>
126-<kbd>g</kbd> / <kbd>G</kbd>164+<kbd>k</kbd>
127 </dt>165 </dt>
128-<dd>first / last row</dd>166+<dd>move the row cursor down / up</dd>
129 </div>167 </div>
130 <div>168 <div>
131 <dt>169 <dt>
132-<kbd>/</kbd>170+<kbd>enter</kbd>
133 </dt>171 </dt>
134-<dd>focus the filter</dd>172+<dd>open the row under the cursor</dd>
135 </div>173 </div>
136 <div>174 <div>
137 <dt>175 <dt>
138-<kbd>g h</kbd>176+<kbd>g g</kbd>
139-<kbd>g t</kbd>177+<kbd>G</kbd>
140 </dt>178 </dt>
141-<dd>go home / tree</dd>179+<dd>first / last row</dd>
142 </div>180 </div>
143 <div>181 <div>
144 <dt>182 <dt>
145-<kbd>g c</kbd>183+<kbd>/</kbd>
146-<kbd>g r</kbd>
147 </dt>184 </dt>
148-<dd>go commits / refs</dd>185+<dd>filter the list</dd>
149 </div>186 </div>
150 <div>187 <div>
151 <dt>188 <dt>
152 <kbd>b</kbd>189 <kbd>b</kbd>
153 </dt>190 </dt>
154-<dd>switch ref (branches, tags)</dd>191+<dd>switch branch or tag</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>
168 </div>192 </div>
169 <div>193 <div>
170 <dt>194 <dt>
@@ -172,15 +196,21 @@
172 <kbd>t</kbd>196 <kbd>t</kbd>
173 <kbd>c</kbd>197 <kbd>c</kbd>
174 <kbd>r</kbd>198 <kbd>r</kbd>
199+<kbd>m</kbd>
175 </dt>200 </dt>
176-<dd>repo sections (when shown)</dd>201+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
177 </div>202 </div>
178 <div>203 <div>
179 <dt>204 <dt>
180-<kbd>m</kbd>205+<kbd>g h</kbd>
181-<kbd>g m</kbd>
182 </dt>206 </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>
184 </div>214 </div>
185 <div>215 <div>
186 <dt>216 <dt>
@@ -200,19 +230,21 @@
200 <dt>230 <dt>
201 <kbd>esc</kbd>231 <kbd>esc</kbd>
202 </dt>232 </dt>
203-<dd>close panel</dd>233+<dd>close a dialog, leave a field</dd>
204 </div>234 </div>
205 </dl>235 </dl>
206 </dialog>236 </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()">
208 <div class="panel-head">238 <div class="panel-head">
209-<label for="refs-filter">ref&gt;</label>239+<label for="refs-filter" class="sr">filter branches and tags</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))">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))">
211-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>241+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
242+<kbd>esc</kbd>
243+</button>
212 </div>244 </div>
213 <div class="panel-cols" data-picklist>245 <div class="panel-cols" data-picklist>
214 <section aria-label="Branches">246 <section aria-label="Branches">
215-<h2>branches <span class="mute">2</span>247+<h2>Branches <span class="count">2</span>
216 </h2>248 </h2>
217 <ul>249 <ul>
218 <li>250 <li>
@@ -226,7 +258,7 @@
226 </ul>258 </ul>
227 </section>259 </section>
228 <section aria-label="Tags">260 <section aria-label="Tags">
229-<h2>tags <span class="mute">1</span>261+<h2>Tags <span class="count">1</span>
230 </h2>262 </h2>
231 <ul>263 <ul>
232 <li>264 <li>
@@ -237,110 +269,130 @@
237 </section>269 </section>
238 </div>270 </div>
239 </dialog>271 </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()">
241 <div class="panel-head">273 <div class="panel-head">
242-<label for="cmd-q" class="hi">:</label>274+<label for="cmd-q" class="sr">go to</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();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();
244 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));276 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
245 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();277 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();
246 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));278 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
247 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;279 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
248 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' })) }280 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' })) }
249 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">281 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>
251 </div>285 </div>
252-<ul id="cmd-list" class="cmd-list">286+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
253 <li data-k="go home" data-cursor>287 <li data-k="go home" data-cursor>
254 <a href="/" tabindex="-1">288 <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>
256 </li>292 </li>
257 <li data-k="section summary">293 <li data-k="section summary">
258 <a href="/files" tabindex="-1">294 <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>
260 </li>298 </li>
261-<li data-k="section tree">299+<li data-k="section files">
262 <a href="/files/tree/main" tabindex="-1">300 <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>
264 </li>304 </li>
265 <li data-k="section commits">305 <li data-k="section commits">
266 <a href="/files/commits/main" tabindex="-1">306 <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>
268 </li>310 </li>
269-<li data-k="section refs">311+<li data-k="section branches &amp; tags">
270 <a href="/files/refs" tabindex="-1">312 <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>
272 </li>316 </li>
273 <li data-k="section merge requests">317 <li data-k="section merge requests">
274 <a href="/files/merge-requests" tabindex="-1">318 <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>
276 </li>322 </li>
277 <li data-k="branch feature/x">323 <li data-k="branch feature/x">
278 <a href="/files/tree/feature/x" tabindex="-1">324 <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>
280 </li>328 </li>
281 <li data-k="branch main">329 <li data-k="branch main">
282 <a href="/files/tree/main" tabindex="-1">330 <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>
284 </li>334 </li>
285 <li data-k="tag v1.0">335 <li data-k="tag v1.0">
286 <a href="/files/tree/v1.0" tabindex="-1">336 <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>
288 </li>340 </li>
289 <li data-k="file x/y.txt">341 <li data-k="file x/y.txt">
290 <a href="/files/blob/feature/x/x/y.txt" tabindex="-1">342 <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>
292 </li>346 </li>
293 </ul>347 </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>
294 </dialog>357 </dialog>
295-<footer class="bar status" aria-label="Status">358+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
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) => {
311 if (q('dialog[open]').count) return;359 if (q('dialog[open]').count) return;
312 let rows = q('[data-rows] > :not([hidden])').arr();360 let rows = q('[data-rows] > :not([hidden])').arr();
313 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));361 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
314 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));362 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
315- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];363+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
316- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
317 })(1);367 })(1);
318- k-k from:body -> ((d) => {368+ k-k from:body -> (async (d) => {
319 if (q('dialog[open]').count) return;369 if (q('dialog[open]').count) return;
320 let rows = q('[data-rows] > :not([hidden])').arr();370 let rows = q('[data-rows] > :not([hidden])').arr();
321 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));371 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
322 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));372 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
323- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];373+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
324- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
325 })(-1);377 })(-1);
326 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));378 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
327 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));379 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));
328 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();380 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
329 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());381 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();382+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
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());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());
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();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();
333 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();385 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
334- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();386+ k-gh from:body -> document.querySelector('.brand')?.click();
335- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();387+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
336- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();388+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
337- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();389+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
338- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();390+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
339- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();391+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
340- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();392+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
341- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();393+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
342- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();394+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
343- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();395+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
344 k-next from:body -> {396 k-next from:body -> {
345 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;397 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
346 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);398 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
M cmd/ermite/testdata/web/tree-subdir.html +170 -114
@@ -6,6 +6,7 @@
6 <meta name="color-scheme" content="light dark">6 <meta name="color-scheme" content="light dark">
7 <title>docs · files · example.test</title>7 <title>docs · files · example.test</title>
8 <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg">8 <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>
9 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">10 <link rel="stylesheet" href="/-/static/css/ermite.HASH.css">
10 <script src="/-/static/js/htmx.min.HASH.js" defer>11 <script src="/-/static/js/htmx.min.HASH.js" defer>
11 </script>12 </script>
@@ -16,12 +17,15 @@
16 <script src="/-/static/js/ermite.HASH.js" defer>17 <script src="/-/static/js/ermite.HASH.js" defer>
17 </script>18 </script>
18 </head>19 </head>
19-<body data-ref="main" data-sha="SHA40" data-sel="">20+<body data-ref="main" data-sha="SHA40" data-sel="" data-nav="">
20 <a class="skip" href="#main">skip to content</a>21 <a class="skip" href="#main">skip to content</a>
21-<header class="bar top">22+<header class="hdr">
22-<a class="blk" href="/" data-g="h" title="home (g h)">example.test</a>23+<div class="hdr-top">
23-<span class="fade" aria-hidden="true">24+<a class="brand" href="/" data-g="h" title="home (g h)">
25+<span class="brand-mark" aria-hidden="true">
24 </span>26 </span>
27+<span>example.test</span>
28+</a>
25 <nav class="crumbs" aria-label="Path">29 <nav class="crumbs" aria-label="Path">
26 <ol>30 <ol>
27 <li>31 <li>
@@ -38,33 +42,52 @@
38 </li>42 </li>
39 </ol>43 </ol>
40 </nav>44 </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">
42 <ul>62 <ul>
43 <li>63 <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>
45 </li>66 </li>
46 <li>67 <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>
48 </li>70 </li>
49 <li>71 <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>
51 </li>74 </li>
52 <li>75 <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>
54 </li>78 </li>
55 <li>79 <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>
57 </li>82 </li>
58 </ul>83 </ul>
59 </nav>84 </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>
62 </header>85 </header>
63 <div class="work">86 <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">
65 <div class="filter">88 <div class="filter">
66-<label for="tree-filter">filter&gt;</label>89+<label for="tree-filter" class="sr">filter</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])')">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])')">
68 </div>91 </div>
69 <table class="list tree" aria-label="Entries">92 <table class="list tree" aria-label="Entries">
70 <thead>93 <thead>
@@ -115,79 +138,82 @@
115 </tr>138 </tr>
116 </tbody>139 </tbody>
117 </table>140 </table>
118-<h2 class="rule-h">141+<section class="card readme" aria-labelledby="readme-h">
119-<span>README.md</span>142+<h2 class="card-h" id="readme-h">
120-</h2>143+<span class="ico i-file" aria-hidden="true">
144+</span> README.md</h2>
121 <article class="prose" aria-label="README.md">145 <article class="prose" aria-label="README.md">
122 <h1 id="md-docs">Docs</h1>146 <h1 id="md-docs">Docs</h1>
123 <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>147 <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>
124 </article>148 </article>
149+</section>
125 </main>150 </main>
126 </div>151 </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()">
128 <div class="panel-head">167 <div class="panel-head">
129-<span>keys</span>168+<h2>Keyboard shortcuts</h2>
130-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>169+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
170+<kbd>esc</kbd> close</button>
131 </div>171 </div>
132 <dl class="keys">172 <dl class="keys">
133 <div>173 <div>
134 <dt>174 <dt>
135-<kbd>j</kbd>175+<kbd>ctrl k</kbd>
136-<kbd>k</kbd>176+<kbd>:</kbd>
137 </dt>177 </dt>
138-<dd>move cursor down / up</dd>178+<dd>go to a section, branch, tag, file or repository</dd>
139 </div>179 </div>
140 <div>180 <div>
141 <dt>181 <dt>
142-<kbd>enter</kbd>182+<kbd>?</kbd>
143 </dt>183 </dt>
144-<dd>open row under cursor</dd>184+<dd>this list</dd>
145 </div>185 </div>
146 <div>186 <div>
147 <dt>187 <dt>
148-<kbd>g</kbd>188+<kbd>j</kbd>
149-<kbd>g</kbd> / <kbd>G</kbd>189+<kbd>k</kbd>
150 </dt>190 </dt>
151-<dd>first / last row</dd>191+<dd>move the row cursor down / up</dd>
152 </div>192 </div>
153 <div>193 <div>
154 <dt>194 <dt>
155-<kbd>/</kbd>195+<kbd>enter</kbd>
156 </dt>196 </dt>
157-<dd>focus the filter</dd>197+<dd>open the row under the cursor</dd>
158 </div>198 </div>
159 <div>199 <div>
160 <dt>200 <dt>
161-<kbd>g h</kbd>201+<kbd>g g</kbd>
162-<kbd>g t</kbd>202+<kbd>G</kbd>
163 </dt>203 </dt>
164-<dd>go home / tree</dd>204+<dd>first / last row</dd>
165 </div>205 </div>
166 <div>206 <div>
167 <dt>207 <dt>
168-<kbd>g c</kbd>208+<kbd>/</kbd>
169-<kbd>g r</kbd>
170 </dt>209 </dt>
171-<dd>go commits / refs</dd>210+<dd>filter the list</dd>
172 </div>211 </div>
173 <div>212 <div>
174 <dt>213 <dt>
175 <kbd>b</kbd>214 <kbd>b</kbd>
176 </dt>215 </dt>
177-<dd>switch ref (branches, tags)</dd>216+<dd>switch branch or tag</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>
191 </div>217 </div>
192 <div>218 <div>
193 <dt>219 <dt>
@@ -195,15 +221,21 @@
195 <kbd>t</kbd>221 <kbd>t</kbd>
196 <kbd>c</kbd>222 <kbd>c</kbd>
197 <kbd>r</kbd>223 <kbd>r</kbd>
224+<kbd>m</kbd>
198 </dt>225 </dt>
199-<dd>repo sections (when shown)</dd>226+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
200 </div>227 </div>
201 <div>228 <div>
202 <dt>229 <dt>
203-<kbd>m</kbd>230+<kbd>g h</kbd>
204-<kbd>g m</kbd>
205 </dt>231 </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>
207 </div>239 </div>
208 <div>240 <div>
209 <dt>241 <dt>
@@ -223,19 +255,21 @@
223 <dt>255 <dt>
224 <kbd>esc</kbd>256 <kbd>esc</kbd>
225 </dt>257 </dt>
226-<dd>close panel</dd>258+<dd>close a dialog, leave a field</dd>
227 </div>259 </div>
228 </dl>260 </dl>
229 </dialog>261 </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()">
231 <div class="panel-head">263 <div class="panel-head">
232-<label for="refs-filter">ref&gt;</label>264+<label for="refs-filter" class="sr">filter branches and tags</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))">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))">
234-<button type="button" hx-on:click="closest('dialog').close()">[esc] close</button>266+<button type="button" class="panel-x" hx-on:click="closest('dialog').close()">
267+<kbd>esc</kbd>
268+</button>
235 </div>269 </div>
236 <div class="panel-cols" data-picklist>270 <div class="panel-cols" data-picklist>
237 <section aria-label="Branches">271 <section aria-label="Branches">
238-<h2>branches <span class="mute">2</span>272+<h2>Branches <span class="count">2</span>
239 </h2>273 </h2>
240 <ul>274 <ul>
241 <li>275 <li>
@@ -249,7 +283,7 @@
249 </ul>283 </ul>
250 </section>284 </section>
251 <section aria-label="Tags">285 <section aria-label="Tags">
252-<h2>tags <span class="mute">1</span>286+<h2>Tags <span class="count">1</span>
253 </h2>287 </h2>
254 <ul>288 <ul>
255 <li>289 <li>
@@ -260,114 +294,136 @@
260 </section>294 </section>
261 </div>295 </div>
262 </dialog>296 </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()">
264 <div class="panel-head">298 <div class="panel-head">
265-<label for="cmd-q" class="hi">:</label>299+<label for="cmd-q" class="sr">go to</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();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();
267 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));301 q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t));
268 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();302 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();
269 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));303 let i = vis.findIndex(li => li.hasAttribute('data-cursor'));
270 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;304 let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
271 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' })) }305 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' })) }
272 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }">306 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>
274 </div>310 </div>
275-<ul id="cmd-list" class="cmd-list">311+<ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
276 <li data-k="go home" data-cursor>312 <li data-k="go home" data-cursor>
277 <a href="/" tabindex="-1">313 <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>
279 </li>317 </li>
280 <li data-k="section summary">318 <li data-k="section summary">
281 <a href="/files" tabindex="-1">319 <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>
283 </li>323 </li>
284-<li data-k="section tree">324+<li data-k="section files">
285 <a href="/files/tree/main" tabindex="-1">325 <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>
287 </li>329 </li>
288 <li data-k="section commits">330 <li data-k="section commits">
289 <a href="/files/commits/main" tabindex="-1">331 <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>
291 </li>335 </li>
292-<li data-k="section refs">336+<li data-k="section branches &amp; tags">
293 <a href="/files/refs" tabindex="-1">337 <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>
295 </li>341 </li>
296 <li data-k="section merge requests">342 <li data-k="section merge requests">
297 <a href="/files/merge-requests" tabindex="-1">343 <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>
299 </li>347 </li>
300 <li data-k="branch feature/x">348 <li data-k="branch feature/x">
301 <a href="/files/tree/feature/x" tabindex="-1">349 <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>
303 </li>353 </li>
304 <li data-k="branch main">354 <li data-k="branch main">
305 <a href="/files/tree/main" tabindex="-1">355 <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>
307 </li>359 </li>
308 <li data-k="tag v1.0">360 <li data-k="tag v1.0">
309 <a href="/files/tree/v1.0" tabindex="-1">361 <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>
311 </li>365 </li>
312 <li data-k="file docs/readme.md">366 <li data-k="file docs/readme.md">
313 <a href="/files/blob/main/docs/README.md" tabindex="-1">367 <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>
315 </li>371 </li>
316 <li data-k="file docs/guide.md">372 <li data-k="file docs/guide.md">
317 <a href="/files/blob/main/docs/guide.md" tabindex="-1">373 <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>
319 </li>377 </li>
320 </ul>378 </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>
321 </dialog>388 </dialog>
322-<footer class="bar status" aria-label="Status">389+<div id="keys" hidden hx-on="k-j from:body -> (async (d) => {
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) => {
338 if (q('dialog[open]').count) return;390 if (q('dialog[open]').count) return;
339 let rows = q('[data-rows] > :not([hidden])').arr();391 let rows = q('[data-rows] > :not([hidden])').arr();
340 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));392 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
341 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));393 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
342- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];394+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
343- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
344 })(1);398 })(1);
345- k-k from:body -> ((d) => {399+ k-k from:body -> (async (d) => {
346 if (q('dialog[open]').count) return;400 if (q('dialog[open]').count) return;
347 let rows = q('[data-rows] > :not([hidden])').arr();401 let rows = q('[data-rows] > :not([hidden])').arr();
348 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));402 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
349 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));403 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
350- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];404+ 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' })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 = ''
352 })(-1);408 })(-1);
353 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));409 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
354 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));410 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));
355 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();411 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
356 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());412 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();413+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
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());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());
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();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();
360 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();416 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
361- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();417+ k-gh from:body -> document.querySelector('.brand')?.click();
362- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();418+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
363- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();419+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
364- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();420+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
365- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();421+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
366- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();422+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
367- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();423+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
368- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();424+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
369- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();425+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
370- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();426+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
371 k-next from:body -> {427 k-next from:body -> {
372 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;428 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
373 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);429 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) {
122 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/"+name+".git"), "main")122 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/"+name+".git"), "main")
123 }123 }
124 124
125-var reOlder = regexp.MustCompile(`<a href="([^"]+)" rel="next">`)125+var reOlder = regexp.MustCompile(`<a class="btn" href="([^"]+)" rel="next">`)
126 126
127 // unescape undoes the HTML escaping of an attribute value.127 // unescape undoes the HTML escaping of an attribute value.
128 func unescape(s string) string { return strings.ReplaceAll(s, "&amp;", "&") }128 func unescape(s string) string { return strings.ReplaceAll(s, "&amp;", "&") }
@@ -174,12 +174,12 @@ func TestWebCommits(t *testing.T) {
174 assert.Equal(t, 50, strings.Count(first, `class="c-sha mute"`), "a page lists 50 commits")174 assert.Equal(t, 50, strings.Count(first, `class="c-sha mute"`), "a page lists 50 commits")
175 assert.Contains(t, first, "commit number 51")175 assert.Contains(t, first, "commit number 51")
176 assert.NotContains(t, first, "commit number 1<", "the oldest are on the next page")176 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>`)
178 second := unescape(reOlder.FindStringSubmatch(first)[1])178 second := unescape(reOlder.FindStringSubmatch(first)[1])
179 page2 := get(second, 200)179 page2 := get(second, 200)
180 assert.Equal(t, 2, strings.Count(page2, `class="c-sha mute"`))180 assert.Equal(t, 2, strings.Count(page2, `class="c-sha mute"`))
181- assert.Contains(t, page2, `href="/long/commits/main">← newest</a>`)181+ assert.Contains(t, page2, `href="/long/commits/main">← Newest</a>`)
182- assert.Contains(t, page2, "<span class=\"mute\">older →</span>")182+ assert.Contains(t, page2, `<span class="btn" aria-disabled="true">Older →</span>`)
183 get("/long/commits/main?page=garbage", 404)183 get("/long/commits/main?page=garbage", 404)
184 })184 })
185 185
@@ -196,8 +196,8 @@ func TestWebCommits(t *testing.T) {
196 assert.Contains(t, get("/diffs/commits/v1.0.0", 200), "modify a file")196 assert.Contains(t, get("/diffs/commits/v1.0.0", 200), "modify a file")
197 assert.Contains(t, get("/diffs/commits/side", 200), "work on the side")197 assert.Contains(t, get("/diffs/commits/side", 200), "work on the side")
198 assert.Contains(t, get("/diffs/commits/"+f.modify[:10], 200), "modify a file")198 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]`)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), `(merge)`)200+ assert.Contains(t, get("/diffs/commits/main", 200), `<span class="pill">merge</span>`)
201 get("/diffs/commits/nope", 404)201 get("/diffs/commits/nope", 404)
202 get("/diffs/commits/mai", 404)202 get("/diffs/commits/mai", 404)
203 get("/nope/commits/main", 404)203 get("/nope/commits/main", 404)
M cmd/ermite/web_mr_test.go +8 -8
@@ -141,7 +141,7 @@ func TestWebMergeRequestPagesRender(t *testing.T) {
141 assert.Contains(t, mr.body, "Finds things.\nSee ", "line breaks are kept")141 assert.Contains(t, mr.body, "Finds things.\nSee ", "line breaks are kept")
142 assert.NotContains(t, mr.body, "<form", "an anonymous visitor gets no form")142 assert.NotContains(t, mr.body, "<form", "an anonymous visitor gets no form")
143 assert.NotContains(t, mr.body, "ermite mr merge", "nor the merge line")143 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>")
145 assert.Contains(t, mr.body, `href="/proj/commit/`+head+`"`, "the commits are in the rail")145 assert.Contains(t, mr.body, `href="/proj/commit/`+head+`"`, "the commits are in the rail")
146 assert.NotContains(t, strings.ToLower(mr.body), "proposal")146 assert.NotContains(t, strings.ToLower(mr.body), "proposal")
147 assertNoInline(t, mr.body)147 assertNoInline(t, mr.body)
@@ -303,7 +303,7 @@ func TestWebMergeRequestReviewAndFastForward(t *testing.T) {
303 assert.Equal(t, base+"#c1", res.hdr.Get("Location"))303 assert.Equal(t, base+"#c1", res.hdr.Get("Location"))
304 own = b.get(base, as(token))304 own = b.get(base, as(token))
305 assert.Contains(t, own.body, `data-resolved="true"`)305 assert.Contains(t, own.body, `data-resolved="true"`)
306- assert.Contains(t, own.body, "[unresolve]")306+ assert.Contains(t, own.body, ">Unresolve</button>")
307 assert.Equal(t, http.StatusSeeOther, b.form(base+"/comments/1/resolve", token, url.Values{"resolved": {"false"}}).status)307 assert.Equal(t, http.StatusSeeOther, b.form(base+"/comments/1/resolve", token, url.Values{"resolved": {"false"}}).status)
308 assert.Equal(t, http.StatusBadRequest, b.form(base+"/comments/1/resolve", token, url.Values{"resolved": {"maybe"}}).status)308 assert.Equal(t, http.StatusBadRequest, b.form(base+"/comments/1/resolve", token, url.Values{"resolved": {"maybe"}}).status)
309 assert.Equal(t, 404, b.form(base+"/comments/42/resolve", token, url.Values{"resolved": {"true"}}).status)309 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) {
312 assert.Contains(t, own.body, `action="/proj/merge-requests/1/read"`)312 assert.Contains(t, own.body, `action="/proj/merge-requests/1/read"`)
313 res = b.form(base+"/read", token, url.Values{"head_id": {head1}})313 res = b.form(base+"/read", token, url.Values{"head_id": {head1}})
314 require.Equal(t, http.StatusSeeOther, res.status, res.body)314 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")
316 r.writeFile(r.wt, "extra.txt", []byte("extra\n"))316 r.writeFile(r.wt, "extra.txt", []byte("extra\n"))
317 r.mustGit(r.wt, "push", "-q", r.url, "feat/search")317 r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
318 head2 := r.headOf("feat/search")318 head2 := r.headOf("feat/search")
@@ -321,7 +321,7 @@ func TestWebMergeRequestReviewAndFastForward(t *testing.T) {
321 assert.Contains(t, all.body, `href="/proj/merge-requests/1/since"`)321 assert.Contains(t, all.body, `href="/proj/merge-requests/1/since"`)
322 assert.Contains(t, all.body, "since your last read")322 assert.Contains(t, all.body, "since your last read")
323 assert.Contains(t, all.body, "<li data-new>", "the commit pushed since is marked")323 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>")
325 assert.Contains(t, all.body, "outdated", "the notes were written on the earlier head")325 assert.Contains(t, all.body, "outdated", "the notes were written on the earlier head")
326 since := b.get(base+"/since", as(token))326 since := b.get(base+"/since", as(token))
327 require.Equal(t, 200, since.status, since.body)327 require.Equal(t, 200, since.status, since.body)
@@ -356,14 +356,14 @@ func TestWebMergeRequestReviewAndFastForward(t *testing.T) {
356 assert.Contains(t, done.body, "fast-forward")356 assert.Contains(t, done.body, "fast-forward")
357 assert.Contains(t, done.body, `href="/proj/commit/`+head2+`"`)357 assert.Contains(t, done.body, `href="/proj/commit/`+head2+`"`)
358 assert.NotContains(t, done.body, "<form")358 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")
360 // Writes on a merged request are refused with a page.360 // Writes on a merged request are refused with a page.
361 late := b.form(base+"/comments", token, url.Values{"text": {"too late"}})361 late := b.form(base+"/comments", token, url.Values{"text": {"too late"}})
362 assert.Equal(t, http.StatusConflict, late.status)362 assert.Equal(t, http.StatusConflict, late.status)
363 assert.Equal(t, http.StatusConflict, b.form(base+"/close", token, nil).status)363 assert.Equal(t, http.StatusConflict, b.form(base+"/close", token, nil).status)
364 list := b.get("/proj/merge-requests", nil)364 list := b.get("/proj/merge-requests", nil)
365- assert.Contains(t, list.body, "merged and closed")365+ assert.Contains(t, list.body, "Merged and closed")
366- assert.Regexp(t, `(?s)merged and closed.*Add search`, list.body)366+ assert.Regexp(t, `(?s)Merged and closed.*Add search`, list.body)
367 }367 }
368 368
369 // Squash and close, and the order of the list: open first, then the decided.369 // Squash and close, and the order of the list: open first, then the decided.
@@ -418,7 +418,7 @@ func TestWebMergeRequestSquashCloseAndList(t *testing.T) {
418 require.True(t, third >= 0 && squash >= 0 && other >= 0, list)418 require.True(t, third >= 0 && squash >= 0 && other >= 0, list)
419 assert.Less(t, third, squash, "the open request comes first")419 assert.Less(t, third, squash, "the open request comes first")
420 assert.Less(t, third, other)420 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"))
422 assert.Less(t, other, squash, "newest first among the decided")422 assert.Less(t, other, squash, "newest first among the decided")
423 423
424 // A compare page of a branch that is behind says so.424 // 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) {
116 assert.NotContains(t, home.body, "secret")116 assert.NotContains(t, home.body, "secret")
117 assert.Equal(t, "no-cache", home.hdr.Get("Cache-Control"))117 assert.Equal(t, "no-cache", home.hdr.Get("Cache-Control"))
118 assert.Empty(t, home.hdr.Values("Vary"))118 assert.Empty(t, home.hdr.Values("Vary"))
119- assert.NotContains(t, home.body, "sign out")119+ assert.NotContains(t, home.body, `action="/-/logout"`)
120 assert.Equal(t, 404, b.get("/secret", nil).status)120 assert.Equal(t, 404, b.get("/secret", nil).status)
121 121
122 // The CLI asks for a link and prints it; stderr says when it ends.122 // The CLI asks for a link and prints it; stderr says when it ends.
@@ -198,7 +198,7 @@ func TestWebLoginEndToEnd(t *testing.T) {
198 }198 }
199 home = b.get("/", as(token))199 home = b.get("/", as(token))
200 assert.Contains(t, home.body, "secret")200 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">`)
202 assert.Contains(t, b.get("/secret/blob/main/notes.txt", as(token)).body, "notes.txt")202 assert.Contains(t, b.get("/secret/blob/main/notes.txt", as(token)).body, "notes.txt")
203 raw := b.get("/secret/raw/main/notes.txt", as(token))203 raw := b.get("/secret/raw/main/notes.txt", as(token))
204 assert.Equal(t, 200, raw.status)204 assert.Equal(t, 200, raw.status)
M cmd/ermite/web_test.go +19 -2
@@ -184,6 +184,7 @@ var (
184 reStyleAttr = regexp.MustCompile(`(?i)\sstyle\s*=`)184 reStyleAttr = regexp.MustCompile(`(?i)\sstyle\s*=`)
185 reStyleTag = regexp.MustCompile(`(?i)<style\b`)185 reStyleTag = regexp.MustCompile(`(?i)<style\b`)
186 reImport = regexp.MustCompile(`(?i)@import|url\(`)186 reImport = regexp.MustCompile(`(?i)@import|url\(`)
187+ reCSSURL = regexp.MustCompile(`url\(\s*([^)]*?)\s*\)`)
187 )188 )
188 189
189 // assertNoInline checks the page has no inline script, style element or190 // assertNoInline checks the page has no inline script, style element or
@@ -231,14 +232,30 @@ func TestWebContentSecurity(t *testing.T) {
231 assert.Equal(t, "public, max-age=31536000, immutable", ah.Get("Cache-Control"), m[1])232 assert.Equal(t, "public, max-age=31536000, immutable", ah.Get("Cache-Control"), m[1])
232 assert.Equal(t, "nosniff", ah.Get("X-Content-Type-Options"), m[1])233 assert.Equal(t, "nosniff", ah.Get("X-Content-Type-Options"), m[1])
233 assert.NotEmpty(t, ab)234 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+ }
235 }244 }
236 }245 }
237 246
238 // The footer links to the source (AGPL §13).247 // The footer links to the source (AGPL §13).
239- _, _, body := h.get("/")248+ _, hdr, body := h.get("/")
240 assert.Contains(t, body, `<a href="http://example.test/ermite"`)249 assert.Contains(t, body, `<a href="http://example.test/ermite"`)
241 250
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+
242 // Unknown, stale or traversing asset names are 404s.259 // Unknown, stale or traversing asset names are 404s.
243 for _, p := range []string{"/-/static/css/ermite.000000000000.css", "/-/static/css/ermite.css", "/-/static/", "/-/static/../x", "/-/static/js/"} {260 for _, p := range []string{"/-/static/css/ermite.000000000000.css", "/-/static/css/ermite.css", "/-/static/", "/-/static/../x", "/-/static/js/"} {
244 st, _, _ := h.get(p)261 st, _, _ := h.get(p)
M internal/adapters/web/ASSETS.md +5 -1
@@ -10,7 +10,11 @@ content-hashed names. Nothing is fetched at runtime.
10 | `static/js/hx-sse.min.js` | htmx 4 `hx-sse` extension | `mockups/assets/htmx/ext/hx-sse.min.js` | `8a834680c4000a9034d79228872372a92e140c810a075cb6d4a76690dfc13085` |10 | `static/js/hx-sse.min.js` | htmx 4 `hx-sse` extension | `mockups/assets/htmx/ext/hx-sse.min.js` | `8a834680c4000a9034d79228872372a92e140c810a075cb6d4a76690dfc13085` |
11 | `static/js/ermite.js` | key sequences to `k-*` events | `mockups/d/js/ermite.js` | |11 | `static/js/ermite.js` | key sequences to `k-*` events | `mockups/d/js/ermite.js` | |
12 | `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 | |12 | `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 | |
14 18
15 The three htmx files are the exact files the approved mockup was built with.19 The three htmx files are the exact files the approved mockup was built with.
16 To upgrade, replace the file, update the checksum here, and run the web tests20 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/"
23 // the content, so they can be cached for ever.23 // the content, so they can be cached for ever.
24 type Assets struct {24 type Assets struct {
25 CSS, HTMX, Live, SSE, App, Icon string25 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
26 }30 }
27 31
28 // asset is one embedded file under its content-hashed name.32 // asset is one embedded file under its content-hashed name.
@@ -49,8 +53,16 @@ var scripts = []struct {
49 {"ermite.js", func(a *Assets, u string) { a.App = u }},53 {"ermite.js", func(a *Assets, u string) { a.App = u }},
50 }54 }
51 55
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+
52 // loadAssets hashes the embedded files. The stylesheet is the concatenation63 // 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.
54 func loadAssets() (*assetSet, error) {66 func loadAssets() (*assetSet, error) {
55 s := &assetSet{byName: map[string]asset{}}67 s := &assetSet{byName: map[string]asset{}}
56 put := func(dir, file string, body []byte, typ string) string {68 put := func(dir, file string, body []byte, typ string) string {
@@ -61,6 +73,26 @@ func loadAssets() (*assetSet, error) {
61 return StaticPrefix + name73 return StaticPrefix + name
62 }74 }
63 75
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+
64 files, err := fs.Glob(staticFS, "static/css/*.css")96 files, err := fs.Glob(staticFS, "static/css/*.css")
65 if err != nil {97 if err != nil {
66 return nil, err98 return nil, err
@@ -75,7 +107,14 @@ func loadAssets() (*assetSet, error) {
75 css.WriteString("/* " + path.Base(f) + " */\n")107 css.WriteString("/* " + path.Base(f) + " */\n")
76 css.Write(b)108 css.Write(b)
77 }109 }
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")
79 118
80 for _, sc := range scripts {119 for _, sc := range scripts {
81 b, err := staticFS.ReadFile("static/js/" + sc.file)120 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
188 pinRef(&l, t)188 pinRef(&l, t)
189 l.Crumbs = pathCrumbs(d.name(), "blob", t)189 l.Crumbs = pathCrumbs(d.name(), "blob", t)
190 l.Title = visibleText(t.path) + " · " + d.name() + " · " + s.instance190 l.Title = visibleText(t.path) + " · " + d.name() + " · " + s.instance
191- l.Status = Status{191+ l.HasRail = true
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"
196 return Blob(l, p), http.StatusOK, nil192 return Blob(l, p), http.StatusOK, nil
197 }193 }
198 194
M internal/adapters/web/blob.templ +6 -6
@@ -12,7 +12,7 @@ templ Blob(l Layout, p BlobPage) {
12 @Shell(l, blobRail(p)) {12 @Shell(l, blobRail(p)) {
13 <h1 class="file-h">13 <h1 class="file-h">
14 <span>{ p.Name }</span>14 <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>
16 </h1>16 </h1>
17 if p.Notice != "" {17 if p.Notice != "" {
18 <p class="notice" role="note">{ p.Notice }</p>18 <p class="notice" role="note">{ p.Notice }</p>
@@ -39,7 +39,7 @@ templ Blob(l Layout, p BlobPage) {
39 // CodeView is the highlighted lines with a link on each number. A click on a39 // CodeView is the highlighted lines with a link on each number. A click on a
40 // number selects the line, shift-click extends the selection to a range; the40 // number selects the line, shift-click extends the selection to a range; the
41 // URL hash (#L10 or #L10-L24) is the state, so a page loaded with a hash is41 // 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.
43 templ CodeView(h template.HTML) {43 templ CodeView(h template.HTML) {
44 <pre44 <pre
45 class="chroma code"45 class="chroma code"
@@ -59,12 +59,12 @@ templ CodeView(h template.HTML) {
59 59
60 templ blobRail(p BlobPage) {60 templ blobRail(p BlobPage) {
61 <section aria-labelledby="r-file">61 <section aria-labelledby="r-file">
62- <h2 id="r-file">file</h2>62+ <h2 id="r-file">File</h2>
63 @Facts(p.Facts)63 @Facts(p.Facts)
64 </section>64 </section>
65 if p.Last != nil {65 if p.Last != nil {
66 <section aria-labelledby="r-last">66 <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>
68 <p><a href={ templ.SafeURL(p.Last.CHref) }>{ p.Last.Short }</a> { p.Last.Subject }</p>68 <p><a href={ templ.SafeURL(p.Last.CHref) }>{ p.Last.Short }</a> { p.Last.Subject }</p>
69 <p class="dim">69 <p class="dim">
70 { p.Last.Author } ·70 { p.Last.Author } ·
@@ -76,13 +76,13 @@ templ blobRail(p BlobPage) {
76 }76 }
77 if len(p.Outline) > 0 {77 if len(p.Outline) > 0 {
78 <section aria-labelledby="r-outline">78 <section aria-labelledby="r-outline">
79- <h2 id="r-outline">outline</h2>79+ <h2 id="r-outline">On this page</h2>
80 @Outline(p.Outline)80 @Outline(p.Outline)
81 </section>81 </section>
82 }82 }
83 if p.HasCode {83 if p.HasCode {
84 <section aria-labelledby="r-sel">84 <section aria-labelledby="r-sel">
85- <h2 id="r-sel">selection</h2>85+ <h2 id="r-sel">Selection</h2>
86 <p :text="document.body.dataset.sel || 'none · click a line number, shift-click extends'">none · click a line number, shift-click extends</p>86 <p :text="document.body.dataset.sel || 'none · click a line number, shift-click extends'">none · click a line number, shift-click extends</p>
87 <p class="dim">y copies a permalink pinned to the commit sha</p>87 <p class="dim">y copies a permalink pinned to the commit sha</p>
88 </section>88 </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,
157 {Label: "commit", Href: history},157 {Label: "commit", Href: history},
158 {Label: p.Short, Current: true},158 {Label: p.Short, Current: true},
159 }159 }
160- l.Status = Status{160+ l.HasRail = true
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")
165 return Commit(l, p), http.StatusOK, nil161 return Commit(l, p), http.StatusOK, nil
166 }162 }
167 163
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-
173 // person converts a signature.164 // person converts a signature.
174 func person(s *pb.Signature, now time.Time) Person {165 func person(s *pb.Signature, now time.Time) Person {
175 p := Person{Name: visibleText(s.GetName())}166 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,
94 } else {94 } else {
95 l.Title = "history of " + visibleText(sel.path) + " · " + d.name() + " · " + s.instance95 l.Title = "history of " + visibleText(sel.path) + " · " + d.name() + " · " + s.instance
96 }96 }
97- what := plural(len(cs), "commit", "commits")97+ l.HasRail = true
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"
109 return Commits(l, p), http.StatusOK, nil98 return Commits(l, p), http.StatusOK, nil
110 }99 }
111 100
M internal/adapters/web/commits.templ +8 -8
@@ -7,12 +7,12 @@ package web
7 templ Commits(l Layout, p CommitsPage) {7 templ Commits(l Layout, p CommitsPage) {
8 @Shell(l, commitsRail(p)) {8 @Shell(l, commitsRail(p)) {
9 <div class="filter">9 <div class="filter">
10- <label for="commit-filter">filter&gt;</label>10+ <label for="commit-filter" class="sr">filter</label>
11 <input11 <input
12 id="commit-filter"12 id="commit-filter"
13 type="search"13 type="search"
14 data-filter14 data-filter
15- placeholder="subject, sha or author ( / )"15+ placeholder="Filter by subject, sha or author…"
16 autocomplete="off"16 autocomplete="off"
17 hx-on:input="let t = this.value.toLowerCase(); q('.days tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t));17 hx-on:input="let t = this.value.toLowerCase(); q('.days tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t));
18 q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])'));18 q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])'));
@@ -39,14 +39,14 @@ templ Commits(l Layout, p CommitsPage) {
39 if p.Older != "" || p.Newest != "" {39 if p.Older != "" || p.Newest != "" {
40 <nav class="pager" aria-label="Pages">40 <nav class="pager" aria-label="Pages">
41 if p.Newest != "" {41 if p.Newest != "" {
42- <a href={ templ.SafeURL(p.Newest) }>← newest</a>42+ <a class="btn" href={ templ.SafeURL(p.Newest) }>← Newest</a>
43 } else {43 } else {
44- <span class="mute">← newest</span>44+ <span class="btn" aria-disabled="true">← Newest</span>
45 }45 }
46 if p.Older != "" {46 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>
48 } else {48 } else {
49- <span class="mute">older →</span>49+ <span class="btn" aria-disabled="true">Older →</span>
50 }50 }
51 </nav>51 </nav>
52 }52 }
@@ -55,7 +55,7 @@ templ Commits(l Layout, p CommitsPage) {
55 55
56 templ commitsRail(p CommitsPage) {56 templ commitsRail(p CommitsPage) {
57 <section aria-labelledby="r-hist">57 <section aria-labelledby="r-hist">
58- <h2 id="r-hist">history</h2>58+ <h2 id="r-hist">History</h2>
59 <dl class="kv">59 <dl class="kv">
60 <dt>ref</dt>60 <dt>ref</dt>
61 <dd>{ p.Ref }</dd>61 <dd>{ p.Ref }</dd>
@@ -72,7 +72,7 @@ templ commitsRail(p CommitsPage) {
72 </dl>72 </dl>
73 </section>73 </section>
74 <section aria-labelledby="r-refs">74 <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>
76 if len(p.Refs) > 0 {76 if len(p.Refs) > 0 {
77 <nav class="outline" aria-label="Refs on this page">77 <nav class="outline" aria-label="Refs on this page">
78 for _, r := range p.Refs {78 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,
132 Crumb{Label: "compare", Current: false},132 Crumb{Label: "compare", Current: false},
133 Crumb{Label: p.Source, Current: true},133 Crumb{Label: p.Source, Current: true},
134 )134 )
135- l.Status = Status{135+ l.HasRail = true
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"
140 return Compare(l, p), http.StatusOK, nil136 return Compare(l, p), http.StatusOK, nil
141 }137 }
142 138
M internal/adapters/web/components.templ +19 -8
@@ -21,11 +21,11 @@ templ CloneBlock(c Clone) {
21 <span class="mute" aria-hidden="true">$ </span><code>{ line }</code>21 <span class="mute" aria-hidden="true">$ </span><code>{ line }</code>
22 <button22 <button
23 type="button"23 type="button"
24- class="copy"24+ class="btn copy"
25- data-label="[copy]"25+ data-label="Copy"
26 aria-label={ "copy: " + line }26 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"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>28+ >Copy</button>
29 </div>29 </div>
30 }30 }
31 </div>31 </div>
@@ -38,7 +38,7 @@ templ HomeRowView(r HomeRow) {
38 <td class="c-name">38 <td class="c-name">
39 <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a>39 <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a>
40 if r.Fresh {40 if r.Fresh {
41- <span class="tagged">● new push</span>41+ <span class="pill new">new push</span>
42 }42 }
43 </td>43 </td>
44 <td class="c-desc dim">{ r.Desc }</td>44 <td class="c-desc dim">{ r.Desc }</td>
@@ -62,6 +62,17 @@ templ Outline(headings []Heading) {
62 </nav>62 </nav>
63 }63 }
64 64
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+
65 // ReadmeView is a rendered README. Its HTML comes from the Markdown76 // ReadmeView is a rendered README. Its HTML comes from the Markdown
66 // renderer, which drops raw HTML and filters URLs.77 // renderer, which drops raw HTML and filters URLs.
67 templ ReadmeView(r Readme) {78 templ ReadmeView(r Readme) {
@@ -91,13 +102,13 @@ templ CommitRows(rows []CommitRow) {
91 <td class="c-subject">102 <td class="c-subject">
92 <a href={ templ.SafeURL(c.CHref) }>{ c.Subject }</a>103 <a href={ templ.SafeURL(c.CHref) }>{ c.Subject }</a>
93 if c.Merge {104 if c.Merge {
94- <span class="tagged">(merge)</span>105+ <span class="pill">merge</span>
95 }106 }
96 for _, b := range c.Branches {107 for _, b := range c.Branches {
97- <span class="tagged">[{ b }]</span>108+ <span class="pill">{ b }</span>
98 }109 }
99 for _, t := range c.Tags {110 for _, t := range c.Tags {
100- <span class="tagged">[tag: { t }]</span>111+ <span class="pill tag"><span class="sr">tag </span>{ t }</span>
101 }112 }
102 </td>113 </td>
103 <td class="c-who dim">{ c.Author }</td>114 <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) {
44 <dt>refs</dt>44 <dt>refs</dt>
45 <dd>45 <dd>
46 for _, r := range p.Refs {46 for _, r := range p.Refs {
47- [{ r }]{ " " }47+ <span class="pill">{ r }</span>
48 }48 }
49 </dd>49 </dd>
50 }50 }
@@ -53,9 +53,9 @@ templ Commit(l Layout, p CommitPage) {
53 <div class="diff-tools" role="toolbar" aria-label="Diff view">53 <div class="diff-tools" role="toolbar" aria-label="Diff view">
54 <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>54 <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>
55 <span class="grow"></span>55 <span class="grow"></span>
56- @viewLink(p.DiffView, "unified", "[u]nified", "u", p.UnifiedHref)56+ @viewLink(p.DiffView, "unified", "Unified", "u", p.UnifiedHref)
57- @viewLink(p.DiffView, "split", "[d] split", "d", p.SplitHref)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'))">[x] collapse all</button>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>
59 </div>59 </div>
60 if p.Invisible {60 if p.Invisible {
61 <p class="notice">61 <p class="notice">
@@ -99,7 +99,7 @@ templ Commit(l Layout, p CommitPage) {
99 templ viewLink(p DiffView, view, label, key, href string) {99 templ viewLink(p DiffView, view, label, key, href string) {
100 <a100 <a
101 href={ templ.SafeURL(href) }101 href={ templ.SafeURL(href) }
102- class="vbtn"102+ class="btn vbtn"
103 data-view={ view }103 data-view={ view }
104 aria-keyshortcuts={ key }104 aria-keyshortcuts={ key }
105 if p.View == view {105 if p.View == view {
@@ -109,7 +109,7 @@ templ viewLink(p DiffView, view, label, key, href string) {
109 hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))"109 hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))"
110 :aria-current={ "q('#diffs').attr['data-diff'] === '" + view + "' ? 'true' : false" }110 :aria-current={ "q('#diffs').attr['data-diff'] === '" + view + "' ? 'true' : false" }
111 }111 }
112- >{ label }</a>112+ >{ label }<kbd>{ key }</kbd></a>
113 }113 }
114 114
115 templ personView(pr Person) {115 templ personView(pr Person) {
@@ -121,7 +121,7 @@ templ personView(pr Person) {
121 121
122 templ commitRail(p CommitPage) {122 templ commitRail(p CommitPage) {
123 <section aria-labelledby="r-files">123 <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>
125 @FileList(p.Files)125 @FileList(p.Files)
126 </section>126 </section>
127 }127 }
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 (
19 )19 )
20 20
21 // Live updates. A page that can change under the eyes of its reader opens21 // 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 follows22+// GET /-/events (the Live component of the hint badge). The handler follows
23 // EventService.Watch as the visitor of the request and turns each event into23 // EventService.Watch as the visitor of the request and turns each event into
24 // the HTML fragments the page shows, sent as Server-Sent Events:24 // the HTML fragments the page shows, sent as Server-Sent Events:
25 //25 //
M internal/adapters/web/format.go +0 -8
@@ -10,14 +10,6 @@ import (
10 10
11 func itoa(n int) string { return strconv.Itoa(n) }11 func itoa(n int) string { return strconv.Itoa(n) }
12 12
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-
21 // ago renders a short relative date: now, 5m ago, 3h ago, 12d ago, 5mo ago,13 // ago renders a short relative date: now, 5m ago, 3h ago, 12d ago, 5mo ago,
22 // 2y ago.14 // 2y ago.
23 func ago(now, t time.Time) string {15 func ago(now, t time.Time) string {
M internal/adapters/web/fragments.templ +8 -11
@@ -76,25 +76,22 @@ templ MRNotice(text, href string) {
76 </div>76 </div>
77 }77 }
78 78
79-// Live is the connection state of the event stream, in the status bar.79+// Live is the connection to the event stream, in the hint badge. It shows
80-// Without a stream it reads "offline" and does nothing. The events of the80+// only once a connection was lost: a page that never connected, or that is
81-// stream are fragments that ermiteLive (ermite.js) swaps in the page.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.
82 templ Live(l Layout) {83 templ Live(l Layout) {
83 if l.Live && l.LiveURL != "" {84 if l.Live && l.LiveURL != "" {
84 <span85 <span
85- class="st-live"86+ class="hint-live"
86- data-state="offline"87+ data-state="connecting"
87- role="status"
88 hx-sse:connect={ l.LiveURL }88 hx-sse:connect={ l.LiveURL }
89 hx-swap="none"89 hx-swap="none"
90- :text="data.state"90+ :text="data.state === 'offline' ? 'live updates paused' : ''"
91- :title="data.state === 'live' ? 'receiving updates over SSE' : 'not connected to the event stream'"
92 hx-on="htmx:sse:after:connection -> data.state = 'live';91 hx-on="htmx:sse:after:connection -> data.state = 'live';
93 htmx:sse:close, htmx:sse:error -> data.state = 'offline';92 htmx:sse:close, htmx:sse:error -> data.state = 'offline';
94 home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);93 home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
95 gap -> ermiteLive.reload()"94 gap -> ermiteLive.reload()"
96- >offline</span>95+ ></span>
97- } else {
98- <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
99 }96 }
100 }97 }
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
109 l := s.base()109 l := s.base()
110 l.Title = s.instance110 l.Title = s.instance
111 l.Live, l.LiveURL = true, liveURL("home", "")111 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- }
123 l.Cmd = append([]CmdItem{{Kind: "go", Label: "home", Href: "/"}}, repoCmds(repos, "")...)112 l.Cmd = append([]CmdItem{{Kind: "go", Label: "home", Href: "/"}}, repoCmds(repos, "")...)
124 return Home(l, p), http.StatusOK, nil113 return Home(l, p), http.StatusOK, nil
125 }114 }
M internal/adapters/web/keys.templ +23 -19
@@ -9,40 +9,44 @@ templ Keys() {
9 <div9 <div
10 id="keys"10 id="keys"
11 hidden11 hidden
12- hx-on="k-j from:body -> ((d) => {12+ hx-on="k-j from:body -> (async (d) => {
13 if (q('dialog[open]').count) return;13 if (q('dialog[open]').count) return;
14 let rows = q('[data-rows] > :not([hidden])').arr();14 let rows = q('[data-rows] > :not([hidden])').arr();
15 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));15 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
16 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));16 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
17- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];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' })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 = ''
19 })(1);21 })(1);
20- k-k from:body -> ((d) => {22+ k-k from:body -> (async (d) => {
21 if (q('dialog[open]').count) return;23 if (q('dialog[open]').count) return;
22 let rows = q('[data-rows] > :not([hidden])').arr();24 let rows = q('[data-rows] > :not([hidden])').arr();
23 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));25 if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180));
24 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));26 let i = rows.findIndex(r => r.hasAttribute('data-cursor'));
25- let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))];27+ let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j];
26- q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' })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 = ''
27 })(-1);31 })(-1);
28 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));32 k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0));
29 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));33 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));
30 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();34 k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click();
31 k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close());35 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();36+ k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal();
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());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());
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();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();
35 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();39 k-filter from:body -> q('[data-filter]').arr()[0]?.focus();
36- k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click();40+ k-gh from:body -> document.querySelector('.brand')?.click();
37- k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click();41+ k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click();
38- k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click();42+ k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click();
39- k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click();43+ k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click();
40- k-s from:body -> document.querySelector('.secs [data-key=s]')?.click();44+ k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click();
41- k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();45+ k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click();
42- k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();46+ k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click();
43- k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();47+ k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click();
44- k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();48+ k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click();
45- k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();49+ k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click();
46 k-next from:body -> {50 k-next from:body -> {
47 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;51 let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
48 let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);52 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,
377 p.Live = true377 p.Live = true
378 l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mrs")378 l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mrs")
379 }379 }
380- rows := len(p.Open) + len(p.Done)380+ l.HasRail = true
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"
386 return MRList(l, p), http.StatusOK, nil381 return MRList(l, p), http.StatusOK, nil
387 }382 }
388 383
@@ -557,11 +552,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
557 }552 }
558 p.Live = true553 p.Live = true
559 l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.GetSourceBranch()))554 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{555+ l.HasRail = true
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"
565 return MergeRequest(l, p), http.StatusOK, nil556 return MergeRequest(l, p), http.StatusOK, nil
566 }557 }
567 558
@@ -714,9 +705,9 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []*pb.C
714 }705 }
715 }706 }
716 if canWrite {707 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"}
718 if c.GetResolved() {709 if c.GetResolved() {
719- v.Resolve.Label = "[unresolve]"710+ v.Resolve.Label = "Unresolve"
720 }711 }
721 }712 }
722 switch {713 switch {
M internal/adapters/web/mr.templ +32 -32
@@ -12,19 +12,19 @@ templ MRList(l Layout, p MRListPage) {
12 <div id="mr-notice"></div>12 <div id="mr-notice"></div>
13 }13 }
14 <div class="filter">14 <div class="filter">
15- <label for="mr-filter">filter&gt;</label>15+ <label for="mr-filter" class="sr">filter</label>
16 <input16 <input
17 id="mr-filter"17 id="mr-filter"
18 type="search"18 type="search"
19 data-filter19 data-filter
20- placeholder="title, branch, author or state ( / )"20+ placeholder="Filter by title, branch, author or state…"
21 autocomplete="off"21 autocomplete="off"
22 hx-on:input="let t = this.value.toLowerCase(); q('.mrs tbody tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t));22 hx-on:input="let t = this.value.toLowerCase(); q('.mrs tbody tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t));
23 let first = q('.mrs tbody tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.mrs tbody tr')"23 let first = q('.mrs tbody tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.mrs tbody tr')"
24 />24 />
25 </div>25 </div>
26 if p.Newest == "" {26 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>
28 @mrRows(p.Open, "Open merge requests")28 @mrRows(p.Open, "Open merge requests")
29 if len(p.Open) == 0 {29 if len(p.Open) == 0 {
30 <p class="lede dim">no open merge requests.</p>30 <p class="lede dim">no open merge requests.</p>
@@ -33,7 +33,7 @@ templ MRList(l Layout, p MRListPage) {
33 <p class="note">{ p.OpenNote }</p>33 <p class="note">{ p.OpenNote }</p>
34 }34 }
35 }35 }
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>
37 @mrRows(p.Done, "Merged and closed merge requests")37 @mrRows(p.Done, "Merged and closed merge requests")
38 if len(p.Done) == 0 {38 if len(p.Done) == 0 {
39 <p class="lede dim">nothing merged or closed yet.</p>39 <p class="lede dim">nothing merged or closed yet.</p>
@@ -41,14 +41,14 @@ templ MRList(l Layout, p MRListPage) {
41 if p.Older != "" || p.Newest != "" {41 if p.Older != "" || p.Newest != "" {
42 <nav class="pager" aria-label="Pages">42 <nav class="pager" aria-label="Pages">
43 if p.Newest != "" {43 if p.Newest != "" {
44- <a href={ templ.SafeURL(p.Newest) }>← newest</a>44+ <a class="btn" href={ templ.SafeURL(p.Newest) }>← Newest</a>
45 } else {45 } else {
46- <span class="mute">← newest</span>46+ <span class="btn" aria-disabled="true">← Newest</span>
47 }47 }
48 if p.Older != "" {48 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>
50 } else {50 } else {
51- <span class="mute">older →</span>51+ <span class="btn" aria-disabled="true">Older →</span>
52 }52 }
53 </nav>53 </nav>
54 }54 }
@@ -80,7 +80,7 @@ templ mrRows(rows []MRRow, label string) {
80 80
81 templ mrListRail(p MRListPage) {81 templ mrListRail(p MRListPage) {
82 <section aria-labelledby="r-states">82 <section aria-labelledby="r-states">
83- <h2 id="r-states">states</h2>83+ <h2 id="r-states">States</h2>
84 <dl class="kv">84 <dl class="kv">
85 <dt>open</dt>85 <dt>open</dt>
86 <dd>awaits review</dd>86 <dd>awaits review</dd>
@@ -91,7 +91,7 @@ templ mrListRail(p MRListPage) {
91 </dl>91 </dl>
92 </section>92 </section>
93 <section aria-labelledby="r-new">93 <section aria-labelledby="r-new">
94- <h2 id="r-new">open one</h2>94+ <h2 id="r-new">Open one</h2>
95 if len(p.Branches) > 0 {95 if len(p.Branches) > 0 {
96 <p class="dim">compare a branch with { p.Default }:</p>96 <p class="dim">compare a branch with { p.Default }:</p>
97 <nav class="outline" aria-label="Branches">97 <nav class="outline" aria-label="Branches">
@@ -166,9 +166,9 @@ templ MergeRequest(l Layout, p MRPage) {
166 <div class="diff-tools" role="toolbar" aria-label="Diff view">166 <div class="diff-tools" role="toolbar" aria-label="Diff view">
167 <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>167 <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>
168 <span class="grow"></span>168 <span class="grow"></span>
169- @viewLink(p.DiffView, "unified", "[u]nified", "u", p.UnifiedHref)169+ @viewLink(p.DiffView, "unified", "Unified", "u", p.UnifiedHref)
170- @viewLink(p.DiffView, "split", "[d] split", "d", p.SplitHref)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'))">[x] collapse all</button>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>
172 </div>172 </div>
173 if p.Invisible {173 if p.Invisible {
174 <p class="notice">174 <p class="notice">
@@ -197,7 +197,7 @@ templ MergeRequest(l Layout, p MRPage) {
197 <p class="note">no changed files.</p>197 <p class="note">no changed files.</p>
198 }198 }
199 </div>199 </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>
201 for _, c := range p.Loose {201 for _, c := range p.Loose {
202 @commentView(c, "")202 @commentView(c, "")
203 }203 }
@@ -257,7 +257,7 @@ templ commentView(c CommentView, suffix string) {
257 if c.Resolve != nil {257 if c.Resolve != nil {
258 <form class="c-act" method="post" action={ templ.SafeURL(c.Resolve.Action) }>258 <form class="c-act" method="post" action={ templ.SafeURL(c.Resolve.Action) }>
259 <input type="hidden" name="resolved" value={ boolText(c.Resolve.Value) }/>259 <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>
261 </form>261 </form>
262 }262 }
263 </div>263 </div>
@@ -291,9 +291,9 @@ templ commentForm(f CommentForm, suffix string) {
291 <input type="hidden" name="side" value={ f.Side }/>291 <input type="hidden" name="side" value={ f.Side }/>
292 }292 }
293 <div class="f-btns">293 <div class="f-btns">
294- <button type="submit" class="f-main">[comment]</button>294+ <button type="submit" class="btn primary">Comment</button>
295 if f.Cancel != "" {295 if f.Cancel != "" {
296- <a href={ templ.SafeURL(f.Cancel) }>[cancel]</a>296+ <a class="btn" href={ templ.SafeURL(f.Cancel) }>Cancel</a>
297 }297 }
298 </div>298 </div>
299 </form>299 </form>
@@ -304,12 +304,12 @@ templ mrRail(p MRPage) {
304 @mergeBox(p)304 @mergeBox(p)
305 }305 }
306 <section aria-labelledby="r-checks">306 <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>
308 <p class="dim">no checks</p>308 <p class="dim">no checks</p>
309 <p class="mute">there is no CI yet: nothing blocks the merge.</p>309 <p class="mute">there is no CI yet: nothing blocks the merge.</p>
310 </section>310 </section>
311 <section aria-labelledby="r-settings">311 <section aria-labelledby="r-settings">
312- <h2 id="r-settings">settings</h2>312+ <h2 id="r-settings">Settings</h2>
313 <dl class="kv">313 <dl class="kv">
314 <dt>modes</dt>314 <dt>modes</dt>
315 <dd>{ strings.Join(p.Modes, ", ") }</dd>315 <dd>{ strings.Join(p.Modes, ", ") }</dd>
@@ -324,7 +324,7 @@ templ mrRail(p MRPage) {
324 </dl>324 </dl>
325 </section>325 </section>
326 <section aria-labelledby="r-commits">326 <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>
328 <ul class="pcommits">328 <ul class="pcommits">
329 for _, c := range p.Commits {329 for _, c := range p.Commits {
330 <li data-new?={ c.New }>330 <li data-new?={ c.New }>
@@ -350,7 +350,7 @@ templ mrRail(p MRPage) {
350 }350 }
351 </section>351 </section>
352 <section aria-labelledby="r-files">352 <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>
354 @FileList(p.Files)354 @FileList(p.Files)
355 </section>355 </section>
356 }356 }
@@ -360,7 +360,7 @@ templ mrRail(p MRPage) {
360 // the other things one does with the request.360 // the other things one does with the request.
361 templ mergeBox(p MRPage) {361 templ mergeBox(p MRPage) {
362 <section class="decide" aria-labelledby="r-merge" data-state="open">362 <section class="decide" aria-labelledby="r-merge" data-state="open">
363- <h2 id="r-merge">merge</h2>363+ <h2 id="r-merge">Merge</h2>
364 <dl class="kv">364 <dl class="kv">
365 <dt>head</dt>365 <dt>head</dt>
366 <dd>366 <dd>
@@ -386,7 +386,7 @@ templ mergeBox(p MRPage) {
386 <label class="d-check"><input type="checkbox" name="delete_branch" value="1"/> delete { p.Source } after the merge</label>386 <label class="d-check"><input type="checkbox" name="delete_branch" value="1"/> delete { p.Source } after the merge</label>
387 }387 }
388 <div class="d-btns">388 <div class="d-btns">
389- <button type="submit" class="d-main">[merge]</button>389+ <button type="submit" class="btn primary">Merge</button>
390 </div>390 </div>
391 </form>391 </form>
392 }392 }
@@ -394,11 +394,11 @@ templ mergeBox(p MRPage) {
394 if p.Box.CanMarkRead {394 if p.Box.CanMarkRead {
395 <form method="post" action={ templ.SafeURL(p.Box.ReadAction) }>395 <form method="post" action={ templ.SafeURL(p.Box.ReadAction) }>
396 <input type="hidden" name="head_id" value={ p.Box.Head }/>396 <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>
398 </form>398 </form>
399 }399 }
400 <form method="post" action={ templ.SafeURL(p.Box.CloseAction) }>400 <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>
402 </form>402 </form>
403 </div>403 </div>
404 if !p.Box.HeadGone {404 if !p.Box.HeadGone {
@@ -438,7 +438,7 @@ templ Compare(l Layout, p ComparePage) {
438 <label for="mr-desc">description</label>438 <label for="mr-desc">description</label>
439 <textarea id="mr-desc" name="description" rows="4" maxlength="8000" placeholder="plain text, optional"></textarea>439 <textarea id="mr-desc" name="description" rows="4" maxlength="8000" placeholder="plain text, optional"></textarea>
440 <div class="f-btns">440 <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>
442 </div>442 </div>
443 </form>443 </form>
444 }444 }
@@ -446,9 +446,9 @@ templ Compare(l Layout, p ComparePage) {
446 <div class="diff-tools" role="toolbar" aria-label="Diff view">446 <div class="diff-tools" role="toolbar" aria-label="Diff view">
447 <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>447 <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>
448 <span class="grow"></span>448 <span class="grow"></span>
449- @viewLink(p.DiffView, "unified", "[u]nified", "u", p.UnifiedHref)449+ @viewLink(p.DiffView, "unified", "Unified", "u", p.UnifiedHref)
450- @viewLink(p.DiffView, "split", "[d] split", "d", p.SplitHref)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'))">[x] collapse all</button>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>
452 </div>452 </div>
453 if p.Invisible {453 if p.Invisible {
454 <p class="notice">454 <p class="notice">
@@ -477,7 +477,7 @@ templ Compare(l Layout, p ComparePage) {
477 477
478 templ compareRail(p ComparePage) {478 templ compareRail(p ComparePage) {
479 <section aria-labelledby="r-ahead">479 <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>
481 <ul class="pcommits">481 <ul class="pcommits">
482 for _, c := range p.Ahead {482 for _, c := range p.Ahead {
483 <li>483 <li>
@@ -498,12 +498,12 @@ templ compareRail(p ComparePage) {
498 </section>498 </section>
499 if p.HasDiff {499 if p.HasDiff {
500 <section aria-labelledby="r-files">500 <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>
502 @FileList(p.Files)502 @FileList(p.Files)
503 </section>503 </section>
504 }504 }
505 <section aria-labelledby="r-cli">505 <section aria-labelledby="r-cli">
506- <h2 id="r-cli">from a terminal</h2>506+ <h2 id="r-cli">From a terminal</h2>
507 @CloneBlock(p.CLI)507 @CloneBlock(p.CLI)
508 </section>508 </section>
509 }509 }
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
109 if back == "" {109 if back == "" {
110 back = "/"110 back = "/"
111 }111 }
112- l := s.stateLayout(title, itoa(status)+" · "+title, []Crumb{{Label: "-"}, {Label: title}})112+ l := s.stateLayout(title, []Crumb{{Label: title, Current: true}})
113 w.Header().Set("Cache-Control", "no-store")113 w.Header().Set("Cache-Control", "no-store")
114 s.write(ctx, w, r, status, State(l, StatePage{114 s.write(ctx, w, r, status, State(l, StatePage{
115 Code: itoa(status), Title: title,115 Code: itoa(status), Title: title,
M internal/adapters/web/mr_templ.go +44 -44 generated file: collapsed
M internal/adapters/web/overlays.templ +26 -26
@@ -6,23 +6,22 @@ import "strings"
6 6
7 // OverlayHelp is the keyboard map panel.7 // OverlayHelp is the keyboard map panel.
8 templ OverlayHelp() {8 templ OverlayHelp() {
9- <dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())">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"><span>keys</span><button type="button" hx-on:click="closest('dialog').close()">[esc] close</button></div>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>
11 <dl class="keys">11 <dl class="keys">
12- <div><dt><kbd>j</kbd> <kbd>k</kbd></dt><dd>move cursor down / up</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>enter</kbd></dt><dd>open row under cursor</dd></div>13+ <div><dt><kbd>?</kbd></dt><dd>this list</dd></div>
14- <div><dt><kbd>g</kbd> <kbd>g</kbd> / <kbd>G</kbd></dt><dd>first / last row</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>/</kbd></dt><dd>focus the filter</dd></div>15+ <div><dt><kbd>enter</kbd></dt><dd>open the row under the cursor</dd></div>
16- <div><dt><kbd>g h</kbd> <kbd>g t</kbd></dt><dd>go home / tree</dd></div>16+ <div><dt><kbd>g g</kbd> <kbd>G</kbd></dt><dd>first / last row</dd></div>
17- <div><dt><kbd>g c</kbd> <kbd>g r</kbd></dt><dd>go commits / refs</dd></div>17+ <div><dt><kbd>/</kbd></dt><dd>filter the list</dd></div>
18- <div><dt><kbd>b</kbd></dt><dd>{ "switch" } ref (branches, tags)</dd></div>18+ <div><dt><kbd>b</kbd></dt><dd>{ "switch" } branch or tag</dd></div>
19- <div><dt><kbd>:</kbd> <kbd>f</kbd></dt><dd>go to: section, ref, file, repo</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>y</kbd></dt><dd>permalink: ref becomes the full sha</dd></div>20+ <div><dt><kbd>g h</kbd></dt><dd>home</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>21+ <div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div>
22- <div><dt><kbd>m</kbd> <kbd>g m</kbd></dt><dd>merge requests (when shown)</dd></div>
23 <div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>diff: split / unified / collapse all</dd></div>22 <div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>diff: split / unified / collapse all</dd></div>
24 <div><dt><kbd>n</kbd></dt><dd>merge request: next note in the diff</dd></div>23 <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>
26 </dl>25 </dl>
27 </dialog>26 </dialog>
28 }27 }
@@ -30,22 +29,22 @@ templ OverlayHelp() {
30 // OverlayRefs is the ref switcher. It exists only inside a repository.29 // OverlayRefs is the ref switcher. It exists only inside a repository.
31 templ OverlayRefs(l Layout) {30 templ OverlayRefs(l Layout) {
32 if l.Ref != "" {31 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()">
34 <div class="panel-head">33 <div class="panel-head">
35- <label for="refs-filter">ref&gt;</label>34+ <label for="refs-filter" class="sr">filter branches and tags</label>
36 <input35 <input
37 id="refs-filter"36 id="refs-filter"
38 type="search"37 type="search"
39- placeholder="filter branches and tags"38+ placeholder="Switch branch or tag…"
40 autocomplete="off"39 autocomplete="off"
41 data-pick40 data-pick
42 hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"41 hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"
43 />42 />
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>
45 </div>44 </div>
46 <div class="panel-cols" data-picklist>45 <div class="panel-cols" data-picklist>
47 <section aria-label="Branches">46 <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>
49 <ul>48 <ul>
50 for _, r := range l.Heads {49 for _, r := range l.Heads {
51 @refItem(r)50 @refItem(r)
@@ -53,7 +52,7 @@ templ OverlayRefs(l Layout) {
53 </ul>52 </ul>
54 </section>53 </section>
55 <section aria-label="Tags">54 <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>
57 <ul>56 <ul>
58 for _, r := range l.Tags {57 for _, r := range l.Tags {
59 @refItem(r)58 @refItem(r)
@@ -87,13 +86,13 @@ templ refItem(r RefItem) {
87 // OverlayCmd is the ":" command line: one filterable list mixing sections,86 // OverlayCmd is the ":" command line: one filterable list mixing sections,
88 // refs, files of this directory and repos.87 // refs, files of this directory and repos.
89 templ OverlayCmd(items []CmdItem) {88 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()">
91 <div class="panel-head">90 <div class="panel-head">
92- <label for="cmd-q" class="hi">:</label>91+ <label for="cmd-q" class="sr">go to</label>
93 <input92 <input
94 id="cmd-q"93 id="cmd-q"
95 type="search"94 type="search"
96- placeholder="section, ref, file or repo"95+ placeholder="Go to a section, branch, tag, file or repository…"
97 autocomplete="off"96 autocomplete="off"
98 spellcheck="false"97 spellcheck="false"
99 aria-controls="cmd-list"98 aria-controls="cmd-list"
@@ -106,12 +105,13 @@ templ OverlayCmd(items []CmdItem) {
106 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' })) }105 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' })) }
107 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"106 else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"
108 />107 />
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>
110 </div>109 </div>
111- <ul id="cmd-list" class="cmd-list">110+ <ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
112 for i, c := range items {111 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>
114 }113 }
115 </ul>114 </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>
116 </dialog>116 </dialog>
117 }117 }
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
6 templ Home(l Layout, p HomePage) {6 templ Home(l Layout, p HomePage) {
7 @Shell(l, nil) {7 @Shell(l, nil) {
8 <div class="filter">8 <div class="filter">
9- <label for="home-filter">filter&gt;</label>9+ <label for="home-filter" class="sr">filter</label>
10 <input10 <input
11 id="home-filter"11 id="home-filter"
12 type="search"12 type="search"
13 data-filter13 data-filter
14- placeholder="name or description ( / )"14+ placeholder="Filter repositories…"
15 autocomplete="off"15 autocomplete="off"
16 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])')"16 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])')"
17 />17 />
@@ -52,23 +52,22 @@ templ Summary(l Layout, p SummaryPage) {
52 <p class="lede">{ p.Desc }</p>52 <p class="lede">{ p.Desc }</p>
53 }53 }
54 if p.Readme != nil {54 if p.Readme != nil {
55- <h2 class="rule-h"><span>{ p.Readme.Name }</span></h2>55+ @ReadmeCard(*p.Readme)
56- @ReadmeView(*p.Readme)
57 }56 }
58 }57 }
59 }58 }
60 59
61 templ summaryRail(p SummaryPage) {60 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)
63 </section>62 </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)
65 </section>64 </section>
66 if len(p.Recent) > 0 {65 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)
68 </section>67 </section>
69 }68 }
70 if len(p.Outline) > 0 && p.Readme != nil {69 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)
72 </section>71 </section>
73 }72 }
74 }73 }
@@ -77,12 +76,12 @@ templ summaryRail(p SummaryPage) {
77 templ Refs(l Layout, p RefsPage) {76 templ Refs(l Layout, p RefsPage) {
78 @Shell(l, refsRail(p)) {77 @Shell(l, refsRail(p)) {
79 <div class="filter">78 <div class="filter">
80- <label for="ref-filter">filter&gt;</label>79+ <label for="ref-filter" class="sr">filter</label>
81 <input80 <input
82 id="ref-filter"81 id="ref-filter"
83 type="search"82 type="search"
84 data-filter83 data-filter
85- placeholder="branch or tag ( / )"84+ placeholder="Filter branches and tags…"
86 autocomplete="off"85 autocomplete="off"
87 hx-on:input="let t = this.value.toLowerCase(); q('#ref-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t));86 hx-on:input="let t = this.value.toLowerCase(); q('#ref-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t));
88 let first = q('#ref-rows > tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#ref-rows > tr')"87 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) {
107 <td class="c-name">106 <td class="c-name">
108 <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a>107 <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a>
109 if r.Default {108 if r.Default {
110- <span class="tagged">*</span>109+ <span class="pill">default</span>
111 }110 }
112 </td>111 </td>
113 <td class="c-sha">112 <td class="c-sha">
@@ -128,20 +127,17 @@ templ refRow(kind string, r RefRow) {
128 127
129 templ refsRail(p RefsPage) {128 templ refsRail(p RefsPage) {
130 <section aria-labelledby="r-refs">129 <section aria-labelledby="r-refs">
131- <h2 id="r-refs">refs</h2>130+ <h2 id="r-refs">Overview</h2>
132 @RefsKV(p)131 @RefsKV(p)
133 </section>132 </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>
138 }133 }
139 134
140 // State is a 404, 500 or empty-repository page inside the normal shell.135 // State is a 404, 500 or empty-repository page inside the normal shell.
141 templ State(l Layout, p StatePage) {136 templ State(l Layout, p StatePage) {
142 @Shell(l, nil) {137 @Shell(l, nil) {
143 <section class="state" aria-labelledby="state-title">138 <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>
145 for _, line := range p.Lines {141 for _, line := range p.Lines {
146 <p class="dim">{ line }</p>142 <p class="dim">{ line }</p>
147 }143 }
@@ -162,14 +158,14 @@ templ State(l Layout, p StatePage) {
162 templ Login(l Layout, p LoginPage) {158 templ Login(l Layout, p LoginPage) {
163 @Shell(l, nil) {159 @Shell(l, nil) {
164 <section class="state" aria-labelledby="state-title">160 <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>
166 for _, line := range p.Lines {162 for _, line := range p.Lines {
167 <p class="dim">{ line }</p>163 <p class="dim">{ line }</p>
168 }164 }
169 <form class="state-act" method="post" action={ templ.SafeURL(p.Action) }>165 <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>
171 </form>167 </form>
172- <p class="state-back"><a href="/">home</a></p>168+ <p class="state-back"><a href="/">Back to home</a></p>
173 </section>169 </section>
174 }170 }
175 }171 }
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 {
165 return false165 return false
166 }166 }
167 167
168-// pathCrumbs is the path in the top bar: repo, kind ("tree", "blob" or168+// pathCrumbs is the path in the header: repo, kind ("tree", "blob" or
169 // "commits"), ref, then the segments of the path. The directories link to169 // "commits"), ref, then the segments of the path. The directories link to
170 // their tree; the last segment links to the file for a blob, and to nothing170 // their tree; the last segment links to the file for a blob, and to nothing
171 // for a history.171 // 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
26 l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=refs")26 l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=refs")
27 l.Crumbs[0].Current = false27 l.Crumbs[0].Current = false
28 l.Crumbs = append(l.Crumbs, Crumb{Label: "refs", Current: true})28 l.Crumbs = append(l.Crumbs, Crumb{Label: "refs", Current: true})
29- l.Status = Status{29+ l.HasRail = true
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"
34 return Refs(l, p), http.StatusOK, nil30 return Refs(l, p), http.StatusOK, nil
35 }31 }
36 32
M internal/adapters/web/repo.go +9 -9
@@ -6,6 +6,7 @@ import (
6 "context"6 "context"
7 "slices"7 "slices"
8 "sort"8 "sort"
9+ "strings"
9 "sync"10 "sync"
10 "time"11 "time"
11 12
@@ -98,23 +99,23 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La
98 l.Ref, l.RefPath = def, def99 l.Ref, l.RefPath = def, def
99 l.SHA, l.Short = d.head.GetCommitId(), short(d.head.GetCommitId())100 l.SHA, l.Short = d.head.GetCommitId(), short(d.head.GetCommitId())
100 }101 }
101- mk := func(key, post, href, id string) Section {102+ mk := func(key, label, href, id string) Section {
102- return Section{Key: key, Post: post, Href: href, Current: section == id}103+ return Section{Key: key, Label: label, Href: href, Current: section == id}
103 }104 }
104 l.Sections = []Section{105 l.Sections = []Section{
105- mk("s", "ummary", repoHref(d.name()), "summary"),106+ mk("s", "Summary", repoHref(d.name()), "summary"),
106- mk("t", "ree", repoHref(d.name(), "tree", def), "tree"),107+ mk("t", "Files", repoHref(d.name(), "tree", def), "tree"),
107- mk("c", "ommits", repoHref(d.name(), "commits", def), "commits"),108+ mk("c", "Commits", repoHref(d.name(), "commits", def), "commits"),
108- mk("r", "efs", repoHref(d.name(), "refs"), "refs"),109+ mk("r", "Branches & tags", repoHref(d.name(), "refs"), "refs"),
109 }110 }
110 if s.cfg.MergeRequests != nil {111 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"))
112 }113 }
113 l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}}114 l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}}
114 115
115 l.Cmd = append(l.Cmd, CmdItem{Kind: "go", Label: "home", Href: "/"})116 l.Cmd = append(l.Cmd, CmdItem{Kind: "go", Label: "home", Href: "/"})
116 for _, sec := range l.Sections {117 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})
118 }119 }
119 if l.Ref != "" {120 if l.Ref != "" {
120 for _, b := range d.branches {121 for _, b := range d.branches {
@@ -233,7 +234,6 @@ func (s *Server) emptyState(ctx context.Context, d *repoData) (Layout, StatePage
233 l.Title = d.name() + " · " + s.instance234 l.Title = d.name() + " · " + s.instance
234 l.Sections = nil235 l.Sections = nil
235 l.Cmd = slices.DeleteFunc(l.Cmd, func(c CmdItem) bool { return c.Kind == "section" })236 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"}
237 branch := d.repo.GetDefaultBranch()237 branch := d.repo.GetDefaultBranch()
238 return l, StatePage{238 return l, StatePage{
239 Code: "empty", Title: d.name() + " is empty",239 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) {
251 s.secretPage(w, r, http.StatusNotFound, s.loginRejectedPage())251 s.secretPage(w, r, http.StatusNotFound, s.loginRejectedPage())
252 return252 return
253 }253 }
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}})
255 s.secretPage(w, r, http.StatusOK, Login(l, LoginPage{255 s.secretPage(w, r, http.StatusOK, Login(l, LoginPage{
256 Title: "sign in as the owner",256 Title: "sign in as the owner",
257 Lines: []string{"this browser will be signed in for 12 hours.", "only confirm if you ran `ermite web login` just now."},257 Lines: []string{"this browser will be signed in for 12 hours.", "only confirm if you ran `ermite web login` just now."},
258 Action: loginPrefix + nonce,258 Action: loginPrefix + nonce,
259- Button: "[sign in]",259+ Button: "Sign in",
260 }))260 }))
261 }261 }
262 262
@@ -310,7 +310,7 @@ func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
310 310
311 // stateOnly is a state page with a title and two lines, for the login flow.311 // stateOnly is a state page with a title and two lines, for the login flow.
312 func (s *Server) stateOnly(crumb, code, title string, lines ...string) templ.Component {312 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}})
314 return State(l, StatePage{Code: code, Title: title, Lines: lines, Back: []Link{{Label: "home", Href: "/"}}})314 return State(l, StatePage{Code: code, Title: title, Lines: lines, Back: []Link{{Label: "home", Href: "/"}}})
315 }315 }
316 316
M internal/adapters/web/shell.templ +96 -84
@@ -2,8 +2,9 @@
2 2
3 package web3 package web
4 4
5-// Shell is the page: head, the two bars, the work area (content and rail),5+// Shell is the page: head, the header, the work area (content and rail), the
6-// the overlays and the key map. The content is the children.6+// footer, the hint badge, the overlays and the key map. The content is the
7+// children.
7 templ Shell(l Layout, rail templ.Component) {8 templ Shell(l Layout, rail templ.Component) {
8 <!DOCTYPE html>9 <!DOCTYPE html>
9 <html lang="en">10 <html lang="en">
@@ -13,132 +14,143 @@ templ Shell(l Layout, rail templ.Component) {
13 <meta name="color-scheme" content="light dark"/>14 <meta name="color-scheme" content="light dark"/>
14 <title>{ l.Title }</title>15 <title>{ l.Title }</title>
15 <link rel="icon" type="image/svg+xml" href={ templ.SafeURL(l.Assets.Icon) }/>16 <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/>
16 <link rel="stylesheet" href={ templ.SafeURL(l.Assets.CSS) }/>18 <link rel="stylesheet" href={ templ.SafeURL(l.Assets.CSS) }/>
17 <script src={ l.Assets.HTMX } defer></script>19 <script src={ l.Assets.HTMX } defer></script>
18 <script src={ l.Assets.Live } defer></script>20 <script src={ l.Assets.Live } defer></script>
19 <script src={ l.Assets.SSE } defer></script>21 <script src={ l.Assets.SSE } defer></script>
20 <script src={ l.Assets.App } defer></script>22 <script src={ l.Assets.App } defer></script>
21 </head>23 </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="">
23 <a class="skip" href="#main">skip to content</a>25 <a class="skip" href="#main">skip to content</a>
24- @Topbar(l)26+ @Header(l)
25- <div class={ "work", templ.KV("has-rail", l.HasRail), templ.KV("read", l.Read) }>27+ <div class={ "work", templ.KV("has-rail", l.HasRail && rail != nil) }>
26- <main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)">28+ <main id="main" tabindex="-1">
27 { children... }29 { children... }
28 </main>30 </main>
29 if l.HasRail && rail != nil {31 if l.HasRail && rail != nil {
30 <aside class="rail" aria-label="Context">32 <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 }">33+ @rail
32- <summary>{ l.RailSum }</summary>
33- @rail
34- </details>
35 </aside>34 </aside>
36 }35 }
37 </div>36 </div>
37+ @Footer(l)
38+ @Hint(l)
38 @OverlayHelp()39 @OverlayHelp()
39 @OverlayRefs(l)40 @OverlayRefs(l)
40 @OverlayCmd(l.Cmd)41 @OverlayCmd(l.Cmd)
41- @Statusbar(l)
42 @Keys()42 @Keys()
43 </body>43 </body>
44 </html>44 </html>
45 }45 }
46 46
47-// Topbar is the top bar, neferbar style: the one accent block (instance)47+// Header is the page header: the instance, the path, the ref switcher, the
48-// fading into the black bar, the path, the repo sections, the ref switcher.48+// "go to" palette and the owner; inside a repository, the tabs below.
49-templ Topbar(l Layout) {49+templ Header(l Layout) {
50- <header class="bar top">50+ <header class="hdr">
51- <a class="blk" href="/" data-g="h" title="home (g h)">{ l.Instance }</a><span class="fade" aria-hidden="true"></span>51+ <div class="hdr-top">
52- <nav class="crumbs" aria-label="Path">52+ <a class="brand" href="/" data-g="h" title="home (g h)">
53- <ol>53+ <span class="brand-mark" aria-hidden="true"></span>
54- for _, c := range l.Crumbs {54+ <span>{ l.Instance }</span>
55- <li>55+ </a>
56- if c.Href != "" {56+ if len(l.Crumbs) > 0 {
57- <a57+ <nav class="crumbs" aria-label="Path">
58- href={ templ.SafeURL(c.Href) }58+ <ol>
59- if c.Current {59+ for _, c := range l.Crumbs {
60- aria-current="page"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>
61 }70 }
62- >{ c.Label }</a>71+ </li>
63- } else {
64- <span aria-current="page">{ c.Label }</span>
65 }72 }
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>
67 }91 }
68- </ol>92+ <button
69- </nav>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>
70 if len(l.Sections) > 0 {114 if len(l.Sections) > 0 {
71- <nav class="secs" aria-label="Sections">115+ <nav class="tabs" aria-label="Sections">
72 <ul>116 <ul>
73 for _, s := range l.Sections {117 for _, s := range l.Sections {
74 <li>118 <li>
75 <a119 <a
76 href={ templ.SafeURL(s.Href) }120 href={ templ.SafeURL(s.Href) }
77 data-key={ s.Key }121 data-key={ s.Key }
78- aria-keyshortcuts={ "g " + s.Key }122+ aria-keyshortcuts={ s.Key }
79 if s.Current {123 if s.Current {
80 aria-current="page"124 aria-current="page"
81 }125 }
82- >{ s.Key }{ s.Post }</a>126+ >{ s.Label }<kbd aria-hidden="true">{ s.Key }</kbd></a>
83 </li>127 </li>
84 }128 }
85 </ul>129 </ul>
86 </nav>130 </nav>
87 }131 }
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- }
99 </header>132 </header>
100 }133 }
101 134
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-
140 // Footer is the version and the link to the source of the running version,135 // Footer is the version and the link to the source of the running version,
141 // which the AGPL §13 asks every user of the service to be offered.136 // which the AGPL §13 asks every user of the service to be offered.
142 templ Footer(l Layout) {137 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>
144 }156 }
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 @@
1 @layer reset, tokens, base, components, utilities;1 @layer reset, tokens, base, components, utilities;
2 2
3-/* Palette: the neferbar "mono" theme, black and grays only.3+/* Fonts: Atkinson Hyperlegible Next for the interface, Atkinson Hyperlegible
4- Page surfaces follow the OS theme; the two bars stay black in both themes,4+ Mono for titles, code, shas, refs and keys. Both are embedded (static/fonts,
5- like neferbar under a light or a dark terminal. */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. */
6 @layer tokens {26 @layer tokens {
7 :root {27 :root {
8 color-scheme: light dark;28 color-scheme: light dark;
9 29
10- /* page */30+ /* surfaces, bottom to top */
11- --paper: light-dark(#f2f2f2, #000000);31+ --bg-canvas: light-dark(#f3f3f3, #0c0c0c); /* the page */
12- --paper-alt: light-dark(#e8e8e8, #0c0c0c);32+ --bg-inset: light-dark(#f8f8f8, #080808); /* code, fields: sunk into a surface */
13- --row: light-dark(#dddddd, #1a1a1a);33+ --bg-surface:light-dark(#ffffff, #141414); /* cards, header, rail blocks */
14- --sel: light-dark(#cbcbcb, #303030);34+ --bg-raised: light-dark(#ececec, #1d1d1d); /* hover, buttons, panel rows */
15- --ink: light-dark(#161616, #d0d0d0);35+ --bg-strong: light-dark(#dedede, #292929); /* selected row, pressed button */
16- --ink-hi: light-dark(#000000, #ffffff);36+ --bg-overlay:light-dark(#ffffff, #171717); /* dialogs */
17- --ink-dim: light-dark(#4a4a4a, #9a9a9a);37+ --backdrop: light-dark(rgb(0 0 0 / .25), rgb(0 0 0 / .6));
18- --ink-mute: light-dark(#5c5c5c, #8a8a8a);
19- --rule: light-dark(#bcbcbc, #303030);
20- --focus: light-dark(#000000, #ffffff);
21 38
22- /* bars (neferbar mono.conf: background, foreground, color4 accent, color0 ink) */39+ --border: light-dark(#dddddd, #242424);
23- --bar: #000000;40+ --border-strong: light-dark(#c2c2c2, #383838);
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;
33 41
34- /* Colour, sparingly, from the owner's Despair theme: only code and diffs carry hue.42+ --ink: light-dark(#1b1b1b, #d6d6d6);
35- Dark values are Despair's own; light values are the same hues darkened to AA on --paper. */43+ --ink-hi: light-dark(#000000, #fafafa);
36- --teal: light-dark(#2f6b6b, #7db5b5); /* Despair bright teal: keywords, additions */44+ --ink-dim: light-dark(#4d4d4d, #a3a3a3);
37- --teal-2: light-dark(#355f5f, #5a8585); /* slate teal: types, numbers */45+ --ink-mute: light-dark(#616161, #8c8c8c);
38- --red: light-dark(#a8404b, #d96c75); /* soft red: deletions */46+ --focus: light-dark(#000000, #ffffff);
39- --rose: light-dark(#6b6570, #a7a2a9); /* rose quartz: strings */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);
40 55
41 --syn-plain: var(--ink);56 --syn-plain: var(--ink);
42 --syn-keyword: var(--teal);57 --syn-keyword: var(--teal);
43 --syn-string: var(--rose);58 --syn-string: var(--rose);
44- --syn-comment: light-dark(#5c5f63, #6a6e73);59+ --syn-comment: light-dark(#5c5f63, #6e7277);
45 --syn-number: var(--teal-2);60 --syn-number: var(--teal-2);
46 --syn-func: var(--ink-hi);61 --syn-func: var(--ink-hi);
47 --syn-type: var(--teal-2);62 --syn-type: var(--teal-2);
48 --syn-punct: var(--ink-dim);63 --syn-punct: var(--ink-dim);
49 64
50- /* diff rows: the line keeps the normal ink; the hue is in a faint row tint and the marker */65+ --add: light-dark(#dcebeb, #111b1b);
51- --add: light-dark(#d8e5e5, #121919);66+ --del: light-dark(#f2dfe1, #231315);
52- --del: light-dark(#eedadc, #231113);
53 67
54- /* one family (the neferbar font when installed), three sizes, two weights */68+ --font: "Atkinson Hyperlegible Next", system-ui, sans-serif;
55- --font: "Adwaita Mono", "AdwaitaMono Nerd Font Mono", ui-monospace, "Cascadia Mono", "DejaVu Sans Mono", Menlo, Consolas, monospace;69+ --mono: "Atkinson Hyperlegible Mono", ui-monospace, "Cascadia Mono", "DejaVu Sans Mono", Menlo, Consolas, monospace;
56- --fs: 14px;70+ --fs: 15px;
57 --fs-code: 13px;71 --fs-code: 13px;
58- --fs-s: 12px;72+ --fs-s: 13px;
59- --lh: 20px;73+ --fs-xs: 12px;
60- --lh-code: 18px;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);
61 88
62- --pad: 2ch;89+ /* names used by the code, diff and merge request sheets */
63- --bar-h: 20px; /* one text line, like neferbar: no padding above or below */90+ --paper: var(--bg-canvas);
64- --stick: var(--bar-h); /* top offset of sticky headers inside the content (0 in the app shell) */91+ --paper-alt: var(--bg-raised);
65- --measure: 80ch;92+ --row: var(--bg-raised);
66- --rail: 40ch;93+ --sel: var(--bg-strong);
94+ --rule: var(--border);
67 }95 }
68 }96 }
M internal/adapters/web/static/css/10-base.css +38 -23
@@ -1,56 +1,71 @@
1 @layer reset {1 @layer reset {
2- *, *::before, *::after { box-sizing: border-box; border-radius: 0; }2+ *, *::before, *::after { box-sizing: border-box; }
3 html { -webkit-text-size-adjust: 100%; }3 html { -webkit-text-size-adjust: 100%; }
4 body, h1, h2, h3, h4, p, ul, ol, dl, dd, figure, pre { margin: 0; }4 body, h1, h2, h3, h4, p, ul, ol, dl, dd, figure, pre { margin: 0; }
5 ul, ol { padding: 0; list-style: none; }5 ul, ol { padding: 0; list-style: none; }
6 table { border-collapse: collapse; border-spacing: 0; }6 table { border-collapse: collapse; border-spacing: 0; }
7 th, td { padding: 0; text-align: left; font-weight: inherit; }7 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; }
9 button { background: none; border: 0; padding: 0; cursor: pointer; text-align: inherit; }9 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; }
11 summary { cursor: pointer; }11 summary { cursor: pointer; }
12 dialog { color: inherit; border: 0; padding: 0; max-width: none; max-height: none; }12 dialog { color: inherit; border: 0; padding: 0; max-width: none; max-height: none; }
13 img { max-width: 100%; }13 img { max-width: 100%; }
14 }14 }
15 15
16 @layer base {16 @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; }
18 body {25 body {
19 min-height: 100dvh;26 min-height: 100dvh;
20- display: grid;27+ display: flex; flex-direction: column;
21- grid-template-rows: auto auto 1fr auto;
22 font: var(--fs) / var(--lh) var(--font);28 font: var(--fs) / var(--lh) var(--font);
23 color: var(--ink);29 color: var(--ink);
24- background: var(--paper);30+ background: var(--bg-canvas);
25- font-variant-ligatures: none;
26 tab-size: 4;31 tab-size: 4;
32+ -webkit-font-smoothing: antialiased;
27 }33 }
28- h1, h2, h3, h4 { font-size: inherit; font-weight: bold; }34+ h1, h2, h3, h4 { font-family: var(--mono); font-weight: 700; color: var(--ink-hi); letter-spacing: -.01em; }
29- b, strong { font-weight: bold; }35+ h1 { font-size: 22px; line-height: 30px; }
30- em, i { font-style: normal; text-decoration: underline; text-underline-offset: 3px; }36+ h2 { font-size: 17px; line-height: 26px; }
37+ h3, h4 { font-size: var(--fs); }
38+ b, strong { font-weight: 700; }
31 small { font-size: var(--fs-s); }39 small { font-size: var(--fs-s); }
32- code, pre, kbd, samp { font: inherit; }40+ code, pre, kbd, samp { font-family: var(--mono); }
33- a { color: inherit; text-decoration: underline; text-decoration-color: var(--rule); text-underline-offset: 3px; }41+ a { color: inherit; text-decoration: underline; text-decoration-color: var(--border-strong); text-underline-offset: 3px; }
34- a:hover { text-decoration-color: currentColor; }42+ a:hover { text-decoration-color: currentColor; color: var(--ink-hi); }
35- hr { border: 0; border-top: 1px solid var(--rule); margin: var(--lh) 0; }43+ hr { border: 0; border-top: 1px solid var(--border); margin: var(--s5) 0; }
36- ::selection { background: var(--ink); color: var(--paper); }44+ ::selection { background: var(--ink-hi); color: var(--on-ink); }
37 45
38 :focus { outline: none; }46 :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; }
40 48
41 [hidden] { display: none !important; }49 [hidden] { display: none !important; }
42 50
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+
43 .skip {57 .skip {
44- position: absolute; left: 0; top: 0; z-index: 30; padding: 0 1ch;58+ position: absolute; left: var(--s2); top: var(--s2); z-index: 30; padding: var(--s1) var(--s3);
45- background: var(--ink); color: var(--paper);59+ background: var(--ink-hi); color: var(--on-ink); border-radius: var(--r-s);
46- transform: translateY(-100%);60+ transform: translateY(-200%);
47 }61 }
48 .skip:focus { transform: none; }62 .skip:focus { transform: none; }
49 63
50- main { padding: 0 var(--pad) var(--lh); min-width: 0; }64+ main { min-width: 0; }
65+ main:focus-visible { outline: none; }
51 66
52 @media (prefers-reduced-motion: no-preference) {67 @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; }
54 }69 }
55 }70 }
56 71
@@ -60,5 +75,5 @@
60 .nowrap { white-space: nowrap; }75 .nowrap { white-space: nowrap; }
61 .num { text-align: right; font-variant-numeric: tabular-nums; }76 .num { text-align: right; font-variant-numeric: tabular-nums; }
62 .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }77 .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); }
64 }79 }
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-
5 @layer components {1 @layer components {
6- /* neferbar: black bars; one accent block (top left) with a 4-cell stepped fade.2+ /* buttons: one shape for links and buttons that act */
7- The fade is four hard-edged 1ch cells (not a blend): it is the bar's own glyph-grid look. */3+ .btn {
8- .bar {4+ display: inline-flex; align-items: center; gap: var(--s2); white-space: nowrap;
9- display: flex; align-items: stretch; column-gap: 1ch;5+ height: 32px; padding: 0 var(--s3);
10- height: var(--bar-h); line-height: var(--bar-h);6+ font: 600 var(--fs-s) / 1 var(--font); color: var(--ink); text-decoration: none;
11- background: var(--bar); color: var(--bar-ink);7+ background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: var(--r-s);
12- white-space: nowrap; overflow: hidden;8+ cursor: pointer;
13- position: sticky; z-index: 20;9+ &:hover { background: var(--bg-strong); color: var(--ink-hi); text-decoration: none; }
14- & a { text-decoration: none; }10+ & kbd { margin-right: -4px; }
15- & a:hover { text-decoration: underline; }11+ &.primary { background: var(--ink-hi); color: var(--on-ink); border-color: var(--ink-hi); }
16- & :focus-visible { outline-color: var(--accent); outline-offset: -2px; }12+ &.primary:hover { background: var(--ink); border-color: var(--ink); color: var(--on-ink); }
17- & .blk { background: var(--accent); color: var(--on-accent); font-weight: bold; padding: 0 1ch; }13+ &.danger:hover { color: var(--red); border-color: var(--red); }
18- & .fade {14+ &.icon { width: 32px; justify-content: center; padding: 0; }
19- flex: none; width: 4ch; margin-left: -1ch;15+ &[aria-disabled="true"] { opacity: .45; pointer-events: none; }
20- background: linear-gradient(to right,16+ &[aria-current="true"], &[aria-pressed="true"] { background: var(--bg-strong); color: var(--ink-hi); border-color: var(--ink-mute); }
21- var(--fade-1) 0 25%, var(--fade-2) 25% 50%, var(--fade-3) 50% 75%, var(--fade-4) 75%);
22- }
23 }17 }
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); }
24 23
25- .top {24+ .pill {
26- top: 0;25+ display: inline-flex; align-items: center; height: 20px; padding: 0 var(--s2); margin-left: var(--s1);
27- & .crumbs ol { display: flex; }26+ font: 600 var(--fs-xs) / 1 var(--mono); color: var(--ink-dim); vertical-align: 1px;
28- & .crumbs li + li::before { content: "/"; color: var(--bar-dim); padding: 0 .5ch; }27+ background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: 999px; white-space: nowrap;
29- & .crumbs [aria-current] { color: #fff; font-weight: bold; }28+ &.tag::before { content: ""; width: 6px; height: 6px; margin-right: 6px; border: 1.5px solid currentColor; border-radius: 1px; transform: rotate(45deg); }
30- & .secs { margin-left: 2ch; overflow: hidden; }29+ &.new { color: var(--teal); border-color: currentColor; }
31- & .secs ul { display: flex; }30+ }
32- & .secs li + li::before { content: "/"; color: var(--bar-dim); }31+ .count {
33- & .secs a { display: inline-block; padding: 0 1ch; color: var(--bar-dim); }32+ display: inline-block; min-width: 22px; padding: 0 7px; margin-left: var(--s1);
34- & .secs a:hover { color: var(--bar-ink); }33+ font: 600 var(--fs-xs) / 20px var(--font); text-align: center; color: var(--ink-dim);
35- & .secs a::first-letter { text-decoration: underline; }34+ background: var(--bg-raised); border-radius: 999px; vertical-align: 1px;
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; }
41 }35 }
42 36
43- .status {37+ /* header: identity, path and actions on top, the repository tabs below.
44- bottom: 0; font-size: var(--fs-s);38+ --hdr-h is its height, the offset of the sticky bars of the diffs. */
45- padding: 0 1ch;39+ body { --hdr-h: 57px; }
46- & > span, & .st-cmd { color: var(--bar-dim); }40+ body:has(.hdr .tabs) { --hdr-h: 101px; }
47- & .mode { color: #fff; font-weight: bold; }41+ .hdr {
48- & .st-ctx { color: var(--bar-ink); }42+ position: sticky; top: 0; z-index: 20;
49- & .st-cmd { flex: none; }43+ background: var(--bg-surface); border-bottom: 1px solid var(--border);
50- & .st-cmd .hi, & .st-cmd:hover { color: #fff; font-weight: bold; }44+ }
51- & .st-sel { color: var(--bar-ink); }45+ .hdr-top {
52- & .st-flash:empty { display: none; }46+ display: flex; align-items: center; gap: var(--s4);
53- & .st-flash { color: #fff; font-weight: bold; }47+ max-width: var(--page); margin: 0 auto; height: 56px; padding: 0 var(--pad);
54- & .st-hints { margin-left: auto; overflow: hidden; text-overflow: ellipsis; min-width: 0; }48+ }
55- & .st-live::before { content: "○ "; }49+ .brand {
56- & .st-live[data-state="live"] { color: var(--bar-ink); }50+ display: flex; align-items: center; gap: var(--s2); flex: none;
57- & .st-live[data-state="live"]::before { content: "● "; }51+ font: 700 var(--fs) / 1 var(--mono); color: var(--ink-hi); text-decoration: none;
58- & .st-owner { flex: none; display: flex; gap: 1ch; }52+ & span { max-width: 22ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
59- & .st-owner span { color: var(--bar-ink); }53+ &:hover { text-decoration: none; }
60- & .st-owner button { color: var(--bar-dim); }54+ }
61- & .st-owner button:hover { color: #fff; text-decoration: underline; }55+ .brand-mark {
62- & .st-pos { min-width: 7ch; text-align: right; color: var(--bar-ink); }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); }
63 }84 }
64 85
65- /* work area: content + context rail.86+ .tabs {
66- Tables and code take the free width and the rail is fixed; on prose pages the text87+ max-width: var(--page); margin: 0 auto; padding: 0 var(--pad);
67- is capped at the reading measure and the rail takes the rest, so no column is idle. */88+ overflow-x: auto; scrollbar-width: none;
68- .work { min-width: 0; }89+ & ul { display: flex; gap: var(--s1); }
69- .rail { padding: 0 var(--pad) var(--lh); min-width: 0; }90+ & a {
70- @media (min-width: 1100px) {91+ position: relative; display: flex; align-items: center; gap: var(--s2); height: 44px; padding: 0 var(--s3);
71- /* app shell: the bars stay put, the content and the rail scroll on their own, so each92+ color: var(--ink-dim); text-decoration: none; white-space: nowrap; font-weight: 600;
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;
90 }93 }
91- .rail-d > summary::-webkit-details-marker { display: none; }94+ & a::after { content: ""; position: absolute; left: var(--s2); right: var(--s2); bottom: -1px; height: 2px; border-radius: 2px; }
92- .rail-d > summary::before { content: "▸ "; color: var(--ink); }95+ & a:hover { color: var(--ink-hi); }
93- .rail-d[open] > summary::before { content: "▾ "; }96+ & a:hover::after { background: var(--border-strong); }
94- .rail-d[open] { padding-bottom: var(--lh); }97+ & a[aria-current] { color: var(--ink-hi); }
95- }98+ & a[aria-current]::after { background: var(--ink-hi); }
96- @media (max-width: 700px) {99+ & kbd { opacity: .55; }
97- /* the sections drop to a second bar row instead of being cut off */100+ & a:hover kbd, & a:focus-visible kbd, & a[aria-current] kbd { opacity: 1; }
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; }
103 }101 }
104 102
105- /* rail blocks: a dim title rule, then key/value rows on the character grid */103+ /* work area: content and rail, centred, the page scrolls */
106- .rail section { margin-top: var(--lh); }104+ .work {
107- .rail h2 {105+ flex: 1; width: 100%; max-width: var(--page); margin: 0 auto;
108- display: flex; gap: 1ch; color: var(--ink-dim); font-weight: normal;106+ padding: var(--s5) var(--pad) var(--s7);
109- &::after { content: ""; flex: 1; align-self: center; border-top: 1px solid var(--rule); }107+ display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--s5); align-items: start;
110 }108 }
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+
111 .kv {129 .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);
113 & dt { color: var(--ink-mute); }132 & 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); }
115 }134 }
116 .outline {135 .outline {
117- & a { display: block; text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }136+ font-size: var(--fs-s);
118- & a:hover { text-decoration: underline; }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; }
119- & .l1 { font-weight: bold; }138+ & a:hover { background: var(--bg-raised); color: var(--ink-hi); }
120- & .l3 { padding-left: 2ch; }139+ & .l1 { font-weight: 700; color: var(--ink); }
121- & .l4, & .l5, & .l6 { padding-left: 4ch; }140+ & .l3 { padding-left: var(--s5); }
122- & a[aria-current]::before { content: "▸"; margin-left: -1ch; }141+ & .l4, & .l5, & .l6 { padding-left: var(--s6); }
123- }142+ }
124- .rail .clone { border: 0; padding: 0; margin-top: 0; }143+
125- .rail .clone-line { flex-wrap: wrap; }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+ }
126 }186 }
M internal/adapters/web/static/css/25-panel.css +58 -33
@@ -1,41 +1,66 @@
1 @layer components {1 @layer components {
2- /* bottom panels above the status bar (help, ref switcher, ":" command line) */2+ /* dialogs: the "go to" palette, the ref switcher and the keys, centred over a backdrop */
3- .panel {3+ .modal {
4- position: fixed; inset: auto 0 var(--bar-h) 0; margin: 0; width: 100%; z-index: 15;4+ position: fixed; inset: 0; margin: 12vh auto auto; z-index: 40;
5- background: var(--paper); color: var(--ink);5+ width: min(640px, calc(100vw - 2 * var(--s4))); max-height: min(70dvh, 560px);
6- border-top: 2px solid var(--ink);6+ display: none; flex-direction: column;
7- max-height: min(46dvh, 28rem); overflow: auto; overscroll-behavior: contain;7+ background: var(--bg-overlay); color: var(--ink);
8- padding: 0 var(--pad) var(--lh);8+ border: 1px solid var(--border-strong); border-radius: var(--r); box-shadow: var(--shadow);
9- &:not([open]) { display: none; }9+ overflow: hidden; overscroll-behavior: contain;
10- & .panel-head {10+ &[open] { display: flex; }
11- position: sticky; top: 0; background: var(--paper); display: flex; gap: 1ch;11+ &::backdrop { background: var(--backdrop); }
12- align-items: baseline; height: calc(var(--lh) + 6px); padding-top: 4px;12+ &#panel-help { width: min(820px, calc(100vw - 2 * var(--s4))); }
13- border-bottom: 1px solid var(--rule); margin-bottom: 4px;13+ }
14- & input { flex: 1; min-width: 0; outline-offset: 2px; }14+ @media (prefers-reduced-motion: no-preference) {
15- & button { margin-left: auto; color: var(--ink-dim); white-space: nowrap; }15+ .modal[open] { animation: modal-in 120ms ease-out; }
16- & button:hover { color: var(--ink); text-decoration: underline; }16+ @keyframes modal-in { from { opacity: 0; transform: translateY(-6px) scale(.99); } }
17- & .hi { font-weight: bold; }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;
18 }42 }
19- & .panel-cols { display: grid; grid-template-columns: repeat(auto-fill, minmax(34ch, 1fr)); column-gap: 3ch; }43+ & li a:hover, & li a:focus-visible { background: var(--bg-raised); color: var(--ink-hi); }
20- & .panel-cols h2 { color: var(--ink-dim); font-weight: normal; }44+ & li a[aria-current] { font-weight: 700; color: var(--ink-hi); }
21- & li a { display: block; text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }45+ & .mark { display: inline-block; width: 1ch; color: var(--teal); }
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; }
25 }46 }
47+
26 .cmd-list {48 .cmd-list {
27- columns: 34ch; column-gap: 3ch;49+ & li a {
28- & li { break-inside: avoid; }50+ display: flex; align-items: center; gap: var(--s3); padding: var(--s2) var(--s3); border-radius: var(--r-s);
29- & li[data-cursor] a { background: var(--row); }51+ text-decoration: none; color: var(--ink);
30- & li[data-cursor] a::before { content: "▸"; position: absolute; margin-left: -1.5ch; }52+ }
31- & a { padding-left: 1.5ch; }53+ & li a:hover { background: var(--bg-raised); }
32- & .k { display: inline-block; width: 8ch; color: var(--ink-mute); }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); }
33 }57 }
58+
34 .keys {59 .keys {
35- display: grid; grid-template-columns: repeat(auto-fill, minmax(46ch, 1fr)); column-gap: 3ch;60+ display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); column-gap: var(--s5); row-gap: var(--s1);
36- & > div { display: grid; grid-template-columns: 14ch 1fr; }61+ padding: var(--s3) var(--s4) var(--s4) !important;
37- & dt { white-space: nowrap; }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); }
38- & dd { color: var(--ink-dim); }63+ & dt { white-space: nowrap; display: flex; gap: var(--s1); }
39- & kbd { border: 1px solid var(--rule); padding: 0 .6ch; }64+ & dd { color: var(--ink-dim); font-size: var(--fs-s); }
40 }65 }
41 }66 }
M internal/adapters/web/static/css/30-list.css +76 -45
@@ -1,76 +1,107 @@
1 @layer components {1 @layer components {
2- /* plain-text tables with a ▸ row cursor */2+ /* tables in a card, with a row cursor (j/k) */
3 .list {3 .list {
4 width: 100%; table-layout: fixed;4 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+ }
6 & th a { text-decoration: none; }12 & th a { text-decoration: none; }
7- & th a:hover { text-decoration: underline; color: var(--ink); }13+ & th a:hover { color: var(--ink-hi); }
8- & th[aria-sort="ascending"], & th[aria-sort="descending"] { color: var(--ink); font-weight: bold; }14+ & th[aria-sort="ascending"], & th[aria-sort="descending"] { color: var(--ink-hi); }
9- & td, & th { padding-right: 2ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }15+ & td { height: 40px; padding: 0 var(--s3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; border-top: 1px solid var(--border); }
10- & .cur { width: 2ch; padding: 0; }16+ & tbody tr:first-child td { border-top: 0; }
11- & tbody tr[data-cursor] { background: var(--row); }17+ & th.cur, & td.cur { width: 3px; padding: 0; }
12- & tbody tr[data-cursor] .cur::before { content: "▸"; font-weight: bold; }18+ & tbody tr:hover { background: var(--bg-raised); }
13- & tbody tr:hover { background: var(--row); }19+ & tbody tr[data-cursor] { background: var(--bg-raised); }
20+ & tbody tr[data-cursor] .cur { background: var(--ink-hi); }
14 & tbody tr[hidden] { display: none; }21 & tbody tr[hidden] { display: none; }
15 & td > a { text-decoration: none; }22 & td > a { text-decoration: none; }
23+ & td > a:first-child { color: var(--ink-hi); }
16 & tr:hover td > a:first-child, & tr[data-cursor] td > a:first-child { text-decoration: underline; }24 & 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); }
17 & .c-sub { color: var(--ink-dim); }28 & .c-sub { color: var(--ink-dim); }
18- & .c-sub .refs { color: var(--ink); }
19 }29 }
20 30
21 .home {31 .home {
22- & .c-name { width: 24ch; font-weight: bold; }32+ & .c-name { width: 26%; font-family: var(--mono); font-weight: 700; }
23- & th.c-name { font-weight: normal; }33+ & th.c-name { font-family: var(--font); }
24- & th.c-name[aria-sort] { font-weight: bold; }
25 & .c-desc { width: auto; }34 & .c-desc { width: auto; }
26- & .c-when { width: 13ch; }35+ & .c-when { width: 130px; }
27- & .c-ref { width: 10ch; }36+ & .c-ref { width: 120px; }
28- & .c-lang { width: 12ch; padding-right: 0; }37+ & .c-lang { width: 120px; }
29 }38 }
30 .list.tree {39 .list.tree {
31- & .c-kind { width: 2ch; color: var(--ink-mute); padding-right: 0; }40+ & .c-kind { width: 36px; color: var(--ink-mute); padding-right: 0; text-align: center; }
32- & .c-name { width: 34ch; }41+ & .c-name { width: 32%; font-family: var(--mono); font-size: var(--fs-s); }
33- & .c-size { width: 9ch; text-align: right; padding-right: 3ch; }42+ & .c-size { width: 90px; text-align: right; }
34- & .c-when { width: 11ch; }43+ & .c-when { width: 100px; }
35 & .c-sub { width: auto; }44 & .c-sub { width: auto; }
36- & .c-who { width: 12ch; }45+ & .c-who { width: 130px; }
37 }46 }
38 .list.commits, .list.refs {47 .list.commits, .list.refs {
39- & .c-sha { width: 8ch; }48+ & .c-sha { width: 96px; }
40- & .c-who { width: 12ch; }49+ & .c-who { width: 140px; }
41- & .c-when { width: 11ch; }50+ & .c-when { width: 100px; }
42 }51 }
43 52
44- /* filter line (home, tree, commits) */53+ /* filter: a search field above a list */
45 .filter {54 .filter {
46- display: flex; gap: 1ch; height: var(--lh); margin: var(--lh) 0 0;55+ position: relative; display: flex; max-width: 420px;
47- & label { color: var(--ink-mute); }56+ &::before {
48- & input { flex: 1; min-width: 0; }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; }
49 & input::placeholder { color: var(--ink-mute); opacity: 1; }70 & input::placeholder { color: var(--ink-mute); opacity: 1; }
71+ &:focus-within::after { display: none; }
50 }72 }
51 73
52- /* headings inside pages read as pager section rules */74+ /* section headings inside a page */
53 .rule-h {75 .rule-h {
54- display: flex; gap: 1ch; margin: var(--lh) 0 0; color: var(--ink-dim);76+ display: flex; align-items: center; gap: var(--s2); margin-top: var(--s5);
55- &::after { content: ""; flex: 1; align-self: center; border-top: 1px solid var(--rule); }77+ font-size: var(--fs);
56- & > :first-child { font-weight: bold; color: var(--ink); }78+ & > :first-child { color: var(--ink-hi); }
57 }79 }
80+ main > .rule-h:first-child { margin-top: 0; }
58 81
59- /* empty / 404 / error */82+ /* empty / 404 / error / sign in: one card in the middle */
60 .state {83 .state {
61- margin-top: var(--lh); max-width: var(--measure);84+ max-width: 560px; margin: var(--s7) auto 0 !important; padding: var(--s6);
62- & h1 { margin-bottom: var(--lh); }85+ background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r);
63- & .code { display: inline-block; min-width: 6ch; }86+ & .eyebrow { font: 700 var(--fs-s) / 1 var(--mono); color: var(--ink-mute); text-transform: uppercase; letter-spacing: .08em; margin-bottom: var(--s2); }
64- & .state-back { margin-top: var(--lh); display: flex; gap: 2ch; }87+ & h1 { margin-bottom: var(--s3); }
65- & .state-act { margin-top: var(--lh); }88+ & p.dim { color: var(--ink-dim); }
66- & .state-act button { color: var(--ink-hi); font-weight: bold; }89+ & .state-act { margin-top: var(--s5); }
67- & .state-act button:hover { text-decoration: underline; }90+ & .state-back { margin-top: var(--s5); display: flex; gap: var(--s4); font-size: var(--fs-s); }
91+ & .clone { margin-top: var(--s4); }
68 }92 }
93+
94+ /* copyable shell lines */
69 .clone {95 .clone {
70- margin: var(--lh) 0 0; border: 1px solid var(--rule); padding: 0 1ch;96+ display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--s2); min-width: 0;
71- & .clone-line { display: flex; gap: 1ch; align-items: baseline; }97+ & .clone-line {
72- & code { flex: 1; min-width: 0; overflow-x: auto; white-space: pre; }98+ min-width: 0;
73- & .copy { color: var(--ink-dim); white-space: nowrap; min-width: 9ch; text-align: right; }99+ display: flex; gap: var(--s2); align-items: center; padding: var(--s1) var(--s1) var(--s1) var(--s3);
74- & .copy:hover { color: var(--ink); text-decoration: underline; }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); }
75 }106 }
76 }107 }
M internal/adapters/web/static/css/40-summary.css +34 -23
@@ -1,38 +1,49 @@
1 @layer components {1 @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; }
3 3
4- /* key/value facts, aligned on a 12ch key column */4+ /* key/value facts */
5 .facts {5 .facts {
6- margin-top: var(--lh); display: grid; grid-template-columns: repeat(auto-fill, minmax(38ch, 1fr)); column-gap: 3ch;6+ display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); column-gap: var(--s5);
7- & > div { display: grid; grid-template-columns: 12ch 1fr; min-width: 0; }7+ & > div { display: grid; grid-template-columns: 120px 1fr; min-width: 0; }
8 & dt { color: var(--ink-mute); }8 & dt { color: var(--ink-mute); }
9 & dd { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }9 & dd { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
10 }10 }
11 11
12- .tagged { color: var(--ink); font-weight: bold; white-space: nowrap; }12+ .tagged { color: var(--ink); font-weight: 700; white-space: nowrap; }
13 .list.commits .c-subject { overflow: hidden; text-overflow: ellipsis; }13 .list.commits .c-subject { overflow: hidden; text-overflow: ellipsis; }
14 14
15- /* markdown, rendered from the fixture */15+ .readme .prose { padding: var(--s5) var(--s6) var(--s6); }
16+
17+ /* rendered Markdown */
16 .prose {18 .prose {
17- max-width: var(--measure); margin-top: var(--lh);19+ max-width: var(--measure); line-height: 1.65; overflow-wrap: break-word;
18- & h1, & h2, & h3, & h4 { margin: var(--lh) 0 0; }20+ & h1, & h2, & h3, & h4 { margin: 1.6em 0 0; }
19- & h1::before { content: "# "; color: var(--ink-mute); }21+ & h1 { font-size: 26px; line-height: 34px; padding-bottom: var(--s2); border-bottom: 1px solid var(--border); }
20- & h2::before { content: "## "; color: var(--ink-mute); }22+ & h2 { font-size: 20px; line-height: 28px; padding-bottom: var(--s1); border-bottom: 1px solid var(--border); }
21- & h3::before { content: "### "; color: var(--ink-mute); }23+ & h3 { font-size: 17px; }
22- & h4::before { content: "#### "; color: var(--ink-mute); }
23 & > :first-child { margin-top: 0; }24 & > :first-child { margin-top: 0; }
24- & p, & ul, & ol, & pre, & table, & blockquote { margin-top: var(--lh); }25+ & p, & ul, & ol, & pre, & table, & blockquote { margin-top: var(--s4); }
25- & ul { list-style: none; & > li::before { content: "- "; color: var(--ink-mute); margin-left: -2ch; } }26+ & ul { list-style: disc; }
26- & ul, & ol { padding-left: 2ch; }27+ & ul, & ol { padding-left: var(--s5); }
27 & ol { list-style: decimal; }28 & ol { list-style: decimal; }
28- & li > ul, & li > ol, & li > p { margin-top: 0; }29+ & li + li { margin-top: var(--s1); }
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+ & li::marker { color: var(--ink-mute); }
30- & :not(pre) > code { border-bottom: 1px dotted var(--ink-mute); overflow-wrap: anywhere; }31+ & li > ul, & li > ol, & li > p { margin-top: var(--s1); }
31- & blockquote { padding-left: 2ch; border-left: 1px solid var(--rule); color: var(--ink-dim); }32+ & pre {
32- & table { border-collapse: collapse; display: block; overflow-x: auto; }33+ padding: var(--s3) var(--s4); overflow-x: auto;
33- & th, & td { padding: 0 2ch 0 0; white-space: nowrap; vertical-align: top; }34+ background: var(--bg-inset); border: 1px solid var(--border); border-radius: var(--r-s);
34- & th { border-bottom: 1px solid var(--rule); }35+ font-size: var(--fs-code); line-height: var(--lh-code);
35- & hr { margin: var(--lh) 0; }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; }
36 & .alt { color: var(--ink-mute); }47 & .alt { color: var(--ink-mute); }
37 & img { display: none; }48 & img { display: none; }
38 }49 }
M internal/adapters/web/static/css/50-code.css +48 -39
@@ -1,17 +1,21 @@
1 @layer components {1 @layer components {
2 /* page title for a file: name, then its actions as plain words */2 /* page title for a file: name, then its actions as plain words */
3 .file-h {3 .file-h {
4- display: flex; gap: 2ch; align-items: baseline; margin-top: var(--lh);4+ display: flex; gap: var(--s4); align-items: center; flex-wrap: wrap; font-size: 18px;
5- padding-bottom: 2px; border-bottom: 1px solid var(--rule);5+ & > span:first-child { overflow-wrap: anywhere; }
6- & .acts { margin-left: auto; font-weight: normal; display: flex; gap: 2ch; }6+ & .acts { margin-left: auto; display: flex; gap: var(--s2); }
7 }7 }
8- .notice { margin-top: var(--lh); border-left: 2px solid var(--ink); padding-left: 1ch; max-width: var(--measure); }8+ .notice {
9- .note { color: var(--ink-dim); }9+ padding: var(--s3) var(--s4); max-width: var(--measure); font-size: var(--fs-s);
10- .pad { padding: 0 1ch; }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); }
11 14
12 /* source view: gutter numbers are links (selectable lines), lines span the full width */15 /* source view: gutter numbers are links (selectable lines), lines span the full width */
13 .code {16 .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);
15 font-size: var(--fs-code); line-height: var(--lh-code);19 font-size: var(--fs-code); line-height: var(--lh-code);
16 & code { display: grid; min-width: max-content; }20 & code { display: grid; min-width: max-content; }
17 & .line { padding-right: var(--pad); border-left: 2px solid transparent; }21 & .line { padding-right: var(--pad); border-left: 2px solid transparent; }
@@ -23,44 +27,43 @@
23 }27 }
24 & .ln:hover, & .sel .ln { color: var(--ink-hi); }28 & .ln:hover, & .sel .ln { color: var(--ink-hi); }
25 }29 }
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); }
27 33
28 /* commit header, like `git show` */34 /* commit header, like `git show` */
29 .show {35 .show {
30- margin-top: var(--lh);36+ padding: var(--s5); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r);
31- & h1 { max-width: var(--measure); }37+ & h1 { max-width: var(--measure); overflow-wrap: anywhere; }
32- & .msg { margin-top: var(--lh); white-space: pre-wrap; max-width: var(--measure); color: var(--ink-dim); }38+ & .msg { margin-top: var(--s3); white-space: pre-wrap; max-width: var(--measure); color: var(--ink-dim); font-size: var(--fs-s); }
33- & .show-kv { margin-top: var(--lh); }39+ & .show-kv { margin-top: var(--s4); }
34- & .show-kv dd { white-space: normal; }40+ & .show-kv dd { white-space: normal; overflow-wrap: anywhere; }
35 }41 }
36 .diff-tools {42 .diff-tools {
37- display: flex; gap: 2ch; align-items: baseline; margin-top: var(--lh);43+ display: flex; gap: var(--s2); align-items: center; flex-wrap: wrap;
38- position: sticky; top: var(--stick); z-index: 5; background: var(--paper);44+ position: sticky; top: var(--hdr-h, 0px); z-index: 5; padding: var(--s2) 0; background: var(--bg-canvas);
39- border-bottom: 1px solid var(--rule);
40 & .grow { flex: 1; }45 & .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: "▸"; }
45 }46 }
46 47
47 /* one file of a diff */48 /* one file of a diff */
48 .dfile {49 .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);
50 & > summary {51 & > summary {
51- position: sticky; top: calc(var(--stick) + var(--lh) + 1px); z-index: 4;52+ position: sticky; top: calc(var(--hdr-h, 0px) + 48px); z-index: 4;
52- background: var(--paper-alt); border: 1px solid var(--rule); padding: 0 1ch;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);
53 white-space: nowrap; overflow: hidden; text-overflow: ellipsis; list-style: none;55 white-space: nowrap; overflow: hidden; text-overflow: ellipsis; list-style: none;
54 }56 }
57+ &:not([open]) > summary { border-radius: var(--r); }
55 & > summary::-webkit-details-marker { display: none; }58 & > summary::-webkit-details-marker { display: none; }
56 & > summary::before { content: "▸ "; }59 & > summary::before { content: "▸ "; }
57 &[open] > summary::before { content: "▾ "; }60 &[open] > summary::before { content: "▾ "; }
58 & .st { font-weight: bold; }61 & .st { font-weight: bold; }
59 & .p { font-weight: bold; }62 & .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); }
61 }64 }
62 .dt {65 .dt {
63- width: max-content; min-width: 100%;66+ width: max-content; min-width: 100%; font-family: var(--mono);
64 font-size: var(--fs-code); line-height: var(--lh-code);67 font-size: var(--fs-code); line-height: var(--lh-code);
65 & td { padding: 0; vertical-align: top; }68 & td { padding: 0; vertical-align: top; }
66 & .ln { width: 6ch; min-width: 6ch; padding-right: 1ch; text-align: right; color: var(--ink-mute); user-select: none; }69 & .ln { width: 6ch; min-width: 6ch; padding-right: 1ch; text-align: right; color: var(--ink-mute); user-select: none; }
@@ -75,13 +78,15 @@
75 & tr.del .ln, & .ln.del { color: var(--red); }78 & tr.del .ln, & .ln.del { color: var(--red); }
76 & .none { background: var(--paper-alt); }79 & .none { background: var(--paper-alt); }
77 }80 }
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); }
79 .spl { width: 100%; table-layout: fixed; & .tx { overflow: hidden; text-overflow: ellipsis; } & .ln { width: 6ch; } & .m { width: 2ch; } }82 .spl { width: 100%; table-layout: fixed; & .tx { overflow: hidden; text-overflow: ellipsis; } & .ln { width: 6ch; } & .m { width: 2ch; } }
80 #diffs[data-diff="unified"] .spl, #diffs[data-diff="split"] .uni { display: none; }83 #diffs[data-diff="unified"] .spl, #diffs[data-diff="split"] .uni { display: none; }
81 84
82 /* diff table of contents in the rail */85 /* diff table of contents in the rail */
83 .files {86 .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); }
85 & a:hover .p { text-decoration: underline; }90 & a:hover .p { text-decoration: underline; }
86 & a[aria-current="true"] { background: var(--row); font-weight: bold; }91 & a[aria-current="true"] { background: var(--row); font-weight: bold; }
87 & .p { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; direction: rtl; text-align: left; }92 & .p { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; direction: rtl; text-align: left; }
@@ -90,26 +95,30 @@
90 }95 }
91 96
92 @media (max-width: 700px) {97 @media (max-width: 700px) {
93- .diff-tools { flex-wrap: wrap; column-gap: 1ch; }
94 .diff-tools .grow { flex-basis: 100%; height: 0; }98 .diff-tools .grow { flex-basis: 100%; height: 0; }
95- .show-kv dd { overflow-wrap: anywhere; }99+ .file-h .acts { margin-left: 0; }
96- .file-h .acts { gap: 1ch; }
97 }100 }
98- .pager { display: flex; gap: 3ch; justify-content: center; margin-top: var(--lh); }101+ .pager { display: flex; gap: var(--s3); justify-content: center; }
99 .days section[hidden] { display: none; }102 .days section[hidden] { display: none; }
100 .list.refs {103 .list.refs {
101- & .c-kind { width: 8ch; }104+ & .c-kind { width: 80px; color: var(--ink-mute); }
102- & .c-name { width: 34ch; }105+ & .c-name { width: 32%; font-family: var(--mono); font-size: var(--fs-s); }
103- & .c-info { width: 11ch; }106+ & .c-sha { width: 96px; }
104- & .c-sha { width: 9ch; }
105 & .c-sub { width: auto; }107 & .c-sub { width: auto; }
106 }108 }
109+ /* the commit table inside a rail block: no frame of its own */
107 .rail .list.commits {110 .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); }
108 & .c-who { display: none; }116 & .c-who { display: none; }
109- & .c-sha { width: 8ch; }117+ & .c-sha { width: 72px; }
110- & .c-when { width: 9ch; padding-right: 0; text-align: right; }118+ & .c-subject { font-size: var(--fs-s); }
119+ & .c-subject .pill { display: none; }
120+ & .c-when { width: 70px; text-align: right; }
111 }121 }
112- .rail p + p { margin-top: 0; }
113 .rail section > p { overflow-wrap: anywhere; }122 .rail section > p { overflow-wrap: anywhere; }
114 @media (max-width: 700px) {123 @media (max-width: 700px) {
115 /* the name takes what the fixed columns leave */124 /* the name takes what the fixed columns leave */
@@ -118,7 +127,7 @@
118 }127 }
119 128
120 @layer components {129 @layer components {
121- .gallery section { margin-top: calc(var(--lh) * 2); }130+ .gallery section { margin-top: var(--s6); }
122 .gallery .rule-h > :last-child { font-weight: normal; color: var(--ink-mute); order: 2; }131 .gallery .rule-h > :last-child { font-weight: normal; color: var(--ink-mute); order: 2; }
123 .gallery .rule-h::after { order: 1; }132 .gallery .rule-h::after { order: 1; }
124 .gallery .files { max-width: var(--rail); }133 .gallery .files { max-width: var(--rail); }
M internal/adapters/web/static/css/55-diff.css +1 -6
@@ -2,12 +2,7 @@
2 /* an invisible character in code, shown instead of applied (Trojan Source) */2 /* an invisible character in code, shown instead of applied (Trojan Source) */
3 .inv {3 .inv {
4 color: var(--red); font-weight: bold; font-size: var(--fs-s);4 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;
6 }6 }
7 .eol { color: var(--ink-mute); padding-left: 1ch; user-select: none; }7 .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: "▸"; }
13 }8 }
M internal/adapters/web/static/css/60-mr.css +41 -45
@@ -1,90 +1,86 @@
1 @layer components {1 @layer components {
2 /* merge requests: the review page. Hue only where it carries a verdict. */2 /* merge requests: the review page. Hue only where it carries a verdict. */
3 .mr-h .show-kv { margin-top: 0; }3 .mr-h .show-kv { margin-top: 0; }
4- .mr-h + * { margin-top: var(--lh); }4+
5 .mr-state[data-state="open"]::before { content: "● "; color: var(--teal); }5 .mr-state[data-state="open"]::before { content: "● "; color: var(--teal); }
6 .mr-state[data-state="merged"]::before { content: "✓ "; color: var(--teal); }6 .mr-state[data-state="merged"]::before { content: "✓ "; color: var(--teal); }
7 .mr-state[data-state="closed"]::before { content: "✗ "; color: var(--red); }7 .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); }
9 .mr-desc a, .c-text a { text-decoration: underline; }9 .mr-desc a, .c-text a { text-decoration: underline; }
10 10
11 .since {11 .since {
12- display: flex; flex-wrap: wrap; column-gap: 3ch; margin-top: var(--lh);12+ display: inline-flex; flex-wrap: wrap; gap: var(--s1); padding: var(--s1);
13- & a { text-decoration: none; color: var(--ink-dim); }13+ background: var(--bg-raised); border-radius: var(--r-s);
14- & a:hover { color: var(--ink); text-decoration: underline; }14+ & a { padding: var(--s1) var(--s3); border-radius: 4px; text-decoration: none; color: var(--ink-dim); font-size: var(--fs-s); }
15- & a[aria-current] { color: var(--ink-hi); font-weight: bold; }15+ & a:hover { color: var(--ink-hi); }
16- & a[aria-current]::before { content: "▸ "; }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: normal; }17+ & .mute { font-weight: 400; font-family: var(--mono); }
18 }18 }
19 19
20 /* merge block: first thing in the rail */20 /* merge block: first thing in the rail */
21 .decide {21 .decide {
22- & .d-acts { margin-top: calc(var(--lh) / 2); }22+ border-color: var(--border-strong) !important;
23 & select {23 & select {
24- display: block; width: 100%; font: inherit; color: inherit; background: var(--paper-alt);24+ display: block; width: 100%; height: 36px; padding: 0 var(--s3);
25- border: 1px solid var(--rule); border-radius: 0; padding: 0 1ch;25+ font: inherit; color: inherit; background: var(--bg-inset);
26+ border: 1px solid var(--border-strong); border-radius: var(--r-s);
26 }27 }
27- & .d-check { display: block; margin-top: 4px; color: var(--ink-dim); cursor: pointer; }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; }
28- & .d-check input { accent-color: var(--ink); margin: 0 1ch 0 0; vertical-align: middle; }29+ & .d-check input { accent-color: var(--ink-hi); margin: 0; }
29- & .d-btns { display: flex; gap: 2ch; margin-top: 4px; flex-wrap: wrap; }30+ & .d-btns { display: flex; gap: var(--s2); margin-top: var(--s3); flex-wrap: wrap; }
30- & .d-btns button { color: var(--ink-dim); }31+ & .d-btns .primary { flex: 1; justify-content: center; }
31- & .d-btns button:hover { color: var(--ink); text-decoration: underline; }32+ & .d-or { font-size: var(--fs-s); }
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); }
35 & .clone { margin-top: 0; }33 & .clone { margin-top: 0; }
36 & .clone code { white-space: pre-wrap; overflow-wrap: anywhere; }34 & .clone code { white-space: pre-wrap; overflow-wrap: anywhere; }
37 }35 }
38 36
39- .pcommits li { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }37+ .pcommits { font-size: var(--fs-s); display: grid; gap: var(--s1); }
40- .pcommits li[data-new] { background: var(--row); }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); }
41 .pcommits .s { color: var(--ink); }41 .pcommits .s { color: var(--ink); }
42- .rail code { border-bottom: 1px dotted var(--ink-mute); }42+
43 43
44 /* a note: a ruled block, under its line in the diff and in the list of comments */44 /* a note: a ruled block, under its line in the diff and in the list of comments */
45 .cmt {45 .cmt {
46- border-left: 2px solid var(--ink); padding-left: 1ch; margin-top: calc(var(--lh) / 2); max-width: var(--measure);46+ padding: var(--s3) var(--s4); margin-top: var(--s3); max-width: var(--measure);
47- white-space: normal;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;
48 & p { white-space: normal; }49 & p { white-space: normal; }
49 & .c-who { font-weight: bold; }50 & .c-who { font-weight: bold; }
50 & .c-state { color: var(--ink-dim); }51 & .c-state { color: var(--ink-dim); }
51 & .c-text { white-space: pre-wrap; overflow-wrap: anywhere; }52 & .c-text { white-space: pre-wrap; overflow-wrap: anywhere; }
52 & .c-where { font-size: var(--fs-s); overflow-wrap: anywhere; }53 & .c-where { font-size: var(--fs-s); overflow-wrap: anywhere; }
53- & .c-act button { color: var(--ink-dim); }54+ & .c-act { margin-top: var(--s2); }
54- & .c-act button:hover { color: var(--ink); text-decoration: underline; }55+ & .c-act button { height: 26px; font-size: var(--fs-xs); }
55- &[data-resolved="true"] { border-left-color: var(--rule); }56+ &[data-resolved="true"] { border-left-color: var(--border-strong); }
56 &[data-resolved="true"] .c-text { color: var(--ink-dim); }57 &[data-resolved="true"] .c-text { color: var(--ink-dim); }
57 }58 }
58- .dt .fnote td { background: var(--paper-alt); padding: 2px 0 4px; white-space: normal; }59+ .dt .fnote td { background: var(--bg-canvas); padding: var(--s2) var(--s3); white-space: normal; }
59- .dt .fnote .cmt, .dt .fnote .cform { margin: 0; font-size: var(--fs); line-height: var(--lh); }60+ .dt .fnote .cmt, .dt .fnote .cform { margin: 0; }
60- .dt .fnote .cmt + .cmt, .dt .fnote .cmt + .cform { margin-top: calc(var(--lh) / 2); }61+ .dt .fnote .cmt + .cmt, .dt .fnote .cmt + .cform { margin-top: var(--s2); }
61 .ln a { color: inherit; text-decoration: none; }62 .ln a { color: inherit; text-decoration: none; }
62 .ln a:hover, .ln a:focus-visible { text-decoration: underline; color: var(--ink-hi); }63 .ln a:hover, .ln a:focus-visible { text-decoration: underline; color: var(--ink-hi); }
63 64
64 /* the forms: a comment, a new merge request */65 /* the forms: a comment, a new merge request */
65 .cform {66 .cform {
66- display: grid; gap: 2px; margin-top: var(--lh); max-width: var(--measure);67+ display: grid; gap: var(--s2); margin-top: var(--s3); max-width: var(--measure);
67- & label { color: var(--ink-dim); }68+ font-family: var(--font); font-size: var(--fs-s);
69+ & label { color: var(--ink-dim); font-weight: 600; }
68 & textarea, & input[type="text"] {70 & textarea, & input[type="text"] {
69- display: block; width: 100%; font: inherit; color: inherit; resize: vertical;71+ display: block; width: 100%; font: inherit; font-size: var(--fs); color: inherit; resize: vertical;
70- background: var(--paper-alt); border: 1px solid var(--rule); border-radius: 0; padding: 0 1ch;72+ background: var(--bg-surface); border: 1px solid var(--border-strong); border-radius: var(--r-s); padding: var(--s2) var(--s3);
71 }73 }
72 & textarea::placeholder { color: var(--ink-mute); opacity: 1; }74 & textarea::placeholder { color: var(--ink-mute); opacity: 1; }
73- & .f-btns { display: flex; gap: 2ch; align-items: baseline; margin-top: 4px; }75+ & .f-btns { display: flex; gap: var(--s2); align-items: center; margin-top: var(--s1); }
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; }
79 }76 }
80- .cform.create { margin-top: var(--lh); }
81 77
82 .list.mrs {78 .list.mrs {
83- & .c-n { width: 5ch; }79+ & .c-n { width: 64px; }
84- & .c-state { width: 9ch; }80+ & .c-state { width: 110px; }
85 & .c-subject { overflow: hidden; text-overflow: ellipsis; }81 & .c-subject { overflow: hidden; text-overflow: ellipsis; }
86- & .c-who { width: 12ch; }82+ & .c-who { width: 140px; }
87- & .c-when { width: 11ch; }83+ & .c-when { width: 100px; }
88 & tr[data-state="open"] .c-state { font-weight: bold; }84 & tr[data-state="open"] .c-state { font-weight: bold; }
89 & tr[data-state="open"] .c-state::before { content: "● "; color: var(--teal); }85 & tr[data-state="open"] .c-state::before { content: "● "; color: var(--teal); }
90 & tr[data-state="merged"] .c-state::before { content: "✓ "; color: var(--teal); }86 & 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 @@
1 @layer components {1 @layer components {
2 /* live updates: the banner of new commits, an empty slot takes no room */2 /* live updates: the banner of new commits, an empty slot takes no room */
3 #commits-head:empty { display: none; }3 #commits-head:empty { display: none; }
4- .live-banner { margin: var(--lh) 0; padding: 0 1ch; background: var(--row); font-weight: bold; }4+ .live-banner {
5- .live-banner a { color: var(--ink); }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); }
6 }9 }
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 @@
9 let pending = 0;9 let pending = 0;
10 const fire = (name) => document.body.dispatchEvent(new CustomEvent('k-' + name, { bubbles: false }));10 const fire = (name) => document.body.dispatchEvent(new CustomEvent('k-' + name, { bubbles: false }));
11 addEventListener('keydown', (e) => {11 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'); }
12 if (e.ctrlKey || e.metaKey || e.altKey) return;14 if (e.ctrlKey || e.metaKey || e.altKey) return;
13 const typing = e.target.closest('input, textarea, select, [contenteditable]');15 const typing = e.target.closest('input, textarea, select, [contenteditable]');
14 if (e.key === 'Escape') { pending = 0; if (typing) e.target.blur(); return fire('esc'); }16 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,
73 l := s.repoLayout(ctx, d, "summary")73 l := s.repoLayout(ctx, d, "summary")
74 l.Title = d.name() + " · " + s.instance74 l.Title = d.name() + " · " + s.instance
75 l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=summary")75 l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=summary")
76- l.Status = Status{76+ l.HasRail = true
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"
81 return Summary(l, p), http.StatusOK, nil77 return Summary(l, p), http.StatusOK, nil
82 }78 }
83 79
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
103 } else {103 } else {
104 l.Title = visibleText(t.path) + " · " + d.name() + " · " + s.instance104 l.Title = visibleText(t.path) + " · " + d.name() + " · " + s.instance
105 }105 }
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- }
110 return Tree(l, p), http.StatusOK, nil106 return Tree(l, p), http.StatusOK, nil
111 }107 }
M internal/adapters/web/tree.templ +3 -4
@@ -7,12 +7,12 @@ package web
7 templ Tree(l Layout, p TreePage) {7 templ Tree(l Layout, p TreePage) {
8 @Shell(l, nil) {8 @Shell(l, nil) {
9 <div class="filter">9 <div class="filter">
10- <label for="tree-filter">filter&gt;</label>10+ <label for="tree-filter" class="sr">filter</label>
11 <input11 <input
12 id="tree-filter"12 id="tree-filter"
13 type="search"13 type="search"
14 data-filter14 data-filter
15- placeholder="this directory ( / )"15+ placeholder="Filter this directory…"
16 autocomplete="off"16 autocomplete="off"
17 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])')"17 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])')"
18 />18 />
@@ -34,8 +34,7 @@ templ Tree(l Layout, p TreePage) {
34 <p class="note">{ n }</p>34 <p class="note">{ n }</p>
35 }35 }
36 if p.Readme != nil {36 if p.Readme != nil {
37- <h2 class="rule-h"><span>{ p.Readme.Name }</span></h2>37+ @ReadmeCard(*p.Readme)
38- @ReadmeView(*p.Readme)
39 }38 }
40 }39 }
41 }40 }
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"
7 // View models handed to the templ components. Components only loop and7 // View models handed to the templ components. Components only loop and
8 // branch on these; the handlers build them from the API messages.8 // branch on these; the handlers build them from the API messages.
9 9
10-// Crumb is one segment of the path in the top bar.10+// Crumb is one segment of the path in the header.
11 type Crumb struct {11 type Crumb struct {
12 Label string12 Label string
13 Href string13 Href string
14 Current bool14 Current bool
15 }15 }
16 16
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.
18 type Section struct {18 type Section struct {
19 Key string // mnemonic key19 Key string // mnemonic key
20- Post string // rest of the label after the key20+ Label string
21 Href string21 Href string
22 Current bool22 Current bool
23 }23 }
24 24
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-
35 // RefItem is a branch or tag in the ref switcher.25 // RefItem is a branch or tag in the ref switcher.
36 type RefItem struct {26 type RefItem struct {
37 Name string27 Name string
@@ -50,10 +40,9 @@ type Layout struct {
50 SourceURL string40 SourceURL string
51 Assets Assets41 Assets Assets
52 Crumbs []Crumb42 Crumbs []Crumb
53- Ref string // ref shown in the top bar, "" outside a repo view43+ Ref string // ref shown in the header, "" outside a repo view
54 Short string // short sha shown after the ref44 Short string // short sha shown after the ref
55 Sections []Section45 Sections []Section
56- Status Status
57 Repo string46 Repo string
58 SHA string // full sha of the ref, for the permalink key47 SHA string // full sha of the ref, for the permalink key
59 RefPath string // the URL segment that `y` rewrites: the ref name48 RefPath string // the URL segment that `y` rewrites: the ref name
@@ -61,9 +50,7 @@ type Layout struct {
61 Tags []RefItem50 Tags []RefItem
62 Cmd []CmdItem51 Cmd []CmdItem
63 HasRail bool52 HasRail bool
64- RailSum string // the rail as one line when it folds above the content (< 1100px)53+ // Live connects the page to the event stream (/-/events).
65- Read bool // prose page: content capped at the reading measure
66- // Live connects the status bar to the event stream (/-/events).
67 Live bool54 Live bool
68 // LiveURL is the stream the page follows, with its query.55 // LiveURL is the stream the page follows, with its query.
69 LiveURL string56 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
196 }196 }
197 h := w.Header()197 h := w.Header()
198 h.Set("Content-Type", "text/html; charset=utf-8")198 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+`"`)
199 // Public pages: the browser and any proxy revalidate each time. A page202 // Public pages: the browser and any proxy revalidate each time. A page
200 // rendered for the owner is private and never stored; a handler that set203 // rendered for the owner is private and never stored; a handler that set
201 // its own Cache-Control keeps it.204 // its own Cache-Control keeps it.
@@ -253,18 +256,16 @@ func (s *Server) base() Layout {
253 }256 }
254 257
255 // stateLayout is the layout of a state page: only the way home.258 // 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 {
257 l := s.base()260 l := s.base()
258 l.Title = title + " · " + s.instance261 l.Title = title + " · " + s.instance
259 l.Crumbs = crumbs262 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"}
262 l.Cmd = []CmdItem{{Kind: "go", Label: "home", Href: "/"}}263 l.Cmd = []CmdItem{{Kind: "go", Label: "home", Href: "/"}}
263 return l264 return l
264 }265 }
265 266
266 func (s *Server) notFoundPage() templ.Component {267 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}})
268 return State(l, StatePage{269 return State(l, StatePage{
269 Code: "404", Title: "not found",270 Code: "404", Title: "not found",
270 Lines: []string{"no such page, ref or path.", "check the URL, or start from the repo list."},271 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 {
273 }274 }
274 275
275 func (s *Server) errorPage(id string) templ.Component {276 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}})
277 return State(l, StatePage{278 return State(l, StatePage{
278 Code: "500", Title: "internal error",279 Code: "500", Title: "internal error",
279 Lines: []string{280 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