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
@@ -137,245 +137,314 @@ func IssueForm(l Layout, p IssueFormPage) templ.Component {
137137 if templ_7745c5c3_Err != nil {
138138 return templ_7745c5c3_Err
139139 }
140- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\"> <input type=\"hidden\" name=\"solution\" value=\"\"> <label for=\"issue-title\">title</label> <input id=\"issue-title\" name=\"title\" type=\"text\" required autocomplete=\"off\" value=\"")
140+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\"> <input type=\"hidden\" name=\"solution\" value=\"\"> <label for=\"issue-title\">title</label> <input id=\"issue-title\" aria-describedby=\"")
141141 if templ_7745c5c3_Err != nil {
142142 return templ_7745c5c3_Err
143143 }
144144 var templ_7745c5c3_Var10 string
145- templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Title)
145+ templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(issueFieldDescription("title", p.ErrorField))
146146 if templ_7745c5c3_Err != nil {
147- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 32, Col: 95}
147+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 34, Col: 67}
148148 }
149149 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
150150 if templ_7745c5c3_Err != nil {
151151 return templ_7745c5c3_Err
152152 }
153- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"> <output data-for=\"issue-title\" data-max=\"")
153+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"")
154+ if templ_7745c5c3_Err != nil {
155+ return templ_7745c5c3_Err
156+ }
157+ if p.ErrorField == "title" {
158+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " aria-invalid=\"true\"")
159+ if templ_7745c5c3_Err != nil {
160+ return templ_7745c5c3_Err
161+ }
162+ }
163+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, " name=\"title\" type=\"text\" required autocomplete=\"off\" value=\"")
154164 if templ_7745c5c3_Err != nil {
155165 return templ_7745c5c3_Err
156166 }
157167 var templ_7745c5c3_Var11 string
158- templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(p.MaxTitle))
168+ templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Title)
159169 if templ_7745c5c3_Err != nil {
160- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 33, Col: 61}
170+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 42, Col: 19}
161171 }
162172 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
163173 if templ_7745c5c3_Err != nil {
164174 return templ_7745c5c3_Err
165175 }
166- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" for=\"issue-title\">")
176+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\"> <output id=\"issue-title-count\" aria-live=\"off\" data-for=\"issue-title\" data-max=\"")
167177 if templ_7745c5c3_Err != nil {
168178 return templ_7745c5c3_Err
169179 }
170180 var templ_7745c5c3_Var12 string
171- templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(runeCount(p.Title)))
181+ templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(p.MaxTitle))
172182 if templ_7745c5c3_Err != nil {
173- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 33, Col: 108}
183+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 44, Col: 100}
174184 }
175- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
185+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
176186 if templ_7745c5c3_Err != nil {
177187 return templ_7745c5c3_Err
178188 }
179- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, " / ")
189+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" for=\"issue-title\">")
180190 if templ_7745c5c3_Err != nil {
181191 return templ_7745c5c3_Err
182192 }
183193 var templ_7745c5c3_Var13 string
184- templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.MaxTitle))
194+ templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(runeCount(p.Title)))
185195 if templ_7745c5c3_Err != nil {
186- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 33, Col: 131}
196+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 44, Col: 147}
187197 }
188198 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
189199 if templ_7745c5c3_Err != nil {
190200 return templ_7745c5c3_Err
191201 }
192- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</output> ")
202+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, " / ")
203+ if templ_7745c5c3_Err != nil {
204+ return templ_7745c5c3_Err
205+ }
206+ var templ_7745c5c3_Var14 string
207+ templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.MaxTitle))
208+ if templ_7745c5c3_Err != nil {
209+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 44, Col: 170}
210+ }
211+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
212+ if templ_7745c5c3_Err != nil {
213+ return templ_7745c5c3_Err
214+ }
215+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</output> ")
193216 if templ_7745c5c3_Err != nil {
194217 return templ_7745c5c3_Err
195218 }
196219 if p.ErrorField == "title" {
197- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<p class=\"f-error\" role=\"alert\">")
220+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<p id=\"issue-title-error\" class=\"f-error\" role=\"alert\">")
198221 if templ_7745c5c3_Err != nil {
199222 return templ_7745c5c3_Err
200223 }
201- var templ_7745c5c3_Var14 string
202- templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(p.Error)
224+ var templ_7745c5c3_Var15 string
225+ templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(p.Error)
203226 if templ_7745c5c3_Err != nil {
204- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 35, Col: 45}
227+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 46, Col: 68}
205228 }
206- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
229+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
207230 if templ_7745c5c3_Err != nil {
208231 return templ_7745c5c3_Err
209232 }
210- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</p>")
233+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</p>")
211234 if templ_7745c5c3_Err != nil {
212235 return templ_7745c5c3_Err
213236 }
214237 }
215- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<label for=\"issue-body\">description</label> <textarea id=\"issue-body\" name=\"body\" rows=\"8\" placeholder=\"Markdown, optional\">")
238+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<label for=\"issue-body\">description</label> <textarea id=\"issue-body\" aria-describedby=\"")
216239 if templ_7745c5c3_Err != nil {
217240 return templ_7745c5c3_Err
218241 }
219- var templ_7745c5c3_Var15 string
220- templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(p.Body)
242+ var templ_7745c5c3_Var16 string
243+ templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(issueFieldDescription("body", p.ErrorField))
244+ if templ_7745c5c3_Err != nil {
245+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 51, Col: 66}
246+ }
247+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
221248 if templ_7745c5c3_Err != nil {
222- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 38, Col: 91}
249+ return templ_7745c5c3_Err
223250 }
224- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
251+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\"")
225252 if templ_7745c5c3_Err != nil {
226253 return templ_7745c5c3_Err
227254 }
228- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</textarea> <output data-for=\"issue-body\" data-max=\"")
255+ if p.ErrorField == "body" {
256+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, " aria-invalid=\"true\"")
257+ if templ_7745c5c3_Err != nil {
258+ return templ_7745c5c3_Err
259+ }
260+ }
261+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, " name=\"body\" rows=\"8\" placeholder=\"Markdown, optional\">")
229262 if templ_7745c5c3_Err != nil {
230263 return templ_7745c5c3_Err
231264 }
232- var templ_7745c5c3_Var16 string
233- templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(p.MaxBody))
265+ var templ_7745c5c3_Var17 string
266+ templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(p.Body)
234267 if templ_7745c5c3_Err != nil {
235- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 39, Col: 59}
268+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 58, Col: 12}
236269 }
237- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
270+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
238271 if templ_7745c5c3_Err != nil {
239272 return templ_7745c5c3_Err
240273 }
241- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" for=\"issue-body\">")
274+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</textarea> <output id=\"issue-body-count\" aria-live=\"off\" data-for=\"issue-body\" data-max=\"")
242275 if templ_7745c5c3_Err != nil {
243276 return templ_7745c5c3_Err
244277 }
245- var templ_7745c5c3_Var17 string
246- templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(runeCount(p.Body)))
278+ var templ_7745c5c3_Var18 string
279+ templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(p.MaxBody))
247280 if templ_7745c5c3_Err != nil {
248- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 39, Col: 104}
281+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 59, Col: 97}
249282 }
250- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
283+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
251284 if templ_7745c5c3_Err != nil {
252285 return templ_7745c5c3_Err
253286 }
254- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, " / ")
287+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" for=\"issue-body\">")
255288 if templ_7745c5c3_Err != nil {
256289 return templ_7745c5c3_Err
257290 }
258- var templ_7745c5c3_Var18 string
259- templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.MaxBody))
291+ var templ_7745c5c3_Var19 string
292+ templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(runeCount(p.Body)))
260293 if templ_7745c5c3_Err != nil {
261- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 39, Col: 126}
294+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 59, Col: 142}
262295 }
263- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
296+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
264297 if templ_7745c5c3_Err != nil {
265298 return templ_7745c5c3_Err
266299 }
267- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</output> ")
300+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " / ")
301+ if templ_7745c5c3_Err != nil {
302+ return templ_7745c5c3_Err
303+ }
304+ var templ_7745c5c3_Var20 string
305+ templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.MaxBody))
306+ if templ_7745c5c3_Err != nil {
307+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 59, Col: 164}
308+ }
309+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
310+ if templ_7745c5c3_Err != nil {
311+ return templ_7745c5c3_Err
312+ }
313+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</output> ")
268314 if templ_7745c5c3_Err != nil {
269315 return templ_7745c5c3_Err
270316 }
271317 if p.ErrorField == "body" {
272- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<p class=\"f-error\" role=\"alert\">")
318+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<p id=\"issue-body-error\" class=\"f-error\" role=\"alert\">")
273319 if templ_7745c5c3_Err != nil {
274320 return templ_7745c5c3_Err
275321 }
276- var templ_7745c5c3_Var19 string
277- templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(p.Error)
322+ var templ_7745c5c3_Var21 string
323+ templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(p.Error)
278324 if templ_7745c5c3_Err != nil {
279- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 41, Col: 45}
325+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 61, Col: 67}
280326 }
281- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
327+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
282328 if templ_7745c5c3_Err != nil {
283329 return templ_7745c5c3_Err
284330 }
285- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</p>")
331+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</p>")
286332 if templ_7745c5c3_Err != nil {
287333 return templ_7745c5c3_Err
288334 }
289335 }
290- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<label for=\"issue-author\">name (optional, not verified)</label> <input id=\"issue-author\" name=\"author\" type=\"text\" autocomplete=\"off\" value=\"")
336+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<label for=\"issue-author\">name (optional, not verified)</label> <input id=\"issue-author\" aria-describedby=\"")
291337 if templ_7745c5c3_Err != nil {
292338 return templ_7745c5c3_Err
293339 }
294- var templ_7745c5c3_Var20 string
295- templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Author)
340+ var templ_7745c5c3_Var22 string
341+ templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(issueFieldDescription("author", p.ErrorField))
342+ if templ_7745c5c3_Err != nil {
343+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 66, Col: 68}
344+ }
345+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
346+ if templ_7745c5c3_Err != nil {
347+ return templ_7745c5c3_Err
348+ }
349+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\"")
350+ if templ_7745c5c3_Err != nil {
351+ return templ_7745c5c3_Err
352+ }
353+ if p.ErrorField == "author" {
354+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " aria-invalid=\"true\"")
355+ if templ_7745c5c3_Err != nil {
356+ return templ_7745c5c3_Err
357+ }
358+ }
359+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, " name=\"author\" type=\"text\" autocomplete=\"off\" value=\"")
296360 if templ_7745c5c3_Err != nil {
297- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 44, Col: 89}
361+ return templ_7745c5c3_Err
298362 }
299- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
363+ var templ_7745c5c3_Var23 string
364+ templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Author)
365+ if templ_7745c5c3_Err != nil {
366+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 73, Col: 20}
367+ }
368+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
300369 if templ_7745c5c3_Err != nil {
301370 return templ_7745c5c3_Err
302371 }
303- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\"> <output data-for=\"issue-author\" data-max=\"")
372+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\"> <output id=\"issue-author-count\" aria-live=\"off\" data-for=\"issue-author\" data-max=\"")
304373 if templ_7745c5c3_Err != nil {
305374 return templ_7745c5c3_Err
306375 }
307- var templ_7745c5c3_Var21 string
308- templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(p.MaxAuthor))
376+ var templ_7745c5c3_Var24 string
377+ templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(p.MaxAuthor))
309378 if templ_7745c5c3_Err != nil {
310- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 45, Col: 63}
379+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 75, Col: 103}
311380 }
312- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
381+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
313382 if templ_7745c5c3_Err != nil {
314383 return templ_7745c5c3_Err
315384 }
316- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" for=\"issue-author\">")
385+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" for=\"issue-author\">")
317386 if templ_7745c5c3_Err != nil {
318387 return templ_7745c5c3_Err
319388 }
320- var templ_7745c5c3_Var22 string
321- templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(runeCount(p.Author)))
389+ var templ_7745c5c3_Var25 string
390+ templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(runeCount(p.Author)))
322391 if templ_7745c5c3_Err != nil {
323- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 45, Col: 112}
392+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 75, Col: 152}
324393 }
325- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
394+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
326395 if templ_7745c5c3_Err != nil {
327396 return templ_7745c5c3_Err
328397 }
329- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, " / ")
398+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " / ")
330399 if templ_7745c5c3_Err != nil {
331400 return templ_7745c5c3_Err
332401 }
333- var templ_7745c5c3_Var23 string
334- templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.MaxAuthor))
402+ var templ_7745c5c3_Var26 string
403+ templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.MaxAuthor))
335404 if templ_7745c5c3_Err != nil {
336- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 45, Col: 136}
405+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 75, Col: 176}
337406 }
338- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
407+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
339408 if templ_7745c5c3_Err != nil {
340409 return templ_7745c5c3_Err
341410 }
342- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</output> ")
411+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</output> ")
343412 if templ_7745c5c3_Err != nil {
344413 return templ_7745c5c3_Err
345414 }
346415 if p.ErrorField == "author" {
347- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<p class=\"f-error\" role=\"alert\">")
416+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<p id=\"issue-author-error\" class=\"f-error\" role=\"alert\">")
348417 if templ_7745c5c3_Err != nil {
349418 return templ_7745c5c3_Err
350419 }
351- var templ_7745c5c3_Var24 string
352- templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(p.Error)
420+ var templ_7745c5c3_Var27 string
421+ templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(p.Error)
353422 if templ_7745c5c3_Err != nil {
354- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 47, Col: 45}
423+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 77, Col: 69}
355424 }
356- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
425+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
357426 if templ_7745c5c3_Err != nil {
358427 return templ_7745c5c3_Err
359428 }
360- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</p>")
429+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</p>")
361430 if templ_7745c5c3_Err != nil {
362431 return templ_7745c5c3_Err
363432 }
364433 }
365- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<div class=\"hp\" aria-hidden=\"true\"><label for=\"issue-website\">Leave this field empty</label> <input id=\"issue-website\" name=\"website\" type=\"text\" tabindex=\"-1\" autocomplete=\"off\" value=\"\"></div><p class=\"dim\" data-status role=\"status\" aria-live=\"polite\">Checking your browser… (this takes a few seconds)</p><div class=\"f-btns\"><button type=\"submit\" class=\"btn primary\" disabled>Submit for moderation</button> <a class=\"btn\" href=\"")
434+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<div class=\"hp\" aria-hidden=\"true\"><label for=\"issue-website\">Leave this field empty</label> <input id=\"issue-website\" name=\"website\" type=\"text\" tabindex=\"-1\" autocomplete=\"off\" value=\"\"></div><p class=\"dim\" data-status role=\"status\" aria-live=\"polite\">Checking your browser… (this takes a few seconds)</p><div class=\"f-btns\"><button type=\"submit\" class=\"btn primary\" disabled>Submit for moderation</button> <a class=\"btn\" href=\"")
366435 if templ_7745c5c3_Err != nil {
367436 return templ_7745c5c3_Err
368437 }
369- var templ_7745c5c3_Var25 templ.SafeURL
370- templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Cancel))
438+ var templ_7745c5c3_Var28 templ.SafeURL
439+ templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Cancel))
371440 if templ_7745c5c3_Err != nil {
372- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 56, Col: 49}
441+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 86, Col: 49}
373442 }
374- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
443+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
375444 if templ_7745c5c3_Err != nil {
376445 return templ_7745c5c3_Err
377446 }
378- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\">Cancel</a></div></form>")
447+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\">Cancel</a></div></form>")
379448 if templ_7745c5c3_Err != nil {
380449 return templ_7745c5c3_Err
381450 }
@@ -405,12 +474,12 @@ func issueFormRail() templ.Component {
405474 }()
406475 }
407476 ctx = templ.InitializeContext(ctx)
408- templ_7745c5c3_Var26 := templ.GetChildren(ctx)
409- if templ_7745c5c3_Var26 == nil {
410- templ_7745c5c3_Var26 = templ.NopComponent
477+ templ_7745c5c3_Var29 := templ.GetChildren(ctx)
478+ if templ_7745c5c3_Var29 == nil {
479+ templ_7745c5c3_Var29 = templ.NopComponent
411480 }
412481 ctx = templ.ClearChildren(ctx)
413- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<section aria-labelledby=\"r-about\"><h2 id=\"r-about\">Before you write</h2><p class=\"dim\">The owner reads every issue before it appears. Plain text and Markdown only; no address of yours is kept.</p></section>")
482+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<section aria-labelledby=\"r-about\"><h2 id=\"r-about\">Before you write</h2><p class=\"dim\">The owner reads every issue before it appears. Plain text and Markdown only; no address of yours is kept.</p></section>")
414483 if templ_7745c5c3_Err != nil {
415484 return templ_7745c5c3_Err
416485 }
@@ -418,4 +487,12 @@ func issueFormRail() templ.Component {
418487 })
419488 }
420489
490+func issueFieldDescription(field, invalid string) string {
491+ ids := "issue-" + field + "-count"
492+ if field == invalid {
493+ ids += " issue-" + field + "-error"
494+ }
495+ return ids
496+}
497+
421498 var _ = templruntime.GeneratedTemplate
@@ -137,245 +137,314 @@ func IssueForm(l Layout, p IssueFormPage) templ.Component {
137 if templ_7745c5c3_Err != nil {137 if templ_7745c5c3_Err != nil {
138 return templ_7745c5c3_Err138 return templ_7745c5c3_Err
139 }139 }
140- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\"> <input type=\"hidden\" name=\"solution\" value=\"\"> <label for=\"issue-title\">title</label> <input id=\"issue-title\" name=\"title\" type=\"text\" required autocomplete=\"off\" value=\"")140+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\"> <input type=\"hidden\" name=\"solution\" value=\"\"> <label for=\"issue-title\">title</label> <input id=\"issue-title\" aria-describedby=\"")
141 if templ_7745c5c3_Err != nil {141 if templ_7745c5c3_Err != nil {
142 return templ_7745c5c3_Err142 return templ_7745c5c3_Err
143 }143 }
144 var templ_7745c5c3_Var10 string144 var templ_7745c5c3_Var10 string
145- templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Title)145+ templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(issueFieldDescription("title", p.ErrorField))
146 if templ_7745c5c3_Err != nil {146 if templ_7745c5c3_Err != nil {
147- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 32, Col: 95}147+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 34, Col: 67}
148 }148 }
149 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)149 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
150 if templ_7745c5c3_Err != nil {150 if templ_7745c5c3_Err != nil {
151 return templ_7745c5c3_Err151 return templ_7745c5c3_Err
152 }152 }
153- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"> <output data-for=\"issue-title\" data-max=\"")153+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"")
154+ if templ_7745c5c3_Err != nil {
155+ return templ_7745c5c3_Err
156+ }
157+ if p.ErrorField == "title" {
158+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " aria-invalid=\"true\"")
159+ if templ_7745c5c3_Err != nil {
160+ return templ_7745c5c3_Err
161+ }
162+ }
163+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, " name=\"title\" type=\"text\" required autocomplete=\"off\" value=\"")
154 if templ_7745c5c3_Err != nil {164 if templ_7745c5c3_Err != nil {
155 return templ_7745c5c3_Err165 return templ_7745c5c3_Err
156 }166 }
157 var templ_7745c5c3_Var11 string167 var templ_7745c5c3_Var11 string
158- templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(p.MaxTitle))168+ templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Title)
159 if templ_7745c5c3_Err != nil {169 if templ_7745c5c3_Err != nil {
160- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 33, Col: 61}170+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 42, Col: 19}
161 }171 }
162 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)172 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
163 if templ_7745c5c3_Err != nil {173 if templ_7745c5c3_Err != nil {
164 return templ_7745c5c3_Err174 return templ_7745c5c3_Err
165 }175 }
166- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" for=\"issue-title\">")176+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\"> <output id=\"issue-title-count\" aria-live=\"off\" data-for=\"issue-title\" data-max=\"")
167 if templ_7745c5c3_Err != nil {177 if templ_7745c5c3_Err != nil {
168 return templ_7745c5c3_Err178 return templ_7745c5c3_Err
169 }179 }
170 var templ_7745c5c3_Var12 string180 var templ_7745c5c3_Var12 string
171- templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(runeCount(p.Title)))181+ templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(p.MaxTitle))
172 if templ_7745c5c3_Err != nil {182 if templ_7745c5c3_Err != nil {
173- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 33, Col: 108}183+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 44, Col: 100}
174 }184 }
175- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))185+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
176 if templ_7745c5c3_Err != nil {186 if templ_7745c5c3_Err != nil {
177 return templ_7745c5c3_Err187 return templ_7745c5c3_Err
178 }188 }
179- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, " / ")189+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" for=\"issue-title\">")
180 if templ_7745c5c3_Err != nil {190 if templ_7745c5c3_Err != nil {
181 return templ_7745c5c3_Err191 return templ_7745c5c3_Err
182 }192 }
183 var templ_7745c5c3_Var13 string193 var templ_7745c5c3_Var13 string
184- templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.MaxTitle))194+ templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(runeCount(p.Title)))
185 if templ_7745c5c3_Err != nil {195 if templ_7745c5c3_Err != nil {
186- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 33, Col: 131}196+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 44, Col: 147}
187 }197 }
188 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))198 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
189 if templ_7745c5c3_Err != nil {199 if templ_7745c5c3_Err != nil {
190 return templ_7745c5c3_Err200 return templ_7745c5c3_Err
191 }201 }
192- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</output> ")202+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, " / ")
203+ if templ_7745c5c3_Err != nil {
204+ return templ_7745c5c3_Err
205+ }
206+ var templ_7745c5c3_Var14 string
207+ templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.MaxTitle))
208+ if templ_7745c5c3_Err != nil {
209+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 44, Col: 170}
210+ }
211+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
212+ if templ_7745c5c3_Err != nil {
213+ return templ_7745c5c3_Err
214+ }
215+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</output> ")
193 if templ_7745c5c3_Err != nil {216 if templ_7745c5c3_Err != nil {
194 return templ_7745c5c3_Err217 return templ_7745c5c3_Err
195 }218 }
196 if p.ErrorField == "title" {219 if p.ErrorField == "title" {
197- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<p class=\"f-error\" role=\"alert\">")220+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<p id=\"issue-title-error\" class=\"f-error\" role=\"alert\">")
198 if templ_7745c5c3_Err != nil {221 if templ_7745c5c3_Err != nil {
199 return templ_7745c5c3_Err222 return templ_7745c5c3_Err
200 }223 }
201- var templ_7745c5c3_Var14 string224+ var templ_7745c5c3_Var15 string
202- templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(p.Error)225+ templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(p.Error)
203 if templ_7745c5c3_Err != nil {226 if templ_7745c5c3_Err != nil {
204- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 35, Col: 45}227+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 46, Col: 68}
205 }228 }
206- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))229+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
207 if templ_7745c5c3_Err != nil {230 if templ_7745c5c3_Err != nil {
208 return templ_7745c5c3_Err231 return templ_7745c5c3_Err
209 }232 }
210- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</p>")233+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</p>")
211 if templ_7745c5c3_Err != nil {234 if templ_7745c5c3_Err != nil {
212 return templ_7745c5c3_Err235 return templ_7745c5c3_Err
213 }236 }
214 }237 }
215- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<label for=\"issue-body\">description</label> <textarea id=\"issue-body\" name=\"body\" rows=\"8\" placeholder=\"Markdown, optional\">")238+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<label for=\"issue-body\">description</label> <textarea id=\"issue-body\" aria-describedby=\"")
216 if templ_7745c5c3_Err != nil {239 if templ_7745c5c3_Err != nil {
217 return templ_7745c5c3_Err240 return templ_7745c5c3_Err
218 }241 }
219- var templ_7745c5c3_Var15 string242+ var templ_7745c5c3_Var16 string
220- templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(p.Body)243+ templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(issueFieldDescription("body", p.ErrorField))
244+ if templ_7745c5c3_Err != nil {
245+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 51, Col: 66}
246+ }
247+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
221 if templ_7745c5c3_Err != nil {248 if templ_7745c5c3_Err != nil {
222- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 38, Col: 91}249+ return templ_7745c5c3_Err
223 }250 }
224- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))251+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\"")
225 if templ_7745c5c3_Err != nil {252 if templ_7745c5c3_Err != nil {
226 return templ_7745c5c3_Err253 return templ_7745c5c3_Err
227 }254 }
228- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</textarea> <output data-for=\"issue-body\" data-max=\"")255+ if p.ErrorField == "body" {
256+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, " aria-invalid=\"true\"")
257+ if templ_7745c5c3_Err != nil {
258+ return templ_7745c5c3_Err
259+ }
260+ }
261+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, " name=\"body\" rows=\"8\" placeholder=\"Markdown, optional\">")
229 if templ_7745c5c3_Err != nil {262 if templ_7745c5c3_Err != nil {
230 return templ_7745c5c3_Err263 return templ_7745c5c3_Err
231 }264 }
232- var templ_7745c5c3_Var16 string265+ var templ_7745c5c3_Var17 string
233- templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(p.MaxBody))266+ templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(p.Body)
234 if templ_7745c5c3_Err != nil {267 if templ_7745c5c3_Err != nil {
235- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 39, Col: 59}268+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 58, Col: 12}
236 }269 }
237- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)270+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
238 if templ_7745c5c3_Err != nil {271 if templ_7745c5c3_Err != nil {
239 return templ_7745c5c3_Err272 return templ_7745c5c3_Err
240 }273 }
241- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" for=\"issue-body\">")274+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</textarea> <output id=\"issue-body-count\" aria-live=\"off\" data-for=\"issue-body\" data-max=\"")
242 if templ_7745c5c3_Err != nil {275 if templ_7745c5c3_Err != nil {
243 return templ_7745c5c3_Err276 return templ_7745c5c3_Err
244 }277 }
245- var templ_7745c5c3_Var17 string278+ var templ_7745c5c3_Var18 string
246- templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(runeCount(p.Body)))279+ templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(p.MaxBody))
247 if templ_7745c5c3_Err != nil {280 if templ_7745c5c3_Err != nil {
248- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 39, Col: 104}281+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 59, Col: 97}
249 }282 }
250- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))283+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
251 if templ_7745c5c3_Err != nil {284 if templ_7745c5c3_Err != nil {
252 return templ_7745c5c3_Err285 return templ_7745c5c3_Err
253 }286 }
254- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, " / ")287+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" for=\"issue-body\">")
255 if templ_7745c5c3_Err != nil {288 if templ_7745c5c3_Err != nil {
256 return templ_7745c5c3_Err289 return templ_7745c5c3_Err
257 }290 }
258- var templ_7745c5c3_Var18 string291+ var templ_7745c5c3_Var19 string
259- templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.MaxBody))292+ templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(runeCount(p.Body)))
260 if templ_7745c5c3_Err != nil {293 if templ_7745c5c3_Err != nil {
261- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 39, Col: 126}294+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 59, Col: 142}
262 }295 }
263- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))296+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
264 if templ_7745c5c3_Err != nil {297 if templ_7745c5c3_Err != nil {
265 return templ_7745c5c3_Err298 return templ_7745c5c3_Err
266 }299 }
267- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</output> ")300+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " / ")
301+ if templ_7745c5c3_Err != nil {
302+ return templ_7745c5c3_Err
303+ }
304+ var templ_7745c5c3_Var20 string
305+ templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.MaxBody))
306+ if templ_7745c5c3_Err != nil {
307+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 59, Col: 164}
308+ }
309+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
310+ if templ_7745c5c3_Err != nil {
311+ return templ_7745c5c3_Err
312+ }
313+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</output> ")
268 if templ_7745c5c3_Err != nil {314 if templ_7745c5c3_Err != nil {
269 return templ_7745c5c3_Err315 return templ_7745c5c3_Err
270 }316 }
271 if p.ErrorField == "body" {317 if p.ErrorField == "body" {
272- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<p class=\"f-error\" role=\"alert\">")318+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<p id=\"issue-body-error\" class=\"f-error\" role=\"alert\">")
273 if templ_7745c5c3_Err != nil {319 if templ_7745c5c3_Err != nil {
274 return templ_7745c5c3_Err320 return templ_7745c5c3_Err
275 }321 }
276- var templ_7745c5c3_Var19 string322+ var templ_7745c5c3_Var21 string
277- templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(p.Error)323+ templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(p.Error)
278 if templ_7745c5c3_Err != nil {324 if templ_7745c5c3_Err != nil {
279- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 41, Col: 45}325+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 61, Col: 67}
280 }326 }
281- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))327+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
282 if templ_7745c5c3_Err != nil {328 if templ_7745c5c3_Err != nil {
283 return templ_7745c5c3_Err329 return templ_7745c5c3_Err
284 }330 }
285- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</p>")331+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</p>")
286 if templ_7745c5c3_Err != nil {332 if templ_7745c5c3_Err != nil {
287 return templ_7745c5c3_Err333 return templ_7745c5c3_Err
288 }334 }
289 }335 }
290- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<label for=\"issue-author\">name (optional, not verified)</label> <input id=\"issue-author\" name=\"author\" type=\"text\" autocomplete=\"off\" value=\"")336+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<label for=\"issue-author\">name (optional, not verified)</label> <input id=\"issue-author\" aria-describedby=\"")
291 if templ_7745c5c3_Err != nil {337 if templ_7745c5c3_Err != nil {
292 return templ_7745c5c3_Err338 return templ_7745c5c3_Err
293 }339 }
294- var templ_7745c5c3_Var20 string340+ var templ_7745c5c3_Var22 string
295- templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Author)341+ templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(issueFieldDescription("author", p.ErrorField))
342+ if templ_7745c5c3_Err != nil {
343+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 66, Col: 68}
344+ }
345+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
346+ if templ_7745c5c3_Err != nil {
347+ return templ_7745c5c3_Err
348+ }
349+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\"")
350+ if templ_7745c5c3_Err != nil {
351+ return templ_7745c5c3_Err
352+ }
353+ if p.ErrorField == "author" {
354+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " aria-invalid=\"true\"")
355+ if templ_7745c5c3_Err != nil {
356+ return templ_7745c5c3_Err
357+ }
358+ }
359+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, " name=\"author\" type=\"text\" autocomplete=\"off\" value=\"")
296 if templ_7745c5c3_Err != nil {360 if templ_7745c5c3_Err != nil {
297- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 44, Col: 89}361+ return templ_7745c5c3_Err
298 }362 }
299- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)363+ var templ_7745c5c3_Var23 string
364+ templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Author)
365+ if templ_7745c5c3_Err != nil {
366+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 73, Col: 20}
367+ }
368+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
300 if templ_7745c5c3_Err != nil {369 if templ_7745c5c3_Err != nil {
301 return templ_7745c5c3_Err370 return templ_7745c5c3_Err
302 }371 }
303- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\"> <output data-for=\"issue-author\" data-max=\"")372+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\"> <output id=\"issue-author-count\" aria-live=\"off\" data-for=\"issue-author\" data-max=\"")
304 if templ_7745c5c3_Err != nil {373 if templ_7745c5c3_Err != nil {
305 return templ_7745c5c3_Err374 return templ_7745c5c3_Err
306 }375 }
307- var templ_7745c5c3_Var21 string376+ var templ_7745c5c3_Var24 string
308- templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(p.MaxAuthor))377+ templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(p.MaxAuthor))
309 if templ_7745c5c3_Err != nil {378 if templ_7745c5c3_Err != nil {
310- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 45, Col: 63}379+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 75, Col: 103}
311 }380 }
312- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)381+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
313 if templ_7745c5c3_Err != nil {382 if templ_7745c5c3_Err != nil {
314 return templ_7745c5c3_Err383 return templ_7745c5c3_Err
315 }384 }
316- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" for=\"issue-author\">")385+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" for=\"issue-author\">")
317 if templ_7745c5c3_Err != nil {386 if templ_7745c5c3_Err != nil {
318 return templ_7745c5c3_Err387 return templ_7745c5c3_Err
319 }388 }
320- var templ_7745c5c3_Var22 string389+ var templ_7745c5c3_Var25 string
321- templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(runeCount(p.Author)))390+ templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(runeCount(p.Author)))
322 if templ_7745c5c3_Err != nil {391 if templ_7745c5c3_Err != nil {
323- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 45, Col: 112}392+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 75, Col: 152}
324 }393 }
325- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))394+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
326 if templ_7745c5c3_Err != nil {395 if templ_7745c5c3_Err != nil {
327 return templ_7745c5c3_Err396 return templ_7745c5c3_Err
328 }397 }
329- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, " / ")398+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " / ")
330 if templ_7745c5c3_Err != nil {399 if templ_7745c5c3_Err != nil {
331 return templ_7745c5c3_Err400 return templ_7745c5c3_Err
332 }401 }
333- var templ_7745c5c3_Var23 string402+ var templ_7745c5c3_Var26 string
334- templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.MaxAuthor))403+ templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.MaxAuthor))
335 if templ_7745c5c3_Err != nil {404 if templ_7745c5c3_Err != nil {
336- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 45, Col: 136}405+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 75, Col: 176}
337 }406 }
338- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))407+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
339 if templ_7745c5c3_Err != nil {408 if templ_7745c5c3_Err != nil {
340 return templ_7745c5c3_Err409 return templ_7745c5c3_Err
341 }410 }
342- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</output> ")411+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</output> ")
343 if templ_7745c5c3_Err != nil {412 if templ_7745c5c3_Err != nil {
344 return templ_7745c5c3_Err413 return templ_7745c5c3_Err
345 }414 }
346 if p.ErrorField == "author" {415 if p.ErrorField == "author" {
347- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<p class=\"f-error\" role=\"alert\">")416+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<p id=\"issue-author-error\" class=\"f-error\" role=\"alert\">")
348 if templ_7745c5c3_Err != nil {417 if templ_7745c5c3_Err != nil {
349 return templ_7745c5c3_Err418 return templ_7745c5c3_Err
350 }419 }
351- var templ_7745c5c3_Var24 string420+ var templ_7745c5c3_Var27 string
352- templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(p.Error)421+ templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(p.Error)
353 if templ_7745c5c3_Err != nil {422 if templ_7745c5c3_Err != nil {
354- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 47, Col: 45}423+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 77, Col: 69}
355 }424 }
356- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))425+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
357 if templ_7745c5c3_Err != nil {426 if templ_7745c5c3_Err != nil {
358 return templ_7745c5c3_Err427 return templ_7745c5c3_Err
359 }428 }
360- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</p>")429+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</p>")
361 if templ_7745c5c3_Err != nil {430 if templ_7745c5c3_Err != nil {
362 return templ_7745c5c3_Err431 return templ_7745c5c3_Err
363 }432 }
364 }433 }
365- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<div class=\"hp\" aria-hidden=\"true\"><label for=\"issue-website\">Leave this field empty</label> <input id=\"issue-website\" name=\"website\" type=\"text\" tabindex=\"-1\" autocomplete=\"off\" value=\"\"></div><p class=\"dim\" data-status role=\"status\" aria-live=\"polite\">Checking your browser… (this takes a few seconds)</p><div class=\"f-btns\"><button type=\"submit\" class=\"btn primary\" disabled>Submit for moderation</button> <a class=\"btn\" href=\"")434+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<div class=\"hp\" aria-hidden=\"true\"><label for=\"issue-website\">Leave this field empty</label> <input id=\"issue-website\" name=\"website\" type=\"text\" tabindex=\"-1\" autocomplete=\"off\" value=\"\"></div><p class=\"dim\" data-status role=\"status\" aria-live=\"polite\">Checking your browser… (this takes a few seconds)</p><div class=\"f-btns\"><button type=\"submit\" class=\"btn primary\" disabled>Submit for moderation</button> <a class=\"btn\" href=\"")
366 if templ_7745c5c3_Err != nil {435 if templ_7745c5c3_Err != nil {
367 return templ_7745c5c3_Err436 return templ_7745c5c3_Err
368 }437 }
369- var templ_7745c5c3_Var25 templ.SafeURL438+ var templ_7745c5c3_Var28 templ.SafeURL
370- templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Cancel))439+ templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Cancel))
371 if templ_7745c5c3_Err != nil {440 if templ_7745c5c3_Err != nil {
372- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 56, Col: 49}441+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 86, Col: 49}
373 }442 }
374- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))443+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
375 if templ_7745c5c3_Err != nil {444 if templ_7745c5c3_Err != nil {
376 return templ_7745c5c3_Err445 return templ_7745c5c3_Err
377 }446 }
378- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\">Cancel</a></div></form>")447+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\">Cancel</a></div></form>")
379 if templ_7745c5c3_Err != nil {448 if templ_7745c5c3_Err != nil {
380 return templ_7745c5c3_Err449 return templ_7745c5c3_Err
381 }450 }
@@ -405,12 +474,12 @@ func issueFormRail() templ.Component {
405 }()474 }()
406 }475 }
407 ctx = templ.InitializeContext(ctx)476 ctx = templ.InitializeContext(ctx)
408- templ_7745c5c3_Var26 := templ.GetChildren(ctx)477+ templ_7745c5c3_Var29 := templ.GetChildren(ctx)
409- if templ_7745c5c3_Var26 == nil {478+ if templ_7745c5c3_Var29 == nil {
410- templ_7745c5c3_Var26 = templ.NopComponent479+ templ_7745c5c3_Var29 = templ.NopComponent
411 }480 }
412 ctx = templ.ClearChildren(ctx)481 ctx = templ.ClearChildren(ctx)
413- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<section aria-labelledby=\"r-about\"><h2 id=\"r-about\">Before you write</h2><p class=\"dim\">The owner reads every issue before it appears. Plain text and Markdown only; no address of yours is kept.</p></section>")482+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<section aria-labelledby=\"r-about\"><h2 id=\"r-about\">Before you write</h2><p class=\"dim\">The owner reads every issue before it appears. Plain text and Markdown only; no address of yours is kept.</p></section>")
414 if templ_7745c5c3_Err != nil {483 if templ_7745c5c3_Err != nil {
415 return templ_7745c5c3_Err484 return templ_7745c5c3_Err
416 }485 }
@@ -418,4 +487,12 @@ func issueFormRail() templ.Component {
418 })487 })
419 }488 }
420 489
490+func issueFieldDescription(field, invalid string) string {
491+ ids := "issue-" + field + "-count"
492+ if field == invalid {
493+ ids += " issue-" + field + "-error"
494+ }
495+ return ids
496+}
497+
421 var _ = templruntime.GeneratedTemplate498 var _ = templruntime.GeneratedTemplate
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 generated file: collapsed

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