docs/plans/2026-09-17-web-design-foundation.md

v1.30.0
gitbay/docs/plans/2026-09-17-web-design-foundation.md rendered · source · history · blame · raw

1395 lines · 72302 bytes

   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.