Commit 82ae29eaa8

82ae29eaa811681d601d53a2172503f474b1e265

parent: eee0cf49cb

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-17 06:13 UTC

docs: web design foundation plan

Ref #218
docs/plans/2026-09-17-web-design-foundation.md added +1395
@@ -0,0 +1,1395 @@
1# Web design foundation implementation plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** Replace the stylesheet with a measured token set and one control family, give pages content widths, and recompose the landing, repository overview, merge request and repository settings pages, with the review's copy fixes.
6
7**Architecture:** One embedded stylesheet (`internal/web/static/style.css`) styles server-rendered Go templates under `internal/web/templates/`, each parsed together with `layout.html`. Two Go tests pin the stylesheet: a token contrast test and a template-class coverage test. Handlers in `internal/httpd` change only where a page needs new data (the MR empty-diff flag, the settings success flash, the prefilled topics field, image routes).
8
9**Tech Stack:** Go 1.2x, `html/template`, hand-written CSS, no JavaScript (CSP `script-src 'none'`), e2e tests against a real `gitbayd`.
10
11**Spec:** `docs/specs/2026-09-17-web-design-foundation-design.md`. Mockups of the four pages with the target tokens: `.claude/mock/` (serve with the `mockups` entry in `.claude/launch.json`, or `python3 -m http.server --directory .claude/mock`). Before-screenshots of every page: `.claude/screenshots/before/`, and `.claude/screenshots/README.md` says how to capture the after set.
12
13## Global constraints
14
15- Tracking issue is #218; every commit message ends with `Ref #218` (the last commit of MR 4 says `Closes #218`).
16- Never push to `main`; each MR is a branch off `main`, merged with `gitbay mr merge <n> --strategy ff` after bay1 CI is green. Commits are signed (the repository requires it).
17- No attribution to any assistant or model anywhere.
18- No JavaScript, no inline `<style>` blocks, no external requests. `style="width:..%"` on the language bar and label colours are the only inline styles and stay.
19- Product name lowercase, `gitbay`, in templates, docs and the CHANGELOG.
20- Colours exist only as tokens on `:root`, each redefined in `@media (prefers-color-scheme: dark)`.
21- Class names that tests pin stay: `error`, `notice`, `railuser`, `actgraph`, `xref`, `blamehunk`, `lineno`, `code`, `syscomment`, `add`, `del`, `difftable`, `chroma`, `authorlink`, `refmenu`, `thread`, `difffold`, `ln`, `src`, `contribs`, `activity`, `line`.
22- Locally: `go build ./... && go vet ./...`, unit tests of touched packages, and at most the one e2e test being written. The full suite runs on bay1.
23- Copy is sentence case; "Sign in" / "Log out"; "Search" for search buttons and the rail placeholder.
24
25---
26
27## MR 1: stylesheet rewrite
28
29Branch `design-stylesheet` off `main`. Tasks 1 to 6.
30
31### Task 1: token contrast test
32
33**Files:**
34- Create: `internal/web/tokens_test.go`
35
36**Interfaces:**
37- Produces: `parseTokens(css []byte) (light, dark map[string]string)` and `contrastHex(a, b string) float64`, package `web`, reused by nothing else but named here so Task 2 does not redefine them.
38
39- [ ] **Step 1: Write the failing test**
40
41```go
42package web
43
44import (
45 "math"
46 "regexp"
47 "strconv"
48 "strings"
49 "testing"
50)
51
52// parseTokens returns the --name: value pairs of the light :root block
53// and of the :root block inside the dark media query. A value that is
54// itself a var() is resolved one level deep within the same scheme.
55func parseTokens(css []byte) (light, dark map[string]string) {
56 s := string(css)
57 rootRe := regexp.MustCompile(`(?s):root\s*\{(.*?)\}`)
58 darkIdx := strings.Index(s, "@media (prefers-color-scheme: dark)")
59 if darkIdx < 0 {
60 return nil, nil
61 }
62 parse := func(block string) map[string]string {
63 m := map[string]string{}
64 lineRe := regexp.MustCompile(`--([a-z0-9-]+)\s*:\s*([^;]+);`)
65 for _, mm := range lineRe.FindAllStringSubmatch(block, -1) {
66 v := strings.TrimSpace(mm[2])
67 if i := strings.Index(v, "/*"); i >= 0 {
68 v = strings.TrimSpace(v[:i])
69 }
70 m[mm[1]] = v
71 }
72 for k, v := range m {
73 if strings.HasPrefix(v, "var(--") {
74 ref := strings.TrimSuffix(strings.TrimPrefix(v, "var(--"), ")")
75 if rv, ok := m[ref]; ok {
76 m[k] = rv
77 }
78 }
79 }
80 return m
81 }
82 lm := rootRe.FindStringSubmatch(s[:darkIdx])
83 dm := rootRe.FindStringSubmatch(s[darkIdx:])
84 if lm == nil || dm == nil {
85 return nil, nil
86 }
87 light = parse(lm[1])
88 dark = parse(dm[1])
89 for k, v := range light {
90 if _, ok := dark[k]; !ok && strings.HasPrefix(v, "#") {
91 dark[k] = v // a light-only colour is a bug; keep it visible below
92 }
93 }
94 return light, dark
95}
96
97func hexChannel(h string) float64 {
98 n, _ := strconv.ParseUint(h, 16, 8)
99 c := float64(n) / 255
100 if c <= 0.03928 {
101 return c / 12.92
102 }
103 return math.Pow((c+0.055)/1.055, 2.4)
104}
105
106func luminanceHex(hex string) float64 {
107 hex = strings.TrimPrefix(hex, "#")
108 if len(hex) == 3 {
109 hex = string([]byte{hex[0], hex[0], hex[1], hex[1], hex[2], hex[2]})
110 }
111 return 0.2126*hexChannel(hex[0:2]) + 0.7152*hexChannel(hex[2:4]) + 0.0722*hexChannel(hex[4:6])
112}
113
114func contrastHex(a, b string) float64 {
115 la, lb := luminanceHex(a), luminanceHex(b)
116 if la < lb {
117 la, lb = lb, la
118 }
119 return (la + 0.05) / (lb + 0.05)
120}
121
122// TestTokenContrast is the contract for the colour tokens in style.css:
123// every text colour clears WCAG AA on every ground it lands on, in both
124// schemes. The hex values in the stylesheet are free to move as long as
125// this passes.
126func TestTokenContrast(t *testing.T) {
127 light, dark := parseTokens(StyleCSS)
128 if light == nil || dark == nil {
129 t.Fatal("could not find the light and dark :root blocks in style.css")
130 }
131 type check struct {
132 fg, bg string
133 floor float64
134 }
135 checks := []check{
136 {"fg", "canvas", 7}, {"fg", "surface", 7},
137 {"muted", "canvas", 4.5}, {"muted", "surface", 4.5},
138 {"link", "canvas", 4.5}, {"link", "surface", 4.5}, {"link", "hover", 4.5},
139 {"fill-fg", "fill", 4.5}, {"fill", "surface", 3},
140 {"line", "canvas", 3},
141 {"mark", "canvas", 3},
142 {"warn", "canvas", 4.5}, {"warn", "surface", 4.5},
143 {"ok", "canvas", 4.5}, {"ok", "surface", 4.5},
144 {"bad", "canvas", 4.5}, {"bad", "surface", 4.5},
145 {"done", "canvas", 4.5}, {"done", "surface", 4.5},
146 {"focus", "canvas", 3},
147 {"shell-fg", "shell-bg", 7}, {"shell-muted", "shell-bg", 4.5}, {"shell-mark", "shell-bg", 3},
148 }
149 for name, scheme := range map[string]map[string]string{"light": light, "dark": dark} {
150 for _, tok := range []string{"canvas", "surface", "inset", "hover", "line", "faint", "fg", "muted", "link", "fill", "fill-fg", "mark", "warn", "ok", "bad", "done", "neutral", "focus", "shell-bg", "shell-fg", "shell-muted", "shell-mark"} {
151 v, ok := scheme[tok]
152 if !ok || !strings.HasPrefix(v, "#") {
153 t.Errorf("%s: --%s is missing or not a hex colour (%q)", name, tok, v)
154 }
155 }
156 if t.Failed() {
157 continue
158 }
159 for _, c := range checks {
160 if got := contrastHex(scheme[c.fg], scheme[c.bg]); got < c.floor {
161 t.Errorf("%s: --%s (%s) on --%s (%s) is %.2f:1, want >= %.1f", name, c.fg, scheme[c.fg], c.bg, scheme[c.bg], got, c.floor)
162 }
163 }
164 // The surface ladder must be visible: canvas, surface and inset
165 // are three grounds, not one.
166 lc, ls, li := luminanceHex(scheme["canvas"]), luminanceHex(scheme["surface"]), luminanceHex(scheme["inset"])
167 if r := (math.Max(lc, ls) + 0.05) / (math.Min(lc, ls) + 0.05); r < 1.15 {
168 t.Errorf("%s: canvas %s and surface %s are %.2f apart, want >= 1.15", name, scheme["canvas"], scheme["surface"], r)
169 }
170 if scheme["inset"] == scheme["surface"] {
171 t.Errorf("%s: inset and surface are the same colour %s", name, scheme["inset"])
172 }
173 }
174}
175```
176
177- [ ] **Step 2: Run it to verify it fails**
178
179Run: `go test ./internal/web -run TestTokenContrast -v`
180Expected: FAIL with lines like `light: --canvas is missing or not a hex colour ("")`, because the current stylesheet has `--bg`, not `--canvas`.
181
182- [ ] **Step 3: Commit the test alone**
183
184```bash
185git add internal/web/tokens_test.go
186git commit -m "web: token contrast test
187
188Ref #218"
189```
190
191The test stays red until Task 3.
192
193### Task 2: template class coverage test
194
195**Files:**
196- Create: `internal/web/classes_test.go`
197
198- [ ] **Step 1: Write the test**
199
200```go
201package web
202
203import (
204 "io/fs"
205 "regexp"
206 "sort"
207 "strings"
208 "testing"
209)
210
211// TestEveryTemplateClassHasARule fails on a class a template uses that
212// no selector in style.css mentions. Class tokens containing template
213// actions ({{...}}) are composed at render time and skipped; their
214// prefixes (chip-, badge-, check-, lang-) are covered by the rules for
215// the concrete values.
216func TestEveryTemplateClassHasARule(t *testing.T) {
217 css := string(StyleCSS)
218 // Every ".name" that appears in a selector position: outside braces.
219 depth := 0
220 var sel strings.Builder
221 for _, r := range css {
222 switch r {
223 case '{':
224 depth++
225 case '}':
226 depth--
227 default:
228 if depth == 0 {
229 sel.WriteRune(r)
230 }
231 }
232 }
233 // Media queries wrap rules one level deeper; strip their headers and
234 // scan again at depth one.
235 depth = 0
236 inMedia := false
237 for i := 0; i < len(css); i++ {
238 if strings.HasPrefix(css[i:], "@media") {
239 inMedia = true
240 }
241 switch css[i] {
242 case '{':
243 depth++
244 if depth == 1 && !inMedia {
245 // ordinary rule; already scanned above
246 }
247 case '}':
248 depth--
249 if depth == 0 {
250 inMedia = false
251 }
252 default:
253 if inMedia && depth == 1 {
254 sel.WriteByte(css[i])
255 }
256 }
257 }
258 classRe := regexp.MustCompile(`\.([a-zA-Z_][a-zA-Z0-9_-]*)`)
259 styled := map[string]bool{}
260 for _, m := range classRe.FindAllStringSubmatch(sel.String(), -1) {
261 styled[m[1]] = true
262 }
263
264 attrRe := regexp.MustCompile(`class="([^"]*)"`)
265 missing := map[string][]string{}
266 entries, err := fs.ReadDir(templateFS, "templates")
267 if err != nil {
268 t.Fatal(err)
269 }
270 for _, e := range entries {
271 src, err := templateFS.ReadFile("templates/" + e.Name())
272 if err != nil {
273 t.Fatal(err)
274 }
275 for _, m := range attrRe.FindAllStringSubmatch(string(src), -1) {
276 for _, c := range strings.Fields(m[1]) {
277 if strings.Contains(c, "{{") || strings.Contains(c, "}}") {
278 continue
279 }
280 if !styled[c] {
281 missing[c] = append(missing[c], e.Name())
282 }
283 }
284 }
285 }
286 var names []string
287 for c := range missing {
288 names = append(names, c)
289 }
290 sort.Strings(names)
291 for _, c := range names {
292 t.Errorf("class %q in %s has no rule in style.css", c, strings.Join(uniq(missing[c]), ", "))
293 }
294}
295
296func uniq(in []string) []string {
297 seen := map[string]bool{}
298 var out []string
299 for _, s := range in {
300 if !seen[s] {
301 seen[s] = true
302 out = append(out, s)
303 }
304 }
305 return out
306}
307```
308
309- [ ] **Step 2: Run it against the current stylesheet**
310
311Run: `go test ./internal/web -run TestEveryTemplateClassHasARule -v`
312Expected: either PASS, or a list of classes the old stylesheet never styled. Record the list in the commit message. Any class listed that is purely a hook with no visual meaning is removed from the template in Task 4; every other one gets a rule in Task 3. Do not add an allowlist.
313
314- [ ] **Step 3: Commit**
315
316```bash
317git add internal/web/classes_test.go
318git commit -m "web: every template class has a stylesheet rule
319
320Ref #218"
321```
322
323### Task 3: write the new stylesheet
324
325**Files:**
326- Modify: `internal/web/static/style.css` (replace whole file)
327- Reference: `.claude/mock/mock.css` (the token block and the components for the four mocked pages), the class inventory below.
328
329**Interfaces:**
330- Produces: the token names in Task 1; the classes `primary`, `btn`, `linklike`, `danger` on buttons; `wide`, `reading`, `bounded` on `main`; `flash` is not introduced (see below).
331
332- [ ] **Step 1: Start from the mock stylesheet**
333
334Copy `.claude/mock/mock.css` over `internal/web/static/style.css`, keep the three `@font-face` blocks from the old file at the top (the mock falls back to system fonts), and replace the header comment with:
335
336```css
337/* gitbay stylesheet. Tokens first, on :root and again in the dark media
338 query; components use tokens only. Two accents with separate jobs:
339 blue is what you can do (links, primary buttons, focus, pressed
340 toggles), orange is where you are and what wants you (current tab,
341 rail counts, pending state, unverified signatures). Orange is never a
342 button ground. internal/web/tokens_test.go measures every token;
343 internal/web/classes_test.go checks every template class has a rule.
344 No external requests: fonts are served from this origin. */
345```
346
347Change `html { font-size: 16px; }` to stay, remove the `.mono` and `.small` helper classes unless a template uses them after Task 4, and change `--fill-fg` to be defined in both scheme blocks.
348
349- [ ] **Step 2: Port every component the mock did not cover**
350
351Work through the old stylesheet from line 150 to the end, and for each selector group write the new rule with tokens. The inventory, with the treatment each gets:
352
353- Focus: `:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }` on everything, `.rail :focus-visible { outline-color: var(--shell-mark); }`. Remove the old `input:focus { border-color }` rule.
354- Skip link `.skip`: as before, on `--fill` with `--fill-fg` when focused.
355- Rail: `.rail`, `.brand`, `.mark`, `.railbody`, `.railsearch`, `.raillist` (+ `.wide`, `.railpinned`), `.raillabel`, `.railgroup`, `.count`, `.railfoot`, `.railuser`, `.avatar`, `.uname`, `.owner`. Values from the mock. The 52rem breakpoint keeps the old strip behaviour: `.shell` block, rail `flex-direction: row`, `border-bottom` instead of `border-right`, `.railgroup { display: none }`, `.raillist { display: flex }` with the current marker as a bottom border.
356- Shell: `.shell`, `.pane`, `main.content` with `padding: var(--sp-5) var(--sp-6) var(--sp-7)`, and the three width classes `main.wide { max-width: none }`, `main.reading { max-width: calc(72rem + 2 * var(--sp-6)) }`, `main.bounded { max-width: calc(48rem + 2 * var(--sp-6)) }`. `footer` as in the mock.
357- Repository header: `.repohead`, `.identity`, `.repotitle` (+ `.owner`, `.sep`), `.grow`, `.chip` beside the title, `.repodesc`, `.topics`, `.repometa`, `.toggles`, `nav.tabs a` (+ `[aria-current]`, `i`). From the mock.
358- Page heads: `h1` 24px, `.pagehead`, `.listhead` (the h1 + filters row on issues, MRs, builds, explore): `display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap; margin-bottom: var(--sp-5)`. `nav.filters a`: secondary button geometry, `.active` gets the pressed treatment (`--surface` ground, `--link` border and text). `form.searchform`: input plus a secondary "Search" button, `gap: var(--sp-2)`. `.logfilter`, `.compareform`: same.
359- Buttons: `button` and `.button` base (32px, 14px, 500, 4px radius); `button.primary`, `button.btn` (+ `[aria-pressed="true"]`), `button.linklike`, `button.danger`; `.btngroup`; `.inline` forms `display: inline`. `button:disabled { opacity: .55; cursor: default }`. A bare `<button>` with no class renders as primary so untouched templates keep working; Task 4 adds `btn` or `danger` where the spec says.
360- Forms: inputs, `textarea`, `select` (with the chevron `--chev` data URI kept, colours swapped to `--muted`), `label`, `.hint`, `.field`, `.check`, custom checkbox 16px drawn as before with `--fill` when checked, `::placeholder`, `::selection`, `input:user-invalid { border-color: var(--bad) }`, `fieldset.segmented` (checked span on `--fill`), `.branchpick`, `.options`, `.editform`, `.commentform` (textarea plus a row: primary submit, quiet cancel), `details.editbox` (summary styled as a secondary button, body a card), `.confirmfield` input (no class today; it is `input[name=confirm]`).
361- Settings: `form.setform` becomes the grid from the mock's `.setrow` but keeps the name `setform`: `grid-template-columns: 14rem minmax(0, 28rem) auto`, `align-items: start`, `padding: var(--sp-3) 0`, `border-bottom: 1px solid var(--faint)`; `.setform label { margin-top: 6px }`, `.setform .hint`, `.setform.stack { grid-template-columns: 1fr }`, under 40rem one column. `nav.sections` for the anchor links. `ul.protlist` rows as list rows with the action at the right.
362- Messages: `.error` and `.notice` share one rule: 3px left edge, 6% tint ground, `--fg` text, 4px right radii, `margin-bottom: var(--sp-4)`, 14px; `.error` edge and tint `--bad`, `.notice` edge and tint `--ok`. No `.flash` class is added: the spec's "flash family" is these two selectors, and `class="error"` keeps its name because e2e tests match it. `.empty-note`, `p.none`, `.empty` in `--muted`.
363- Containers: `.card`, `.cardhead`, `.cardbody`, `ul.list`; `table`, `th`, `td`, `tr.cols`, `.tablewrap`; `table.tree` (+ `td.name`, `.dir`, `td.lastcommit`, `td.age`), `table.refs`, `table.keys` (flush: no outer border, header without ground), `.tipbar` as the first row on `--surface`, `.readme`, `.code`, `.bigcode`, `ul.loglist` (+ `.commitmain`, `.commitside`, `.subject`, `.sha`, `.when`), `ul.repolist` (+ `.reponame`, `.desc`, `.meta`), `ul.issuelist` (+ `.issuemain`, `.title`, `.meta`), `ul.milestonelist` (+ `.msmain`, `.progress`, `.bar`), `article.release` (+ `.releasehead`, `.assets`), `ul.pinlist`, `.matchlist`, `.matchpath`, `.matchline`, `.pager`, `.snippetfile`, `.filefacts`, `.dmeta`, `.notfound`, `.buildlog`. All on the one container recipe; rows `--faint` separators, `--hover` hover; the row title `--fg` turning `--link` on hover.
364- Two-column: `.withaside`, `.mainside`, `aside.aside`, `.grp`, `.row`, `.sub`, `.dot` (+ `.ok`, `.bad`, `.pend`), `form.actions` (buttons wrap, `select` full width), `.prose { max-width: 78ch }`. Under 62rem: one column with `.aside { order: -1 }`.
365- Comments and threads: `article.comment`, `.commenthead`, `.commentbody`/`.rendered` inside it, `.syscomment`, `.thread` (+ `.resolved`, `.pending`, `.stale`, `.composing`, `.threadrow`, `.threadstate`, `.threadact`, `.threadreply`), `nav.subtabs` (+ `[aria-current]`, `i`).
366- Diffs and code: `.diffstat` (+ `.add`, `.del`), `details.difffold` (+ `summary`, `.fpath`, `.fstat`), `table.difftable` (+ `td.ln`, `td.src`, `tr.add`, `tr.del`, `.hunk`, `.cmt`), `.chroma` container, `.blame`, `.blameinfo`, `.blamehunk`, `.blamecode`, `.lineno`, `.blobimage`, `.plain`, `.fullsha`. Grounds `--inset`; diff row tints stay the four old values as tokens `--diff-add`, `--diff-del`.
367- Chips and badges: `.chip`, `.badge` (same rule), `.chip-open`, `.chip-closed`, `.chip-merged`, `.chip-done`, `.chip-neutral`, `.chip-source_gone`, `.chip-stale`, `.chip.topic`, `.chip.label` (colour from its inline style), `.badge-verified`, `.badge-unsigned`, `.badge-signed_key_expired`, `.badge-bad_signature`, `.badge.check-*` and `.chip.check-*` for `success`, `pending`, `failure`, `error`, `span.check-*` text colours, `.memberchip`, `.refchip`, `.refmenu` + `.refdrop` + `.allrefs` (dropdown on `--canvas` with `--line` border and `--shadow`), `.crumbs`, `.pathbar`, `.act` links become `.button.btn`.
368- Repository facts: `.facts` two-column grid at reading width, `.clone` with `label` and `pre`, `.factgrid`, `.counts`, `.fact`, `.langbar`, `.lang`, `.langs`, `.lang-name`, `.contribs`, `.label`. From the mock.
369- Landing: `.landing`, `.lede`, `pre.quickstart`, `.shot` (+ `img`), `.facets`, `.routes`, `.explorelink` (removed in Task 8; keep the rule until then), `.signupform`.
370- Profile: `.profilehead`, `.desc`, `.activity`, `.actgraph-scroll`, `.actgraph`, `.actweek`, `.actday` and `.l0` to `.l4` levels as tints of `--link`, `.teambody`.
371- Wiki: `.wikilayout`, `.wikinav` (14rem, list rows), `.wikipage`, under 40rem stacked.
372- Misc: `.meta`, `.muted`, `.vh`, `.spacer`, `.mono`, `.compact`, `.headrow`, `.message` (a `pre` for a command to copy), `.size`, `.role`, `.was`, `.who`, `.age`, `.name`, `.icon`, `.xref` (link colour, no underline), `.sigbadge`.
373- Breakpoints, all of them: 62rem (aside stacks), 52rem (rail strip, content padding `var(--sp-4)`, tabs wrap), 40rem (facets, wiki, tree columns hidden: `td.lastcommit, th.lastcommit { display: none }`, `.clone pre` wraps with `white-space: pre-wrap; word-break: break-all`, settings rows one column).
374
375Every rule uses tokens; `grep -n '#[0-9a-fA-F]\{3,6\}' internal/web/static/style.css` must match only inside the two `:root` blocks and the `--chev` data URIs.
376
377- [ ] **Step 3: Run the two stylesheet tests and the existing web tests**
378
379Run: `go test ./internal/web -v`
380Expected: `TestTokenContrast` PASS, `TestEveryTemplateClassHasARule` PASS (or a list that Task 4 resolves by editing templates; anything still listed after Task 4 is a rule missing here), `TestHeaderRowsAreLeftAligned` PASS. Adjust hex values until the contrast test passes; do not lower a floor.
381
382- [ ] **Step 4: Run the stylesheet-dependent httpd tests**
383
384Run: `go test ./internal/httpd -run 'TestStylesheetFontsAreServed|TestStylesheetRevalidates|TestSyntaxPaletteContrast' -v`
385Expected: PASS. `TestSyntaxPaletteContrast` measures against hard-coded grounds; update its `page` ground for dark to `#101114` and `code` to `#0b0c0e`, light `code` to `#ffffff`, and rerun. If a chroma token fails on the new ground, exempt nothing: pick the ground so the palette passes, then set `--inset` to that value and rerun Task 1's test.
386
387- [ ] **Step 5: Commit**
388
389```bash
390git add internal/web/static/style.css internal/httpd/palette_test.go
391git commit -m "web: rewrite the stylesheet on measured tokens
392
393Ref #218"
394```
395
396### Task 4: template markup for the new components
397
398**Files:**
399- Modify: every template under `internal/web/templates/` that the list below names.
400
401The stylesheet is class-compatible; these are the markup changes the new components need. Do not change page composition here (that is MR 2).
402
403- [ ] **Step 1: Button classes**
404
405In every template: a `<button type="submit">` that is the page's one main action keeps no class (renders primary). Every other button gets `class="btn"`: the header toggles already have it; add it to per-field Save buttons in `settings.html` and `account.html`, to filter and search submit buttons, to Approve / Request changes / Convert to draft / Ready for review in `mr.html`, and to `admin.html` row actions. Buttons that destroy or refuse get `class="danger"`: Close without merging (`mr.html`), Unprotect / Detach / Remove / Delete this team / Archive (`settings.html`, `owner.html`, `account.html`, `labels.html`, `snippet.html`, `releases.html`). Buttons that were `class="linklike"` and are destructive become `class="danger"` only when they sit beside a `confirmfield`; the rest stay quiet.
406
407- [ ] **Step 2: Search and filter forms**
408
409`explore.html`, `issues.html`, `mrs.html`, `builds.html`, `globalsearch.html`, `search.html`, `log.html`: the text filter form gets `<button type="submit" class="btn">Search</button>` after the input (`log.html` and `search.html` already have a button; relabel to "Search" and add `btn`), and when the handler passes a count the list is preceded by `<p class="meta">{{.Count}} result{{if ne .Count 1}}s{{end}}</p>`; where the page struct has no count, leave it (do not add handler work in this MR).
410
411- [ ] **Step 3: Copy vocabulary**
412
413`layout.html`: rail search `placeholder="Search"` (already), footer unchanged, `Sign in` (already), `Log out` (already). `login.html`: `<h1>Sign in</h1>`, button "Email me a link" stays. `register.html`: labels "Username", "Email", "SSH public key" in sentence case. `new.html`, `issuenew.html`, `mrnew.html`, `snippetnew.html`: headings and buttons in sentence case. `{{.Site}}` renders the configured title; templates that hardcode `gitbay` keep it lowercase.
414
415- [ ] **Step 4: Run the template tests and the class coverage test**
416
417Run: `go test ./internal/web ./internal/httpd -run 'TestEveryPageTemplateParses|TestEveryTemplateClassHasARule|TestEveryInputHasAnAccessibleName|TestHeaderRowsAreLeftAligned' -v`
418Expected: PASS.
419
420- [ ] **Step 5: Commit**
421
422```bash
423git add internal/web/templates
424git commit -m "web: button treatments, explicit search buttons, sentence case
425
426Ref #218"
427```
428
429### Task 5: visual check of every page family
430
431**Files:**
432- Create: `.claude/screenshots/local.sh` (ignored by git; a helper, not a deliverable)
433
434- [ ] **Step 1: Write the local instance script**
435
436```bash
437#!/bin/sh
438# Start a local gitbayd with this checkout pushed into krz/gitbay, for
439# screenshots. Prints the HTTP base and a browser login URL.
440set -eu
441ROOT=${ROOT:-/tmp/gitbay-local}
442rm -rf "$ROOT"; mkdir -p "$ROOT"
443go build -o "$ROOT/gitbayd" ./cmd/gitbayd
444cat > "$ROOT/config.toml" <<EOF
445[server]
446root = "$ROOT/data"
447site_url = "http://127.0.0.1:8090"
448[ssh]
449port = 8022
450[http]
451addr = "127.0.0.1:8090"
452tls = "off"
453[web]
454mode = "accounts"
455title = "gitbay"
456EOF
457"$ROOT/gitbayd" --config "$ROOT/config.toml" serve >"$ROOT/log" 2>&1 &
458echo $! > "$ROOT/pid"
459sleep 1
460ssh-keygen -q -t ed25519 -N "" -f "$ROOT/key"
461"$ROOT/gitbayd" --config "$ROOT/config.toml" admin user create cmc --key "$ROOT/key.pub" --email cmc@example.test --verified
462"$ROOT/gitbayd" --config "$ROOT/config.toml" admin user promote cmc
463S="ssh -p 8022 -i $ROOT/key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null git@127.0.0.1"
464$S repo create krz/gitbay >/dev/null
465GIT_SSH_COMMAND="ssh -i $ROOT/key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" \
466 git push -q "ssh://git@127.0.0.1:8022/krz/gitbay.git" HEAD:refs/heads/main
467$S issue create krz/gitbay --title "ios notifications" --file - <<'EOF'
468research options for ios notification support for builds, MRs, assignments, etc.
469EOF
470echo "base http://127.0.0.1:8090"
471$S web login
472```
473
474Run it with `sh .claude/screenshots/local.sh`, open the printed login URL in the browser pane, then walk every page family in both schemes at desktop and 375px: landing, login, register, explore, profile, repository code/tree/blob/blame/log/commit/refs/releases/wiki/search, issues list and page, MR list and page (three views), builds, settings, account, admin, dashboard, notifications, bookmarks, snippets, 404. Compare each against `.claude/screenshots/before/` and the mockups. Fix rules in `style.css` for anything broken; commit each fix as `web: <what>`. Stop the instance with `kill $(cat /tmp/gitbay-local/pid)`.
475
476- [ ] **Step 2: Keyboard and zoom pass**
477
478In the browser pane at 1280 wide: Tab through the landing, register, repository overview, an MR and settings; every stop shows the 2px ring, the order follows the document. Set the pane to 200% zoom (the app's zoom, or 640px width as a proxy) and confirm no horizontal page scroll on those five pages.
479
480- [ ] **Step 3: Commit any fixes, then vet**
481
482Run: `go build ./... && go vet ./... && go test ./internal/web ./internal/httpd`
483Expected: PASS.
484
485### Task 6: open and merge MR 1
486
487- [ ] **Step 1: Push and open the MR**
488
489```bash
490git push -u origin design-stylesheet
491gitbay mr create --source design-stylesheet --target main --title "web: stylesheet rewrite on measured tokens" --file - <<'EOF'
492Replaces style.css: tokens on :root in both schemes with a contrast test, one control family (primary, secondary, quiet, destructive), one container recipe, explicit search buttons, sentence case. Template class coverage is tested.
493
494Ref #218
495EOF
496```
497
498- [ ] **Step 2: Wait for bay1, merge, delete the branch**
499
500`gitbay build list --json` until the MR head's `build` and `test` jobs report success (one poll per minute at most). Then:
501
502```bash
503gitbay mr merge <n> --strategy ff
504git switch main && git pull --ff-only && git branch -d design-stylesheet && git push origin --delete design-stylesheet
505```
506
507If the merge reports the branch is behind, `git rebase main`, re-push, merge again.
508
509---
510
511## MR 2: layout and the four reference pages
512
513Branch `design-pages` off `main` after MR 1 merges. Tasks 7 to 12.
514
515### Task 7: content width per page
516
517**Files:**
518- Modify: `internal/web/templates/layout.html` (the `<main>` line and a new default block)
519- Modify: `tree.html`, `blob.html`, `blame.html`, `log.html`, `commit.html`, `compare.html`, `builds.html`, `build.html`, `search.html`, `globalsearch.html` (wide); `landing.html`, `login.html`, `register.html`, `registered.html`, `new.html`, `issuenew.html`, `mrnew.html`, `settings.html`, `account.html`, `admin.html`, `edit.html`, `snippetnew.html`, `privacy.html`, `404.html` (bounded)
520- Test: `internal/web/web_test.go`
521
522- [ ] **Step 1: Write the failing test**
523
524Append to `internal/web/web_test.go`:
525
526```go
527// TestMainWidthClass renders every page against an empty struct and
528// checks main carries exactly one width class, and that the pages the
529// spec calls wide or bounded say so.
530func TestMainWidthClass(t *testing.T) {
531 wide := map[string]bool{"tree.html": true, "blob.html": true, "blame.html": true, "log.html": true, "commit.html": true, "compare.html": true, "builds.html": true, "build.html": true, "search.html": true, "globalsearch.html": true}
532 bounded := map[string]bool{"landing.html": true, "login.html": true, "register.html": true, "registered.html": true, "new.html": true, "issuenew.html": true, "mrnew.html": true, "settings.html": true, "account.html": true, "admin.html": true, "edit.html": true, "snippetnew.html": true, "privacy.html": true, "404.html": true}
533 for _, name := range Pages() {
534 src, err := TemplateSource(name)
535 if err != nil {
536 t.Fatal(err)
537 }
538 has := strings.Contains(src, `{{define "width"}}`)
539 switch {
540 case wide[name] && !strings.Contains(src, `{{define "width"}}wide{{end}}`):
541 t.Errorf("%s: want width wide", name)
542 case bounded[name] && !strings.Contains(src, `{{define "width"}}bounded{{end}}`):
543 t.Errorf("%s: want width bounded", name)
544 case !wide[name] && !bounded[name] && has:
545 t.Errorf("%s: defines a width but the spec calls it reading", name)
546 }
547 }
548 layout, err := TemplateSource("layout.html")
549 if err != nil {
550 t.Fatal(err)
551 }
552 if !strings.Contains(layout, `<main id="content" class="content {{template "width" .}}">`) {
553 t.Error("layout.html: main does not carry the width block")
554 }
555 if !strings.Contains(layout, `{{define "width"}}reading{{end}}`) {
556 t.Error("layout.html: no default width")
557 }
558}
559```
560
561Add `"strings"` to the imports if missing.
562
563- [ ] **Step 2: Run it to verify it fails**
564
565Run: `go test ./internal/web -run TestMainWidthClass -v`
566Expected: FAIL on every wide and bounded page and on the layout lines.
567
568- [ ] **Step 3: Implement**
569
570In `layout.html`, change the main line to `<main id="content" class="content {{template "width" .}}">` and add, after the `mark` definition, `{{define "width"}}reading{{end}}`. In each wide page add `{{define "width"}}wide{{end}}` as its first line; in each bounded page `{{define "width"}}bounded{{end}}`. Because each page is parsed after the layout, a page's definition replaces the default.
571
572- [ ] **Step 4: Run the tests**
573
574Run: `go test ./internal/web -v`
575Expected: PASS, including `TestEveryPageTemplateParses`.
576
577- [ ] **Step 5: Commit**
578
579```bash
580git add internal/web
581git commit -m "web: content width chosen per page
582
583Ref #218"
584```
585
586### Task 8: two-level repository header and the landing, register and login pages
587
588**Files:**
589- Modify: `internal/web/templates/layout.html:63-103` (the `repohead` block), `landing.html`, `register.html`, `login.html`
590- Modify: `internal/web/static/style.css` (`.toggles`, `.routes`, `.shot`)
591- Test: `e2e/design_test.go` (extend `TestReadmeRelativeLinks`)
592
593- [ ] **Step 1: Write the failing e2e assertions**
594
595In `e2e/design_test.go`, inside `TestReadmeRelativeLinks` after the existing checks that the topic chips appear on `/issues`, `/mrs` and `/releases`, invert those three: the description and topic chips must appear on the repo home and must not appear on `/issues`, `/mrs`, `/releases`:
596
597```go
598 for _, p := range []string{"/alice/app/issues", "/alice/app/mrs", "/alice/app/releases"} {
599 if _, body := inst.get(t, p); strings.Contains(body, `class="chip topic"`) {
600 t.Errorf("%s: header still carries topics on a task tab", p)
601 }
602 }
603 if _, body := inst.get(t, "/alice/app"); !strings.Contains(body, `class="chip topic"`) {
604 t.Error("repo home lost its topics")
605 }
606```
607
608Read the existing function first: it asserts the chips on those pages today, so delete those assertions when adding the inverted ones.
609
610Add a new test in the same file:
611
612```go
613// TestLandingRoutes checks the landing page's copy and the two routes.
614func TestLandingRoutes(t *testing.T) {
615 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n[registration]\nmode = \"open\"\n")
616 _, body := inst.get(t, "/")
617 for _, want := range []string{
618 "A git forge you drive from the terminal.",
619 `class="button primary" href="/explore">Explore repositories</a>`,
620 `class="button btn" href="/register">Create an account</a>`,
621 "web login</code>",
622 } {
623 if !strings.Contains(body, want) {
624 t.Errorf("landing lacks %q", want)
625 }
626 }
627 if strings.Contains(body, "is the whole onboarding") {
628 t.Error("landing still calls repo create the whole onboarding")
629 }
630 _, reg := inst.get(t, "/register")
631 if !strings.Contains(reg, "Paste the contents of your public key file") || !strings.Contains(reg, "/krz/gitbay/wiki/SSH-keys") {
632 t.Error("register page lacks the key hint or the wiki link")
633 }
634}
635```
636
637- [ ] **Step 2: Run to verify they fail**
638
639Run: `go test ./e2e -run 'TestReadmeRelativeLinks|TestLandingRoutes' -v`
640Expected: FAIL on the topic assertions and the landing strings.
641
642- [ ] **Step 3: Rewrite the header block**
643
644In `layout.html` the `repohead` block becomes:
645
646```gotemplate
647<header class="repohead">
648 <div class="identity">
649 {{if field $ "RepoHome"}}<h1 class="repotitle"><a class="owner" href="/{{.OwnerName}}">{{.OwnerName}}</a><span class="sep">/</span>{{.Name}}</h1>
650 {{else}}<p class="repotitle"><a class="owner" href="/{{.OwnerName}}">{{.OwnerName}}</a><span class="sep">/</span><a href="/{{.OwnerName}}/{{.Name}}">{{.Name}}</a></p>{{end}}
651 {{if eq .Visibility "private"}}<span class="chip">Private</span>{{end}}
652 {{if .Settings.Archived}}<span class="chip">Archived</span>{{end}}
653 <span class="grow"></span>
654 {{if $.Viewer}}<form method="post" action="/{{.OwnerName}}/{{.Name}}/pin" class="inline"><button type="submit" class="btn" aria-pressed="{{if field $ "Pinned"}}true{{else}}false{{end}}"><span aria-hidden="true">{{if field $ "Pinned"}}★{{else}}☆{{end}}</span> {{if field $ "Pinned"}}Pinned{{else}}Pin{{end}}</button></form>
655 <form method="post" action="/{{.OwnerName}}/{{.Name}}/watch" class="inline"><button type="submit" class="btn" aria-pressed="{{if eq (str $ "Watch") "watching"}}true{{else}}false{{end}}">{{if eq (str $ "Watch") "watching"}}Watching{{else}}Watch{{end}}</button></form>
656 <form method="post" action="/{{.OwnerName}}/{{.Name}}/bookmark" class="inline"><button type="submit" class="btn" aria-pressed="{{if field $ "Marked"}}true{{else}}false{{end}}">{{if field $ "Marked"}}Bookmarked{{else}}Bookmark{{end}}</button></form>
657 <form method="post" action="/{{.OwnerName}}/{{.Name}}/fork" class="inline"><button type="submit" class="btn">Fork</button></form>{{end}}
658 </div>
659 {{$top := topTab (str $ "Tab")}}
660 {{/* Description, topics and metadata belong to the code tab. On task
661 tabs only the identity row and the tab bar render, and the header
662 is identical on every page within a tab. */}}
663 {{if eq $top "code"}}
664 <p class="repodesc">{{with field $ "Desc"}}{{.}}{{end}} {{with field $ "Topics"}}{{range .}}<a class="chip topic" href="/explore?q={{.}}">{{.}}</a> {{end}}{{end}}</p>
665 {{if or $.Repo.Settings.Website (field $ "Mirrors")}}<p class="repometa">{{with $.Repo.Settings.Website}}<a href="{{.}}" rel="nofollow">{{.}}</a>{{end}}{{range field $ "Mirrors"}} · {{if eq .Direction "push"}}mirrors to{{else}}mirrors from{{end}} <a href="{{.URL}}" rel="nofollow">{{.Target}}</a>{{if .Error}}, <span class="bad">sync error: {{.Error}}</span>{{else if .Synced}}, synced {{.Synced}}{{end}}{{end}}</p>{{end}}
666 {{if $.Viewer}}<p class="toggles">Pinned shows in your rail. Watching sends every issue, request and build to your inbox. Bookmarked lists it under Bookmarks.</p>{{end}}
667 {{end}}
668 <nav class="tabs" aria-label="Repository">
669 ...unchanged...
670 </nav>
671</header>
672```
673
674`topTab` (`internal/web/web.go:97`) maps `files`, `log`, `refs` and `search` to `"code"`, and the tree, blob, blame, commit and compare pages all set `Tab: "files"`, so `$top` is `"code"` on every code page.
675
676- [ ] **Step 4: Rewrite landing.html**
677
678```gotemplate
679{{define "title"}}{{.Site}}{{end}}
680{{define "width"}}bounded{{end}}
681{{define "content"}}
682<div class="landing">
683<h1>{{template "mark"}}{{.Site}}</h1>
684<p class="lede">A git forge you drive from the terminal. Repositories, issues, merge requests and CI over SSH, with a fast, readable web view of the same state.</p>
685<pre class="quickstart">ssh git@{{.Host}} help # every command, no client to install
686git clone ssh://git@{{.Host}}/owner/repo.git</pre>
687{{if .Picture}}<div class="shot"><picture>
688 <source srcset="/static/img/mr-dark.png" media="(prefers-color-scheme: dark)">
689 <img src="/static/img/mr-light.png" width="1280" height="900" alt="A merge request page: the conversation on the left, checks and reviewers on the right.">
690</picture></div>{{end}}
691<div class="facets">
692 <section><h2>Read</h2><p>Browse and clone any public repository over HTTPS or <code>git://</code>, no account. Every commit shows whether its signature verified.</p></section>
693 <section><h2>Write</h2><p>Push over SSH with the key you already have. Create a repository, file an issue, open and merge a request, all as commands.</p></section>
694 <section><h2>Review</h2><p>Read a diff, comment on a line, approve, merge, in the browser or the terminal.</p></section>
695</div>
696<div class="routes"><a class="button primary" href="/explore">Explore repositories</a>{{if .Signup}}<a class="button btn" href="/register">Create an account</a>{{end}}</div>
697{{if .Accounts}}<p class="meta">Have an account? {{if .EmailLogin}}<a href="/login">Sign in with an emailed link</a>, or run{{else}}Run{{end}} <code>ssh git@{{.Host}} web login</code>.</p>{{end}}
698</div>
699{{end}}
700```
701
702`Picture` and `EmailLogin` are new fields on the anonymous struct in `index` (`internal/httpd/web.go:162-168`, today `basePage`, `Host`, `Accounts`, `Signup`); add `Picture bool` set to `true` when both `/static/img/mr-dark.png` and `/static/img/mr-light.png` exist in `web.ImageFS` (Task 15 adds the FS; until then set it from a package variable `landingPicture = false` defined next to the handler and flipped in Task 15), and `EmailLogin bool` set from `s.emailLoginEnabled()`, as the login page does (`internal/httpd/accounts.go:91`). The `{{template "mark"}}` in the h1 renders the existing 19px SVG; add `.landing h1 .mark { width: 32px; height: 32px }` to the stylesheet.
703
704- [ ] **Step 5: Rewrite register.html**
705
706```gotemplate
707{{define "title"}}register · {{.Site}}{{end}}
708{{define "width"}}bounded{{end}}
709{{define "content"}}
710<h1>Create an account</h1>
711{{if eq .Mode "invite"}}<p class="lede">This instance is invite-only. You need an invite code from an admin.</p>
712{{else}}<p class="lede">Open registration. Your account activates once you verify your email.</p>{{end}}
713{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
714<form method="post" action="/register" class="signupform">
715<div class="field"><label for="username">Username</label><input type="text" id="username" name="username" value="{{.Username}}" required autofocus></div>
716{{if eq .Mode "invite"}}<div class="field"><label for="invite">Invite code</label><input type="text" id="invite" name="invite" required></div>
717{{else}}<div class="field"><label for="email">Email</label><input type="text" id="email" name="email" required></div>{{end}}
718<div class="field"><label for="key">SSH public key</label>
719<p class="hint">Paste the contents of your public key file, usually <code>~/.ssh/id_ed25519.pub</code>. It starts with <code>ssh-ed25519</code> or <code>ssh-rsa</code>. No key yet? <a href="/krz/gitbay/wiki/SSH-keys">Make one</a>.</p>
720<textarea id="key" name="key" rows="3" required placeholder="ssh-ed25519 AAAA... you@host"></textarea></div>
721<p><button type="submit">Create account</button></p>
722</form>
723<p class="meta">Prefer the terminal? <code>ssh git@{{.Host}} register --username you {{if eq .Mode "invite"}}--invite &lt;code&gt;{{else}}--email you@example.org{{end}}</code></p>
724{{end}}
725```
726
727The wiki link points at this repository's wiki on the instance; on another instance the page may not exist, which the spec accepts. In `login.html`, change the heading to `Sign in`, wrap the input in `<div class="field">`, and keep everything else.
728
729- [ ] **Step 6: Run the tests**
730
731Run: `go test ./internal/web ./internal/httpd && go test ./e2e -run 'TestReadmeRelativeLinks|TestLandingRoutes|TestAccounts' -v`
732Expected: PASS. (`TestAccounts` in `e2e/accounts_test.go` renders the landing with a title; it must still find the title string.)
733
734- [ ] **Step 7: Commit**
735
736```bash
737git add internal/web internal/httpd e2e/design_test.go
738git commit -m "web: two-level repository header, landing and register copy
739
740Ref #218"
741```
742
743### Task 9: repository overview
744
745**Files:**
746- Modify: `internal/web/templates/tree.html:5-34`
747- Test: `e2e/facts_test.go` (it reads `<p class="contribs">`; keep that markup)
748
749- [ ] **Step 1: Write the failing test**
750
751Append to `e2e/design_test.go`:
752
753```go
754// TestTreeSearchCodeAndClone: the overview links "Search code", not
755// "Find file", and shows two labelled clone blocks after the file table.
756func TestTreeSearchCodeAndClone(t *testing.T) {
757 inst := startInstance(t)
758 key := inst.newKey(t, "alice")
759 inst.admin(t, "admin", "user", "create", "alice", "--key", key+".pub")
760 inst.ssh(t, key, "", "repo", "create", "alice/app")
761 dir := t.TempDir()
762 run := func(args ...string) {
763 t.Helper()
764 cmd := exec.Command("git", args...)
765 cmd.Dir = dir
766 cmd.Env = append(os.Environ(), inst.gitEnv(key)...)
767 if out, err := cmd.CombinedOutput(); err != nil {
768 t.Fatalf("git %v: %v\n%s", args, err, out)
769 }
770 }
771 run("init", "-q", "-b", "main")
772 os.WriteFile(filepath.Join(dir, "README.md"), []byte("# app\n"), 0o644)
773 run("add", "."); run("-c", "user.name=a", "-c", "user.email=a@example.test", "commit", "-q", "-m", "init")
774 run("push", "-q", inst.sshURL("alice/app"), "main")
775 _, body := inst.get(t, "/alice/app")
776 if strings.Contains(body, ">Find file<") || !strings.Contains(body, ">Search code<") {
777 t.Error("overview still says Find file")
778 }
779 if i, j := strings.Index(body, `<table class="tree">`), strings.Index(body, `<div class="clone">`); i < 0 || j < i {
780 t.Error("clone block does not follow the file table")
781 }
782 if !strings.Contains(body, `<label>SSH</label>`) || !strings.Contains(body, `<label>HTTPS</label>`) {
783 t.Error("clone blocks are not labelled")
784 }
785}
786```
787
788Add `os`, `os/exec`, `path/filepath` to the imports. Copy the push helper shape from an existing e2e test that pushes (grep `gitEnv` in `e2e/git_test.go`) if this shape differs from the harness.
789
790- [ ] **Step 2: Run to verify it fails**
791
792Run: `go test ./e2e -run TestTreeSearchCodeAndClone -v`
793Expected: FAIL on "Find file".
794
795- [ ] **Step 3: Recompose tree.html**
796
797Replace lines 5-34 with:
798
799```gotemplate
800<div class="pathbar">
801 {{template "refmenu" .}}
802 <span class="crumbs"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a>/{{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}</span>
803 <div class="acts">
804 <a class="button btn" href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/search">Search code</a>
805 <a class="button btn" href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log/{{.Ref}}">History</a>
806 <a class="button btn" href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/archive/{{.Ref}}.tar.gz">Download</a>
807 </div>
808</div>
809```
810
811Move the `tipbar` block inside the table as its first row: in the `<table class="tree">`, before `<tr class="cols">`, render
812
813```gotemplate
814{{with .Tip}}{{if .SHA}}<tr class="tipbar"><td colspan="3"><span class="who">{{template "authorname" dict "Name" .Author "User" .User "Email" .Email}}</span> <a class="subject" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.Subject}}</a><span class="spacer"></span><a class="sha" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}"><code>{{short .SHA}}</code></a> <span class="age"{{if not .When.IsZero}} title="{{whenT .When}}"{{end}}>{{ago .When}}</span></td></tr>{{end}}{{end}}
815```
816
817and delete the old `<div class="tipbar">`. Keep `<tr class="cols">` with headers `Name`, `Last commit`, `Updated`.
818
819After the README section (the `section.readme` at the end of the file), when `.Entries` is non-empty, add:
820
821```gotemplate
822{{if .Entries}}<div class="facts">
823 <div class="clone">
824 <h2>Clone</h2>
825 <label>SSH</label><pre><code>git clone {{.SSHCloneURL}}</code></pre>
826 <label>HTTPS</label><pre><code>git clone {{.CloneURL}}</code></pre>
827 </div>
828 {{if .Facts.Commits}}{{$r := printf "/%s/%s" .Repo.OwnerName .Repo.Name}}<div class="about">
829 <h2>About</h2>
830 <div class="factgrid">
831 <a href="{{$r}}/log/{{.Ref}}"><b>{{.Facts.Commits}}</b> commit{{if ne .Facts.Commits 1}}s{{end}}</a>
832 <a href="{{$r}}/refs"><b>{{.Facts.Branches}}</b> branch{{if ne .Facts.Branches 1}}es{{end}}</a>
833 <a href="{{$r}}/refs"><b>{{.Facts.Tags}}</b> tag{{if ne .Facts.Tags 1}}s{{end}}</a>
834 {{if .Facts.Bookmarks}}<span><b>{{.Facts.Bookmarks}}</b> bookmark{{if ne .Facts.Bookmarks 1}}s{{end}}</span>{{end}}
835 {{with .Facts.License}}<span class="fact">{{.}}</span>{{end}}
836 {{with .Facts.Release}}<a href="{{$r}}/releases">latest <b>{{.}}</b></a>{{end}}
837 {{with .Facts.Build}}<a href="{{$r}}/builds">build <span class="badge badge-{{.}}">{{.}}</span></a>{{end}}
838 </div>
839 {{with .Facts.Languages}}<p class="langbar" aria-hidden="true">{{range .}}<span class="lang lang-{{slug .Name}}" style="width:{{pct .Percent}}%"></span>{{end}}</p>
840 <p class="langs">{{range .}}<span class="lang-name"><span class="dot lang-{{slug .Name}}"></span>{{.Name}} <span class="muted">{{pct .Percent}}%</span></span>{{end}}</p>{{end}}
841 {{with .Facts.Contributors}}<p class="contribs"><span class="label">{{len .}} contributor{{if ne (len .) 1}}s{{end}}</span>{{range .}}{{template "authorname" dict "Name" .Name "User" .User "Email" .Title}}{{end}}</p>{{end}}
842 </div>{{end}}
843</div>{{end}}
844```
845
846Delete the old `<p class="clone">` and the old `<div class="facts">` block. The labels `Name`, `Last commit`, `Updated` are what `TestHeaderRowsAreLeftAligned` reads; check it still passes. Add `.about` to the stylesheet beside `.clone`.
847
848- [ ] **Step 4: Run the tests**
849
850Run: `go test ./internal/web && go test ./e2e -run 'TestTreeSearchCodeAndClone|TestFacts|TestReadme' -v`
851Expected: PASS. `e2e/facts_test.go` cuts the body at `<p class="contribs">`, which is preserved.
852
853- [ ] **Step 5: Commit**
854
855```bash
856git add internal/web e2e/design_test.go
857git commit -m "web: repository overview leads with the files
858
859Ref #218"
860```
861
862### Task 10: merge request page
863
864**Files:**
865- Modify: `internal/httpd/web.go:1820-1960` (the `mr` handler struct and the diff computation)
866- Modify: `internal/web/templates/mr.html`
867- Test: `e2e/mrweb_test.go`
868
869**Interfaces:**
870- Produces: `HeadMerged bool` on the MR page struct: the head is already reachable from the target, so the diff is empty by construction.
871
872- [ ] **Step 1: Write the failing test**
873
874In `e2e/mrweb_test.go` add a test that opens an MR, merges its branch into the target by fast-forward through git (push the head to `main` directly with a key that has write access, in a repository without require-MR), then loads `?view=diff` and expects the explanation:
875
876```go
877// TestMRDiffEmptyExplained: a merge request whose head was fast-forwarded
878// into the target outside the request shows why its diff is empty.
879func TestMRDiffEmptyExplained(t *testing.T) {
880 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
881 key := inst.newKey(t, "alice")
882 inst.admin(t, "admin", "user", "create", "alice", "--key", key+".pub", "--email", "alice@example.test", "--verified")
883 inst.ssh(t, key, "", "repo", "create", "alice/app")
884 dir := pushInitial(t, inst, key, "alice/app") // reuse the helper this file already has for creating a repo with a main branch; if it is named differently, use that name
885 gitIn(t, dir, key, "checkout", "-q", "-b", "feature")
886 os.WriteFile(filepath.Join(dir, "f.txt"), []byte("one\n"), 0o644)
887 gitIn(t, dir, key, "add", "."); gitIn(t, dir, key, "commit", "-q", "-m", "one")
888 gitIn(t, dir, key, "push", "-q", inst.sshURL("alice/app"), "feature")
889 if _, errOut, code := inst.ssh(t, key, "", "mr", "create", "alice/app", "--source", "feature", "--target", "main", "--title", "one"); code != 0 {
890 t.Fatal(errOut)
891 }
892 gitIn(t, dir, key, "push", "-q", inst.sshURL("alice/app"), "feature:main")
893 _, body := inst.get(t, "/alice/app/mrs/1?view=diff")
894 if !strings.Contains(body, "No changes between the source and target.") ||
895 !strings.Contains(body, "already merged or fast-forwarded into <code>main</code>") {
896 t.Fatalf("empty diff unexplained:\n%s", body)
897 }
898}
899```
900
901Before writing it, read the top of `e2e/mrweb_test.go` and `e2e/git_test.go` for the helper names that create a repository with an initial commit and run git with the key's environment; use those exact names in place of `pushInitial` and `gitIn`. If none exist, write `gitIn(t, dir, key, args...)` in this file with the `exec.Command("git", args...)` shape from Task 9.
902
903- [ ] **Step 2: Run to verify it fails**
904
905Run: `go test ./e2e -run TestMRDiffEmptyExplained -v`
906Expected: FAIL: the body has `0 files changed` and no explanation.
907
908- [ ] **Step 3: Handler: compute HeadMerged**
909
910In `web.go`'s `mr` handler, after `files, diffTruncated` are computed, add:
911
912```go
913 headMerged := false
914 if len(files) == 0 && m.HeadSHA != "" {
915 if targetSHA, err := gitutil.ResolveRef(p.Dir, "refs/heads/"+m.TargetRef); err == nil {
916 if ok, err := gitutil.IsAncestor(p.Dir, m.HeadSHA, targetSHA); err == nil {
917 headMerged = ok
918 }
919 }
920 }
921```
922
923`gitutil.IsAncestor(dir, old, new)` reports whether `old` is an ancestor of `new`. Add `HeadMerged bool` to the anonymous struct and set `HeadMerged: headMerged`. The handler already resolves the target SHA for `Gates`; reuse that variable if it is in scope.
924
925- [ ] **Step 4: Template: title line, diff empty case, aside order**
926
927In `mr.html`:
928
929Title block (replace the `h1.issuetitle`, `p.issuemeta` and the stacked notes):
930
931```gotemplate
932<h1 class="issuetitle">{{.MR.Title}} <span class="issuenumber">!{{.MR.Number}}</span></h1>
933<p class="issuemeta"><span class="chip chip-{{.MR.State}}">{{if eq .MR.State "source_gone"}}source gone{{else}}{{.MR.State}}{{end}}</span>{{if .MR.Draft}} <span class="chip chip-neutral">draft</span>{{end}}
934 {{if eq .MR.State "merged"}}merged by <a href="/{{.MR.MergedBy}}">{{.MR.MergedBy}}</a> on {{when .MR.MergedAt}}{{else if eq .MR.State "closed"}}closed without merging by <a href="/{{.MR.ClosedBy}}">{{.MR.ClosedBy}}</a> on {{when .MR.ClosedAt}}{{else}}opened by <a href="/{{.MR.Author}}">{{.MR.Author}}</a> on {{when .MR.CreatedAt}}{{end}}
935 · <code>{{.MR.SourceRef}}</code> into <code>{{.MR.TargetRef}}</code></p>
936{{with .StackedOn}}<p class="meta">stacked on <a href="{{$base | dir}}/{{.Number}}">!{{.Number}}</a></p>{{end}}
937```
938
939`store.MR` has `Author`, `SourceRef`, `TargetRef`, `Title`, `MergedBy`, `MergedAt`, `ClosedBy`, `ClosedAt`, `CreatedAt` (`internal/store/mrs.go:9-37`). Keep the existing stacked-on/stacked-children paragraphs as they are if their expressions differ from the sketch above.
940
941Diff arm (replace lines 76-80):
942
943```gotemplate
944{{else}}
945{{if .DiffFiles}}<p class="diffstat">{{.Stat.Files}} file{{if ne .Stat.Files 1}}s{{end}} changed, <span class="add">+{{.Stat.Adds}}</span> <span class="del">−{{.Stat.Dels}}</span>{{if .DiffTruncated}} · shown up to 4 MiB; the counts and the last file are partial{{end}}</p>
946{{if .DiffTruncated}}<p class="error" role="alert">This diff is larger than 4 MiB and is cut off below. Fetch the branch to see all of it.</p>{{end}}
947{{template "difffiles" dict "Files" .DiffFiles "Base" $base "Viewer" .Viewer}}
948{{else}}<p class="empty-note">No changes between the source and target.{{if .HeadMerged}} The source branch was already merged or fast-forwarded into <code>{{.MR.TargetRef}}</code>.{{end}}</p>{{end}}
949{{end}}
950```
951
952Aside: reorder the `div.grp` blocks to Review (when open and can write), Merge, Merge gates, Checks, Reviews, Reviewers, Source and target (merge the old `Target` and `Source` groups into one `h2` "Source and target" with the source ref, target ref, head and base SHAs, and the delete-branch form), Milestone. Every button in the aside: Merge is primary; Approve, Request changes, Ready for review, Convert to draft are `btn`; Close without merging and Delete branch are `danger`. Wrap the conversation column's contents in `<div class="prose">`.
953
954- [ ] **Step 5: Run the tests**
955
956Run: `go build ./... && go vet ./... && go test ./internal/web ./internal/httpd && go test ./e2e -run 'TestMRDiffEmptyExplained|TestMRWeb|TestDiffWeb|TestDiffComment' -v`
957Expected: PASS.
958
959- [ ] **Step 6: Commit**
960
961```bash
962git add internal/httpd/web.go internal/web/templates/mr.html e2e/mrweb_test.go
963git commit -m "web: merge request page states its resolution and explains an empty diff
964
965Ref #218"
966```
967
968### Task 11: repository settings
969
970**Files:**
971- Modify: `internal/httpd/settings.go:18-26, 53-57, 111-120, 139-144`
972- Modify: `internal/web/templates/settings.html`
973- Test: `e2e/settingsweb_test.go`
974
975**Interfaces:**
976- Produces: `Saved bool` on `settingsPage`; success flash text `Saved the <field>.`; the `topics` form field `topics` (comma separated) replacing `add`/`remove`.
977
978- [ ] **Step 1: Write the failing test**
979
980In `e2e/settingsweb_test.go`, inside `TestRepoSettingsWeb` after the `post` closure, add:
981
982```go
983 if body := post(url.Values{"field": {"description"}, "description": {"a thing"}}); !strings.Contains(body, `class="notice" role="status">Saved the description.`) {
984 t.Fatalf("no success flash after saving the description:\n%s", body)
985 }
986 if body := post(url.Values{"field": {"topics"}, "topics": {"cli, forge"}}); !strings.Contains(body, `value="cli, forge"`) {
987 t.Fatalf("topics field is not prefilled after save:\n%s", body)
988 }
989 if body := post(url.Values{"field": {"topics"}, "topics": {"forge"}}); strings.Contains(body, `>cli<`) || !strings.Contains(body, `value="forge"`) {
990 t.Fatalf("removing a topic through the field failed:\n%s", body)
991 }
992 if body := post(url.Values{"field": {"website"}, "website": {"javascript:alert(1)"}}); !strings.Contains(body, `class="error"`) || !strings.Contains(body, `value="javascript:alert(1)"`) {
993 t.Fatalf("error does not keep the submitted website:\n%s", body)
994 }
995```
996
997Delete the older website assertion at lines 91-93 since this one supersedes it, and any assertion that posts `add`/`remove` topics.
998
999- [ ] **Step 2: Run to verify it fails**
1000
1001Run: `go test ./e2e -run TestRepoSettingsWeb -v`
1002Expected: FAIL on the success flash.
1003
1004- [ ] **Step 3: Handler**
1005
1006`settingsPage` gains `Saved bool` and `Submitted map[string]string`. In `settingsForm`, after `Notice: s.takeFlash(w, r)`, set `Saved: strings.HasPrefix(notice, "Saved ")` (take the flash into a local first). Retaining a submitted value on error: the redirect loses the form, so the error path re-renders instead. Change the tail of `settingsSubmit`:
1007
1008```go
1009 _, msg, ok := s.runControl(u, argv)
1010 if ok {
1011 s.settingsRedirect(w, r, "Saved the "+fieldLabel(field)+".")
1012 return
1013 }
1014 s.settingsFormWith(w, r, msg, r.Form)
1015```
1016
1017where `fieldLabel` maps the `field` value to its label in lower case (`description`, `website`, `visibility`, `default branch`, `git:// serving`, `required checks`, `approvals`, `review threads`, `CODEOWNERS`, `require-MR`, `signed commits`, `protected branch`, `protected tag`, `dependency scanning`, `archive`, `topics`, `runner`), and `settingsFormWith(w, r, notice string, submitted url.Values)` is `settingsForm` refactored to take the notice and a map of submitted values that the template prefers over stored ones: `Submitted: map[string]string{"description": submitted.Get("description"), "website": submitted.Get("website"), "topics": submitted.Get("topics")}`. `settingsForm` calls `settingsFormWith(w, r, s.takeFlash(w, r), nil)`.
1018
1019Topics case:
1020
1021```go
1022 case "topics":
1023 want := map[string]bool{}
1024 var order []string
1025 for _, t := range strings.Split(v("topics"), ",") {
1026 if t = strings.ToLower(strings.TrimSpace(t)); t != "" && !want[t] {
1027 want[t] = true
1028 order = append(order, t)
1029 }
1030 }
1031 have, err := s.st.ListTopics(p.Repo.ID)
1032 if err != nil {
1033 s.settingsRedirect(w, r, err.Error())
1034 return
1035 }
1036 var add, remove []string
1037 for _, t := range order {
1038 if !slices.Contains(have, t) {
1039 add = append(add, t)
1040 }
1041 }
1042 for _, t := range have {
1043 if !want[t] {
1044 remove = append(remove, t)
1045 }
1046 }
1047 if len(remove) > 0 {
1048 if _, msg, ok := s.runControl(u, append([]string{"repo", "topics", "remove", repo}, remove...)); !ok {
1049 s.settingsFormWith(w, r, msg, r.Form)
1050 return
1051 }
1052 }
1053 if len(add) > 0 {
1054 argv = append([]string{"repo", "topics", "add", repo}, add...)
1055 } else {
1056 s.settingsRedirect(w, r, "Saved the topics.")
1057 return
1058 }
1059```
1060
1061`p.Repo.ID` is however the handler names the resolved repository; read the top of `settingsSubmit` for the variable. `s.st.ListTopics` already exists (used in `settingsForm`).
1062
1063- [ ] **Step 4: Template**
1064
1065Rewrite `settings.html` after the title line:
1066
1067```gotemplate
1068{{define "width"}}bounded{{end}}
1069{{define "content"}}
1070{{$base := printf "/%s/%s/settings" .Repo.OwnerName .Repo.Name}}
1071<h1>Settings</h1>
1072{{if .Notice}}{{if .Saved}}<p class="notice" role="status">{{.Notice}}</p>{{else}}<p class="error" role="alert">{{.Notice}}</p>{{end}}{{end}}
1073<nav class="sections" aria-label="Sections"><a href="#identity">Identity</a><a href="#access">Access</a><a href="#gates">Merge gates</a><a href="#branches">Protected branches</a><a href="#tags">Protected tags</a>{{if .DepsEnabled}}<a href="#deps">Dependencies</a>{{end}}<a href="#runners">Runners</a><a href="#lifecycle">Lifecycle</a></nav>
1074
1075<section id="identity"><h2>Identity</h2>
1076<form method="post" action="{{$base}}" class="setform">
1077 <input type="hidden" name="field" value="description">
1078 <div><label for="description">Description</label></div>
1079 <div><input type="text" id="description" name="description" value="{{or (index .Submitted "description") .Desc}}" placeholder="one line, shown in listings"></div>
1080 <div><button type="submit">Save</button></div>
1081</form>
1082<form method="post" action="{{$base}}" class="setform">
1083 <input type="hidden" name="field" value="website">
1084 <div><label for="website">Website</label></div>
1085 <div><input type="text" id="website" name="website" value="{{or (index .Submitted "website") .Repo.Settings.Website}}" placeholder="https://example.org"></div>
1086 <div><button type="submit" class="btn">Save</button></div>
1087</form>
1088<form method="post" action="{{$base}}" class="setform">
1089 <input type="hidden" name="field" value="topics">
1090 <div><label for="topics">Topics</label><p class="hint">Comma separated, lower case.</p></div>
1091 <div><input type="text" id="topics" name="topics" value="{{or (index .Submitted "topics") (join .Topics ", ")}}"></div>
1092 <div><button type="submit" class="btn">Save</button></div>
1093</form>
1094{{if .Branches}}<form method="post" action="{{$base}}" class="setform">
1095 <input type="hidden" name="field" value="default-branch">
1096 <div><label for="default-branch">Default branch</label><p class="hint">What a clone checks out and what CI builds on trigger.</p></div>
1097 <div><select id="default-branch" name="default-branch">{{$cur := .Repo.DefaultBranch}}{{range .Branches}}<option value="{{.Name}}"{{if eq .Name $cur}} selected{{end}}>{{.Name}}</option>{{end}}</select></div>
1098 <div><button type="submit" class="btn">Save</button></div>
1099</form>{{end}}
1100</section>
1101```
1102
1103`join` is a new template func: add `"join": strings.Join` to `funcs` in `internal/web/web.go`. `.Submitted` is nil on a plain GET; `index` on a nil map returns the zero string, and `or` falls through to the stored value.
1104
1105Then the remaining sections in the same three-column shape, each control's hint from the spec:
1106
1107- Access: Visibility (radio Public / Private; hint "Private repositories answer not found to everyone without access, including in search and on your profile."), git:// serving (checkbox; hint "Unauthenticated, unencrypted read access on port 9418. Public repositories only.").
1108- Merge gates: Required checks ("A request waits until every CI job that would report on its head has succeeded."), Approvals (number, "Approvals from people with write access. Zero means none required."), Review threads ("Every thread on the diff must be resolved before merging."), CODEOWNERS ("Owners of every touched path must approve."), Signed commits ("Every commit in the request must carry a verified signature. Squash and merge strategies are refused, since both mint an unsigned commit.").
1109- Protected branches: each protected branch a row with its name, a hint "Direct pushes refused, merge requests only." when require-MR is on, and `Unprotect` as `danger`; the protect form; the require-MR checkbox with hint "Every protected branch changes through a merge request. A direct push is refused in pre-receive."
1110- Protected tags: same shape with `danger` Unprotect.
1111- Dependencies, Runners: rows as today, `Detach` as `danger`, the attach textarea in a `setform stack`.
1112- Lifecycle: Archive with hint "Read-only for everyone. Issues and requests close to new activity. Reversible." and the `confirmfield` beside a `danger` button.
1113
1114Keep every `name=` and `field` value exactly as `settingsSubmit` reads them (list in `internal/httpd/settings.go:67-133`).
1115
1116- [ ] **Step 5: Run the tests**
1117
1118Run: `go build ./... && go vet ./... && go test ./internal/web ./internal/httpd && go test ./e2e -run TestRepoSettingsWeb -v`
1119Expected: PASS.
1120
1121- [ ] **Step 6: Commit**
1122
1123```bash
1124git add internal/httpd/settings.go internal/web
1125git commit -m "web: settings as bounded rows with consequences and a saved flash
1126
1127Ref #218"
1128```
1129
1130### Task 12: dashboard aside, visual check, MR 2
1131
1132**Files:**
1133- Modify: `internal/web/templates/dashboard.html` (remove the Pinned `div.grp` from the aside)
1134- Test: `e2e/dashboard_test.go` (if it asserts the pinned group in the aside, change it to assert the rail's pinned list instead: `class="raillist" aria-labelledby="rail-pinned"`)
1135
1136- [ ] **Step 1: Remove the duplicate pinned group and run the dashboard test**
1137
1138Run: `go test ./e2e -run TestDashboard -v`
1139Expected: PASS after the assertion change.
1140
1141- [ ] **Step 2: Visual and keyboard check**
1142
1143Rerun Task 5's script and walk: landing, register, login, repository overview, MR (three views), settings, dashboard, issues, explore at 1280 and 375, both schemes, and the keyboard pass on the five reference pages. Compare against the mockups. Commit fixes as `web: <what>`.
1144
1145- [ ] **Step 3: Push, open MR 2, merge**
1146
1147```bash
1148git push -u origin design-pages
1149gitbay mr create --source design-pages --target main --title "web: content widths and the four reference pages" --file - <<'EOF'
1150A width class per page; the repository header trimmed on task tabs; landing, register, repository overview, merge request and repository settings recomposed per the design spec; Search code replaces Find file; settings report a saved flash and keep a rejected value; topics are one field.
1151
1152Ref #218
1153EOF
1154```
1155
1156Wait for green, `gitbay mr merge <n> --strategy ff`, delete the branch both places.
1157
1158---
1159
1160## MR 3: wiki and docs
1161
1162Branch `design-docs` off `main`. Task 13.
1163
1164### Task 13: SSH-keys page, Admin title, Users vocabulary, CHANGELOG
1165
1166**Files:**
1167- Create: `.gitbay/wiki/SSH-keys.org`
1168- Modify: `.gitbay/wiki/Admin.org:84-89`, `.gitbay/wiki/Users.org:53-77`, `.gitbay/wiki/Home.org`, `CHANGELOG.org`
1169
1170- [ ] **Step 1: Write SSH-keys.org**
1171
1172```org
1173#+title: SSH keys
1174
1175Your key is your identity on gitbay: there are no passwords. Registering
1176pastes the public half of a key pair; the private half never leaves your
1177machine.
1178
1179* Make a key
1180
1181#+begin_src sh
1182ssh-keygen -t ed25519 -C "you@host"
1183#+end_src
1184
1185Accept the default path. Two files land in =~/.ssh/=: =id_ed25519= is
1186private, keep it; =id_ed25519.pub= is public, paste it.
1187
1188* Get the public half
1189
1190#+begin_src sh
1191cat ~/.ssh/id_ed25519.pub
1192#+end_src
1193
1194One line, starting =ssh-ed25519 AAAA…= and ending with your comment. That
1195whole line is what the registration form and =register= want.
1196
1197* Check it works
1198
1199#+begin_src sh
1200ssh git@<host> whoami
1201#+end_src
1202
1203More keys, labels and scopes: [[Users][the user guide]] under "SSH keys".
1204```
1205
1206- [ ] **Step 2: Edit the other pages**
1207
1208`Users.org` under `* SSH keys`: add as the first paragraph "New to SSH keys? [[SSH-keys]] makes one in two commands." `Home.org`: add `- [[SSH-keys][SSH keys]] — make a key and find its public half` after the Quickstart line. `Admin.org` under `** [web]`: add bullets `- =title= — the instance's display name in the rail and page titles; empty falls back to the site host. Lower case is the convention for gitbay itself.` and `- =privacy_notice= — operator text shown on =/privacy= under the fixed statement.`
1209
1210Users.org under "Output rules" or a new `* Web vocabulary` heading: "Sign in / Log out, Search, sentence-case headings and buttons, product name lowercase."
1211
1212- [ ] **Step 3: CHANGELOG entry**
1213
1214Insert above `* v1.22.1`:
1215
1216```org
1217* v1.23.0 — <date of the release>
1218
1219The web design foundation (#218). Tokens measured in both schemes, one
1220control family, content widths per page, and the landing, repository
1221overview, merge request and settings pages recomposed. Set =[web]
1222title= to =gitbay= in lower case; the templates already are.
1223
1224- =style.css= rewritten on tokens defined on =:root= and again for dark;
1225 =TestTokenContrast= enforces the floors and
1226 =TestEveryTemplateClassHasARule= that every template class has a rule.
1227- Buttons are primary, secondary, quiet or destructive; one focus ring on
1228 everything; success and error flashes share one shape.
1229- Every page picks a width: wide for code, reading for lists and threads,
1230 bounded for forms.
1231- The repository header shows description and topics on the code tab
1232 only; the overview leads with the file table and puts clone commands
1233 and facts after the README; "Find file" is "Search code".
1234- A merge request states who merged or closed it under its title and
1235 explains an empty diff.
1236- Repository settings are bounded rows with a consequence beside each
1237 control, a saved flash, a rejected value kept, and topics as one field.
1238- Landing copy says what the product is and where to go; the register
1239 form says to paste the contents of the key file and links [[SSH-keys]].
1240```
1241
1242Fill the date when the release is cut.
1243
1244- [ ] **Step 4: Commit, MR, merge**
1245
1246```bash
1247git add .gitbay/wiki CHANGELOG.org
1248git commit -m "wiki: SSH keys page, web title, design vocabulary; CHANGELOG v1.23.0
1249
1250Ref #218"
1251git push -u origin design-docs
1252gitbay mr create --source design-docs --target main --title "wiki: SSH keys page and the v1.23.0 changelog" --file - <<'EOF'
1253Ref #218
1254EOF
1255```
1256
1257The `test` job skips wiki-only changes; the `build` job still runs. Merge with ff, delete the branch.
1258
1259---
1260
1261## MR 4: deploy, screenshots, release
1262
1263Branch `design-picture` off `main`. Tasks 14 and 15.
1264
1265### Task 14: deploy and capture
1266
1267- [ ] **Step 1: Deploy main**
1268
1269```bash
1270git switch main && git pull --ff-only && make deploy
1271```
1272
1273Then the user sets `title = "gitbay"` under `[web]` in `/etc/gitbay/config.toml` on bay1 and restarts; hand that to them, do not ssh to bay1.
1274
1275- [ ] **Step 2: Capture the after set**
1276
1277Follow `.claude/screenshots/README.md` (`python3 shoot.py after/...`). Compare `after/` with `before/` page by page; anything wrong is a fix on a new branch before the release.
1278
1279- [ ] **Step 3: Capture the landing picture**
1280
1281```bash
1282cd .claude/screenshots
1283printf 'mr https://gitbay.org/krz/gitbay/mrs/315\n' > picture.txt
1284python3 shoot.py pic/dark dark 1280 picture.txt
1285python3 shoot.py pic/light light 1280 picture.txt
1286```
1287
1288`shoot.py` captures full page; crop each to 1280x900 from the top (`sips -c 900 1280 pic/dark/mr.png --out mr-dark.png`, same for light), then `pngquant`-free size check: each under 400KB (`ls -l`); if larger, `sips -s format png` at `--resampleWidth 1280` is already the size, so reduce with `sips -s formatOptions 70` to JPEG only if PNG cannot get under 1MB. Copy to `internal/web/static/img/mr-dark.png` and `mr-light.png`.
1289
1290### Task 15: serve the images and wire the picture
1291
1292**Files:**
1293- Modify: `internal/web/web.go` (embed), `internal/httpd/routes.go:52-56`, `internal/httpd/web.go:95-107`, `internal/httpd/web.go` (landing struct `Picture`)
1294- Test: `internal/httpd/fonts_test.go` (extend), `e2e/design_test.go` (extend `TestLandingRoutes`)
1295
1296- [ ] **Step 1: Write the failing tests**
1297
1298Append to `internal/httpd/fonts_test.go`:
1299
1300```go
1301// TestLandingImagesAreServed: every file under static/img has a route
1302// that answers 200 with an image type and the stylesheet's cache policy.
1303func TestLandingImagesAreServed(t *testing.T) {
1304 s := newTestServer(t) // the constructor the file's other test uses
1305 entries, err := fs.ReadDir(web.ImageFS, "static/img")
1306 if err != nil {
1307 t.Fatal(err)
1308 }
1309 if len(entries) == 0 {
1310 t.Fatal("no images embedded")
1311 }
1312 for _, e := range entries {
1313 rec := httptest.NewRecorder()
1314 s.Handler().ServeHTTP(rec, httptest.NewRequest("GET", "/static/img/"+e.Name(), nil))
1315 if rec.Code != 200 || !strings.HasPrefix(rec.Header().Get("Content-Type"), "image/") {
1316 t.Errorf("%s: %d %s", e.Name(), rec.Code, rec.Header().Get("Content-Type"))
1317 }
1318 }
1319}
1320```
1321
1322Use the same server construction and request shape as `TestStylesheetFontsAreServed` in that file. In `e2e/design_test.go`'s `TestLandingRoutes` add `"/static/img/mr-dark.png"` and `<picture>` to the wanted strings.
1323
1324- [ ] **Step 2: Run to verify they fail**
1325
1326Run: `go test ./internal/httpd -run TestLandingImagesAreServed`
1327Expected: FAIL to compile on `web.ImageFS`.
1328
1329- [ ] **Step 3: Implement**
1330
1331`internal/web/web.go`: add
1332
1333```go
1334//go:embed static/img/*.png
1335var ImageFS embed.FS
1336```
1337
1338`routes.go`, after the font loop:
1339
1340```go
1341 images, _ := fs.ReadDir(web.ImageFS, "static/img")
1342 for _, f := range images {
1343 routes = append(routes, Route{Method: "GET", Pattern: "/static/img/" + f.Name(), Handler: s.image})
1344 }
1345```
1346
1347`web.go` beside `font`:
1348
1349```go
1350// image serves the embedded landing pictures with the font cache policy.
1351func (s *Server) image(w http.ResponseWriter, r *http.Request) {
1352 data, err := web.ImageFS.ReadFile("static" + r.URL.Path[len("/static"):])
1353 if err != nil {
1354 http.NotFound(w, r)
1355 return
1356 }
1357 w.Header().Set("Content-Type", "image/png")
1358 w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
1359 w.Write(data)
1360}
1361```
1362
1363In the landing handler replace the `landingPicture` variable from Task 8 with a check that both files exist in `web.ImageFS` (`fs.Stat`), computed once at server construction.
1364
1365- [ ] **Step 4: Run the tests**
1366
1367Run: `go build ./... && go vet ./... && go test ./internal/web ./internal/httpd && go test ./e2e -run 'TestLandingRoutes|TestTopLevelRouteWordsAreReserved' -v`
1368Expected: PASS; `static` is already reserved.
1369
1370- [ ] **Step 5: Commit, MR, merge, release**
1371
1372```bash
1373git add internal/web internal/httpd e2e/design_test.go
1374git commit -m "web: landing picture, light and dark
1375
1376Closes #218"
1377git push -u origin design-picture
1378gitbay mr create --source design-picture --target main --title "web: landing picture" --file - <<'EOF'
1379Closes #218
1380EOF
1381```
1382
1383After the merge: fill the CHANGELOG date in a one-line commit on a branch if it was left blank, then follow the release steps the previous versions used (`deploy/release.sh`, tag `v1.23.0` on main, `make deploy`, bump the tap). Capture the after set once more so `.claude/screenshots/after/` shows the released state.
1384
1385---
1386
1387## Self-review against the spec
1388
1389- Rules: two accents (Task 3), link/fill split and token test (Tasks 1, 3), class coverage (Task 2), authored states (Task 3), lowercase name (Tasks 4, 13, 14).
1390- Tokens, type, spacing, radius: Task 3.
1391- Components: Task 3 rules, Task 4 markup; flash family resolved as shared `.error`/`.notice` rules.
1392- Shell and layout: widths (Task 7), aside 18rem and stacking above (Task 3), rail (Task 3), header (Task 8), page head and filters (Tasks 3, 4), responsive (Tasks 3, 5).
1393- Reference pages: landing and register (Task 8), overview (Task 9), MR (Task 10), settings (Task 11), dashboard pinned (Task 12).
1394- Copy: Tasks 4, 8, 9, 13.
1395- Landing order: MRs 1 to 4. Verification: Tasks 5, 12, 14. Not in spec: filed as issues when MR 4 merges.