detached 6b81fd4

fix(web): show MR footer panels without scripts and style the tabs as buttons

commit
6b81fd45748becc31641bbbd6e6619869f340a0c
author
brice · · 2026-10-20 08:59 UTC
parent
f525ac6
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
checks
2/4
6 files · +38 -19
M docs/merge-requests.md +2 -2
@@ -112,8 +112,8 @@ collapsed by default. Clicking a tab opens its panel, clicking the open tab
112112 folds it again, clicking the other tab switches. Arrow keys (and Home / End)
113113 move between the tabs. Without scripts the tabs are anchor links and the URL
114114 fragment (`#mr-commits`, `#mr-checks`) shows the panel. The checks summary above
115-the diff opens the Checks panel and continues to update live. Commit totals use `N+` when the branch
116-list is capped: the reader's existing count operation counts all reachable
115+the diff opens the Checks panel and continues to update live. Commit totals use
116+`N+` when the branch list is capped: the reader's existing count operation counts all reachable
117117 ancestors, not the source-minus-target range.
118118
119119 The rail keeps Files (including its `g f` shortcut) and Verdicts. Merge settings
@@ -112,8 +112,8 @@ collapsed by default. Clicking a tab opens its panel, clicking the open tab
112 folds it again, clicking the other tab switches. Arrow keys (and Home / End)112 folds it again, clicking the other tab switches. Arrow keys (and Home / End)
113 move between the tabs. Without scripts the tabs are anchor links and the URL113 move between the tabs. Without scripts the tabs are anchor links and the URL
114 fragment (`#mr-commits`, `#mr-checks`) shows the panel. The checks summary above114 fragment (`#mr-commits`, `#mr-checks`) shows the panel. The checks summary above
115-the diff opens the Checks panel and continues to update live. Commit totals use `N+` when the branch115+the diff opens the Checks panel and continues to update live. Commit totals use
116-list is capped: the reader's existing count operation counts all reachable116+`N+` when the branch list is capped: the reader's existing count operation counts all reachable
117 ancestors, not the source-minus-target range.117 ancestors, not the source-minus-target range.
118 118
119 The rail keeps Files (including its `g f` shortcut) and Verdicts. Merge settings119 The rail keeps Files (including its `g f` shortcut) and Verdicts. Merge settings
M internal/adapters/web/mr.templ +2 -2
@@ -391,7 +391,7 @@ templ mrFooter(p MRPage) {
391391 </span>
392392 </a>
393393 </nav>
394- <div id="mr-commits" data-mr-panel hidden aria-labelledby="mr-tab-commits">
394+ <div id="mr-commits" data-mr-panel role="region" aria-labelledby="mr-tab-commits">
395395 <table class="mr-commits">
396396 <thead><tr><th>Commit</th><th>Subject</th><th>Author</th><th>Date</th><th>Signature</th><th>Status</th></tr></thead>
397397 <tbody>
@@ -426,7 +426,7 @@ templ mrFooter(p MRPage) {
426426 <p class="note">{ p.CommitsNote }</p>
427427 }
428428 </div>
429- <div id="mr-checks-panel" data-mr-panel hidden aria-labelledby="mr-tab-checks">
429+ <div id="mr-checks-panel" data-mr-panel role="region" aria-labelledby="mr-tab-checks">
430430 @MRChecks(p.Checks)
431431 </div>
432432 </footer>
@@ -391,7 +391,7 @@ templ mrFooter(p MRPage) {
391 </span>391 </span>
392 </a>392 </a>
393 </nav>393 </nav>
394- <div id="mr-commits" data-mr-panel hidden aria-labelledby="mr-tab-commits">394+ <div id="mr-commits" data-mr-panel role="region" aria-labelledby="mr-tab-commits">
395 <table class="mr-commits">395 <table class="mr-commits">
396 <thead><tr><th>Commit</th><th>Subject</th><th>Author</th><th>Date</th><th>Signature</th><th>Status</th></tr></thead>396 <thead><tr><th>Commit</th><th>Subject</th><th>Author</th><th>Date</th><th>Signature</th><th>Status</th></tr></thead>
397 <tbody>397 <tbody>
@@ -426,7 +426,7 @@ templ mrFooter(p MRPage) {
426 <p class="note">{ p.CommitsNote }</p>426 <p class="note">{ p.CommitsNote }</p>
427 }427 }
428 </div>428 </div>
429- <div id="mr-checks-panel" data-mr-panel hidden aria-labelledby="mr-tab-checks">429+ <div id="mr-checks-panel" data-mr-panel role="region" aria-labelledby="mr-tab-checks">
430 @MRChecks(p.Checks)430 @MRChecks(p.Checks)
431 </div>431 </div>
432 </footer>432 </footer>
M internal/adapters/web/mr_templ.go +2 -2
@@ -2079,7 +2079,7 @@ func mrFooter(p MRPage) templ.Component {
20792079 return templ_7745c5c3_Err
20802080 }
20812081 }
2082- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 191, "</span></a></nav><div id=\"mr-commits\" data-mr-panel hidden aria-labelledby=\"mr-tab-commits\"><table class=\"mr-commits\"><thead><tr><th>Commit</th><th>Subject</th><th>Author</th><th>Date</th><th>Signature</th><th>Status</th></tr></thead> <tbody>")
2082+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 191, "</span></a></nav><div id=\"mr-commits\" data-mr-panel role=\"region\" aria-labelledby=\"mr-tab-commits\"><table class=\"mr-commits\"><thead><tr><th>Commit</th><th>Subject</th><th>Author</th><th>Date</th><th>Signature</th><th>Status</th></tr></thead> <tbody>")
20832083 if templ_7745c5c3_Err != nil {
20842084 return templ_7745c5c3_Err
20852085 }
@@ -2236,7 +2236,7 @@ func mrFooter(p MRPage) templ.Component {
22362236 return templ_7745c5c3_Err
22372237 }
22382238 }
2239- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 210, "</div><div id=\"mr-checks-panel\" data-mr-panel hidden aria-labelledby=\"mr-tab-checks\">")
2239+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 210, "</div><div id=\"mr-checks-panel\" data-mr-panel role=\"region\" aria-labelledby=\"mr-tab-checks\">")
22402240 if templ_7745c5c3_Err != nil {
22412241 return templ_7745c5c3_Err
22422242 }
@@ -2079,7 +2079,7 @@ func mrFooter(p MRPage) templ.Component {
2079 return templ_7745c5c3_Err2079 return templ_7745c5c3_Err
2080 }2080 }
2081 }2081 }
2082- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 191, "</span></a></nav><div id=\"mr-commits\" data-mr-panel hidden aria-labelledby=\"mr-tab-commits\"><table class=\"mr-commits\"><thead><tr><th>Commit</th><th>Subject</th><th>Author</th><th>Date</th><th>Signature</th><th>Status</th></tr></thead> <tbody>")2082+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 191, "</span></a></nav><div id=\"mr-commits\" data-mr-panel role=\"region\" aria-labelledby=\"mr-tab-commits\"><table class=\"mr-commits\"><thead><tr><th>Commit</th><th>Subject</th><th>Author</th><th>Date</th><th>Signature</th><th>Status</th></tr></thead> <tbody>")
2083 if templ_7745c5c3_Err != nil {2083 if templ_7745c5c3_Err != nil {
2084 return templ_7745c5c3_Err2084 return templ_7745c5c3_Err
2085 }2085 }
@@ -2236,7 +2236,7 @@ func mrFooter(p MRPage) templ.Component {
2236 return templ_7745c5c3_Err2236 return templ_7745c5c3_Err
2237 }2237 }
2238 }2238 }
2239- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 210, "</div><div id=\"mr-checks-panel\" data-mr-panel hidden aria-labelledby=\"mr-tab-checks\">")2239+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 210, "</div><div id=\"mr-checks-panel\" data-mr-panel role=\"region\" aria-labelledby=\"mr-tab-checks\">")
2240 if templ_7745c5c3_Err != nil {2240 if templ_7745c5c3_Err != nil {
2241 return templ_7745c5c3_Err2241 return templ_7745c5c3_Err
2242 }2242 }
M internal/adapters/web/mr_test.go +5 -2
@@ -137,8 +137,11 @@ func TestMRPanelsAndMenus(t *testing.T) {
137137 assert.Contains(t, footer, `<footer class="mr-footer"><nav data-mr-tabs`)
138138 assert.NotContains(t, footer, `<details`)
139139 assert.NotContains(t, footer, `<summary`)
140- assert.Contains(t, footer, `id="mr-commits" data-mr-panel hidden`)
141- assert.Contains(t, footer, `id="mr-checks-panel" data-mr-panel hidden`)
140+ // No hidden attribute: the CSS shows the panel of the URL fragment until
141+ // mr.js takes over, so the tabs work as anchors without scripts.
142+ assert.Contains(t, footer, `id="mr-commits" data-mr-panel role="region"`)
143+ assert.Contains(t, footer, `id="mr-checks-panel" data-mr-panel role="region"`)
144+ assert.NotContains(t, footer, `data-mr-panel hidden`)
142145 assert.Contains(t, footer, `data-mr-tabs`)
143146 assert.Contains(t, footer, `id="mr-commits"`)
144147 assert.Contains(t, footer, `id="mr-checks"`)
@@ -137,8 +137,11 @@ func TestMRPanelsAndMenus(t *testing.T) {
137 assert.Contains(t, footer, `<footer class="mr-footer"><nav data-mr-tabs`)137 assert.Contains(t, footer, `<footer class="mr-footer"><nav data-mr-tabs`)
138 assert.NotContains(t, footer, `<details`)138 assert.NotContains(t, footer, `<details`)
139 assert.NotContains(t, footer, `<summary`)139 assert.NotContains(t, footer, `<summary`)
140- assert.Contains(t, footer, `id="mr-commits" data-mr-panel hidden`)140+ // No hidden attribute: the CSS shows the panel of the URL fragment until
141- assert.Contains(t, footer, `id="mr-checks-panel" data-mr-panel hidden`)141+ // mr.js takes over, so the tabs work as anchors without scripts.
142+ assert.Contains(t, footer, `id="mr-commits" data-mr-panel role="region"`)
143+ assert.Contains(t, footer, `id="mr-checks-panel" data-mr-panel role="region"`)
144+ assert.NotContains(t, footer, `data-mr-panel hidden`)
142 assert.Contains(t, footer, `data-mr-tabs`)145 assert.Contains(t, footer, `data-mr-tabs`)
143 assert.Contains(t, footer, `id="mr-commits"`)146 assert.Contains(t, footer, `id="mr-commits"`)
144 assert.Contains(t, footer, `id="mr-checks"`)147 assert.Contains(t, footer, `id="mr-checks"`)
M internal/adapters/web/static/css/60-mr.css +20 -9
@@ -9,18 +9,29 @@
99 }
1010
1111 .mr-footer {
12- border-top: 1px solid var(--border); padding: var(--s3) var(--s5);
13- /* a touch darker than the card body; the card's overflow clips the corners */
14- background: var(--bg-canvas);
12+ border-top: 1px solid var(--border); padding: var(--s2) var(--s4);
13+ /* the shade of the card header; the card's overflow clips the corners */
14+ background: var(--bg-raised);
1515 font-size: var(--fs-s);
16- & nav { display: flex; gap: var(--s3); padding-block: var(--s3); }
17- & [data-mr-tabs] a { padding: var(--s1) var(--s2); border-radius: var(--r-s); color: var(--ink-dim); cursor: pointer; }
18- & [data-mr-tabs] a:hover { background: var(--bg-raised); color: var(--ink-hi); }
19- & [data-mr-tabs] a[aria-expanded="true"] { background: var(--bg-strong); color: var(--ink-hi); }
16+ /* the tabs are buttons: a thin border, the count in a darker badge;
17+ the open one in the teal accent */
18+ & nav { display: flex; flex-wrap: wrap; gap: var(--s2); }
19+ & [data-mr-tabs] a {
20+ display: inline-flex; align-items: center; gap: var(--s1);
21+ padding: 2px var(--s2); border: 1px solid var(--border-strong); border-radius: var(--r-s);
22+ color: var(--ink-dim); text-decoration: none; cursor: pointer;
23+ }
24+ & [data-checks-label] { color: var(--ink-dim); font-size: var(--fs-xs); }
25+ & [data-mr-tabs] .count { margin-left: 0; color: var(--ink-hi); background: var(--bg-canvas); box-shadow: inset 0 0 0 1px var(--border); }
26+ & [data-mr-tabs] a:hover { background: var(--bg-strong); color: var(--ink-hi); }
27+ & [data-mr-tabs] a[aria-expanded="true"] { border-color: var(--teal); color: var(--teal); background: var(--bg-surface); }
28+ & [data-mr-panel] { padding-block-start: var(--s2); }
2029 & [data-mr-panel] { overflow-x: auto; }
2130 & [hidden] { display: none; }
22- /* Without scripts the panels are shown by the URL fragment alone. */
23- html:not(.js) & [data-mr-panel][hidden]:is(:target, :has(:target)) { display: block; }
31+ /* Until mr.js drives the panels (data-panels, then [hidden]), only the
32+ panel of the URL fragment shows: the tabs work as anchors without
33+ scripts, and no panel flashes open before the script runs. */
34+ &:not([data-panels]) [data-mr-panel]:not(:target, :has(:target)) { display: none; }
2435 & section h2 { font-size: var(--fs-s); color: var(--ink-dim); }
2536 & table { width: 100%; border-collapse: collapse; }
2637 & th, & td { text-align: left; padding: var(--s2); border-bottom: 1px solid var(--border); }
@@ -9,18 +9,29 @@
9 }9 }
10 10
11 .mr-footer {11 .mr-footer {
12- border-top: 1px solid var(--border); padding: var(--s3) var(--s5);12+ border-top: 1px solid var(--border); padding: var(--s2) var(--s4);
13- /* a touch darker than the card body; the card's overflow clips the corners */13+ /* the shade of the card header; the card's overflow clips the corners */
14- background: var(--bg-canvas);14+ background: var(--bg-raised);
15 font-size: var(--fs-s);15 font-size: var(--fs-s);
16- & nav { display: flex; gap: var(--s3); padding-block: var(--s3); }16+ /* the tabs are buttons: a thin border, the count in a darker badge;
17- & [data-mr-tabs] a { padding: var(--s1) var(--s2); border-radius: var(--r-s); color: var(--ink-dim); cursor: pointer; }17+ the open one in the teal accent */
18- & [data-mr-tabs] a:hover { background: var(--bg-raised); color: var(--ink-hi); }18+ & nav { display: flex; flex-wrap: wrap; gap: var(--s2); }
19- & [data-mr-tabs] a[aria-expanded="true"] { background: var(--bg-strong); color: var(--ink-hi); }19+ & [data-mr-tabs] a {
20+ display: inline-flex; align-items: center; gap: var(--s1);
21+ padding: 2px var(--s2); border: 1px solid var(--border-strong); border-radius: var(--r-s);
22+ color: var(--ink-dim); text-decoration: none; cursor: pointer;
23+ }
24+ & [data-checks-label] { color: var(--ink-dim); font-size: var(--fs-xs); }
25+ & [data-mr-tabs] .count { margin-left: 0; color: var(--ink-hi); background: var(--bg-canvas); box-shadow: inset 0 0 0 1px var(--border); }
26+ & [data-mr-tabs] a:hover { background: var(--bg-strong); color: var(--ink-hi); }
27+ & [data-mr-tabs] a[aria-expanded="true"] { border-color: var(--teal); color: var(--teal); background: var(--bg-surface); }
28+ & [data-mr-panel] { padding-block-start: var(--s2); }
20 & [data-mr-panel] { overflow-x: auto; }29 & [data-mr-panel] { overflow-x: auto; }
21 & [hidden] { display: none; }30 & [hidden] { display: none; }
22- /* Without scripts the panels are shown by the URL fragment alone. */31+ /* Until mr.js drives the panels (data-panels, then [hidden]), only the
23- html:not(.js) & [data-mr-panel][hidden]:is(:target, :has(:target)) { display: block; }32+ panel of the URL fragment shows: the tabs work as anchors without
33+ scripts, and no panel flashes open before the script runs. */
34+ &:not([data-panels]) [data-mr-panel]:not(:target, :has(:target)) { display: none; }
24 & section h2 { font-size: var(--fs-s); color: var(--ink-dim); }35 & section h2 { font-size: var(--fs-s); color: var(--ink-dim); }
25 & table { width: 100%; border-collapse: collapse; }36 & table { width: 100%; border-collapse: collapse; }
26 & th, & td { text-align: left; padding: var(--s2); border-bottom: 1px solid var(--border); }37 & th, & td { text-align: left; padding: var(--s2); border-bottom: 1px solid var(--border); }
M internal/adapters/web/static/js/mr.js +7 -2
@@ -349,10 +349,13 @@ window.ermiteMR = (() => {
349349 e.preventDefault(); tabs[next].focus();
350350 });
351351 });
352+ // hash opens the panel of the URL fragment. Its target was hidden when the
353+ // browser scrolled, so the panel is brought in; never on a refresh, which
354+ // keeps the scroll.
352355 function hash() {
353356 const h = location.hash;
354357 const index = h === '#mr-checks' || h === '#mr-checks-panel' ? 1 : h === '#mr-commits' ? 0 : -1;
355- if (index >= 0) show(index);
358+ if (index >= 0) { show(index); panels[index].scrollIntoView?.({ block: 'nearest' }); }
356359 }
357360 // The checks badge always opens Checks; it never folds it.
358361 const checksClick = (e) => {
@@ -375,7 +378,9 @@ window.ermiteMR = (() => {
375378 observer.observe(summary.parentElement, { childList: true });
376379 sync();
377380 }
378- show(initial); hash();
381+ show(initial);
382+ footer.setAttribute('data-panels', '');
383+ if (initial < 0) hash();
379384 addEventListener('hashchange', hash);
380385 disposePanels = () => {
381386 document.removeEventListener?.('click', checksClick);
@@ -349,10 +349,13 @@ window.ermiteMR = (() => {
349 e.preventDefault(); tabs[next].focus();349 e.preventDefault(); tabs[next].focus();
350 });350 });
351 });351 });
352+ // hash opens the panel of the URL fragment. Its target was hidden when the
353+ // browser scrolled, so the panel is brought in; never on a refresh, which
354+ // keeps the scroll.
352 function hash() {355 function hash() {
353 const h = location.hash;356 const h = location.hash;
354 const index = h === '#mr-checks' || h === '#mr-checks-panel' ? 1 : h === '#mr-commits' ? 0 : -1;357 const index = h === '#mr-checks' || h === '#mr-checks-panel' ? 1 : h === '#mr-commits' ? 0 : -1;
355- if (index >= 0) show(index);358+ if (index >= 0) { show(index); panels[index].scrollIntoView?.({ block: 'nearest' }); }
356 }359 }
357 // The checks badge always opens Checks; it never folds it.360 // The checks badge always opens Checks; it never folds it.
358 const checksClick = (e) => {361 const checksClick = (e) => {
@@ -375,7 +378,9 @@ window.ermiteMR = (() => {
375 observer.observe(summary.parentElement, { childList: true });378 observer.observe(summary.parentElement, { childList: true });
376 sync();379 sync();
377 }380 }
378- show(initial); hash();381+ show(initial);
382+ footer.setAttribute('data-panels', '');
383+ if (initial < 0) hash();
379 addEventListener('hashchange', hash);384 addEventListener('hashchange', hash);
380 disposePanels = () => {385 disposePanels = () => {
381 document.removeEventListener?.('click', checksClick);386 document.removeEventListener?.('click', checksClick);

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 44

↑↓ moveenter openesc close