Commit 82ae29eaa8
Verified · cmc
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 | ||
| 29 | Branch `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 | |
| 42 | package web | |
| 43 | ||
| 44 | import ( | |
| 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. | |
| 55 | func 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 | ||
| 97 | func 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 | ||
| 106 | func 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 | ||
| 114 | func 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. | |
| 126 | func 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 | ||
| 179 | Run: `go test ./internal/web -run TestTokenContrast -v` | |
| 180 | Expected: 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 | |
| 185 | git add internal/web/tokens_test.go | |
| 186 | git commit -m "web: token contrast test | |
| 187 | ||
| 188 | Ref #218" | |
| 189 | ``` | |
| 190 | ||
| 191 | The 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 | |
| 201 | package web | |
| 202 | ||
| 203 | import ( | |
| 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. | |
| 216 | func 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 | ||
| 296 | func 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 | ||
| 311 | Run: `go test ./internal/web -run TestEveryTemplateClassHasARule -v` | |
| 312 | Expected: 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 | |
| 317 | git add internal/web/classes_test.go | |
| 318 | git commit -m "web: every template class has a stylesheet rule | |
| 319 | ||
| 320 | Ref #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 | ||
| 334 | Copy `.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 | ||
| 347 | Change `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 | ||
| 351 | Work 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 | ||
| 375 | Every 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 | ||
| 379 | Run: `go test ./internal/web -v` | |
| 380 | Expected: `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 | ||
| 384 | Run: `go test ./internal/httpd -run 'TestStylesheetFontsAreServed|TestStylesheetRevalidates|TestSyntaxPaletteContrast' -v` | |
| 385 | Expected: 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 | |
| 390 | git add internal/web/static/style.css internal/httpd/palette_test.go | |
| 391 | git commit -m "web: rewrite the stylesheet on measured tokens | |
| 392 | ||
| 393 | Ref #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 | ||
| 401 | The 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 | ||
| 405 | In 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 | ||
| 417 | Run: `go test ./internal/web ./internal/httpd -run 'TestEveryPageTemplateParses|TestEveryTemplateClassHasARule|TestEveryInputHasAnAccessibleName|TestHeaderRowsAreLeftAligned' -v` | |
| 418 | Expected: PASS. | |
| 419 | ||
| 420 | - [ ] **Step 5: Commit** | |
| 421 | ||
| 422 | ```bash | |
| 423 | git add internal/web/templates | |
| 424 | git commit -m "web: button treatments, explicit search buttons, sentence case | |
| 425 | ||
| 426 | Ref #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. | |
| 440 | set -eu | |
| 441 | ROOT=${ROOT:-/tmp/gitbay-local} | |
| 442 | rm -rf "$ROOT"; mkdir -p "$ROOT" | |
| 443 | go build -o "$ROOT/gitbayd" ./cmd/gitbayd | |
| 444 | cat > "$ROOT/config.toml" <<EOF | |
| 445 | [server] | |
| 446 | root = "$ROOT/data" | |
| 447 | site_url = "http://127.0.0.1:8090" | |
| 448 | [ssh] | |
| 449 | port = 8022 | |
| 450 | [http] | |
| 451 | addr = "127.0.0.1:8090" | |
| 452 | tls = "off" | |
| 453 | [web] | |
| 454 | mode = "accounts" | |
| 455 | title = "gitbay" | |
| 456 | EOF | |
| 457 | "$ROOT/gitbayd" --config "$ROOT/config.toml" serve >"$ROOT/log" 2>&1 & | |
| 458 | echo $! > "$ROOT/pid" | |
| 459 | sleep 1 | |
| 460 | ssh-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 | |
| 463 | S="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 | |
| 465 | GIT_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' | |
| 468 | research options for ios notification support for builds, MRs, assignments, etc. | |
| 469 | EOF | |
| 470 | echo "base http://127.0.0.1:8090" | |
| 471 | $S web login | |
| 472 | ``` | |
| 473 | ||
| 474 | Run 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 | ||
| 478 | In 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 | ||
| 482 | Run: `go build ./... && go vet ./... && go test ./internal/web ./internal/httpd` | |
| 483 | Expected: PASS. | |
| 484 | ||
| 485 | ### Task 6: open and merge MR 1 | |
| 486 | ||
| 487 | - [ ] **Step 1: Push and open the MR** | |
| 488 | ||
| 489 | ```bash | |
| 490 | git push -u origin design-stylesheet | |
| 491 | gitbay mr create --source design-stylesheet --target main --title "web: stylesheet rewrite on measured tokens" --file - <<'EOF' | |
| 492 | Replaces 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 | ||
| 494 | Ref #218 | |
| 495 | EOF | |
| 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 | |
| 503 | gitbay mr merge <n> --strategy ff | |
| 504 | git switch main && git pull --ff-only && git branch -d design-stylesheet && git push origin --delete design-stylesheet | |
| 505 | ``` | |
| 506 | ||
| 507 | If 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 | ||
| 513 | Branch `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 | ||
| 524 | Append 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. | |
| 530 | func 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 | ||
| 561 | Add `"strings"` to the imports if missing. | |
| 562 | ||
| 563 | - [ ] **Step 2: Run it to verify it fails** | |
| 564 | ||
| 565 | Run: `go test ./internal/web -run TestMainWidthClass -v` | |
| 566 | Expected: FAIL on every wide and bounded page and on the layout lines. | |
| 567 | ||
| 568 | - [ ] **Step 3: Implement** | |
| 569 | ||
| 570 | In `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 | ||
| 574 | Run: `go test ./internal/web -v` | |
| 575 | Expected: PASS, including `TestEveryPageTemplateParses`. | |
| 576 | ||
| 577 | - [ ] **Step 5: Commit** | |
| 578 | ||
| 579 | ```bash | |
| 580 | git add internal/web | |
| 581 | git commit -m "web: content width chosen per page | |
| 582 | ||
| 583 | Ref #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 | ||
| 595 | In `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 | ||
| 608 | Read the existing function first: it asserts the chips on those pages today, so delete those assertions when adding the inverted ones. | |
| 609 | ||
| 610 | Add a new test in the same file: | |
| 611 | ||
| 612 | ```go | |
| 613 | // TestLandingRoutes checks the landing page's copy and the two routes. | |
| 614 | func 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 | ||
| 639 | Run: `go test ./e2e -run 'TestReadmeRelativeLinks|TestLandingRoutes' -v` | |
| 640 | Expected: FAIL on the topic assertions and the landing strings. | |
| 641 | ||
| 642 | - [ ] **Step 3: Rewrite the header block** | |
| 643 | ||
| 644 | In `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 | |
| 686 | git 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 <code>{{else}}--email you@example.org{{end}}</code></p> | |
| 724 | {{end}} | |
| 725 | ``` | |
| 726 | ||
| 727 | The 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 | ||
| 731 | Run: `go test ./internal/web ./internal/httpd && go test ./e2e -run 'TestReadmeRelativeLinks|TestLandingRoutes|TestAccounts' -v` | |
| 732 | Expected: 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 | |
| 737 | git add internal/web internal/httpd e2e/design_test.go | |
| 738 | git commit -m "web: two-level repository header, landing and register copy | |
| 739 | ||
| 740 | Ref #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 | ||
| 751 | Append 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. | |
| 756 | func 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 | ||
| 788 | Add `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 | ||
| 792 | Run: `go test ./e2e -run TestTreeSearchCodeAndClone -v` | |
| 793 | Expected: FAIL on "Find file". | |
| 794 | ||
| 795 | - [ ] **Step 3: Recompose tree.html** | |
| 796 | ||
| 797 | Replace 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 | ||
| 811 | Move 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 | ||
| 817 | and delete the old `<div class="tipbar">`. Keep `<tr class="cols">` with headers `Name`, `Last commit`, `Updated`. | |
| 818 | ||
| 819 | After 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 | ||
| 846 | Delete 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 | ||
| 850 | Run: `go test ./internal/web && go test ./e2e -run 'TestTreeSearchCodeAndClone|TestFacts|TestReadme' -v` | |
| 851 | Expected: PASS. `e2e/facts_test.go` cuts the body at `<p class="contribs">`, which is preserved. | |
| 852 | ||
| 853 | - [ ] **Step 5: Commit** | |
| 854 | ||
| 855 | ```bash | |
| 856 | git add internal/web e2e/design_test.go | |
| 857 | git commit -m "web: repository overview leads with the files | |
| 858 | ||
| 859 | Ref #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 | ||
| 874 | In `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. | |
| 879 | func 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 | ||
| 901 | Before 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 | ||
| 905 | Run: `go test ./e2e -run TestMRDiffEmptyExplained -v` | |
| 906 | Expected: FAIL: the body has `0 files changed` and no explanation. | |
| 907 | ||
| 908 | - [ ] **Step 3: Handler: compute HeadMerged** | |
| 909 | ||
| 910 | In `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 | ||
| 927 | In `mr.html`: | |
| 928 | ||
| 929 | Title 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 | ||
| 941 | Diff 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 | ||
| 952 | Aside: 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 | ||
| 956 | Run: `go build ./... && go vet ./... && go test ./internal/web ./internal/httpd && go test ./e2e -run 'TestMRDiffEmptyExplained|TestMRWeb|TestDiffWeb|TestDiffComment' -v` | |
| 957 | Expected: PASS. | |
| 958 | ||
| 959 | - [ ] **Step 6: Commit** | |
| 960 | ||
| 961 | ```bash | |
| 962 | git add internal/httpd/web.go internal/web/templates/mr.html e2e/mrweb_test.go | |
| 963 | git commit -m "web: merge request page states its resolution and explains an empty diff | |
| 964 | ||
| 965 | Ref #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 | ||
| 980 | In `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 | ||
| 997 | Delete 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 | ||
| 1001 | Run: `go test ./e2e -run TestRepoSettingsWeb -v` | |
| 1002 | Expected: 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 | ||
| 1017 | where `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 | ||
| 1019 | Topics 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 | ||
| 1065 | Rewrite `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 | ||
| 1105 | Then 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 | ||
| 1114 | Keep 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 | ||
| 1118 | Run: `go build ./... && go vet ./... && go test ./internal/web ./internal/httpd && go test ./e2e -run TestRepoSettingsWeb -v` | |
| 1119 | Expected: PASS. | |
| 1120 | ||
| 1121 | - [ ] **Step 6: Commit** | |
| 1122 | ||
| 1123 | ```bash | |
| 1124 | git add internal/httpd/settings.go internal/web | |
| 1125 | git commit -m "web: settings as bounded rows with consequences and a saved flash | |
| 1126 | ||
| 1127 | Ref #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 | ||
| 1138 | Run: `go test ./e2e -run TestDashboard -v` | |
| 1139 | Expected: PASS after the assertion change. | |
| 1140 | ||
| 1141 | - [ ] **Step 2: Visual and keyboard check** | |
| 1142 | ||
| 1143 | Rerun 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 | |
| 1148 | git push -u origin design-pages | |
| 1149 | gitbay mr create --source design-pages --target main --title "web: content widths and the four reference pages" --file - <<'EOF' | |
| 1150 | A 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 | ||
| 1152 | Ref #218 | |
| 1153 | EOF | |
| 1154 | ``` | |
| 1155 | ||
| 1156 | Wait for green, `gitbay mr merge <n> --strategy ff`, delete the branch both places. | |
| 1157 | ||
| 1158 | --- | |
| 1159 | ||
| 1160 | ## MR 3: wiki and docs | |
| 1161 | ||
| 1162 | Branch `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 | ||
| 1175 | Your key is your identity on gitbay: there are no passwords. Registering | |
| 1176 | pastes the public half of a key pair; the private half never leaves your | |
| 1177 | machine. | |
| 1178 | ||
| 1179 | * Make a key | |
| 1180 | ||
| 1181 | #+begin_src sh | |
| 1182 | ssh-keygen -t ed25519 -C "you@host" | |
| 1183 | #+end_src | |
| 1184 | ||
| 1185 | Accept the default path. Two files land in =~/.ssh/=: =id_ed25519= is | |
| 1186 | private, keep it; =id_ed25519.pub= is public, paste it. | |
| 1187 | ||
| 1188 | * Get the public half | |
| 1189 | ||
| 1190 | #+begin_src sh | |
| 1191 | cat ~/.ssh/id_ed25519.pub | |
| 1192 | #+end_src | |
| 1193 | ||
| 1194 | One line, starting =ssh-ed25519 AAAA…= and ending with your comment. That | |
| 1195 | whole line is what the registration form and =register= want. | |
| 1196 | ||
| 1197 | * Check it works | |
| 1198 | ||
| 1199 | #+begin_src sh | |
| 1200 | ssh git@<host> whoami | |
| 1201 | #+end_src | |
| 1202 | ||
| 1203 | More 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 | ||
| 1210 | Users.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 | ||
| 1214 | Insert above `* v1.22.1`: | |
| 1215 | ||
| 1216 | ```org | |
| 1217 | * v1.23.0 — <date of the release> | |
| 1218 | ||
| 1219 | The web design foundation (#218). Tokens measured in both schemes, one | |
| 1220 | control family, content widths per page, and the landing, repository | |
| 1221 | overview, merge request and settings pages recomposed. Set =[web] | |
| 1222 | title= 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 | ||
| 1242 | Fill the date when the release is cut. | |
| 1243 | ||
| 1244 | - [ ] **Step 4: Commit, MR, merge** | |
| 1245 | ||
| 1246 | ```bash | |
| 1247 | git add .gitbay/wiki CHANGELOG.org | |
| 1248 | git commit -m "wiki: SSH keys page, web title, design vocabulary; CHANGELOG v1.23.0 | |
| 1249 | ||
| 1250 | Ref #218" | |
| 1251 | git push -u origin design-docs | |
| 1252 | gitbay mr create --source design-docs --target main --title "wiki: SSH keys page and the v1.23.0 changelog" --file - <<'EOF' | |
| 1253 | Ref #218 | |
| 1254 | EOF | |
| 1255 | ``` | |
| 1256 | ||
| 1257 | The `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 | ||
| 1263 | Branch `design-picture` off `main`. Tasks 14 and 15. | |
| 1264 | ||
| 1265 | ### Task 14: deploy and capture | |
| 1266 | ||
| 1267 | - [ ] **Step 1: Deploy main** | |
| 1268 | ||
| 1269 | ```bash | |
| 1270 | git switch main && git pull --ff-only && make deploy | |
| 1271 | ``` | |
| 1272 | ||
| 1273 | Then 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 | ||
| 1277 | Follow `.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 | |
| 1282 | cd .claude/screenshots | |
| 1283 | printf 'mr https://gitbay.org/krz/gitbay/mrs/315\n' > picture.txt | |
| 1284 | python3 shoot.py pic/dark dark 1280 picture.txt | |
| 1285 | python3 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 | ||
| 1298 | Append 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. | |
| 1303 | func 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 | ||
| 1322 | Use 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 | ||
| 1326 | Run: `go test ./internal/httpd -run TestLandingImagesAreServed` | |
| 1327 | Expected: 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 | |
| 1335 | var 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. | |
| 1351 | func (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 | ||
| 1363 | In 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 | ||
| 1367 | Run: `go build ./... && go vet ./... && go test ./internal/web ./internal/httpd && go test ./e2e -run 'TestLandingRoutes|TestTopLevelRouteWordsAreReserved' -v` | |
| 1368 | Expected: PASS; `static` is already reserved. | |
| 1369 | ||
| 1370 | - [ ] **Step 5: Commit, MR, merge, release** | |
| 1371 | ||
| 1372 | ```bash | |
| 1373 | git add internal/web internal/httpd e2e/design_test.go | |
| 1374 | git commit -m "web: landing picture, light and dark | |
| 1375 | ||
| 1376 | Closes #218" | |
| 1377 | git push -u origin design-picture | |
| 1378 | gitbay mr create --source design-picture --target main --title "web: landing picture" --file - <<'EOF' | |
| 1379 | Closes #218 | |
| 1380 | EOF | |
| 1381 | ``` | |
| 1382 | ||
| 1383 | After 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. | |