Commit df17e46ad2

df17e46ad26cfba5d78e9e309418dcb573304566

parent: 7353d620de

Verified · cmc ci/build: success ci/test: success

cmc <hello@cleberg.net> · 2026-09-18 21:04 UTC

web: pinned repositories move to the dashboard

They render as chips under the heading at every width, since the rail no
longer lists them. TestRailIconsAreLabelled reads layout.html and fails
on a rail control without an aria-label, a hidden name or a glyph, and on
any svg in the layout that is not aria-hidden.

Ref #220
e2e/dashboard_test.go +1 −1
@@ -93,7 +93,7 @@ func TestDashboard(t *testing.T) {
9393 t.Fatalf("dashboard: %d", status)
9494 }
9595 for _, want := range []string{
96 `aria-labelledby="rail-pinned"`, `</span>app</a>`, // rail lists the pinned repo
96 `aria-label="Pinned repositories"`, `</span>app</a>`, // the dashboard lists the pinned repo
9797 "Waiting on your review", "Assigned to you", "Recent activity",
9898 "from bob", "alice/app!1", "todo one", "alice/app#1",
9999 `href="/alice/app/mrs/1"`, `href="/alice/app/issues/1"`,
internal/web/static/style.css +4
@@ -991,6 +991,10 @@ pre.message {
991991.blobimage { text-align: center; }
992992.blobimage img { max-width: 100%; border: 1px solid var(--line); border-radius: var(--r-card); background: var(--surface); }
993993
994/* pinned repositories, on the dashboard now that the rail is icons only */
995.pinned { display: flex; flex-wrap: wrap; gap: var(--sp-2); margin-bottom: var(--sp-4); }
996.pinned .owner { color: var(--muted); }
997
994998/* ---- two-column: conversation and aside ---- */
995999.withaside { display: grid; grid-template-columns: minmax(0, 1fr) 18rem; gap: var(--sp-6); align-items: start; }
9961000.withaside .mainside { min-width: 0; }
internal/web/templates/dashboard.html +1
@@ -17,6 +17,7 @@
1717{{end}}
1818{{define "content"}}
1919<h1>Dashboard</h1>
20{{with .Rail.Pinned}}<p class="pinned" aria-label="Pinned repositories">{{range .}}<a class="chip" href="/{{.Owner}}/{{.Name}}"><span class="owner">{{.Owner}}/</span>{{.Name}}</a> {{end}}</p>{{end}}
2021
2122<div class="withaside">
2223<div class="mainside">
internal/web/web_test.go +60
@@ -120,3 +120,63 @@ func TestMainWidthClass(t *testing.T) {
120120 t.Error("layout.html: no default width")
121121 }
122122}
123
124// The rail carries no visible text, so every one of its controls has to
125// name itself twice over: an aria-label for the accessibility tree and a
126// visually hidden span for anything reading the DOM text, with the glyph
127// itself hidden from both so it is never announced as a graphic. This
128// parses layout.html, so an icon link that forgets one fails here rather
129// than on the page.
130func TestRailIconsAreLabelled(t *testing.T) {
131 src, err := TemplateSource("layout.html")
132 if err != nil {
133 t.Fatal(err)
134 }
135 // A control names its glyph either through the icon partial or with an
136 // svg of its own; the aria-hidden loop below covers both.
137 glyph := func(s string) bool {
138 return strings.Contains(s, `{{template "icon" `) || strings.Contains(s, "<svg")
139 }
140 tagRe := regexp.MustCompile(`(?s)<a class="railicon".*?</a>|<button [^>]*class="railicon".*?</button>`)
141 controls := tagRe.FindAllString(src, -1)
142 if len(controls) < 7 {
143 t.Fatalf("found %d railicon controls in layout.html, want the rail's full set", len(controls))
144 }
145 for _, c := range controls {
146 for _, want := range []string{`aria-label="`, `<span class="vh">`} {
147 if !strings.Contains(c, want) {
148 t.Errorf("railicon control missing %s: %.80s", want, c)
149 }
150 }
151 if !glyph(c) {
152 t.Errorf("railicon control draws no glyph: %.80s", c)
153 }
154 }
155
156 // Every button in the rail's foot is an icon button under the same
157 // rule: the Log out form's submit is the only one today.
158 foot := src[strings.Index(src, `<div class="railfoot">`):]
159 foot = foot[:strings.Index(foot, "</nav>")]
160 buttons := regexp.MustCompile(`(?s)<button.*?</button>`).FindAllString(foot, -1)
161 if len(buttons) == 0 {
162 t.Fatal("no button in the rail foot")
163 }
164 for _, b := range buttons {
165 for _, want := range []string{`aria-label="`, `<span class="vh">`} {
166 if !strings.Contains(b, want) {
167 t.Errorf("rail foot button missing %s: %.80s", want, b)
168 }
169 }
170 if !glyph(b) {
171 t.Errorf("rail foot button draws no glyph: %.80s", b)
172 }
173 }
174
175 // No decorative graphic anywhere in the layout is exposed, the brand
176 // mark included: the link around it carries the name.
177 for _, svg := range regexp.MustCompile(`<svg[^>]*>`).FindAllString(src, -1) {
178 if !strings.Contains(svg, `aria-hidden="true"`) {
179 t.Errorf("svg without aria-hidden: %s", svg)
180 }
181 }
182}