detached 21b3830

fix(web): improve ARIA semantics of MR panels, dialogs, palette, forms and status regions

commit
21b3830097882493ffc715631de54bea52f5940a
author
brice · · 2026-10-22 07:30 UTC
parent
cf26365
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
21 files · +374 -152
A internal/adapters/web/accessibility_test.go +44 -0
@@ -0,0 +1,44 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+)
10+
11+func TestIssueFieldAccessibility(t *testing.T) {
12+ for _, field := range []string{"title", "body", "author"} {
13+ t.Run(field, func(t *testing.T) {
14+ html := renderToString(t, IssueForm(Layout{}, IssueFormPage{ErrorField: field, Error: "Too long"}))
15+ assert.Contains(t, html, `id="issue-`+field+`" aria-describedby="issue-`+field+`-count issue-`+field+`-error" aria-invalid="true"`)
16+ assert.Contains(t, html, `<p id="issue-`+field+`-error" class="f-error" role="alert">Too long</p>`)
17+ for _, f := range []string{"title", "body", "author"} {
18+ assert.Contains(t, html, `<output id="issue-`+f+`-count" aria-live="off"`)
19+ }
20+ })
21+ }
22+}
23+
24+func TestHintAccessibility(t *testing.T) {
25+ html := renderToString(t, Hint(Layout{Live: true, LiveURL: "/events"}))
26+ assert.Contains(t, html, `<div class="hint"><span aria-hidden="true" class="hint-nav"`)
27+ assert.Contains(t, html, `<span role="status" aria-live="polite"><span class="hint-sel"`)
28+ assert.Contains(t, html, `</span></span><span class="hint-live" role="status"`)
29+}
30+
31+func TestPaletteAccessibilityWithoutNavigationItems(t *testing.T) {
32+ html := renderToString(t, OverlayCmd(nil))
33+ // The shortcut preference action is always present, even with no targets.
34+ assert.Contains(t, html, `aria-expanded="true"`)
35+ assert.Contains(t, html, `aria-activedescendant="cmd-o0"`)
36+ assert.Contains(t, html, `id="cmd-o0" role="option" aria-selected="true" data-cursor`)
37+ assert.Contains(t, html, `<a role="presentation" href="#" data-fire="keys" tabindex="-1">`)
38+ assert.Contains(t, html, `aria-label="Close (esc)"`)
39+}
40+
41+func TestFinderStatusOutsideHiddenResults(t *testing.T) {
42+ html := renderToString(t, Tree(Layout{}, TreePage{}))
43+ assert.Contains(t, html, `<p id="finder-status" class="dim" role="status"></p><div id="finder" class="tree-card" hidden>`)
44+}
@@ -0,0 +1,44 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+)
10+
11+func TestIssueFieldAccessibility(t *testing.T) {
12+ for _, field := range []string{"title", "body", "author"} {
13+ t.Run(field, func(t *testing.T) {
14+ html := renderToString(t, IssueForm(Layout{}, IssueFormPage{ErrorField: field, Error: "Too long"}))
15+ assert.Contains(t, html, `id="issue-`+field+`" aria-describedby="issue-`+field+`-count issue-`+field+`-error" aria-invalid="true"`)
16+ assert.Contains(t, html, `<p id="issue-`+field+`-error" class="f-error" role="alert">Too long</p>`)
17+ for _, f := range []string{"title", "body", "author"} {
18+ assert.Contains(t, html, `<output id="issue-`+f+`-count" aria-live="off"`)
19+ }
20+ })
21+ }
22+}
23+
24+func TestHintAccessibility(t *testing.T) {
25+ html := renderToString(t, Hint(Layout{Live: true, LiveURL: "/events"}))
26+ assert.Contains(t, html, `<div class="hint"><span aria-hidden="true" class="hint-nav"`)
27+ assert.Contains(t, html, `<span role="status" aria-live="polite"><span class="hint-sel"`)
28+ assert.Contains(t, html, `</span></span><span class="hint-live" role="status"`)
29+}
30+
31+func TestPaletteAccessibilityWithoutNavigationItems(t *testing.T) {
32+ html := renderToString(t, OverlayCmd(nil))
33+ // The shortcut preference action is always present, even with no targets.
34+ assert.Contains(t, html, `aria-expanded="true"`)
35+ assert.Contains(t, html, `aria-activedescendant="cmd-o0"`)
36+ assert.Contains(t, html, `id="cmd-o0" role="option" aria-selected="true" data-cursor`)
37+ assert.Contains(t, html, `<a role="presentation" href="#" data-fire="keys" tabindex="-1">`)
38+ assert.Contains(t, html, `aria-label="Close (esc)"`)
39+}
40+
41+func TestFinderStatusOutsideHiddenResults(t *testing.T) {
42+ html := renderToString(t, Tree(Layout{}, TreePage{}))
43+ assert.Contains(t, html, `<p id="finder-status" class="dim" role="status"></p><div id="finder" class="tree-card" hidden>`)
44+}
M internal/adapters/web/fragments.templ +1 -0
@@ -94,6 +94,7 @@ templ Live(l Layout) {
9494 if l.Live && l.LiveURL != "" {
9595 <span
9696 class="hint-live"
97+ role="status"
9798 data-state="connecting"
9899 hx-sse:connect={ l.LiveURL }
99100 hx-swap="none"
@@ -94,6 +94,7 @@ templ Live(l Layout) {
94 if l.Live && l.LiveURL != "" {94 if l.Live && l.LiveURL != "" {
95 <span95 <span
96 class="hint-live"96 class="hint-live"
97+ role="status"
97 data-state="connecting"98 data-state="connecting"
98 hx-sse:connect={ l.LiveURL }99 hx-sse:connect={ l.LiveURL }
99 hx-swap="none"100 hx-swap="none"
M internal/adapters/web/fragments_templ.go +2 -2 generated file: collapsed
M internal/adapters/web/issues_form.templ +47 -9
@@ -29,22 +29,52 @@ templ IssueForm(l Layout, p IssueFormPage) {
2929 <input type="hidden" name="form_token" value={ p.FormToken }/>
3030 <input type="hidden" name="solution" value=""/>
3131 <label for="issue-title">title</label>
32- <input id="issue-title" name="title" type="text" required autocomplete="off" value={ p.Title }/>
33- <output data-for="issue-title" data-max={ itoa(p.MaxTitle) } for="issue-title">{ itoa(runeCount(p.Title)) } / { itoa(p.MaxTitle) }</output>
32+ <input
33+ id="issue-title"
34+ aria-describedby={ issueFieldDescription("title", p.ErrorField) }
35+ if p.ErrorField == "title" {
36+ aria-invalid="true"
37+ }
38+ name="title"
39+ type="text"
40+ required
41+ autocomplete="off"
42+ value={ p.Title }
43+ />
44+ <output id="issue-title-count" aria-live="off" data-for="issue-title" data-max={ itoa(p.MaxTitle) } for="issue-title">{ itoa(runeCount(p.Title)) } / { itoa(p.MaxTitle) }</output>
3445 if p.ErrorField == "title" {
35- <p class="f-error" role="alert">{ p.Error }</p>
46+ <p id="issue-title-error" class="f-error" role="alert">{ p.Error }</p>
3647 }
3748 <label for="issue-body">description</label>
38- <textarea id="issue-body" name="body" rows="8" placeholder="Markdown, optional">{ p.Body }</textarea>
39- <output data-for="issue-body" data-max={ itoa(p.MaxBody) } for="issue-body">{ itoa(runeCount(p.Body)) } / { itoa(p.MaxBody) }</output>
49+ <textarea
50+ id="issue-body"
51+ aria-describedby={ issueFieldDescription("body", p.ErrorField) }
52+ if p.ErrorField == "body" {
53+ aria-invalid="true"
54+ }
55+ name="body"
56+ rows="8"
57+ placeholder="Markdown, optional"
58+ >{ p.Body }</textarea>
59+ <output id="issue-body-count" aria-live="off" data-for="issue-body" data-max={ itoa(p.MaxBody) } for="issue-body">{ itoa(runeCount(p.Body)) } / { itoa(p.MaxBody) }</output>
4060 if p.ErrorField == "body" {
41- <p class="f-error" role="alert">{ p.Error }</p>
61+ <p id="issue-body-error" class="f-error" role="alert">{ p.Error }</p>
4262 }
4363 <label for="issue-author">name (optional, not verified)</label>
44- <input id="issue-author" name="author" type="text" autocomplete="off" value={ p.Author }/>
45- <output data-for="issue-author" data-max={ itoa(p.MaxAuthor) } for="issue-author">{ itoa(runeCount(p.Author)) } / { itoa(p.MaxAuthor) }</output>
64+ <input
65+ id="issue-author"
66+ aria-describedby={ issueFieldDescription("author", p.ErrorField) }
67+ if p.ErrorField == "author" {
68+ aria-invalid="true"
69+ }
70+ name="author"
71+ type="text"
72+ autocomplete="off"
73+ value={ p.Author }
74+ />
75+ <output id="issue-author-count" aria-live="off" data-for="issue-author" data-max={ itoa(p.MaxAuthor) } for="issue-author">{ itoa(runeCount(p.Author)) } / { itoa(p.MaxAuthor) }</output>
4676 if p.ErrorField == "author" {
47- <p class="f-error" role="alert">{ p.Error }</p>
77+ <p id="issue-author-error" class="f-error" role="alert">{ p.Error }</p>
4878 }
4979 <div class="hp" aria-hidden="true">
5080 <label for="issue-website">Leave this field empty</label>
@@ -65,3 +95,11 @@ templ issueFormRail() {
6595 <p class="dim">The owner reads every issue before it appears. Plain text and Markdown only; no address of yours is kept.</p>
6696 </section>
6797 }
98+
99+func issueFieldDescription(field, invalid string) string {
100+ ids := "issue-" + field + "-count"
101+ if field == invalid {
102+ ids += " issue-" + field + "-error"
103+ }
104+ return ids
105+}
@@ -29,22 +29,52 @@ templ IssueForm(l Layout, p IssueFormPage) {
29 <input type="hidden" name="form_token" value={ p.FormToken }/>29 <input type="hidden" name="form_token" value={ p.FormToken }/>
30 <input type="hidden" name="solution" value=""/>30 <input type="hidden" name="solution" value=""/>
31 <label for="issue-title">title</label>31 <label for="issue-title">title</label>
32- <input id="issue-title" name="title" type="text" required autocomplete="off" value={ p.Title }/>32+ <input
33- <output data-for="issue-title" data-max={ itoa(p.MaxTitle) } for="issue-title">{ itoa(runeCount(p.Title)) } / { itoa(p.MaxTitle) }</output>33+ id="issue-title"
34+ aria-describedby={ issueFieldDescription("title", p.ErrorField) }
35+ if p.ErrorField == "title" {
36+ aria-invalid="true"
37+ }
38+ name="title"
39+ type="text"
40+ required
41+ autocomplete="off"
42+ value={ p.Title }
43+ />
44+ <output id="issue-title-count" aria-live="off" data-for="issue-title" data-max={ itoa(p.MaxTitle) } for="issue-title">{ itoa(runeCount(p.Title)) } / { itoa(p.MaxTitle) }</output>
34 if p.ErrorField == "title" {45 if p.ErrorField == "title" {
35- <p class="f-error" role="alert">{ p.Error }</p>46+ <p id="issue-title-error" class="f-error" role="alert">{ p.Error }</p>
36 }47 }
37 <label for="issue-body">description</label>48 <label for="issue-body">description</label>
38- <textarea id="issue-body" name="body" rows="8" placeholder="Markdown, optional">{ p.Body }</textarea>49+ <textarea
39- <output data-for="issue-body" data-max={ itoa(p.MaxBody) } for="issue-body">{ itoa(runeCount(p.Body)) } / { itoa(p.MaxBody) }</output>50+ id="issue-body"
51+ aria-describedby={ issueFieldDescription("body", p.ErrorField) }
52+ if p.ErrorField == "body" {
53+ aria-invalid="true"
54+ }
55+ name="body"
56+ rows="8"
57+ placeholder="Markdown, optional"
58+ >{ p.Body }</textarea>
59+ <output id="issue-body-count" aria-live="off" data-for="issue-body" data-max={ itoa(p.MaxBody) } for="issue-body">{ itoa(runeCount(p.Body)) } / { itoa(p.MaxBody) }</output>
40 if p.ErrorField == "body" {60 if p.ErrorField == "body" {
41- <p class="f-error" role="alert">{ p.Error }</p>61+ <p id="issue-body-error" class="f-error" role="alert">{ p.Error }</p>
42 }62 }
43 <label for="issue-author">name (optional, not verified)</label>63 <label for="issue-author">name (optional, not verified)</label>
44- <input id="issue-author" name="author" type="text" autocomplete="off" value={ p.Author }/>64+ <input
45- <output data-for="issue-author" data-max={ itoa(p.MaxAuthor) } for="issue-author">{ itoa(runeCount(p.Author)) } / { itoa(p.MaxAuthor) }</output>65+ id="issue-author"
66+ aria-describedby={ issueFieldDescription("author", p.ErrorField) }
67+ if p.ErrorField == "author" {
68+ aria-invalid="true"
69+ }
70+ name="author"
71+ type="text"
72+ autocomplete="off"
73+ value={ p.Author }
74+ />
75+ <output id="issue-author-count" aria-live="off" data-for="issue-author" data-max={ itoa(p.MaxAuthor) } for="issue-author">{ itoa(runeCount(p.Author)) } / { itoa(p.MaxAuthor) }</output>
46 if p.ErrorField == "author" {76 if p.ErrorField == "author" {
47- <p class="f-error" role="alert">{ p.Error }</p>77+ <p id="issue-author-error" class="f-error" role="alert">{ p.Error }</p>
48 }78 }
49 <div class="hp" aria-hidden="true">79 <div class="hp" aria-hidden="true">
50 <label for="issue-website">Leave this field empty</label>80 <label for="issue-website">Leave this field empty</label>
@@ -65,3 +95,11 @@ templ issueFormRail() {
65 <p class="dim">The owner reads every issue before it appears. Plain text and Markdown only; no address of yours is kept.</p>95 <p class="dim">The owner reads every issue before it appears. Plain text and Markdown only; no address of yours is kept.</p>
66 </section>96 </section>
67 }97 }
98+
99+func issueFieldDescription(field, invalid string) string {
100+ ids := "issue-" + field + "-count"
101+ if field == invalid {
102+ ids += " issue-" + field + "-error"
103+ }
104+ return ids
105+}
M internal/adapters/web/issues_form_templ.go +161 -84 generated file: collapsed
M internal/adapters/web/issues_form_test.go +2 -2
@@ -79,9 +79,9 @@ func TestIssueFormGet(t *testing.T) {
7979 `action="/proj/issues/new"`, ` hidden`, `data-issue-form`, `data-nonce="AAECAwQFBgcICQoLDA0ODw"`,
8080 `data-difficulty="18"`, `data-min-submit-at="1700000003000"`, `data-worker="/-/static/js/pow-worker.`,
8181 `name="token" value="tok-1.sig"`, `name="form_token" value="form-1"`, `name="solution" value=""`,
82- `name="title" type="text" required`, `<output data-for="issue-body" data-max="10000"`, `<output data-for="issue-author" data-max="40"`,
82+ `name="title" type="text" required`, `<output id="issue-body-count" aria-live="off" data-for="issue-body" data-max="10000"`, `<output id="issue-author-count" aria-live="off" data-for="issue-author" data-max="40"`,
8383 `<div class="hp" aria-hidden="true">`, `name="website" type="text" tabindex="-1" autocomplete="off"`,
84- `<output data-for="issue-title" data-max="120"`, `0 / 10000`, `Checking your browser… (this takes a few seconds)`,
84+ `<output id="issue-title-count" aria-live="off" data-for="issue-title" data-max="120"`, `0 / 10000`, `Checking your browser… (this takes a few seconds)`,
8585 `/-/static/js/issue-form.`,
8686 } {
8787 assert.Contains(t, body, want)
@@ -79,9 +79,9 @@ func TestIssueFormGet(t *testing.T) {
79 `action="/proj/issues/new"`, ` hidden`, `data-issue-form`, `data-nonce="AAECAwQFBgcICQoLDA0ODw"`,79 `action="/proj/issues/new"`, ` hidden`, `data-issue-form`, `data-nonce="AAECAwQFBgcICQoLDA0ODw"`,
80 `data-difficulty="18"`, `data-min-submit-at="1700000003000"`, `data-worker="/-/static/js/pow-worker.`,80 `data-difficulty="18"`, `data-min-submit-at="1700000003000"`, `data-worker="/-/static/js/pow-worker.`,
81 `name="token" value="tok-1.sig"`, `name="form_token" value="form-1"`, `name="solution" value=""`,81 `name="token" value="tok-1.sig"`, `name="form_token" value="form-1"`, `name="solution" value=""`,
82- `name="title" type="text" required`, `<output data-for="issue-body" data-max="10000"`, `<output data-for="issue-author" data-max="40"`,82+ `name="title" type="text" required`, `<output id="issue-body-count" aria-live="off" data-for="issue-body" data-max="10000"`, `<output id="issue-author-count" aria-live="off" data-for="issue-author" data-max="40"`,
83 `<div class="hp" aria-hidden="true">`, `name="website" type="text" tabindex="-1" autocomplete="off"`,83 `<div class="hp" aria-hidden="true">`, `name="website" type="text" tabindex="-1" autocomplete="off"`,
84- `<output data-for="issue-title" data-max="120"`, `0 / 10000`, `Checking your browser… (this takes a few seconds)`,84+ `<output id="issue-title-count" aria-live="off" data-for="issue-title" data-max="120"`, `0 / 10000`, `Checking your browser… (this takes a few seconds)`,
85 `/-/static/js/issue-form.`,85 `/-/static/js/issue-form.`,
86 } {86 } {
87 assert.Contains(t, body, want)87 assert.Contains(t, body, want)
M internal/adapters/web/jstest/a11y.test.mjs +34 -0
@@ -381,3 +381,37 @@ test('mobile MR layout shortcuts preserve the saved preference until back on des
381381 doc.querySelector = oldQuery;
382382 }
383383 });
384+
385+test('dialog restores its row opener on close, but not over a replacement dialog', () => {
386+ const row = el('li', { tabindex: '0' });
387+ const first = el('dialog', { id: 'focus-first' });
388+ const second = el('dialog', { id: 'focus-second' });
389+ dom.body.append(row, first, second);
390+ const queued = [];
391+ const all = doc.querySelectorAll;
392+ const one = doc.querySelector;
393+ const open = () => [first, second].filter((d) => d.hasAttribute('open'));
394+ doc.querySelectorAll = (sel) => sel === 'dialog[open]' ? open() : all.call(doc, sel);
395+ doc.querySelector = (sel) => sel === 'dialog[open]' ? open()[0] ?? null : one.call(doc, sel);
396+ for (const d of [first, second]) {
397+ let onClose;
398+ d.addEventListener = (type, fn) => { if (type === 'close') onClose = fn; };
399+ d.showModal = () => { d.setAttribute('open', ''); d.focus(); };
400+ d.close = () => { d.removeAttribute('open'); queued.push(onClose); };
401+ }
402+ row.focus();
403+ window.ermite.dialog('focus-first');
404+ first.close();
405+ queued.shift()();
406+ assert.equal(doc.activeElement, row);
407+
408+ row.focus();
409+ window.ermite.dialog('focus-first');
410+ window.ermite.dialog('focus-second');
411+ queued.shift()();
412+ assert.equal(doc.activeElement, second);
413+ second.close();
414+ queued.shift()();
415+ doc.querySelectorAll = all;
416+ doc.querySelector = one;
417+});
@@ -381,3 +381,37 @@ test('mobile MR layout shortcuts preserve the saved preference until back on des
381 doc.querySelector = oldQuery;381 doc.querySelector = oldQuery;
382 }382 }
383 });383 });
384+
385+test('dialog restores its row opener on close, but not over a replacement dialog', () => {
386+ const row = el('li', { tabindex: '0' });
387+ const first = el('dialog', { id: 'focus-first' });
388+ const second = el('dialog', { id: 'focus-second' });
389+ dom.body.append(row, first, second);
390+ const queued = [];
391+ const all = doc.querySelectorAll;
392+ const one = doc.querySelector;
393+ const open = () => [first, second].filter((d) => d.hasAttribute('open'));
394+ doc.querySelectorAll = (sel) => sel === 'dialog[open]' ? open() : all.call(doc, sel);
395+ doc.querySelector = (sel) => sel === 'dialog[open]' ? open()[0] ?? null : one.call(doc, sel);
396+ for (const d of [first, second]) {
397+ let onClose;
398+ d.addEventListener = (type, fn) => { if (type === 'close') onClose = fn; };
399+ d.showModal = () => { d.setAttribute('open', ''); d.focus(); };
400+ d.close = () => { d.removeAttribute('open'); queued.push(onClose); };
401+ }
402+ row.focus();
403+ window.ermite.dialog('focus-first');
404+ first.close();
405+ queued.shift()();
406+ assert.equal(doc.activeElement, row);
407+
408+ row.focus();
409+ window.ermite.dialog('focus-first');
410+ window.ermite.dialog('focus-second');
411+ queued.shift()();
412+ assert.equal(doc.activeElement, second);
413+ second.close();
414+ queued.shift()();
415+ doc.querySelectorAll = all;
416+ doc.querySelector = one;
417+});
M internal/adapters/web/mr.templ +9 -9
@@ -296,7 +296,7 @@ templ commentView(c CommentView, suffix string) {
296296 <button type="button" class="btn" data-js data-quote={ c.Quote }>Quote</button>
297297 }
298298 if c.Edit != nil {
299- <details><summary>Edit</summary><form method="post" action={ templ.SafeURL(c.Edit.Action) }><input type="hidden" name="from" value={ itoa(c.Edit.From) }/><textarea name="text" maxlength="8000">{ c.Edit.Text }</textarea><button class="btn" type="submit">Save</button></form></details>
299+ <details><summary>Edit</summary><form method="post" action={ templ.SafeURL(c.Edit.Action) }><input type="hidden" name="from" value={ itoa(c.Edit.From) }/><textarea name="text" maxlength="8000" aria-label="Edit note">{ c.Edit.Text }</textarea><button class="btn" type="submit">Save</button></form></details>
300300 }
301301 if c.Delete != nil {
302302 <details><summary>Delete</summary><form method="post" action={ templ.SafeURL(c.Delete.Action) }><input type="hidden" name="from" value={ itoa(c.Delete.From) }/><p>Delete this note?</p><button class="btn" type="submit">Confirm delete</button></form></details>
@@ -373,9 +373,9 @@ templ commentForm(f CommentForm, suffix string) {
373373
374374 templ mrFooter(p MRPage) {
375375 <footer class="mr-footer">
376- <nav data-mr-tabs aria-label="Branch details">
377- <a id="mr-tab-commits" href="#mr-commits">Commits <span class="count">{ p.CommitCount }</span></a>
378- <a id="mr-tab-checks" href="#mr-checks">
376+ <div data-mr-tabs data-js role="group" aria-label="Branch details">
377+ <button type="button" id="mr-tab-commits" aria-expanded="true" aria-controls="mr-commits">Commits <span class="count">{ p.CommitCount }</span></button>
378+ <button type="button" id="mr-tab-checks" aria-expanded="true" aria-controls="mr-checks-panel">
379379 { "Checks " }
380380 <span data-checks-label>
381381 if p.ChecksSum.Total > 0 {
@@ -384,10 +384,10 @@ templ mrFooter(p MRPage) {
384384 no checks
385385 }
386386 </span>
387- </a>
388- </nav>
389- <div id="mr-commits" data-mr-panel role="region" aria-labelledby="mr-tab-commits">
390- <table class="mr-commits">
387+ </button>
388+ </div>
389+ <div id="mr-commits" data-mr-panel role="group" aria-labelledby="mr-tab-commits">
390+ <table class="mr-commits" aria-label="Commits">
391391 <thead><tr><th>Commit</th><th>Subject</th><th>Author</th><th>Date</th><th>Signature</th><th>Status</th></tr></thead>
392392 <tbody>
393393 for _, c := range p.Commits {
@@ -421,7 +421,7 @@ templ mrFooter(p MRPage) {
421421 <p class="note">{ p.CommitsNote }</p>
422422 }
423423 </div>
424- <div id="mr-checks-panel" data-mr-panel role="region" aria-labelledby="mr-tab-checks">
424+ <div id="mr-checks-panel" data-mr-panel role="group" aria-labelledby="mr-tab-checks">
425425 @MRChecks(p.Checks)
426426 </div>
427427 </footer>
@@ -296,7 +296,7 @@ templ commentView(c CommentView, suffix string) {
296 <button type="button" class="btn" data-js data-quote={ c.Quote }>Quote</button>296 <button type="button" class="btn" data-js data-quote={ c.Quote }>Quote</button>
297 }297 }
298 if c.Edit != nil {298 if c.Edit != nil {
299- <details><summary>Edit</summary><form method="post" action={ templ.SafeURL(c.Edit.Action) }><input type="hidden" name="from" value={ itoa(c.Edit.From) }/><textarea name="text" maxlength="8000">{ c.Edit.Text }</textarea><button class="btn" type="submit">Save</button></form></details>299+ <details><summary>Edit</summary><form method="post" action={ templ.SafeURL(c.Edit.Action) }><input type="hidden" name="from" value={ itoa(c.Edit.From) }/><textarea name="text" maxlength="8000" aria-label="Edit note">{ c.Edit.Text }</textarea><button class="btn" type="submit">Save</button></form></details>
300 }300 }
301 if c.Delete != nil {301 if c.Delete != nil {
302 <details><summary>Delete</summary><form method="post" action={ templ.SafeURL(c.Delete.Action) }><input type="hidden" name="from" value={ itoa(c.Delete.From) }/><p>Delete this note?</p><button class="btn" type="submit">Confirm delete</button></form></details>302 <details><summary>Delete</summary><form method="post" action={ templ.SafeURL(c.Delete.Action) }><input type="hidden" name="from" value={ itoa(c.Delete.From) }/><p>Delete this note?</p><button class="btn" type="submit">Confirm delete</button></form></details>
@@ -373,9 +373,9 @@ templ commentForm(f CommentForm, suffix string) {
373 373
374 templ mrFooter(p MRPage) {374 templ mrFooter(p MRPage) {
375 <footer class="mr-footer">375 <footer class="mr-footer">
376- <nav data-mr-tabs aria-label="Branch details">376+ <div data-mr-tabs data-js role="group" aria-label="Branch details">
377- <a id="mr-tab-commits" href="#mr-commits">Commits <span class="count">{ p.CommitCount }</span></a>377+ <button type="button" id="mr-tab-commits" aria-expanded="true" aria-controls="mr-commits">Commits <span class="count">{ p.CommitCount }</span></button>
378- <a id="mr-tab-checks" href="#mr-checks">378+ <button type="button" id="mr-tab-checks" aria-expanded="true" aria-controls="mr-checks-panel">
379 { "Checks " }379 { "Checks " }
380 <span data-checks-label>380 <span data-checks-label>
381 if p.ChecksSum.Total > 0 {381 if p.ChecksSum.Total > 0 {
@@ -384,10 +384,10 @@ templ mrFooter(p MRPage) {
384 no checks384 no checks
385 }385 }
386 </span>386 </span>
387- </a>387+ </button>
388- </nav>388+ </div>
389- <div id="mr-commits" data-mr-panel role="region" aria-labelledby="mr-tab-commits">389+ <div id="mr-commits" data-mr-panel role="group" aria-labelledby="mr-tab-commits">
390- <table class="mr-commits">390+ <table class="mr-commits" aria-label="Commits">
391 <thead><tr><th>Commit</th><th>Subject</th><th>Author</th><th>Date</th><th>Signature</th><th>Status</th></tr></thead>391 <thead><tr><th>Commit</th><th>Subject</th><th>Author</th><th>Date</th><th>Signature</th><th>Status</th></tr></thead>
392 <tbody>392 <tbody>
393 for _, c := range p.Commits {393 for _, c := range p.Commits {
@@ -421,7 +421,7 @@ templ mrFooter(p MRPage) {
421 <p class="note">{ p.CommitsNote }</p>421 <p class="note">{ p.CommitsNote }</p>
422 }422 }
423 </div>423 </div>
424- <div id="mr-checks-panel" data-mr-panel role="region" aria-labelledby="mr-tab-checks">424+ <div id="mr-checks-panel" data-mr-panel role="group" aria-labelledby="mr-tab-checks">
425 @MRChecks(p.Checks)425 @MRChecks(p.Checks)
426 </div>426 </div>
427 </footer>427 </footer>
M internal/adapters/web/mr_templ.go +7 -7 generated file: collapsed
M internal/adapters/web/mr_test.go +8 -7
@@ -137,21 +137,22 @@ func TestMRPanelsAndMenus(t *testing.T) {
137137 p := MRPage{CommitCount: "500+", CommitsNote: "more commits", Modes: []string{"squash", "merge"}, ChecksBlock: true,
138138 ChecksSum: ChecksSummary{Total: 2, Label: "1/2", Title: "1/2 failed"}, Box: &MergeBox{Signed: true, Fingerprint: "fingerprint", HeadGone: true}}
139139 footer := renderToString(t, mrFooter(p))
140- assert.Contains(t, footer, `<footer class="mr-footer"><nav data-mr-tabs`)
140+ assert.Contains(t, footer, `<footer class="mr-footer"><div data-mr-tabs data-js role="group"`)
141141 assert.NotContains(t, footer, `<details`)
142142 assert.NotContains(t, footer, `<summary`)
143- // No hidden attribute: the CSS shows the panel of the URL fragment until
144- // mr.js takes over, so the tabs work as anchors without scripts.
145- assert.Contains(t, footer, `id="mr-commits" data-mr-panel role="region"`)
146- assert.Contains(t, footer, `id="mr-checks-panel" data-mr-panel role="region"`)
143+ // Both panels are readable without scripts; JS enhances native buttons.
144+ assert.Contains(t, footer, `id="mr-commits" data-mr-panel role="group"`)
145+ assert.Contains(t, footer, `id="mr-checks-panel" data-mr-panel role="group"`)
147146 assert.NotContains(t, footer, `data-mr-panel hidden`)
148147 css, err := staticFS.ReadFile("static/css/60-mr.css")
149148 require.NoError(t, err)
150- assert.Contains(t, string(css), `&:not([data-panels]) [data-mr-panel]:not(:target, :has(:target)) { display: none; }`)
149+ assert.NotContains(t, string(css), `:not([data-panels])`)
150+ assert.Contains(t, footer, `<button type="button" id="mr-tab-commits" aria-expanded="true" aria-controls="mr-commits">`)
151+ assert.Contains(t, footer, `<button type="button" id="mr-tab-checks" aria-expanded="true" aria-controls="mr-checks-panel">`)
151152 assert.Contains(t, footer, `data-mr-tabs`)
152153 assert.Contains(t, footer, `id="mr-commits"`)
153154 assert.Contains(t, footer, `id="mr-checks"`)
154- assert.Contains(t, footer, `<table class="mr-commits">`)
155+ assert.Contains(t, footer, `<table class="mr-commits" aria-label="Commits">`)
155156 assert.Contains(t, footer, "500+")
156157 assert.Contains(t, footer, "more commits")
157158 assert.Contains(t, footer, "1/2 failed")
@@ -137,21 +137,22 @@ func TestMRPanelsAndMenus(t *testing.T) {
137 p := MRPage{CommitCount: "500+", CommitsNote: "more commits", Modes: []string{"squash", "merge"}, ChecksBlock: true,137 p := MRPage{CommitCount: "500+", CommitsNote: "more commits", Modes: []string{"squash", "merge"}, ChecksBlock: true,
138 ChecksSum: ChecksSummary{Total: 2, Label: "1/2", Title: "1/2 failed"}, Box: &MergeBox{Signed: true, Fingerprint: "fingerprint", HeadGone: true}}138 ChecksSum: ChecksSummary{Total: 2, Label: "1/2", Title: "1/2 failed"}, Box: &MergeBox{Signed: true, Fingerprint: "fingerprint", HeadGone: true}}
139 footer := renderToString(t, mrFooter(p))139 footer := renderToString(t, mrFooter(p))
140- assert.Contains(t, footer, `<footer class="mr-footer"><nav data-mr-tabs`)140+ assert.Contains(t, footer, `<footer class="mr-footer"><div data-mr-tabs data-js role="group"`)
141 assert.NotContains(t, footer, `<details`)141 assert.NotContains(t, footer, `<details`)
142 assert.NotContains(t, footer, `<summary`)142 assert.NotContains(t, footer, `<summary`)
143- // No hidden attribute: the CSS shows the panel of the URL fragment until143+ // Both panels are readable without scripts; JS enhances native buttons.
144- // mr.js takes over, so the tabs work as anchors without scripts.144+ assert.Contains(t, footer, `id="mr-commits" data-mr-panel role="group"`)
145- assert.Contains(t, footer, `id="mr-commits" data-mr-panel role="region"`)145+ assert.Contains(t, footer, `id="mr-checks-panel" data-mr-panel role="group"`)
146- assert.Contains(t, footer, `id="mr-checks-panel" data-mr-panel role="region"`)
147 assert.NotContains(t, footer, `data-mr-panel hidden`)146 assert.NotContains(t, footer, `data-mr-panel hidden`)
148 css, err := staticFS.ReadFile("static/css/60-mr.css")147 css, err := staticFS.ReadFile("static/css/60-mr.css")
149 require.NoError(t, err)148 require.NoError(t, err)
150- assert.Contains(t, string(css), `&:not([data-panels]) [data-mr-panel]:not(:target, :has(:target)) { display: none; }`)149+ assert.NotContains(t, string(css), `:not([data-panels])`)
150+ assert.Contains(t, footer, `<button type="button" id="mr-tab-commits" aria-expanded="true" aria-controls="mr-commits">`)
151+ assert.Contains(t, footer, `<button type="button" id="mr-tab-checks" aria-expanded="true" aria-controls="mr-checks-panel">`)
151 assert.Contains(t, footer, `data-mr-tabs`)152 assert.Contains(t, footer, `data-mr-tabs`)
152 assert.Contains(t, footer, `id="mr-commits"`)153 assert.Contains(t, footer, `id="mr-commits"`)
153 assert.Contains(t, footer, `id="mr-checks"`)154 assert.Contains(t, footer, `id="mr-checks"`)
154- assert.Contains(t, footer, `<table class="mr-commits">`)155+ assert.Contains(t, footer, `<table class="mr-commits" aria-label="Commits">`)
155 assert.Contains(t, footer, "500+")156 assert.Contains(t, footer, "500+")
156 assert.Contains(t, footer, "more commits")157 assert.Contains(t, footer, "more commits")
157 assert.Contains(t, footer, "1/2 failed")158 assert.Contains(t, footer, "1/2 failed")
M internal/adapters/web/overlays.templ +5 -5
@@ -11,7 +11,7 @@ import (
1111 // show on that page only.
1212 templ OverlayHelp(mr bool) {
1313 <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()">
14- <div class="panel-head"><h2>Keyboard shortcuts</h2><button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"><kbd>esc</kbd> close</button></div>
14+ <div class="panel-head"><h2>Keyboard shortcuts</h2><button type="button" class="panel-x" aria-label="Close (esc)" hx-on:click="closest('dialog').close()"><kbd>esc</kbd> close</button></div>
1515 <div class="keys-pref">
1616 <label><input type="checkbox" id="keys-off" aria-describedby="keys-note" hx-on:change="ermite.setKeysOff(this.checked)"/> Turn off single-key shortcuts</label>
1717 <p id="keys-note" class="dim">Single-key shortcuts are on. ctrl k and esc work whatever this says.</p>
@@ -60,7 +60,7 @@ templ OverlayRefs(l Layout) {
6060 data-pick="#panel-refs li"
6161 hx-on:input="ermite.filter(this)"
6262 />
63- <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"><kbd>esc</kbd></button>
63+ <button type="button" class="panel-x" aria-label="Close (esc)" hx-on:click="closest('dialog').close()"><kbd>esc</kbd></button>
6464 </div>
6565 <div class="panel-cols" data-picklist>
6666 <section aria-label="Branches">
@@ -123,13 +123,13 @@ templ OverlayCmd(items []CmdItem) {
123123 hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)"
124124 hx-on:keydown="ermite.cmdKey(event)"
125125 />
126- <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"><kbd>esc</kbd></button>
126+ <button type="button" class="panel-x" aria-label="Close (esc)" hx-on:click="closest('dialog').close()"><kbd>esc</kbd></button>
127127 </div>
128128 <ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
129129 for i, c := range items {
130- <li id={ "cmd-o" + itoa(i) } role="option" aria-selected={ strconv.FormatBool(i == 0) } 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>
130+ <li id={ "cmd-o" + itoa(i) } role="option" aria-selected={ strconv.FormatBool(i == 0) } data-k={ strings.ToLower(c.Kind + " " + c.Label) } data-cursor?={ i == 0 }><a role="presentation" href={ templ.SafeURL(c.Href) } tabindex="-1"><span class="lbl">{ c.Label }</span><span class="k">{ c.Kind }</span></a></li>
131131 }
132- <li id={ "cmd-o" + itoa(len(items)) } role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"><a href="#" data-fire="keys" tabindex="-1"><span class="lbl">turn single-key shortcuts off</span><span class="k">action</span></a></li>
132+ <li id={ "cmd-o" + itoa(len(items)) } role="option" aria-selected={ strconv.FormatBool(len(items) == 0) } data-cursor?={ len(items) == 0 } data-k="action turn single-key shortcuts on off keyboard"><a role="presentation" href="#" data-fire="keys" tabindex="-1"><span class="lbl">turn single-key shortcuts off</span><span class="k">action</span></a></li>
133133 </ul>
134134 <p class="panel-foot"><span><kbd>↑</kbd><kbd>↓</kbd> move</span><span><kbd>enter</kbd> open</span><span><kbd>esc</kbd> close</span></p>
135135 </dialog>
@@ -11,7 +11,7 @@ import (
11 // show on that page only.11 // show on that page only.
12 templ OverlayHelp(mr bool) {12 templ OverlayHelp(mr bool) {
13 <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()">13 <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()">
14- <div class="panel-head"><h2>Keyboard shortcuts</h2><button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"><kbd>esc</kbd> close</button></div>14+ <div class="panel-head"><h2>Keyboard shortcuts</h2><button type="button" class="panel-x" aria-label="Close (esc)" hx-on:click="closest('dialog').close()"><kbd>esc</kbd> close</button></div>
15 <div class="keys-pref">15 <div class="keys-pref">
16 <label><input type="checkbox" id="keys-off" aria-describedby="keys-note" hx-on:change="ermite.setKeysOff(this.checked)"/> Turn off single-key shortcuts</label>16 <label><input type="checkbox" id="keys-off" aria-describedby="keys-note" hx-on:change="ermite.setKeysOff(this.checked)"/> Turn off single-key shortcuts</label>
17 <p id="keys-note" class="dim">Single-key shortcuts are on. ctrl k and esc work whatever this says.</p>17 <p id="keys-note" class="dim">Single-key shortcuts are on. ctrl k and esc work whatever this says.</p>
@@ -60,7 +60,7 @@ templ OverlayRefs(l Layout) {
60 data-pick="#panel-refs li"60 data-pick="#panel-refs li"
61 hx-on:input="ermite.filter(this)"61 hx-on:input="ermite.filter(this)"
62 />62 />
63- <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"><kbd>esc</kbd></button>63+ <button type="button" class="panel-x" aria-label="Close (esc)" hx-on:click="closest('dialog').close()"><kbd>esc</kbd></button>
64 </div>64 </div>
65 <div class="panel-cols" data-picklist>65 <div class="panel-cols" data-picklist>
66 <section aria-label="Branches">66 <section aria-label="Branches">
@@ -123,13 +123,13 @@ templ OverlayCmd(items []CmdItem) {
123 hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)"123 hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)"
124 hx-on:keydown="ermite.cmdKey(event)"124 hx-on:keydown="ermite.cmdKey(event)"
125 />125 />
126- <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"><kbd>esc</kbd></button>126+ <button type="button" class="panel-x" aria-label="Close (esc)" hx-on:click="closest('dialog').close()"><kbd>esc</kbd></button>
127 </div>127 </div>
128 <ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">128 <ul id="cmd-list" class="cmd-list" role="listbox" aria-label="Targets">
129 for i, c := range items {129 for i, c := range items {
130- <li id={ "cmd-o" + itoa(i) } role="option" aria-selected={ strconv.FormatBool(i == 0) } 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>130+ <li id={ "cmd-o" + itoa(i) } role="option" aria-selected={ strconv.FormatBool(i == 0) } data-k={ strings.ToLower(c.Kind + " " + c.Label) } data-cursor?={ i == 0 }><a role="presentation" href={ templ.SafeURL(c.Href) } tabindex="-1"><span class="lbl">{ c.Label }</span><span class="k">{ c.Kind }</span></a></li>
131 }131 }
132- <li id={ "cmd-o" + itoa(len(items)) } role="option" aria-selected="false" data-k="action turn single-key shortcuts on off keyboard"><a href="#" data-fire="keys" tabindex="-1"><span class="lbl">turn single-key shortcuts off</span><span class="k">action</span></a></li>132+ <li id={ "cmd-o" + itoa(len(items)) } role="option" aria-selected={ strconv.FormatBool(len(items) == 0) } data-cursor?={ len(items) == 0 } data-k="action turn single-key shortcuts on off keyboard"><a role="presentation" href="#" data-fire="keys" tabindex="-1"><span class="lbl">turn single-key shortcuts off</span><span class="k">action</span></a></li>
133 </ul>133 </ul>
134 <p class="panel-foot"><span><kbd>↑</kbd><kbd>↓</kbd> move</span><span><kbd>enter</kbd> open</span><span><kbd>esc</kbd> close</span></p>134 <p class="panel-foot"><span><kbd>↑</kbd><kbd>↓</kbd> move</span><span><kbd>enter</kbd> open</span><span><kbd>esc</kbd> close</span></p>
135 </dialog>135 </dialog>
M internal/adapters/web/overlays_templ.go +31 -8 generated file: collapsed
M internal/adapters/web/shell.templ +4 -4
@@ -207,10 +207,10 @@ templ Footer(l Layout) {
207207 // move, the selected lines of a file, a flash message (permalink copied) and
208208 // a lost event stream. Empty, it takes no room.
209209 templ Hint(l Layout) {
210- <div class="hint" role="status" aria-live="polite">
211- <span class="hint-nav" :text="document.body.dataset.nav || ''"></span>
212- <span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''"></span>
213- <span class="hint-flash" :text="document.body.dataset.flash || ''"></span>
210+ <div class="hint">
211+ <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''"></span>
212+ <span role="status" aria-live="polite"><span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''"></span>
213+ <span class="hint-flash" :text="document.body.dataset.flash || ''"></span></span>
214214 @Live(l)
215215 </div>
216216 }
@@ -207,10 +207,10 @@ templ Footer(l Layout) {
207 // move, the selected lines of a file, a flash message (permalink copied) and207 // move, the selected lines of a file, a flash message (permalink copied) and
208 // a lost event stream. Empty, it takes no room.208 // a lost event stream. Empty, it takes no room.
209 templ Hint(l Layout) {209 templ Hint(l Layout) {
210- <div class="hint" role="status" aria-live="polite">210+ <div class="hint">
211- <span class="hint-nav" :text="document.body.dataset.nav || ''"></span>211+ <span aria-hidden="true" class="hint-nav" :text="document.body.dataset.nav || ''"></span>
212- <span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''"></span>212+ <span role="status" aria-live="polite"><span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''"></span>
213- <span class="hint-flash" :text="document.body.dataset.flash || ''"></span>213+ <span class="hint-flash" :text="document.body.dataset.flash || ''"></span></span>
214 @Live(l)214 @Live(l)
215 </div>215 </div>
216 }216 }
M internal/adapters/web/shell_templ.go +1 -1 generated file: collapsed
M internal/adapters/web/static/css/20-shell.css +2 -1
@@ -204,12 +204,13 @@
204204 background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--r); box-shadow: var(--shadow);
205205 pointer-events: none;
206206 & > :empty { display: none; }
207+ & > [role="status"] { display: contents; }
207208 & .hint-live { color: var(--red); }
208209 }
209210 /* empty, the badge is out of sight but stays a status region (display: none
210211 would drop it from the accessibility tree and its next message would not be
211212 announced) */
212- .hint:not(:has(> :not(:empty))) {
213+ .hint:not(:has(.hint-nav:not(:empty), .hint-sel:not(:empty), .hint-flash:not(:empty), .hint-live:not(:empty))) {
213214 width: 1px; height: 1px; padding: 0; border: 0; overflow: hidden; clip-path: inset(50%); box-shadow: none;
214215 }
215216
@@ -204,12 +204,13 @@
204 background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--r); box-shadow: var(--shadow);204 background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--r); box-shadow: var(--shadow);
205 pointer-events: none;205 pointer-events: none;
206 & > :empty { display: none; }206 & > :empty { display: none; }
207+ & > [role="status"] { display: contents; }
207 & .hint-live { color: var(--red); }208 & .hint-live { color: var(--red); }
208 }209 }
209 /* empty, the badge is out of sight but stays a status region (display: none210 /* empty, the badge is out of sight but stays a status region (display: none
210 would drop it from the accessibility tree and its next message would not be211 would drop it from the accessibility tree and its next message would not be
211 announced) */212 announced) */
212- .hint:not(:has(> :not(:empty))) {213+ .hint:not(:has(.hint-nav:not(:empty), .hint-sel:not(:empty), .hint-flash:not(:empty), .hint-live:not(:empty))) {
213 width: 1px; height: 1px; padding: 0; border: 0; overflow: hidden; clip-path: inset(50%); box-shadow: none;214 width: 1px; height: 1px; padding: 0; border: 0; overflow: hidden; clip-path: inset(50%); box-shadow: none;
214 }215 }
215 216
M internal/adapters/web/static/css/60-mr.css +5 -8
@@ -17,22 +17,19 @@
1717 font-size: var(--fs-s);
1818 /* the tabs are buttons: a thin border, the count in a darker badge;
1919 the open one in the teal accent */
20- & nav { display: flex; flex-wrap: wrap; gap: var(--s2); }
21- & [data-mr-tabs] a {
20+ & [data-mr-tabs] { display: flex; flex-wrap: wrap; gap: var(--s2); }
21+ & [data-mr-tabs] button {
2222 display: inline-flex; align-items: center; gap: var(--s1);
2323 padding: 2px var(--s2); border: 1px solid var(--border-strong); border-radius: var(--r-s);
2424 color: var(--ink-dim); text-decoration: none; cursor: pointer;
2525 }
2626 & [data-checks-label] { color: var(--ink-dim); font-size: var(--fs-xs); }
2727 & [data-mr-tabs] .count { margin-left: 0; color: var(--ink-hi); background: var(--bg-canvas); box-shadow: inset 0 0 0 1px var(--border); }
28- & [data-mr-tabs] a:hover { background: var(--bg-strong); color: var(--ink-hi); }
29- & [data-mr-tabs] a[aria-expanded="true"] { border-color: var(--teal); color: var(--teal); background: var(--bg-surface); }
28+ & [data-mr-tabs] button:hover { background: var(--bg-strong); color: var(--ink-hi); }
29+ & [data-mr-tabs] button[aria-expanded="true"] { border-color: var(--teal); color: var(--teal); background: var(--bg-surface); }
3030 & [data-mr-panel] { padding-block-start: var(--s2); overflow-x: auto; }
3131 & [hidden] { display: none; }
32- /* Until mr.js drives the panels (data-panels, then [hidden]), only the
33- panel of the URL fragment shows: the tabs work as anchors without
34- scripts, and no panel flashes open before the script runs. */
35- &:not([data-panels]) [data-mr-panel]:not(:target, :has(:target)) { display: none; }
32+ /* Without scripts both panels remain visible; disclosure controls use data-js. */
3633 & section h2 { font-size: var(--fs-s); color: var(--ink-dim); }
3734 & table { width: 100%; border-collapse: collapse; }
3835 & th, & td { text-align: left; padding: var(--s2); border-bottom: 1px solid var(--border); }
@@ -17,22 +17,19 @@
17 font-size: var(--fs-s);17 font-size: var(--fs-s);
18 /* the tabs are buttons: a thin border, the count in a darker badge;18 /* the tabs are buttons: a thin border, the count in a darker badge;
19 the open one in the teal accent */19 the open one in the teal accent */
20- & nav { display: flex; flex-wrap: wrap; gap: var(--s2); }20+ & [data-mr-tabs] { display: flex; flex-wrap: wrap; gap: var(--s2); }
21- & [data-mr-tabs] a {21+ & [data-mr-tabs] button {
22 display: inline-flex; align-items: center; gap: var(--s1);22 display: inline-flex; align-items: center; gap: var(--s1);
23 padding: 2px var(--s2); border: 1px solid var(--border-strong); border-radius: var(--r-s);23 padding: 2px var(--s2); border: 1px solid var(--border-strong); border-radius: var(--r-s);
24 color: var(--ink-dim); text-decoration: none; cursor: pointer;24 color: var(--ink-dim); text-decoration: none; cursor: pointer;
25 }25 }
26 & [data-checks-label] { color: var(--ink-dim); font-size: var(--fs-xs); }26 & [data-checks-label] { color: var(--ink-dim); font-size: var(--fs-xs); }
27 & [data-mr-tabs] .count { margin-left: 0; color: var(--ink-hi); background: var(--bg-canvas); box-shadow: inset 0 0 0 1px var(--border); }27 & [data-mr-tabs] .count { margin-left: 0; color: var(--ink-hi); background: var(--bg-canvas); box-shadow: inset 0 0 0 1px var(--border); }
28- & [data-mr-tabs] a:hover { background: var(--bg-strong); color: var(--ink-hi); }28+ & [data-mr-tabs] button:hover { background: var(--bg-strong); color: var(--ink-hi); }
29- & [data-mr-tabs] a[aria-expanded="true"] { border-color: var(--teal); color: var(--teal); background: var(--bg-surface); }29+ & [data-mr-tabs] button[aria-expanded="true"] { border-color: var(--teal); color: var(--teal); background: var(--bg-surface); }
30 & [data-mr-panel] { padding-block-start: var(--s2); overflow-x: auto; }30 & [data-mr-panel] { padding-block-start: var(--s2); overflow-x: auto; }
31 & [hidden] { display: none; }31 & [hidden] { display: none; }
32- /* Until mr.js drives the panels (data-panels, then [hidden]), only the32+ /* Without scripts both panels remain visible; disclosure controls use data-js. */
33- panel of the URL fragment shows: the tabs work as anchors without
34- scripts, and no panel flashes open before the script runs. */
35- &:not([data-panels]) [data-mr-panel]:not(:target, :has(:target)) { display: none; }
36 & section h2 { font-size: var(--fs-s); color: var(--ink-dim); }33 & section h2 { font-size: var(--fs-s); color: var(--ink-dim); }
37 & table { width: 100%; border-collapse: collapse; }34 & table { width: 100%; border-collapse: collapse; }
38 & th, & td { text-align: left; padding: var(--s2); border-bottom: 1px solid var(--border); }35 & th, & td { text-align: left; padding: var(--s2); border-bottom: 1px solid var(--border); }
M internal/adapters/web/static/css/85-mobile.css +1 -1
@@ -48,6 +48,6 @@
4848 #diffs[data-mr] .ln a[data-range-start] { outline: 2px solid var(--focus); background: var(--sel); }
4949 body:has(#diffs[data-mr]) :is(.cform .btn, .menu .btn, .mr-touch-range .btn, .c-act button) { min-height: 40px; height: auto; padding-block: var(--s2); }
5050 #diffs[data-mr] .cform { scroll-margin-block: calc(var(--hdr-h) + 12px); }
51- .mr-footer [data-mr-tabs] a { display: inline-flex; align-items: center; min-height: 40px; }
51+ .mr-footer [data-mr-tabs] button { display: inline-flex; align-items: center; min-height: 40px; }
5252 }
5353 }
@@ -48,6 +48,6 @@
48 #diffs[data-mr] .ln a[data-range-start] { outline: 2px solid var(--focus); background: var(--sel); }48 #diffs[data-mr] .ln a[data-range-start] { outline: 2px solid var(--focus); background: var(--sel); }
49 body:has(#diffs[data-mr]) :is(.cform .btn, .menu .btn, .mr-touch-range .btn, .c-act button) { min-height: 40px; height: auto; padding-block: var(--s2); }49 body:has(#diffs[data-mr]) :is(.cform .btn, .menu .btn, .mr-touch-range .btn, .c-act button) { min-height: 40px; height: auto; padding-block: var(--s2); }
50 #diffs[data-mr] .cform { scroll-margin-block: calc(var(--hdr-h) + 12px); }50 #diffs[data-mr] .cform { scroll-margin-block: calc(var(--hdr-h) + 12px); }
51- .mr-footer [data-mr-tabs] a { display: inline-flex; align-items: center; min-height: 40px; }51+ .mr-footer [data-mr-tabs] button { display: inline-flex; align-items: center; min-height: 40px; }
52 }52 }
53 }53 }
M internal/adapters/web/static/js/ermite.js +6 -0
@@ -134,7 +134,12 @@
134134 dialog(id, focusId) {
135135 const d = document.getElementById(id);
136136 if (!d) return;
137+ const opener = document.activeElement;
137138 ermite.closeDialogs();
139+ d.addEventListener('close', () => {
140+ // close events are queued: a replacement dialog may already be open.
141+ if (!document.querySelector('dialog[open]') && opener?.isConnected) opener.focus();
142+ }, { once: true });
138143 d.showModal();
139144 if (id === 'panel-cmd') window.ermiteCmd?.sync();
140145 if (focusId) document.getElementById(focusId)?.focus();
@@ -284,6 +289,7 @@
284289 for (const li of all) {
285290 li.id ||= 'cmd-h' + seq++;
286291 li.setAttribute('role', 'option');
292+ li.querySelector('a')?.setAttribute('role', 'presentation');
287293 li.setAttribute('aria-selected', li === cur ? 'true' : 'false');
288294 }
289295 input.setAttribute('aria-expanded', shown.length ? 'true' : 'false');
@@ -134,7 +134,12 @@
134 dialog(id, focusId) {134 dialog(id, focusId) {
135 const d = document.getElementById(id);135 const d = document.getElementById(id);
136 if (!d) return;136 if (!d) return;
137+ const opener = document.activeElement;
137 ermite.closeDialogs();138 ermite.closeDialogs();
139+ d.addEventListener('close', () => {
140+ // close events are queued: a replacement dialog may already be open.
141+ if (!document.querySelector('dialog[open]') && opener?.isConnected) opener.focus();
142+ }, { once: true });
138 d.showModal();143 d.showModal();
139 if (id === 'panel-cmd') window.ermiteCmd?.sync();144 if (id === 'panel-cmd') window.ermiteCmd?.sync();
140 if (focusId) document.getElementById(focusId)?.focus();145 if (focusId) document.getElementById(focusId)?.focus();
@@ -284,6 +289,7 @@
284 for (const li of all) {289 for (const li of all) {
285 li.id ||= 'cmd-h' + seq++;290 li.id ||= 'cmd-h' + seq++;
286 li.setAttribute('role', 'option');291 li.setAttribute('role', 'option');
292+ li.querySelector('a')?.setAttribute('role', 'presentation');
287 li.setAttribute('aria-selected', li === cur ? 'true' : 'false');293 li.setAttribute('aria-selected', li === cur ? 'true' : 'false');
288 }294 }
289 input.setAttribute('aria-expanded', shown.length ? 'true' : 'false');295 input.setAttribute('aria-expanded', shown.length ? 'true' : 'false');
M internal/adapters/web/static/js/mr.js +2 -2
@@ -241,7 +241,7 @@ window.ermiteMR = (() => {
241241 reveal(id);
242242 }
243243
244- // Enhance the ordinary anchor navigation only when scripts are available.
244+ // Enhance the server-rendered disclosures when scripts are available.
245245 let disposePanels = () => {};
246246 // The tabs are disclosure buttons (aria-expanded + aria-controls), not a
247247 // tablist: a tablist always has a selected tab, and here every panel can be
@@ -250,7 +250,7 @@ window.ermiteMR = (() => {
250250 disposePanels();
251251 const footer = $('.mr-footer'), nav = $('[data-mr-tabs]');
252252 if (!footer || !nav) return;
253- const tabs = $$('a', nav), panels = $$('[data-mr-panel]', footer);
253+ const tabs = $$('button', nav), panels = $$('[data-mr-panel]', footer);
254254 const ids = new Set(['#mr-commits', '#mr-checks', '#mr-checks-panel']);
255255 let current = -1;
256256 // show applies the state; fromUser also keeps the URL fragment in step.
@@ -241,7 +241,7 @@ window.ermiteMR = (() => {
241 reveal(id);241 reveal(id);
242 }242 }
243 243
244- // Enhance the ordinary anchor navigation only when scripts are available.244+ // Enhance the server-rendered disclosures when scripts are available.
245 let disposePanels = () => {};245 let disposePanels = () => {};
246 // The tabs are disclosure buttons (aria-expanded + aria-controls), not a246 // The tabs are disclosure buttons (aria-expanded + aria-controls), not a
247 // tablist: a tablist always has a selected tab, and here every panel can be247 // tablist: a tablist always has a selected tab, and here every panel can be
@@ -250,7 +250,7 @@ window.ermiteMR = (() => {
250 disposePanels();250 disposePanels();
251 const footer = $('.mr-footer'), nav = $('[data-mr-tabs]');251 const footer = $('.mr-footer'), nav = $('[data-mr-tabs]');
252 if (!footer || !nav) return;252 if (!footer || !nav) return;
253- const tabs = $$('a', nav), panels = $$('[data-mr-panel]', footer);253+ const tabs = $$('button', nav), panels = $$('[data-mr-panel]', footer);
254 const ids = new Set(['#mr-commits', '#mr-checks', '#mr-checks-panel']);254 const ids = new Set(['#mr-commits', '#mr-checks', '#mr-checks-panel']);
255 let current = -1;255 let current = -1;
256 // show applies the state; fromUser also keeps the URL fragment in step.256 // show applies the state; fromUser also keeps the URL fragment in step.
M internal/adapters/web/tree.templ +1 -1
@@ -28,8 +28,8 @@ templ Tree(l Layout, p TreePage) {
2828 </div>
2929 <p class="tree-keys" data-js aria-hidden="true"><kbd>j</kbd> <kbd>k</kbd> move · <kbd>l</kbd> open · <kbd>h</kbd> back</p>
3030 </div>
31+ <p id="finder-status" class="dim" role="status"></p>
3132 <div id="finder" class="tree-card" hidden>
32- <p id="finder-status" class="dim" aria-live="polite"></p>
3333 <ul id="finder-list" class="cmd-list" role="listbox" aria-label="Files"></ul>
3434 </div>
3535 <div id="tree-view" class="tree-card">
@@ -28,8 +28,8 @@ templ Tree(l Layout, p TreePage) {
28 </div>28 </div>
29 <p class="tree-keys" data-js aria-hidden="true"><kbd>j</kbd> <kbd>k</kbd> move · <kbd>l</kbd> open · <kbd>h</kbd> back</p>29 <p class="tree-keys" data-js aria-hidden="true"><kbd>j</kbd> <kbd>k</kbd> move · <kbd>l</kbd> open · <kbd>h</kbd> back</p>
30 </div>30 </div>
31+ <p id="finder-status" class="dim" role="status"></p>
31 <div id="finder" class="tree-card" hidden>32 <div id="finder" class="tree-card" hidden>
32- <p id="finder-status" class="dim" aria-live="polite"></p>
33 <ul id="finder-list" class="cmd-list" role="listbox" aria-label="Files"></ul>33 <ul id="finder-list" class="cmd-list" role="listbox" aria-label="Files"></ul>
34 </div>34 </div>
35 <div id="tree-view" class="tree-card">35 <div id="tree-view" class="tree-card">
M internal/adapters/web/tree_templ.go +1 -1
@@ -46,7 +46,7 @@ func Tree(l Layout, p TreePage) templ.Component {
4646 }()
4747 }
4848 ctx = templ.InitializeContext(ctx)
49- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"tree-find\"><div class=\"filter wide\"><label for=\"finder-q\" class=\"sr\">find a file</label> <input id=\"finder-q\" type=\"search\" data-filter role=\"combobox\" aria-autocomplete=\"list\" aria-controls=\"finder-list\" aria-expanded=\"false\" placeholder=\"Find a file in this repository…\" autocomplete=\"off\" spellcheck=\"false\" hx-on:focus=\"ermiteFinder.load()\" hx-on:input=\"ermiteFinder.input(this)\" hx-on:keydown=\"ermiteFinder.key(this, event)\"></div><p class=\"tree-keys\" data-js aria-hidden=\"true\"><kbd>j</kbd> <kbd>k</kbd> move · <kbd>l</kbd> open · <kbd>h</kbd> back</p></div><div id=\"finder\" class=\"tree-card\" hidden><p id=\"finder-status\" class=\"dim\" aria-live=\"polite\"></p><ul id=\"finder-list\" class=\"cmd-list\" role=\"listbox\" aria-label=\"Files\"></ul></div><div id=\"tree-view\" class=\"tree-card\"><div class=\"tree-head\"><span class=\"tree-path\">")
49+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"tree-find\"><div class=\"filter wide\"><label for=\"finder-q\" class=\"sr\">find a file</label> <input id=\"finder-q\" type=\"search\" data-filter role=\"combobox\" aria-autocomplete=\"list\" aria-controls=\"finder-list\" aria-expanded=\"false\" placeholder=\"Find a file in this repository…\" autocomplete=\"off\" spellcheck=\"false\" hx-on:focus=\"ermiteFinder.load()\" hx-on:input=\"ermiteFinder.input(this)\" hx-on:keydown=\"ermiteFinder.key(this, event)\"></div><p class=\"tree-keys\" data-js aria-hidden=\"true\"><kbd>j</kbd> <kbd>k</kbd> move · <kbd>l</kbd> open · <kbd>h</kbd> back</p></div><p id=\"finder-status\" class=\"dim\" role=\"status\"></p><div id=\"finder\" class=\"tree-card\" hidden><ul id=\"finder-list\" class=\"cmd-list\" role=\"listbox\" aria-label=\"Files\"></ul></div><div id=\"tree-view\" class=\"tree-card\"><div class=\"tree-head\"><span class=\"tree-path\">")
5050 if templ_7745c5c3_Err != nil {
5151 return templ_7745c5c3_Err
5252 }
@@ -46,7 +46,7 @@ func Tree(l Layout, p TreePage) templ.Component {
46 }()46 }()
47 }47 }
48 ctx = templ.InitializeContext(ctx)48 ctx = templ.InitializeContext(ctx)
49- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"tree-find\"><div class=\"filter wide\"><label for=\"finder-q\" class=\"sr\">find a file</label> <input id=\"finder-q\" type=\"search\" data-filter role=\"combobox\" aria-autocomplete=\"list\" aria-controls=\"finder-list\" aria-expanded=\"false\" placeholder=\"Find a file in this repository…\" autocomplete=\"off\" spellcheck=\"false\" hx-on:focus=\"ermiteFinder.load()\" hx-on:input=\"ermiteFinder.input(this)\" hx-on:keydown=\"ermiteFinder.key(this, event)\"></div><p class=\"tree-keys\" data-js aria-hidden=\"true\"><kbd>j</kbd> <kbd>k</kbd> move · <kbd>l</kbd> open · <kbd>h</kbd> back</p></div><div id=\"finder\" class=\"tree-card\" hidden><p id=\"finder-status\" class=\"dim\" aria-live=\"polite\"></p><ul id=\"finder-list\" class=\"cmd-list\" role=\"listbox\" aria-label=\"Files\"></ul></div><div id=\"tree-view\" class=\"tree-card\"><div class=\"tree-head\"><span class=\"tree-path\">")49+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"tree-find\"><div class=\"filter wide\"><label for=\"finder-q\" class=\"sr\">find a file</label> <input id=\"finder-q\" type=\"search\" data-filter role=\"combobox\" aria-autocomplete=\"list\" aria-controls=\"finder-list\" aria-expanded=\"false\" placeholder=\"Find a file in this repository…\" autocomplete=\"off\" spellcheck=\"false\" hx-on:focus=\"ermiteFinder.load()\" hx-on:input=\"ermiteFinder.input(this)\" hx-on:keydown=\"ermiteFinder.key(this, event)\"></div><p class=\"tree-keys\" data-js aria-hidden=\"true\"><kbd>j</kbd> <kbd>k</kbd> move · <kbd>l</kbd> open · <kbd>h</kbd> back</p></div><p id=\"finder-status\" class=\"dim\" role=\"status\"></p><div id=\"finder\" class=\"tree-card\" hidden><ul id=\"finder-list\" class=\"cmd-list\" role=\"listbox\" aria-label=\"Files\"></ul></div><div id=\"tree-view\" class=\"tree-card\"><div class=\"tree-head\"><span class=\"tree-path\">")
50 if templ_7745c5c3_Err != nil {50 if templ_7745c5c3_Err != nil {
51 return templ_7745c5c3_Err51 return templ_7745c5c3_Err
52 }52 }

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 42

↑↓ moveenter openesc close