web: stylesheet rewrite on measured tokens !395

merged merged by cmc on 2026-09-17 15:42 UTC · krz/gitbay:design-foundation into main

25 files changed, +3095 −1249

Layout: unified · split

docs/plans/2026-09-17-web-design-foundation.md added +1395
@@ -0,0 +1,1395 @@
1# Web design foundation implementation plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** Replace the stylesheet with a measured token set and one control family, give pages content widths, and recompose the landing, repository overview, merge request and repository settings pages, with the review's copy fixes.
6
7**Architecture:** One embedded stylesheet (`internal/web/static/style.css`) styles server-rendered Go templates under `internal/web/templates/`, each parsed together with `layout.html`. Two Go tests pin the stylesheet: a token contrast test and a template-class coverage test. Handlers in `internal/httpd` change only where a page needs new data (the MR empty-diff flag, the settings success flash, the prefilled topics field, image routes).
8
9**Tech Stack:** Go 1.2x, `html/template`, hand-written CSS, no JavaScript (CSP `script-src 'none'`), e2e tests against a real `gitbayd`.
10
11**Spec:** `docs/specs/2026-09-17-web-design-foundation-design.md`. Mockups of the four pages with the target tokens: `.claude/mock/` (serve with the `mockups` entry in `.claude/launch.json`, or `python3 -m http.server --directory .claude/mock`). Before-screenshots of every page: `.claude/screenshots/before/`, and `.claude/screenshots/README.md` says how to capture the after set.
12
13## Global constraints
14
15- Tracking issue is #218; every commit message ends with `Ref #218` (the last commit of MR 4 says `Closes #218`).
16- Never push to `main`; each MR is a branch off `main`, merged with `gitbay mr merge <n> --strategy ff` after bay1 CI is green. Commits are signed (the repository requires it).
17- No attribution to any assistant or model anywhere.
18- No JavaScript, no inline `<style>` blocks, no external requests. `style="width:..%"` on the language bar and label colours are the only inline styles and stay.
19- Product name lowercase, `gitbay`, in templates, docs and the CHANGELOG.
20- Colours exist only as tokens on `:root`, each redefined in `@media (prefers-color-scheme: dark)`.
21- Class names that tests pin stay: `error`, `notice`, `railuser`, `actgraph`, `xref`, `blamehunk`, `lineno`, `code`, `syscomment`, `add`, `del`, `difftable`, `chroma`, `authorlink`, `refmenu`, `thread`, `difffold`, `ln`, `src`, `contribs`, `activity`, `line`.
22- Locally: `go build ./... && go vet ./...`, unit tests of touched packages, and at most the one e2e test being written. The full suite runs on bay1.
23- Copy is sentence case; "Sign in" / "Log out"; "Search" for search buttons and the rail placeholder.
24
25---
26
27## MR 1: stylesheet rewrite
28
29Branch `design-stylesheet` off `main`. Tasks 1 to 6.
30
31### Task 1: token contrast test
32
33**Files:**
34- Create: `internal/web/tokens_test.go`
35
36**Interfaces:**
37- Produces: `parseTokens(css []byte) (light, dark map[string]string)` and `contrastHex(a, b string) float64`, package `web`, reused by nothing else but named here so Task 2 does not redefine them.
38
39- [ ] **Step 1: Write the failing test**
40
41```go
42package web
43
44import (
45 "math"
46 "regexp"
47 "strconv"
48 "strings"
49 "testing"
50)
51
52// parseTokens returns the --name: value pairs of the light :root block
53// and of the :root block inside the dark media query. A value that is
54// itself a var() is resolved one level deep within the same scheme.
55func parseTokens(css []byte) (light, dark map[string]string) {
56 s := string(css)
57 rootRe := regexp.MustCompile(`(?s):root\s*\{(.*?)\}`)
58 darkIdx := strings.Index(s, "@media (prefers-color-scheme: dark)")
59 if darkIdx < 0 {
60 return nil, nil
61 }
62 parse := func(block string) map[string]string {
63 m := map[string]string{}
64 lineRe := regexp.MustCompile(`--([a-z0-9-]+)\s*:\s*([^;]+);`)
65 for _, mm := range lineRe.FindAllStringSubmatch(block, -1) {
66 v := strings.TrimSpace(mm[2])
67 if i := strings.Index(v, "/*"); i >= 0 {
68 v = strings.TrimSpace(v[:i])
69 }
70 m[mm[1]] = v
71 }
72 for k, v := range m {
73 if strings.HasPrefix(v, "var(--") {
74 ref := strings.TrimSuffix(strings.TrimPrefix(v, "var(--"), ")")
75 if rv, ok := m[ref]; ok {
76 m[k] = rv
77 }
78 }
79 }
80 return m
81 }
82 lm := rootRe.FindStringSubmatch(s[:darkIdx])
83 dm := rootRe.FindStringSubmatch(s[darkIdx:])
84 if lm == nil || dm == nil {
85 return nil, nil
86 }
87 light = parse(lm[1])
88 dark = parse(dm[1])
89 for k, v := range light {
90 if _, ok := dark[k]; !ok && strings.HasPrefix(v, "#") {
91 dark[k] = v // a light-only colour is a bug; keep it visible below
92 }
93 }
94 return light, dark
95}
96
97func hexChannel(h string) float64 {
98 n, _ := strconv.ParseUint(h, 16, 8)
99 c := float64(n) / 255
100 if c <= 0.03928 {
101 return c / 12.92
102 }
103 return math.Pow((c+0.055)/1.055, 2.4)
104}
105
106func luminanceHex(hex string) float64 {
107 hex = strings.TrimPrefix(hex, "#")
108 if len(hex) == 3 {
109 hex = string([]byte{hex[0], hex[0], hex[1], hex[1], hex[2], hex[2]})
110 }
111 return 0.2126*hexChannel(hex[0:2]) + 0.7152*hexChannel(hex[2:4]) + 0.0722*hexChannel(hex[4:6])
112}
113
114func contrastHex(a, b string) float64 {
115 la, lb := luminanceHex(a), luminanceHex(b)
116 if la < lb {
117 la, lb = lb, la
118 }
119 return (la + 0.05) / (lb + 0.05)
120}
121
122// TestTokenContrast is the contract for the colour tokens in style.css:
123// every text colour clears WCAG AA on every ground it lands on, in both
124// schemes. The hex values in the stylesheet are free to move as long as
125// this passes.
126func TestTokenContrast(t *testing.T) {
127 light, dark := parseTokens(StyleCSS)
128 if light == nil || dark == nil {
129 t.Fatal("could not find the light and dark :root blocks in style.css")
130 }
131 type check struct {
132 fg, bg string
133 floor float64
134 }
135 checks := []check{
136 {"fg", "canvas", 7}, {"fg", "surface", 7},
137 {"muted", "canvas", 4.5}, {"muted", "surface", 4.5},
138 {"link", "canvas", 4.5}, {"link", "surface", 4.5}, {"link", "hover", 4.5},
139 {"fill-fg", "fill", 4.5}, {"fill", "surface", 3},
140 {"line", "canvas", 3},
141 {"mark", "canvas", 3},
142 {"warn", "canvas", 4.5}, {"warn", "surface", 4.5},
143 {"ok", "canvas", 4.5}, {"ok", "surface", 4.5},
144 {"bad", "canvas", 4.5}, {"bad", "surface", 4.5},
145 {"done", "canvas", 4.5}, {"done", "surface", 4.5},
146 {"focus", "canvas", 3},
147 {"shell-fg", "shell-bg", 7}, {"shell-muted", "shell-bg", 4.5}, {"shell-mark", "shell-bg", 3},
148 }
149 for name, scheme := range map[string]map[string]string{"light": light, "dark": dark} {
150 for _, tok := range []string{"canvas", "surface", "inset", "hover", "line", "faint", "fg", "muted", "link", "fill", "fill-fg", "mark", "warn", "ok", "bad", "done", "neutral", "focus", "shell-bg", "shell-fg", "shell-muted", "shell-mark"} {
151 v, ok := scheme[tok]
152 if !ok || !strings.HasPrefix(v, "#") {
153 t.Errorf("%s: --%s is missing or not a hex colour (%q)", name, tok, v)
154 }
155 }
156 if t.Failed() {
157 continue
158 }
159 for _, c := range checks {
160 if got := contrastHex(scheme[c.fg], scheme[c.bg]); got < c.floor {
161 t.Errorf("%s: --%s (%s) on --%s (%s) is %.2f:1, want >= %.1f", name, c.fg, scheme[c.fg], c.bg, scheme[c.bg], got, c.floor)
162 }
163 }
164 // The surface ladder must be visible: canvas, surface and inset
165 // are three grounds, not one.
166 lc, ls, li := luminanceHex(scheme["canvas"]), luminanceHex(scheme["surface"]), luminanceHex(scheme["inset"])
167 if r := (math.Max(lc, ls) + 0.05) / (math.Min(lc, ls) + 0.05); r < 1.15 {
168 t.Errorf("%s: canvas %s and surface %s are %.2f apart, want >= 1.15", name, scheme["canvas"], scheme["surface"], r)
169 }
170 if scheme["inset"] == scheme["surface"] {
171 t.Errorf("%s: inset and surface are the same colour %s", name, scheme["inset"])
172 }
173 }
174}
175```
176
177- [ ] **Step 2: Run it to verify it fails**
178
179Run: `go test ./internal/web -run TestTokenContrast -v`
180Expected: FAIL with lines like `light: --canvas is missing or not a hex colour ("")`, because the current stylesheet has `--bg`, not `--canvas`.
181
182- [ ] **Step 3: Commit the test alone**
183
184```bash
185git add internal/web/tokens_test.go
186git commit -m "web: token contrast test
187
188Ref #218"
189```
190
191The test stays red until Task 3.
192
193### Task 2: template class coverage test
194
195**Files:**
196- Create: `internal/web/classes_test.go`
197
198- [ ] **Step 1: Write the test**
199
200```go
201package web
202
203import (
204 "io/fs"
205 "regexp"
206 "sort"
207 "strings"
208 "testing"
209)
210
211// TestEveryTemplateClassHasARule fails on a class a template uses that
212// no selector in style.css mentions. Class tokens containing template
213// actions ({{...}}) are composed at render time and skipped; their
214// prefixes (chip-, badge-, check-, lang-) are covered by the rules for
215// the concrete values.
216func TestEveryTemplateClassHasARule(t *testing.T) {
217 css := string(StyleCSS)
218 // Every ".name" that appears in a selector position: outside braces.
219 depth := 0
220 var sel strings.Builder
221 for _, r := range css {
222 switch r {
223 case '{':
224 depth++
225 case '}':
226 depth--
227 default:
228 if depth == 0 {
229 sel.WriteRune(r)
230 }
231 }
232 }
233 // Media queries wrap rules one level deeper; strip their headers and
234 // scan again at depth one.
235 depth = 0
236 inMedia := false
237 for i := 0; i < len(css); i++ {
238 if strings.HasPrefix(css[i:], "@media") {
239 inMedia = true
240 }
241 switch css[i] {
242 case '{':
243 depth++
244 if depth == 1 && !inMedia {
245 // ordinary rule; already scanned above
246 }
247 case '}':
248 depth--
249 if depth == 0 {
250 inMedia = false
251 }
252 default:
253 if inMedia && depth == 1 {
254 sel.WriteByte(css[i])
255 }
256 }
257 }
258 classRe := regexp.MustCompile(`\.([a-zA-Z_][a-zA-Z0-9_-]*)`)
259 styled := map[string]bool{}
260 for _, m := range classRe.FindAllStringSubmatch(sel.String(), -1) {
261 styled[m[1]] = true
262 }
263
264 attrRe := regexp.MustCompile(`class="([^"]*)"`)
265 missing := map[string][]string{}
266 entries, err := fs.ReadDir(templateFS, "templates")
267 if err != nil {
268 t.Fatal(err)
269 }
270 for _, e := range entries {
271 src, err := templateFS.ReadFile("templates/" + e.Name())
272 if err != nil {
273 t.Fatal(err)
274 }
275 for _, m := range attrRe.FindAllStringSubmatch(string(src), -1) {
276 for _, c := range strings.Fields(m[1]) {
277 if strings.Contains(c, "{{") || strings.Contains(c, "}}") {
278 continue
279 }
280 if !styled[c] {
281 missing[c] = append(missing[c], e.Name())
282 }
283 }
284 }
285 }
286 var names []string
287 for c := range missing {
288 names = append(names, c)
289 }
290 sort.Strings(names)
291 for _, c := range names {
292 t.Errorf("class %q in %s has no rule in style.css", c, strings.Join(uniq(missing[c]), ", "))
293 }
294}
295
296func uniq(in []string) []string {
297 seen := map[string]bool{}
298 var out []string
299 for _, s := range in {
300 if !seen[s] {
301 seen[s] = true
302 out = append(out, s)
303 }
304 }
305 return out
306}
307```
308
309- [ ] **Step 2: Run it against the current stylesheet**
310
311Run: `go test ./internal/web -run TestEveryTemplateClassHasARule -v`
312Expected: either PASS, or a list of classes the old stylesheet never styled. Record the list in the commit message. Any class listed that is purely a hook with no visual meaning is removed from the template in Task 4; every other one gets a rule in Task 3. Do not add an allowlist.
313
314- [ ] **Step 3: Commit**
315
316```bash
317git add internal/web/classes_test.go
318git commit -m "web: every template class has a stylesheet rule
319
320Ref #218"
321```
322
323### Task 3: write the new stylesheet
324
325**Files:**
326- Modify: `internal/web/static/style.css` (replace whole file)
327- Reference: `.claude/mock/mock.css` (the token block and the components for the four mocked pages), the class inventory below.
328
329**Interfaces:**
330- Produces: the token names in Task 1; the classes `primary`, `btn`, `linklike`, `danger` on buttons; `wide`, `reading`, `bounded` on `main`; `flash` is not introduced (see below).
331
332- [ ] **Step 1: Start from the mock stylesheet**
333
334Copy `.claude/mock/mock.css` over `internal/web/static/style.css`, keep the three `@font-face` blocks from the old file at the top (the mock falls back to system fonts), and replace the header comment with:
335
336```css
337/* gitbay stylesheet. Tokens first, on :root and again in the dark media
338 query; components use tokens only. Two accents with separate jobs:
339 blue is what you can do (links, primary buttons, focus, pressed
340 toggles), orange is where you are and what wants you (current tab,
341 rail counts, pending state, unverified signatures). Orange is never a
342 button ground. internal/web/tokens_test.go measures every token;
343 internal/web/classes_test.go checks every template class has a rule.
344 No external requests: fonts are served from this origin. */
345```
346
347Change `html { font-size: 16px; }` to stay, remove the `.mono` and `.small` helper classes unless a template uses them after Task 4, and change `--fill-fg` to be defined in both scheme blocks.
348
349- [ ] **Step 2: Port every component the mock did not cover**
350
351Work through the old stylesheet from line 150 to the end, and for each selector group write the new rule with tokens. The inventory, with the treatment each gets:
352
353- Focus: `:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }` on everything, `.rail :focus-visible { outline-color: var(--shell-mark); }`. Remove the old `input:focus { border-color }` rule.
354- Skip link `.skip`: as before, on `--fill` with `--fill-fg` when focused.
355- Rail: `.rail`, `.brand`, `.mark`, `.railbody`, `.railsearch`, `.raillist` (+ `.wide`, `.railpinned`), `.raillabel`, `.railgroup`, `.count`, `.railfoot`, `.railuser`, `.avatar`, `.uname`, `.owner`. Values from the mock. The 52rem breakpoint keeps the old strip behaviour: `.shell` block, rail `flex-direction: row`, `border-bottom` instead of `border-right`, `.railgroup { display: none }`, `.raillist { display: flex }` with the current marker as a bottom border.
356- Shell: `.shell`, `.pane`, `main.content` with `padding: var(--sp-5) var(--sp-6) var(--sp-7)`, and the three width classes `main.wide { max-width: none }`, `main.reading { max-width: calc(72rem + 2 * var(--sp-6)) }`, `main.bounded { max-width: calc(48rem + 2 * var(--sp-6)) }`. `footer` as in the mock.
357- Repository header: `.repohead`, `.identity`, `.repotitle` (+ `.owner`, `.sep`), `.grow`, `.chip` beside the title, `.repodesc`, `.topics`, `.repometa`, `.toggles`, `nav.tabs a` (+ `[aria-current]`, `i`). From the mock.
358- Page heads: `h1` 24px, `.pagehead`, `.listhead` (the h1 + filters row on issues, MRs, builds, explore): `display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap; margin-bottom: var(--sp-5)`. `nav.filters a`: secondary button geometry, `.active` gets the pressed treatment (`--surface` ground, `--link` border and text). `form.searchform`: input plus a secondary "Search" button, `gap: var(--sp-2)`. `.logfilter`, `.compareform`: same.
359- Buttons: `button` and `.button` base (32px, 14px, 500, 4px radius); `button.primary`, `button.btn` (+ `[aria-pressed="true"]`), `button.linklike`, `button.danger`; `.btngroup`; `.inline` forms `display: inline`. `button:disabled { opacity: .55; cursor: default }`. A bare `<button>` with no class renders as primary so untouched templates keep working; Task 4 adds `btn` or `danger` where the spec says.
360- Forms: inputs, `textarea`, `select` (with the chevron `--chev` data URI kept, colours swapped to `--muted`), `label`, `.hint`, `.field`, `.check`, custom checkbox 16px drawn as before with `--fill` when checked, `::placeholder`, `::selection`, `input:user-invalid { border-color: var(--bad) }`, `fieldset.segmented` (checked span on `--fill`), `.branchpick`, `.options`, `.editform`, `.commentform` (textarea plus a row: primary submit, quiet cancel), `details.editbox` (summary styled as a secondary button, body a card), `.confirmfield` input (no class today; it is `input[name=confirm]`).
361- Settings: `form.setform` becomes the grid from the mock's `.setrow` but keeps the name `setform`: `grid-template-columns: 14rem minmax(0, 28rem) auto`, `align-items: start`, `padding: var(--sp-3) 0`, `border-bottom: 1px solid var(--faint)`; `.setform label { margin-top: 6px }`, `.setform .hint`, `.setform.stack { grid-template-columns: 1fr }`, under 40rem one column. `nav.sections` for the anchor links. `ul.protlist` rows as list rows with the action at the right.
362- Messages: `.error` and `.notice` share one rule: 3px left edge, 6% tint ground, `--fg` text, 4px right radii, `margin-bottom: var(--sp-4)`, 14px; `.error` edge and tint `--bad`, `.notice` edge and tint `--ok`. No `.flash` class is added: the spec's "flash family" is these two selectors, and `class="error"` keeps its name because e2e tests match it. `.empty-note`, `p.none`, `.empty` in `--muted`.
363- Containers: `.card`, `.cardhead`, `.cardbody`, `ul.list`; `table`, `th`, `td`, `tr.cols`, `.tablewrap`; `table.tree` (+ `td.name`, `.dir`, `td.lastcommit`, `td.age`), `table.refs`, `table.keys` (flush: no outer border, header without ground), `.tipbar` as the first row on `--surface`, `.readme`, `.code`, `.bigcode`, `ul.loglist` (+ `.commitmain`, `.commitside`, `.subject`, `.sha`, `.when`), `ul.repolist` (+ `.reponame`, `.desc`, `.meta`), `ul.issuelist` (+ `.issuemain`, `.title`, `.meta`), `ul.milestonelist` (+ `.msmain`, `.progress`, `.bar`), `article.release` (+ `.releasehead`, `.assets`), `ul.pinlist`, `.matchlist`, `.matchpath`, `.matchline`, `.pager`, `.snippetfile`, `.filefacts`, `.dmeta`, `.notfound`, `.buildlog`. All on the one container recipe; rows `--faint` separators, `--hover` hover; the row title `--fg` turning `--link` on hover.
364- Two-column: `.withaside`, `.mainside`, `aside.aside`, `.grp`, `.row`, `.sub`, `.dot` (+ `.ok`, `.bad`, `.pend`), `form.actions` (buttons wrap, `select` full width), `.prose { max-width: 78ch }`. Under 62rem: one column with `.aside { order: -1 }`.
365- Comments and threads: `article.comment`, `.commenthead`, `.commentbody`/`.rendered` inside it, `.syscomment`, `.thread` (+ `.resolved`, `.pending`, `.stale`, `.composing`, `.threadrow`, `.threadstate`, `.threadact`, `.threadreply`), `nav.subtabs` (+ `[aria-current]`, `i`).
366- Diffs and code: `.diffstat` (+ `.add`, `.del`), `details.difffold` (+ `summary`, `.fpath`, `.fstat`), `table.difftable` (+ `td.ln`, `td.src`, `tr.add`, `tr.del`, `.hunk`, `.cmt`), `.chroma` container, `.blame`, `.blameinfo`, `.blamehunk`, `.blamecode`, `.lineno`, `.blobimage`, `.plain`, `.fullsha`. Grounds `--inset`; diff row tints stay the four old values as tokens `--diff-add`, `--diff-del`.
367- Chips and badges: `.chip`, `.badge` (same rule), `.chip-open`, `.chip-closed`, `.chip-merged`, `.chip-done`, `.chip-neutral`, `.chip-source_gone`, `.chip-stale`, `.chip.topic`, `.chip.label` (colour from its inline style), `.badge-verified`, `.badge-unsigned`, `.badge-signed_key_expired`, `.badge-bad_signature`, `.badge.check-*` and `.chip.check-*` for `success`, `pending`, `failure`, `error`, `span.check-*` text colours, `.memberchip`, `.refchip`, `.refmenu` + `.refdrop` + `.allrefs` (dropdown on `--canvas` with `--line` border and `--shadow`), `.crumbs`, `.pathbar`, `.act` links become `.button.btn`.
368- Repository facts: `.facts` two-column grid at reading width, `.clone` with `label` and `pre`, `.factgrid`, `.counts`, `.fact`, `.langbar`, `.lang`, `.langs`, `.lang-name`, `.contribs`, `.label`. From the mock.
369- Landing: `.landing`, `.lede`, `pre.quickstart`, `.shot` (+ `img`), `.facets`, `.routes`, `.explorelink` (removed in Task 8; keep the rule until then), `.signupform`.
370- Profile: `.profilehead`, `.desc`, `.activity`, `.actgraph-scroll`, `.actgraph`, `.actweek`, `.actday` and `.l0` to `.l4` levels as tints of `--link`, `.teambody`.
371- Wiki: `.wikilayout`, `.wikinav` (14rem, list rows), `.wikipage`, under 40rem stacked.
372- Misc: `.meta`, `.muted`, `.vh`, `.spacer`, `.mono`, `.compact`, `.headrow`, `.message` (a `pre` for a command to copy), `.size`, `.role`, `.was`, `.who`, `.age`, `.name`, `.icon`, `.xref` (link colour, no underline), `.sigbadge`.
373- Breakpoints, all of them: 62rem (aside stacks), 52rem (rail strip, content padding `var(--sp-4)`, tabs wrap), 40rem (facets, wiki, tree columns hidden: `td.lastcommit, th.lastcommit { display: none }`, `.clone pre` wraps with `white-space: pre-wrap; word-break: break-all`, settings rows one column).
374
375Every rule uses tokens; `grep -n '#[0-9a-fA-F]\{3,6\}' internal/web/static/style.css` must match only inside the two `:root` blocks and the `--chev` data URIs.
376
377- [ ] **Step 3: Run the two stylesheet tests and the existing web tests**
378
379Run: `go test ./internal/web -v`
380Expected: `TestTokenContrast` PASS, `TestEveryTemplateClassHasARule` PASS (or a list that Task 4 resolves by editing templates; anything still listed after Task 4 is a rule missing here), `TestHeaderRowsAreLeftAligned` PASS. Adjust hex values until the contrast test passes; do not lower a floor.
381
382- [ ] **Step 4: Run the stylesheet-dependent httpd tests**
383
384Run: `go test ./internal/httpd -run 'TestStylesheetFontsAreServed|TestStylesheetRevalidates|TestSyntaxPaletteContrast' -v`
385Expected: PASS. `TestSyntaxPaletteContrast` measures against hard-coded grounds; update its `page` ground for dark to `#101114` and `code` to `#0b0c0e`, light `code` to `#ffffff`, and rerun. If a chroma token fails on the new ground, exempt nothing: pick the ground so the palette passes, then set `--inset` to that value and rerun Task 1's test.
386
387- [ ] **Step 5: Commit**
388
389```bash
390git add internal/web/static/style.css internal/httpd/palette_test.go
391git commit -m "web: rewrite the stylesheet on measured tokens
392
393Ref #218"
394```
395
396### Task 4: template markup for the new components
397
398**Files:**
399- Modify: every template under `internal/web/templates/` that the list below names.
400
401The stylesheet is class-compatible; these are the markup changes the new components need. Do not change page composition here (that is MR 2).
402
403- [ ] **Step 1: Button classes**
404
405In every template: a `<button type="submit">` that is the page's one main action keeps no class (renders primary). Every other button gets `class="btn"`: the header toggles already have it; add it to per-field Save buttons in `settings.html` and `account.html`, to filter and search submit buttons, to Approve / Request changes / Convert to draft / Ready for review in `mr.html`, and to `admin.html` row actions. Buttons that destroy or refuse get `class="danger"`: Close without merging (`mr.html`), Unprotect / Detach / Remove / Delete this team / Archive (`settings.html`, `owner.html`, `account.html`, `labels.html`, `snippet.html`, `releases.html`). Buttons that were `class="linklike"` and are destructive become `class="danger"` only when they sit beside a `confirmfield`; the rest stay quiet.
406
407- [ ] **Step 2: Search and filter forms**
408
409`explore.html`, `issues.html`, `mrs.html`, `builds.html`, `globalsearch.html`, `search.html`, `log.html`: the text filter form gets `<button type="submit" class="btn">Search</button>` after the input (`log.html` and `search.html` already have a button; relabel to "Search" and add `btn`), and when the handler passes a count the list is preceded by `<p class="meta">{{.Count}} result{{if ne .Count 1}}s{{end}}</p>`; where the page struct has no count, leave it (do not add handler work in this MR).
410
411- [ ] **Step 3: Copy vocabulary**
412
413`layout.html`: rail search `placeholder="Search"` (already), footer unchanged, `Sign in` (already), `Log out` (already). `login.html`: `<h1>Sign in</h1>`, button "Email me a link" stays. `register.html`: labels "Username", "Email", "SSH public key" in sentence case. `new.html`, `issuenew.html`, `mrnew.html`, `snippetnew.html`: headings and buttons in sentence case. `{{.Site}}` renders the configured title; templates that hardcode `gitbay` keep it lowercase.
414
415- [ ] **Step 4: Run the template tests and the class coverage test**
416
417Run: `go test ./internal/web ./internal/httpd -run 'TestEveryPageTemplateParses|TestEveryTemplateClassHasARule|TestEveryInputHasAnAccessibleName|TestHeaderRowsAreLeftAligned' -v`
418Expected: PASS.
419
420- [ ] **Step 5: Commit**
421
422```bash
423git add internal/web/templates
424git commit -m "web: button treatments, explicit search buttons, sentence case
425
426Ref #218"
427```
428
429### Task 5: visual check of every page family
430
431**Files:**
432- Create: `.claude/screenshots/local.sh` (ignored by git; a helper, not a deliverable)
433
434- [ ] **Step 1: Write the local instance script**
435
436```bash
437#!/bin/sh
438# Start a local gitbayd with this checkout pushed into krz/gitbay, for
439# screenshots. Prints the HTTP base and a browser login URL.
440set -eu
441ROOT=${ROOT:-/tmp/gitbay-local}
442rm -rf "$ROOT"; mkdir -p "$ROOT"
443go build -o "$ROOT/gitbayd" ./cmd/gitbayd
444cat > "$ROOT/config.toml" <<EOF
445[server]
446root = "$ROOT/data"
447site_url = "http://127.0.0.1:8090"
448[ssh]
449port = 8022
450[http]
451addr = "127.0.0.1:8090"
452tls = "off"
453[web]
454mode = "accounts"
455title = "gitbay"
456EOF
457"$ROOT/gitbayd" --config "$ROOT/config.toml" serve >"$ROOT/log" 2>&1 &
458echo $! > "$ROOT/pid"
459sleep 1
460ssh-keygen -q -t ed25519 -N "" -f "$ROOT/key"
461"$ROOT/gitbayd" --config "$ROOT/config.toml" admin user create cmc --key "$ROOT/key.pub" --email cmc@example.test --verified
462"$ROOT/gitbayd" --config "$ROOT/config.toml" admin user promote cmc
463S="ssh -p 8022 -i $ROOT/key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null git@127.0.0.1"
464$S repo create krz/gitbay >/dev/null
465GIT_SSH_COMMAND="ssh -i $ROOT/key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" \
466 git push -q "ssh://git@127.0.0.1:8022/krz/gitbay.git" HEAD:refs/heads/main
467$S issue create krz/gitbay --title "ios notifications" --file - <<'EOF'
468research options for ios notification support for builds, MRs, assignments, etc.
469EOF
470echo "base http://127.0.0.1:8090"
471$S web login
472```
473
474Run it with `sh .claude/screenshots/local.sh`, open the printed login URL in the browser pane, then walk every page family in both schemes at desktop and 375px: landing, login, register, explore, profile, repository code/tree/blob/blame/log/commit/refs/releases/wiki/search, issues list and page, MR list and page (three views), builds, settings, account, admin, dashboard, notifications, bookmarks, snippets, 404. Compare each against `.claude/screenshots/before/` and the mockups. Fix rules in `style.css` for anything broken; commit each fix as `web: <what>`. Stop the instance with `kill $(cat /tmp/gitbay-local/pid)`.
475
476- [ ] **Step 2: Keyboard and zoom pass**
477
478In the browser pane at 1280 wide: Tab through the landing, register, repository overview, an MR and settings; every stop shows the 2px ring, the order follows the document. Set the pane to 200% zoom (the app's zoom, or 640px width as a proxy) and confirm no horizontal page scroll on those five pages.
479
480- [ ] **Step 3: Commit any fixes, then vet**
481
482Run: `go build ./... && go vet ./... && go test ./internal/web ./internal/httpd`
483Expected: PASS.
484
485### Task 6: open and merge MR 1
486
487- [ ] **Step 1: Push and open the MR**
488
489```bash
490git push -u origin design-stylesheet
491gitbay mr create --source design-stylesheet --target main --title "web: stylesheet rewrite on measured tokens" --file - <<'EOF'
492Replaces style.css: tokens on :root in both schemes with a contrast test, one control family (primary, secondary, quiet, destructive), one container recipe, explicit search buttons, sentence case. Template class coverage is tested.
493
494Ref #218
495EOF
496```
497
498- [ ] **Step 2: Wait for bay1, merge, delete the branch**
499
500`gitbay build list --json` until the MR head's `build` and `test` jobs report success (one poll per minute at most). Then:
501
502```bash
503gitbay mr merge <n> --strategy ff
504git switch main && git pull --ff-only && git branch -d design-stylesheet && git push origin --delete design-stylesheet
505```
506
507If the merge reports the branch is behind, `git rebase main`, re-push, merge again.
508
509---
510
511## MR 2: layout and the four reference pages
512
513Branch `design-pages` off `main` after MR 1 merges. Tasks 7 to 12.
514
515### Task 7: content width per page
516
517**Files:**
518- Modify: `internal/web/templates/layout.html` (the `<main>` line and a new default block)
519- Modify: `tree.html`, `blob.html`, `blame.html`, `log.html`, `commit.html`, `compare.html`, `builds.html`, `build.html`, `search.html`, `globalsearch.html` (wide); `landing.html`, `login.html`, `register.html`, `registered.html`, `new.html`, `issuenew.html`, `mrnew.html`, `settings.html`, `account.html`, `admin.html`, `edit.html`, `snippetnew.html`, `privacy.html`, `404.html` (bounded)
520- Test: `internal/web/web_test.go`
521
522- [ ] **Step 1: Write the failing test**
523
524Append to `internal/web/web_test.go`:
525
526```go
527// TestMainWidthClass renders every page against an empty struct and
528// checks main carries exactly one width class, and that the pages the
529// spec calls wide or bounded say so.
530func TestMainWidthClass(t *testing.T) {
531 wide := map[string]bool{"tree.html": true, "blob.html": true, "blame.html": true, "log.html": true, "commit.html": true, "compare.html": true, "builds.html": true, "build.html": true, "search.html": true, "globalsearch.html": true}
532 bounded := map[string]bool{"landing.html": true, "login.html": true, "register.html": true, "registered.html": true, "new.html": true, "issuenew.html": true, "mrnew.html": true, "settings.html": true, "account.html": true, "admin.html": true, "edit.html": true, "snippetnew.html": true, "privacy.html": true, "404.html": true}
533 for _, name := range Pages() {
534 src, err := TemplateSource(name)
535 if err != nil {
536 t.Fatal(err)
537 }
538 has := strings.Contains(src, `{{define "width"}}`)
539 switch {
540 case wide[name] && !strings.Contains(src, `{{define "width"}}wide{{end}}`):
541 t.Errorf("%s: want width wide", name)
542 case bounded[name] && !strings.Contains(src, `{{define "width"}}bounded{{end}}`):
543 t.Errorf("%s: want width bounded", name)
544 case !wide[name] && !bounded[name] && has:
545 t.Errorf("%s: defines a width but the spec calls it reading", name)
546 }
547 }
548 layout, err := TemplateSource("layout.html")
549 if err != nil {
550 t.Fatal(err)
551 }
552 if !strings.Contains(layout, `<main id="content" class="content {{template "width" .}}">`) {
553 t.Error("layout.html: main does not carry the width block")
554 }
555 if !strings.Contains(layout, `{{define "width"}}reading{{end}}`) {
556 t.Error("layout.html: no default width")
557 }
558}
559```
560
561Add `"strings"` to the imports if missing.
562
563- [ ] **Step 2: Run it to verify it fails**
564
565Run: `go test ./internal/web -run TestMainWidthClass -v`
566Expected: FAIL on every wide and bounded page and on the layout lines.
567
568- [ ] **Step 3: Implement**
569
570In `layout.html`, change the main line to `<main id="content" class="content {{template "width" .}}">` and add, after the `mark` definition, `{{define "width"}}reading{{end}}`. In each wide page add `{{define "width"}}wide{{end}}` as its first line; in each bounded page `{{define "width"}}bounded{{end}}`. Because each page is parsed after the layout, a page's definition replaces the default.
571
572- [ ] **Step 4: Run the tests**
573
574Run: `go test ./internal/web -v`
575Expected: PASS, including `TestEveryPageTemplateParses`.
576
577- [ ] **Step 5: Commit**
578
579```bash
580git add internal/web
581git commit -m "web: content width chosen per page
582
583Ref #218"
584```
585
586### Task 8: two-level repository header and the landing, register and login pages
587
588**Files:**
589- Modify: `internal/web/templates/layout.html:63-103` (the `repohead` block), `landing.html`, `register.html`, `login.html`
590- Modify: `internal/web/static/style.css` (`.toggles`, `.routes`, `.shot`)
591- Test: `e2e/design_test.go` (extend `TestReadmeRelativeLinks`)
592
593- [ ] **Step 1: Write the failing e2e assertions**
594
595In `e2e/design_test.go`, inside `TestReadmeRelativeLinks` after the existing checks that the topic chips appear on `/issues`, `/mrs` and `/releases`, invert those three: the description and topic chips must appear on the repo home and must not appear on `/issues`, `/mrs`, `/releases`:
596
597```go
598 for _, p := range []string{"/alice/app/issues", "/alice/app/mrs", "/alice/app/releases"} {
599 if _, body := inst.get(t, p); strings.Contains(body, `class="chip topic"`) {
600 t.Errorf("%s: header still carries topics on a task tab", p)
601 }
602 }
603 if _, body := inst.get(t, "/alice/app"); !strings.Contains(body, `class="chip topic"`) {
604 t.Error("repo home lost its topics")
605 }
606```
607
608Read the existing function first: it asserts the chips on those pages today, so delete those assertions when adding the inverted ones.
609
610Add a new test in the same file:
611
612```go
613// TestLandingRoutes checks the landing page's copy and the two routes.
614func TestLandingRoutes(t *testing.T) {
615 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n[registration]\nmode = \"open\"\n")
616 _, body := inst.get(t, "/")
617 for _, want := range []string{
618 "A git forge you drive from the terminal.",
619 `class="button primary" href="/explore">Explore repositories</a>`,
620 `class="button btn" href="/register">Create an account</a>`,
621 "web login</code>",
622 } {
623 if !strings.Contains(body, want) {
624 t.Errorf("landing lacks %q", want)
625 }
626 }
627 if strings.Contains(body, "is the whole onboarding") {
628 t.Error("landing still calls repo create the whole onboarding")
629 }
630 _, reg := inst.get(t, "/register")
631 if !strings.Contains(reg, "Paste the contents of your public key file") || !strings.Contains(reg, "/krz/gitbay/wiki/SSH-keys") {
632 t.Error("register page lacks the key hint or the wiki link")
633 }
634}
635```
636
637- [ ] **Step 2: Run to verify they fail**
638
639Run: `go test ./e2e -run 'TestReadmeRelativeLinks|TestLandingRoutes' -v`
640Expected: FAIL on the topic assertions and the landing strings.
641
642- [ ] **Step 3: Rewrite the header block**
643
644In `layout.html` the `repohead` block becomes:
645
646```gotemplate
647<header class="repohead">
648 <div class="identity">
649 {{if field $ "RepoHome"}}<h1 class="repotitle"><a class="owner" href="/{{.OwnerName}}">{{.OwnerName}}</a><span class="sep">/</span>{{.Name}}</h1>
650 {{else}}<p class="repotitle"><a class="owner" href="/{{.OwnerName}}">{{.OwnerName}}</a><span class="sep">/</span><a href="/{{.OwnerName}}/{{.Name}}">{{.Name}}</a></p>{{end}}
651 {{if eq .Visibility "private"}}<span class="chip">Private</span>{{end}}
652 {{if .Settings.Archived}}<span class="chip">Archived</span>{{end}}
653 <span class="grow"></span>
654 {{if $.Viewer}}<form method="post" action="/{{.OwnerName}}/{{.Name}}/pin" class="inline"><button type="submit" class="btn" aria-pressed="{{if field $ "Pinned"}}true{{else}}false{{end}}"><span aria-hidden="true">{{if field $ "Pinned"}}★{{else}}☆{{end}}</span> {{if field $ "Pinned"}}Pinned{{else}}Pin{{end}}</button></form>
655 <form method="post" action="/{{.OwnerName}}/{{.Name}}/watch" class="inline"><button type="submit" class="btn" aria-pressed="{{if eq (str $ "Watch") "watching"}}true{{else}}false{{end}}">{{if eq (str $ "Watch") "watching"}}Watching{{else}}Watch{{end}}</button></form>
656 <form method="post" action="/{{.OwnerName}}/{{.Name}}/bookmark" class="inline"><button type="submit" class="btn" aria-pressed="{{if field $ "Marked"}}true{{else}}false{{end}}">{{if field $ "Marked"}}Bookmarked{{else}}Bookmark{{end}}</button></form>
657 <form method="post" action="/{{.OwnerName}}/{{.Name}}/fork" class="inline"><button type="submit" class="btn">Fork</button></form>{{end}}
658 </div>
659 {{$top := topTab (str $ "Tab")}}
660 {{/* Description, topics and metadata belong to the code tab. On task
661 tabs only the identity row and the tab bar render, and the header
662 is identical on every page within a tab. */}}
663 {{if eq $top "code"}}
664 <p class="repodesc">{{with field $ "Desc"}}{{.}}{{end}} {{with field $ "Topics"}}{{range .}}<a class="chip topic" href="/explore?q={{.}}">{{.}}</a> {{end}}{{end}}</p>
665 {{if or $.Repo.Settings.Website (field $ "Mirrors")}}<p class="repometa">{{with $.Repo.Settings.Website}}<a href="{{.}}" rel="nofollow">{{.}}</a>{{end}}{{range field $ "Mirrors"}} · {{if eq .Direction "push"}}mirrors to{{else}}mirrors from{{end}} <a href="{{.URL}}" rel="nofollow">{{.Target}}</a>{{if .Error}}, <span class="bad">sync error: {{.Error}}</span>{{else if .Synced}}, synced {{.Synced}}{{end}}{{end}}</p>{{end}}
666 {{if $.Viewer}}<p class="toggles">Pinned shows in your rail. Watching sends every issue, request and build to your inbox. Bookmarked lists it under Bookmarks.</p>{{end}}
667 {{end}}
668 <nav class="tabs" aria-label="Repository">
669 ...unchanged...
670 </nav>
671</header>
672```
673
674`topTab` (`internal/web/web.go:97`) maps `files`, `log`, `refs` and `search` to `"code"`, and the tree, blob, blame, commit and compare pages all set `Tab: "files"`, so `$top` is `"code"` on every code page.
675
676- [ ] **Step 4: Rewrite landing.html**
677
678```gotemplate
679{{define "title"}}{{.Site}}{{end}}
680{{define "width"}}bounded{{end}}
681{{define "content"}}
682<div class="landing">
683<h1>{{template "mark"}}{{.Site}}</h1>
684<p class="lede">A git forge you drive from the terminal. Repositories, issues, merge requests and CI over SSH, with a fast, readable web view of the same state.</p>
685<pre class="quickstart">ssh git@{{.Host}} help # every command, no client to install
686git clone ssh://git@{{.Host}}/owner/repo.git</pre>
687{{if .Picture}}<div class="shot"><picture>
688 <source srcset="/static/img/mr-dark.png" media="(prefers-color-scheme: dark)">
689 <img src="/static/img/mr-light.png" width="1280" height="900" alt="A merge request page: the conversation on the left, checks and reviewers on the right.">
690</picture></div>{{end}}
691<div class="facets">
692 <section><h2>Read</h2><p>Browse and clone any public repository over HTTPS or <code>git://</code>, no account. Every commit shows whether its signature verified.</p></section>
693 <section><h2>Write</h2><p>Push over SSH with the key you already have. Create a repository, file an issue, open and merge a request, all as commands.</p></section>
694 <section><h2>Review</h2><p>Read a diff, comment on a line, approve, merge, in the browser or the terminal.</p></section>
695</div>
696<div class="routes"><a class="button primary" href="/explore">Explore repositories</a>{{if .Signup}}<a class="button btn" href="/register">Create an account</a>{{end}}</div>
697{{if .Accounts}}<p class="meta">Have an account? {{if .EmailLogin}}<a href="/login">Sign in with an emailed link</a>, or run{{else}}Run{{end}} <code>ssh git@{{.Host}} web login</code>.</p>{{end}}
698</div>
699{{end}}
700```
701
702`Picture` and `EmailLogin` are new fields on the anonymous struct in `index` (`internal/httpd/web.go:162-168`, today `basePage`, `Host`, `Accounts`, `Signup`); add `Picture bool` set to `true` when both `/static/img/mr-dark.png` and `/static/img/mr-light.png` exist in `web.ImageFS` (Task 15 adds the FS; until then set it from a package variable `landingPicture = false` defined next to the handler and flipped in Task 15), and `EmailLogin bool` set from `s.emailLoginEnabled()`, as the login page does (`internal/httpd/accounts.go:91`). The `{{template "mark"}}` in the h1 renders the existing 19px SVG; add `.landing h1 .mark { width: 32px; height: 32px }` to the stylesheet.
703
704- [ ] **Step 5: Rewrite register.html**
705
706```gotemplate
707{{define "title"}}register · {{.Site}}{{end}}
708{{define "width"}}bounded{{end}}
709{{define "content"}}
710<h1>Create an account</h1>
711{{if eq .Mode "invite"}}<p class="lede">This instance is invite-only. You need an invite code from an admin.</p>
712{{else}}<p class="lede">Open registration. Your account activates once you verify your email.</p>{{end}}
713{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
714<form method="post" action="/register" class="signupform">
715<div class="field"><label for="username">Username</label><input type="text" id="username" name="username" value="{{.Username}}" required autofocus></div>
716{{if eq .Mode "invite"}}<div class="field"><label for="invite">Invite code</label><input type="text" id="invite" name="invite" required></div>
717{{else}}<div class="field"><label for="email">Email</label><input type="text" id="email" name="email" required></div>{{end}}
718<div class="field"><label for="key">SSH public key</label>
719<p class="hint">Paste the contents of your public key file, usually <code>~/.ssh/id_ed25519.pub</code>. It starts with <code>ssh-ed25519</code> or <code>ssh-rsa</code>. No key yet? <a href="/krz/gitbay/wiki/SSH-keys">Make one</a>.</p>
720<textarea id="key" name="key" rows="3" required placeholder="ssh-ed25519 AAAA... you@host"></textarea></div>
721<p><button type="submit">Create account</button></p>
722</form>
723<p class="meta">Prefer the terminal? <code>ssh git@{{.Host}} register --username you {{if eq .Mode "invite"}}--invite &lt;code&gt;{{else}}--email you@example.org{{end}}</code></p>
724{{end}}
725```
726
727The wiki link points at this repository's wiki on the instance; on another instance the page may not exist, which the spec accepts. In `login.html`, change the heading to `Sign in`, wrap the input in `<div class="field">`, and keep everything else.
728
729- [ ] **Step 6: Run the tests**
730
731Run: `go test ./internal/web ./internal/httpd && go test ./e2e -run 'TestReadmeRelativeLinks|TestLandingRoutes|TestAccounts' -v`
732Expected: PASS. (`TestAccounts` in `e2e/accounts_test.go` renders the landing with a title; it must still find the title string.)
733
734- [ ] **Step 7: Commit**
735
736```bash
737git add internal/web internal/httpd e2e/design_test.go
738git commit -m "web: two-level repository header, landing and register copy
739
740Ref #218"
741```
742
743### Task 9: repository overview
744
745**Files:**
746- Modify: `internal/web/templates/tree.html:5-34`
747- Test: `e2e/facts_test.go` (it reads `<p class="contribs">`; keep that markup)
748
749- [ ] **Step 1: Write the failing test**
750
751Append to `e2e/design_test.go`:
752
753```go
754// TestTreeSearchCodeAndClone: the overview links "Search code", not
755// "Find file", and shows two labelled clone blocks after the file table.
756func TestTreeSearchCodeAndClone(t *testing.T) {
757 inst := startInstance(t)
758 key := inst.newKey(t, "alice")
759 inst.admin(t, "admin", "user", "create", "alice", "--key", key+".pub")
760 inst.ssh(t, key, "", "repo", "create", "alice/app")
761 dir := t.TempDir()
762 run := func(args ...string) {
763 t.Helper()
764 cmd := exec.Command("git", args...)
765 cmd.Dir = dir
766 cmd.Env = append(os.Environ(), inst.gitEnv(key)...)
767 if out, err := cmd.CombinedOutput(); err != nil {
768 t.Fatalf("git %v: %v\n%s", args, err, out)
769 }
770 }
771 run("init", "-q", "-b", "main")
772 os.WriteFile(filepath.Join(dir, "README.md"), []byte("# app\n"), 0o644)
773 run("add", "."); run("-c", "user.name=a", "-c", "user.email=a@example.test", "commit", "-q", "-m", "init")
774 run("push", "-q", inst.sshURL("alice/app"), "main")
775 _, body := inst.get(t, "/alice/app")
776 if strings.Contains(body, ">Find file<") || !strings.Contains(body, ">Search code<") {
777 t.Error("overview still says Find file")
778 }
779 if i, j := strings.Index(body, `<table class="tree">`), strings.Index(body, `<div class="clone">`); i < 0 || j < i {
780 t.Error("clone block does not follow the file table")
781 }
782 if !strings.Contains(body, `<label>SSH</label>`) || !strings.Contains(body, `<label>HTTPS</label>`) {
783 t.Error("clone blocks are not labelled")
784 }
785}
786```
787
788Add `os`, `os/exec`, `path/filepath` to the imports. Copy the push helper shape from an existing e2e test that pushes (grep `gitEnv` in `e2e/git_test.go`) if this shape differs from the harness.
789
790- [ ] **Step 2: Run to verify it fails**
791
792Run: `go test ./e2e -run TestTreeSearchCodeAndClone -v`
793Expected: FAIL on "Find file".
794
795- [ ] **Step 3: Recompose tree.html**
796
797Replace lines 5-34 with:
798
799```gotemplate
800<div class="pathbar">
801 {{template "refmenu" .}}
802 <span class="crumbs"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a>/{{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}</span>
803 <div class="acts">
804 <a class="button btn" href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/search">Search code</a>
805 <a class="button btn" href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log/{{.Ref}}">History</a>
806 <a class="button btn" href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/archive/{{.Ref}}.tar.gz">Download</a>
807 </div>
808</div>
809```
810
811Move the `tipbar` block inside the table as its first row: in the `<table class="tree">`, before `<tr class="cols">`, render
812
813```gotemplate
814{{with .Tip}}{{if .SHA}}<tr class="tipbar"><td colspan="3"><span class="who">{{template "authorname" dict "Name" .Author "User" .User "Email" .Email}}</span> <a class="subject" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.Subject}}</a><span class="spacer"></span><a class="sha" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}"><code>{{short .SHA}}</code></a> <span class="age"{{if not .When.IsZero}} title="{{whenT .When}}"{{end}}>{{ago .When}}</span></td></tr>{{end}}{{end}}
815```
816
817and delete the old `<div class="tipbar">`. Keep `<tr class="cols">` with headers `Name`, `Last commit`, `Updated`.
818
819After the README section (the `section.readme` at the end of the file), when `.Entries` is non-empty, add:
820
821```gotemplate
822{{if .Entries}}<div class="facts">
823 <div class="clone">
824 <h2>Clone</h2>
825 <label>SSH</label><pre><code>git clone {{.SSHCloneURL}}</code></pre>
826 <label>HTTPS</label><pre><code>git clone {{.CloneURL}}</code></pre>
827 </div>
828 {{if .Facts.Commits}}{{$r := printf "/%s/%s" .Repo.OwnerName .Repo.Name}}<div class="about">
829 <h2>About</h2>
830 <div class="factgrid">
831 <a href="{{$r}}/log/{{.Ref}}"><b>{{.Facts.Commits}}</b> commit{{if ne .Facts.Commits 1}}s{{end}}</a>
832 <a href="{{$r}}/refs"><b>{{.Facts.Branches}}</b> branch{{if ne .Facts.Branches 1}}es{{end}}</a>
833 <a href="{{$r}}/refs"><b>{{.Facts.Tags}}</b> tag{{if ne .Facts.Tags 1}}s{{end}}</a>
834 {{if .Facts.Bookmarks}}<span><b>{{.Facts.Bookmarks}}</b> bookmark{{if ne .Facts.Bookmarks 1}}s{{end}}</span>{{end}}
835 {{with .Facts.License}}<span class="fact">{{.}}</span>{{end}}
836 {{with .Facts.Release}}<a href="{{$r}}/releases">latest <b>{{.}}</b></a>{{end}}
837 {{with .Facts.Build}}<a href="{{$r}}/builds">build <span class="badge badge-{{.}}">{{.}}</span></a>{{end}}
838 </div>
839 {{with .Facts.Languages}}<p class="langbar" aria-hidden="true">{{range .}}<span class="lang lang-{{slug .Name}}" style="width:{{pct .Percent}}%"></span>{{end}}</p>
840 <p class="langs">{{range .}}<span class="lang-name"><span class="dot lang-{{slug .Name}}"></span>{{.Name}} <span class="muted">{{pct .Percent}}%</span></span>{{end}}</p>{{end}}
841 {{with .Facts.Contributors}}<p class="contribs"><span class="label">{{len .}} contributor{{if ne (len .) 1}}s{{end}}</span>{{range .}}{{template "authorname" dict "Name" .Name "User" .User "Email" .Title}}{{end}}</p>{{end}}
842 </div>{{end}}
843</div>{{end}}
844```
845
846Delete the old `<p class="clone">` and the old `<div class="facts">` block. The labels `Name`, `Last commit`, `Updated` are what `TestHeaderRowsAreLeftAligned` reads; check it still passes. Add `.about` to the stylesheet beside `.clone`.
847
848- [ ] **Step 4: Run the tests**
849
850Run: `go test ./internal/web && go test ./e2e -run 'TestTreeSearchCodeAndClone|TestFacts|TestReadme' -v`
851Expected: PASS. `e2e/facts_test.go` cuts the body at `<p class="contribs">`, which is preserved.
852
853- [ ] **Step 5: Commit**
854
855```bash
856git add internal/web e2e/design_test.go
857git commit -m "web: repository overview leads with the files
858
859Ref #218"
860```
861
862### Task 10: merge request page
863
864**Files:**
865- Modify: `internal/httpd/web.go:1820-1960` (the `mr` handler struct and the diff computation)
866- Modify: `internal/web/templates/mr.html`
867- Test: `e2e/mrweb_test.go`
868
869**Interfaces:**
870- Produces: `HeadMerged bool` on the MR page struct: the head is already reachable from the target, so the diff is empty by construction.
871
872- [ ] **Step 1: Write the failing test**
873
874In `e2e/mrweb_test.go` add a test that opens an MR, merges its branch into the target by fast-forward through git (push the head to `main` directly with a key that has write access, in a repository without require-MR), then loads `?view=diff` and expects the explanation:
875
876```go
877// TestMRDiffEmptyExplained: a merge request whose head was fast-forwarded
878// into the target outside the request shows why its diff is empty.
879func TestMRDiffEmptyExplained(t *testing.T) {
880 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
881 key := inst.newKey(t, "alice")
882 inst.admin(t, "admin", "user", "create", "alice", "--key", key+".pub", "--email", "alice@example.test", "--verified")
883 inst.ssh(t, key, "", "repo", "create", "alice/app")
884 dir := pushInitial(t, inst, key, "alice/app") // reuse the helper this file already has for creating a repo with a main branch; if it is named differently, use that name
885 gitIn(t, dir, key, "checkout", "-q", "-b", "feature")
886 os.WriteFile(filepath.Join(dir, "f.txt"), []byte("one\n"), 0o644)
887 gitIn(t, dir, key, "add", "."); gitIn(t, dir, key, "commit", "-q", "-m", "one")
888 gitIn(t, dir, key, "push", "-q", inst.sshURL("alice/app"), "feature")
889 if _, errOut, code := inst.ssh(t, key, "", "mr", "create", "alice/app", "--source", "feature", "--target", "main", "--title", "one"); code != 0 {
890 t.Fatal(errOut)
891 }
892 gitIn(t, dir, key, "push", "-q", inst.sshURL("alice/app"), "feature:main")
893 _, body := inst.get(t, "/alice/app/mrs/1?view=diff")
894 if !strings.Contains(body, "No changes between the source and target.") ||
895 !strings.Contains(body, "already merged or fast-forwarded into <code>main</code>") {
896 t.Fatalf("empty diff unexplained:\n%s", body)
897 }
898}
899```
900
901Before writing it, read the top of `e2e/mrweb_test.go` and `e2e/git_test.go` for the helper names that create a repository with an initial commit and run git with the key's environment; use those exact names in place of `pushInitial` and `gitIn`. If none exist, write `gitIn(t, dir, key, args...)` in this file with the `exec.Command("git", args...)` shape from Task 9.
902
903- [ ] **Step 2: Run to verify it fails**
904
905Run: `go test ./e2e -run TestMRDiffEmptyExplained -v`
906Expected: FAIL: the body has `0 files changed` and no explanation.
907
908- [ ] **Step 3: Handler: compute HeadMerged**
909
910In `web.go`'s `mr` handler, after `files, diffTruncated` are computed, add:
911
912```go
913 headMerged := false
914 if len(files) == 0 && m.HeadSHA != "" {
915 if targetSHA, err := gitutil.ResolveRef(p.Dir, "refs/heads/"+m.TargetRef); err == nil {
916 if ok, err := gitutil.IsAncestor(p.Dir, m.HeadSHA, targetSHA); err == nil {
917 headMerged = ok
918 }
919 }
920 }
921```
922
923`gitutil.IsAncestor(dir, old, new)` reports whether `old` is an ancestor of `new`. Add `HeadMerged bool` to the anonymous struct and set `HeadMerged: headMerged`. The handler already resolves the target SHA for `Gates`; reuse that variable if it is in scope.
924
925- [ ] **Step 4: Template: title line, diff empty case, aside order**
926
927In `mr.html`:
928
929Title block (replace the `h1.issuetitle`, `p.issuemeta` and the stacked notes):
930
931```gotemplate
932<h1 class="issuetitle">{{.MR.Title}} <span class="issuenumber">!{{.MR.Number}}</span></h1>
933<p class="issuemeta"><span class="chip chip-{{.MR.State}}">{{if eq .MR.State "source_gone"}}source gone{{else}}{{.MR.State}}{{end}}</span>{{if .MR.Draft}} <span class="chip chip-neutral">draft</span>{{end}}
934 {{if eq .MR.State "merged"}}merged by <a href="/{{.MR.MergedBy}}">{{.MR.MergedBy}}</a> on {{when .MR.MergedAt}}{{else if eq .MR.State "closed"}}closed without merging by <a href="/{{.MR.ClosedBy}}">{{.MR.ClosedBy}}</a> on {{when .MR.ClosedAt}}{{else}}opened by <a href="/{{.MR.Author}}">{{.MR.Author}}</a> on {{when .MR.CreatedAt}}{{end}}
935 · <code>{{.MR.SourceRef}}</code> into <code>{{.MR.TargetRef}}</code></p>
936{{with .StackedOn}}<p class="meta">stacked on <a href="{{$base | dir}}/{{.Number}}">!{{.Number}}</a></p>{{end}}
937```
938
939`store.MR` has `Author`, `SourceRef`, `TargetRef`, `Title`, `MergedBy`, `MergedAt`, `ClosedBy`, `ClosedAt`, `CreatedAt` (`internal/store/mrs.go:9-37`). Keep the existing stacked-on/stacked-children paragraphs as they are if their expressions differ from the sketch above.
940
941Diff arm (replace lines 76-80):
942
943```gotemplate
944{{else}}
945{{if .DiffFiles}}<p class="diffstat">{{.Stat.Files}} file{{if ne .Stat.Files 1}}s{{end}} changed, <span class="add">+{{.Stat.Adds}}</span> <span class="del">−{{.Stat.Dels}}</span>{{if .DiffTruncated}} · shown up to 4 MiB; the counts and the last file are partial{{end}}</p>
946{{if .DiffTruncated}}<p class="error" role="alert">This diff is larger than 4 MiB and is cut off below. Fetch the branch to see all of it.</p>{{end}}
947{{template "difffiles" dict "Files" .DiffFiles "Base" $base "Viewer" .Viewer}}
948{{else}}<p class="empty-note">No changes between the source and target.{{if .HeadMerged}} The source branch was already merged or fast-forwarded into <code>{{.MR.TargetRef}}</code>.{{end}}</p>{{end}}
949{{end}}
950```
951
952Aside: reorder the `div.grp` blocks to Review (when open and can write), Merge, Merge gates, Checks, Reviews, Reviewers, Source and target (merge the old `Target` and `Source` groups into one `h2` "Source and target" with the source ref, target ref, head and base SHAs, and the delete-branch form), Milestone. Every button in the aside: Merge is primary; Approve, Request changes, Ready for review, Convert to draft are `btn`; Close without merging and Delete branch are `danger`. Wrap the conversation column's contents in `<div class="prose">`.
953
954- [ ] **Step 5: Run the tests**
955
956Run: `go build ./... && go vet ./... && go test ./internal/web ./internal/httpd && go test ./e2e -run 'TestMRDiffEmptyExplained|TestMRWeb|TestDiffWeb|TestDiffComment' -v`
957Expected: PASS.
958
959- [ ] **Step 6: Commit**
960
961```bash
962git add internal/httpd/web.go internal/web/templates/mr.html e2e/mrweb_test.go
963git commit -m "web: merge request page states its resolution and explains an empty diff
964
965Ref #218"
966```
967
968### Task 11: repository settings
969
970**Files:**
971- Modify: `internal/httpd/settings.go:18-26, 53-57, 111-120, 139-144`
972- Modify: `internal/web/templates/settings.html`
973- Test: `e2e/settingsweb_test.go`
974
975**Interfaces:**
976- Produces: `Saved bool` on `settingsPage`; success flash text `Saved the <field>.`; the `topics` form field `topics` (comma separated) replacing `add`/`remove`.
977
978- [ ] **Step 1: Write the failing test**
979
980In `e2e/settingsweb_test.go`, inside `TestRepoSettingsWeb` after the `post` closure, add:
981
982```go
983 if body := post(url.Values{"field": {"description"}, "description": {"a thing"}}); !strings.Contains(body, `class="notice" role="status">Saved the description.`) {
984 t.Fatalf("no success flash after saving the description:\n%s", body)
985 }
986 if body := post(url.Values{"field": {"topics"}, "topics": {"cli, forge"}}); !strings.Contains(body, `value="cli, forge"`) {
987 t.Fatalf("topics field is not prefilled after save:\n%s", body)
988 }
989 if body := post(url.Values{"field": {"topics"}, "topics": {"forge"}}); strings.Contains(body, `>cli<`) || !strings.Contains(body, `value="forge"`) {
990 t.Fatalf("removing a topic through the field failed:\n%s", body)
991 }
992 if body := post(url.Values{"field": {"website"}, "website": {"javascript:alert(1)"}}); !strings.Contains(body, `class="error"`) || !strings.Contains(body, `value="javascript:alert(1)"`) {
993 t.Fatalf("error does not keep the submitted website:\n%s", body)
994 }
995```
996
997Delete the older website assertion at lines 91-93 since this one supersedes it, and any assertion that posts `add`/`remove` topics.
998
999- [ ] **Step 2: Run to verify it fails**
1000
1001Run: `go test ./e2e -run TestRepoSettingsWeb -v`
1002Expected: FAIL on the success flash.
1003
1004- [ ] **Step 3: Handler**
1005
1006`settingsPage` gains `Saved bool` and `Submitted map[string]string`. In `settingsForm`, after `Notice: s.takeFlash(w, r)`, set `Saved: strings.HasPrefix(notice, "Saved ")` (take the flash into a local first). Retaining a submitted value on error: the redirect loses the form, so the error path re-renders instead. Change the tail of `settingsSubmit`:
1007
1008```go
1009 _, msg, ok := s.runControl(u, argv)
1010 if ok {
1011 s.settingsRedirect(w, r, "Saved the "+fieldLabel(field)+".")
1012 return
1013 }
1014 s.settingsFormWith(w, r, msg, r.Form)
1015```
1016
1017where `fieldLabel` maps the `field` value to its label in lower case (`description`, `website`, `visibility`, `default branch`, `git:// serving`, `required checks`, `approvals`, `review threads`, `CODEOWNERS`, `require-MR`, `signed commits`, `protected branch`, `protected tag`, `dependency scanning`, `archive`, `topics`, `runner`), and `settingsFormWith(w, r, notice string, submitted url.Values)` is `settingsForm` refactored to take the notice and a map of submitted values that the template prefers over stored ones: `Submitted: map[string]string{"description": submitted.Get("description"), "website": submitted.Get("website"), "topics": submitted.Get("topics")}`. `settingsForm` calls `settingsFormWith(w, r, s.takeFlash(w, r), nil)`.
1018
1019Topics case:
1020
1021```go
1022 case "topics":
1023 want := map[string]bool{}
1024 var order []string
1025 for _, t := range strings.Split(v("topics"), ",") {
1026 if t = strings.ToLower(strings.TrimSpace(t)); t != "" && !want[t] {
1027 want[t] = true
1028 order = append(order, t)
1029 }
1030 }
1031 have, err := s.st.ListTopics(p.Repo.ID)
1032 if err != nil {
1033 s.settingsRedirect(w, r, err.Error())
1034 return
1035 }
1036 var add, remove []string
1037 for _, t := range order {
1038 if !slices.Contains(have, t) {
1039 add = append(add, t)
1040 }
1041 }
1042 for _, t := range have {
1043 if !want[t] {
1044 remove = append(remove, t)
1045 }
1046 }
1047 if len(remove) > 0 {
1048 if _, msg, ok := s.runControl(u, append([]string{"repo", "topics", "remove", repo}, remove...)); !ok {
1049 s.settingsFormWith(w, r, msg, r.Form)
1050 return
1051 }
1052 }
1053 if len(add) > 0 {
1054 argv = append([]string{"repo", "topics", "add", repo}, add...)
1055 } else {
1056 s.settingsRedirect(w, r, "Saved the topics.")
1057 return
1058 }
1059```
1060
1061`p.Repo.ID` is however the handler names the resolved repository; read the top of `settingsSubmit` for the variable. `s.st.ListTopics` already exists (used in `settingsForm`).
1062
1063- [ ] **Step 4: Template**
1064
1065Rewrite `settings.html` after the title line:
1066
1067```gotemplate
1068{{define "width"}}bounded{{end}}
1069{{define "content"}}
1070{{$base := printf "/%s/%s/settings" .Repo.OwnerName .Repo.Name}}
1071<h1>Settings</h1>
1072{{if .Notice}}{{if .Saved}}<p class="notice" role="status">{{.Notice}}</p>{{else}}<p class="error" role="alert">{{.Notice}}</p>{{end}}{{end}}
1073<nav class="sections" aria-label="Sections"><a href="#identity">Identity</a><a href="#access">Access</a><a href="#gates">Merge gates</a><a href="#branches">Protected branches</a><a href="#tags">Protected tags</a>{{if .DepsEnabled}}<a href="#deps">Dependencies</a>{{end}}<a href="#runners">Runners</a><a href="#lifecycle">Lifecycle</a></nav>
1074
1075<section id="identity"><h2>Identity</h2>
1076<form method="post" action="{{$base}}" class="setform">
1077 <input type="hidden" name="field" value="description">
1078 <div><label for="description">Description</label></div>
1079 <div><input type="text" id="description" name="description" value="{{or (index .Submitted "description") .Desc}}" placeholder="one line, shown in listings"></div>
1080 <div><button type="submit">Save</button></div>
1081</form>
1082<form method="post" action="{{$base}}" class="setform">
1083 <input type="hidden" name="field" value="website">
1084 <div><label for="website">Website</label></div>
1085 <div><input type="text" id="website" name="website" value="{{or (index .Submitted "website") .Repo.Settings.Website}}" placeholder="https://example.org"></div>
1086 <div><button type="submit" class="btn">Save</button></div>
1087</form>
1088<form method="post" action="{{$base}}" class="setform">
1089 <input type="hidden" name="field" value="topics">
1090 <div><label for="topics">Topics</label><p class="hint">Comma separated, lower case.</p></div>
1091 <div><input type="text" id="topics" name="topics" value="{{or (index .Submitted "topics") (join .Topics ", ")}}"></div>
1092 <div><button type="submit" class="btn">Save</button></div>
1093</form>
1094{{if .Branches}}<form method="post" action="{{$base}}" class="setform">
1095 <input type="hidden" name="field" value="default-branch">
1096 <div><label for="default-branch">Default branch</label><p class="hint">What a clone checks out and what CI builds on trigger.</p></div>
1097 <div><select id="default-branch" name="default-branch">{{$cur := .Repo.DefaultBranch}}{{range .Branches}}<option value="{{.Name}}"{{if eq .Name $cur}} selected{{end}}>{{.Name}}</option>{{end}}</select></div>
1098 <div><button type="submit" class="btn">Save</button></div>
1099</form>{{end}}
1100</section>
1101```
1102
1103`join` is a new template func: add `"join": strings.Join` to `funcs` in `internal/web/web.go`. `.Submitted` is nil on a plain GET; `index` on a nil map returns the zero string, and `or` falls through to the stored value.
1104
1105Then the remaining sections in the same three-column shape, each control's hint from the spec:
1106
1107- Access: Visibility (radio Public / Private; hint "Private repositories answer not found to everyone without access, including in search and on your profile."), git:// serving (checkbox; hint "Unauthenticated, unencrypted read access on port 9418. Public repositories only.").
1108- Merge gates: Required checks ("A request waits until every CI job that would report on its head has succeeded."), Approvals (number, "Approvals from people with write access. Zero means none required."), Review threads ("Every thread on the diff must be resolved before merging."), CODEOWNERS ("Owners of every touched path must approve."), Signed commits ("Every commit in the request must carry a verified signature. Squash and merge strategies are refused, since both mint an unsigned commit.").
1109- Protected branches: each protected branch a row with its name, a hint "Direct pushes refused, merge requests only." when require-MR is on, and `Unprotect` as `danger`; the protect form; the require-MR checkbox with hint "Every protected branch changes through a merge request. A direct push is refused in pre-receive."
1110- Protected tags: same shape with `danger` Unprotect.
1111- Dependencies, Runners: rows as today, `Detach` as `danger`, the attach textarea in a `setform stack`.
1112- Lifecycle: Archive with hint "Read-only for everyone. Issues and requests close to new activity. Reversible." and the `confirmfield` beside a `danger` button.
1113
1114Keep every `name=` and `field` value exactly as `settingsSubmit` reads them (list in `internal/httpd/settings.go:67-133`).
1115
1116- [ ] **Step 5: Run the tests**
1117
1118Run: `go build ./... && go vet ./... && go test ./internal/web ./internal/httpd && go test ./e2e -run TestRepoSettingsWeb -v`
1119Expected: PASS.
1120
1121- [ ] **Step 6: Commit**
1122
1123```bash
1124git add internal/httpd/settings.go internal/web
1125git commit -m "web: settings as bounded rows with consequences and a saved flash
1126
1127Ref #218"
1128```
1129
1130### Task 12: dashboard aside, visual check, MR 2
1131
1132**Files:**
1133- Modify: `internal/web/templates/dashboard.html` (remove the Pinned `div.grp` from the aside)
1134- Test: `e2e/dashboard_test.go` (if it asserts the pinned group in the aside, change it to assert the rail's pinned list instead: `class="raillist" aria-labelledby="rail-pinned"`)
1135
1136- [ ] **Step 1: Remove the duplicate pinned group and run the dashboard test**
1137
1138Run: `go test ./e2e -run TestDashboard -v`
1139Expected: PASS after the assertion change.
1140
1141- [ ] **Step 2: Visual and keyboard check**
1142
1143Rerun Task 5's script and walk: landing, register, login, repository overview, MR (three views), settings, dashboard, issues, explore at 1280 and 375, both schemes, and the keyboard pass on the five reference pages. Compare against the mockups. Commit fixes as `web: <what>`.
1144
1145- [ ] **Step 3: Push, open MR 2, merge**
1146
1147```bash
1148git push -u origin design-pages
1149gitbay mr create --source design-pages --target main --title "web: content widths and the four reference pages" --file - <<'EOF'
1150A width class per page; the repository header trimmed on task tabs; landing, register, repository overview, merge request and repository settings recomposed per the design spec; Search code replaces Find file; settings report a saved flash and keep a rejected value; topics are one field.
1151
1152Ref #218
1153EOF
1154```
1155
1156Wait for green, `gitbay mr merge <n> --strategy ff`, delete the branch both places.
1157
1158---
1159
1160## MR 3: wiki and docs
1161
1162Branch `design-docs` off `main`. Task 13.
1163
1164### Task 13: SSH-keys page, Admin title, Users vocabulary, CHANGELOG
1165
1166**Files:**
1167- Create: `.gitbay/wiki/SSH-keys.org`
1168- Modify: `.gitbay/wiki/Admin.org:84-89`, `.gitbay/wiki/Users.org:53-77`, `.gitbay/wiki/Home.org`, `CHANGELOG.org`
1169
1170- [ ] **Step 1: Write SSH-keys.org**
1171
1172```org
1173#+title: SSH keys
1174
1175Your key is your identity on gitbay: there are no passwords. Registering
1176pastes the public half of a key pair; the private half never leaves your
1177machine.
1178
1179* Make a key
1180
1181#+begin_src sh
1182ssh-keygen -t ed25519 -C "you@host"
1183#+end_src
1184
1185Accept the default path. Two files land in =~/.ssh/=: =id_ed25519= is
1186private, keep it; =id_ed25519.pub= is public, paste it.
1187
1188* Get the public half
1189
1190#+begin_src sh
1191cat ~/.ssh/id_ed25519.pub
1192#+end_src
1193
1194One line, starting =ssh-ed25519 AAAA…= and ending with your comment. That
1195whole line is what the registration form and =register= want.
1196
1197* Check it works
1198
1199#+begin_src sh
1200ssh git@<host> whoami
1201#+end_src
1202
1203More keys, labels and scopes: [[Users][the user guide]] under "SSH keys".
1204```
1205
1206- [ ] **Step 2: Edit the other pages**
1207
1208`Users.org` under `* SSH keys`: add as the first paragraph "New to SSH keys? [[SSH-keys]] makes one in two commands." `Home.org`: add `- [[SSH-keys][SSH keys]] — make a key and find its public half` after the Quickstart line. `Admin.org` under `** [web]`: add bullets `- =title= — the instance's display name in the rail and page titles; empty falls back to the site host. Lower case is the convention for gitbay itself.` and `- =privacy_notice= — operator text shown on =/privacy= under the fixed statement.`
1209
1210Users.org under "Output rules" or a new `* Web vocabulary` heading: "Sign in / Log out, Search, sentence-case headings and buttons, product name lowercase."
1211
1212- [ ] **Step 3: CHANGELOG entry**
1213
1214Insert above `* v1.22.1`:
1215
1216```org
1217* v1.23.0 — <date of the release>
1218
1219The web design foundation (#218). Tokens measured in both schemes, one
1220control family, content widths per page, and the landing, repository
1221overview, merge request and settings pages recomposed. Set =[web]
1222title= to =gitbay= in lower case; the templates already are.
1223
1224- =style.css= rewritten on tokens defined on =:root= and again for dark;
1225 =TestTokenContrast= enforces the floors and
1226 =TestEveryTemplateClassHasARule= that every template class has a rule.
1227- Buttons are primary, secondary, quiet or destructive; one focus ring on
1228 everything; success and error flashes share one shape.
1229- Every page picks a width: wide for code, reading for lists and threads,
1230 bounded for forms.
1231- The repository header shows description and topics on the code tab
1232 only; the overview leads with the file table and puts clone commands
1233 and facts after the README; "Find file" is "Search code".
1234- A merge request states who merged or closed it under its title and
1235 explains an empty diff.
1236- Repository settings are bounded rows with a consequence beside each
1237 control, a saved flash, a rejected value kept, and topics as one field.
1238- Landing copy says what the product is and where to go; the register
1239 form says to paste the contents of the key file and links [[SSH-keys]].
1240```
1241
1242Fill the date when the release is cut.
1243
1244- [ ] **Step 4: Commit, MR, merge**
1245
1246```bash
1247git add .gitbay/wiki CHANGELOG.org
1248git commit -m "wiki: SSH keys page, web title, design vocabulary; CHANGELOG v1.23.0
1249
1250Ref #218"
1251git push -u origin design-docs
1252gitbay mr create --source design-docs --target main --title "wiki: SSH keys page and the v1.23.0 changelog" --file - <<'EOF'
1253Ref #218
1254EOF
1255```
1256
1257The `test` job skips wiki-only changes; the `build` job still runs. Merge with ff, delete the branch.
1258
1259---
1260
1261## MR 4: deploy, screenshots, release
1262
1263Branch `design-picture` off `main`. Tasks 14 and 15.
1264
1265### Task 14: deploy and capture
1266
1267- [ ] **Step 1: Deploy main**
1268
1269```bash
1270git switch main && git pull --ff-only && make deploy
1271```
1272
1273Then the user sets `title = "gitbay"` under `[web]` in `/etc/gitbay/config.toml` on bay1 and restarts; hand that to them, do not ssh to bay1.
1274
1275- [ ] **Step 2: Capture the after set**
1276
1277Follow `.claude/screenshots/README.md` (`python3 shoot.py after/...`). Compare `after/` with `before/` page by page; anything wrong is a fix on a new branch before the release.
1278
1279- [ ] **Step 3: Capture the landing picture**
1280
1281```bash
1282cd .claude/screenshots
1283printf 'mr https://gitbay.org/krz/gitbay/mrs/315\n' > picture.txt
1284python3 shoot.py pic/dark dark 1280 picture.txt
1285python3 shoot.py pic/light light 1280 picture.txt
1286```
1287
1288`shoot.py` captures full page; crop each to 1280x900 from the top (`sips -c 900 1280 pic/dark/mr.png --out mr-dark.png`, same for light), then `pngquant`-free size check: each under 400KB (`ls -l`); if larger, `sips -s format png` at `--resampleWidth 1280` is already the size, so reduce with `sips -s formatOptions 70` to JPEG only if PNG cannot get under 1MB. Copy to `internal/web/static/img/mr-dark.png` and `mr-light.png`.
1289
1290### Task 15: serve the images and wire the picture
1291
1292**Files:**
1293- Modify: `internal/web/web.go` (embed), `internal/httpd/routes.go:52-56`, `internal/httpd/web.go:95-107`, `internal/httpd/web.go` (landing struct `Picture`)
1294- Test: `internal/httpd/fonts_test.go` (extend), `e2e/design_test.go` (extend `TestLandingRoutes`)
1295
1296- [ ] **Step 1: Write the failing tests**
1297
1298Append to `internal/httpd/fonts_test.go`:
1299
1300```go
1301// TestLandingImagesAreServed: every file under static/img has a route
1302// that answers 200 with an image type and the stylesheet's cache policy.
1303func TestLandingImagesAreServed(t *testing.T) {
1304 s := newTestServer(t) // the constructor the file's other test uses
1305 entries, err := fs.ReadDir(web.ImageFS, "static/img")
1306 if err != nil {
1307 t.Fatal(err)
1308 }
1309 if len(entries) == 0 {
1310 t.Fatal("no images embedded")
1311 }
1312 for _, e := range entries {
1313 rec := httptest.NewRecorder()
1314 s.Handler().ServeHTTP(rec, httptest.NewRequest("GET", "/static/img/"+e.Name(), nil))
1315 if rec.Code != 200 || !strings.HasPrefix(rec.Header().Get("Content-Type"), "image/") {
1316 t.Errorf("%s: %d %s", e.Name(), rec.Code, rec.Header().Get("Content-Type"))
1317 }
1318 }
1319}
1320```
1321
1322Use the same server construction and request shape as `TestStylesheetFontsAreServed` in that file. In `e2e/design_test.go`'s `TestLandingRoutes` add `"/static/img/mr-dark.png"` and `<picture>` to the wanted strings.
1323
1324- [ ] **Step 2: Run to verify they fail**
1325
1326Run: `go test ./internal/httpd -run TestLandingImagesAreServed`
1327Expected: FAIL to compile on `web.ImageFS`.
1328
1329- [ ] **Step 3: Implement**
1330
1331`internal/web/web.go`: add
1332
1333```go
1334//go:embed static/img/*.png
1335var ImageFS embed.FS
1336```
1337
1338`routes.go`, after the font loop:
1339
1340```go
1341 images, _ := fs.ReadDir(web.ImageFS, "static/img")
1342 for _, f := range images {
1343 routes = append(routes, Route{Method: "GET", Pattern: "/static/img/" + f.Name(), Handler: s.image})
1344 }
1345```
1346
1347`web.go` beside `font`:
1348
1349```go
1350// image serves the embedded landing pictures with the font cache policy.
1351func (s *Server) image(w http.ResponseWriter, r *http.Request) {
1352 data, err := web.ImageFS.ReadFile("static" + r.URL.Path[len("/static"):])
1353 if err != nil {
1354 http.NotFound(w, r)
1355 return
1356 }
1357 w.Header().Set("Content-Type", "image/png")
1358 w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
1359 w.Write(data)
1360}
1361```
1362
1363In the landing handler replace the `landingPicture` variable from Task 8 with a check that both files exist in `web.ImageFS` (`fs.Stat`), computed once at server construction.
1364
1365- [ ] **Step 4: Run the tests**
1366
1367Run: `go build ./... && go vet ./... && go test ./internal/web ./internal/httpd && go test ./e2e -run 'TestLandingRoutes|TestTopLevelRouteWordsAreReserved' -v`
1368Expected: PASS; `static` is already reserved.
1369
1370- [ ] **Step 5: Commit, MR, merge, release**
1371
1372```bash
1373git add internal/web internal/httpd e2e/design_test.go
1374git commit -m "web: landing picture, light and dark
1375
1376Closes #218"
1377git push -u origin design-picture
1378gitbay mr create --source design-picture --target main --title "web: landing picture" --file - <<'EOF'
1379Closes #218
1380EOF
1381```
1382
1383After the merge: fill the CHANGELOG date in a one-line commit on a branch if it was left blank, then follow the release steps the previous versions used (`deploy/release.sh`, tag `v1.23.0` on main, `make deploy`, bump the tap). Capture the after set once more so `.claude/screenshots/after/` shows the released state.
1384
1385---
1386
1387## Self-review against the spec
1388
1389- Rules: two accents (Task 3), link/fill split and token test (Tasks 1, 3), class coverage (Task 2), authored states (Task 3), lowercase name (Tasks 4, 13, 14).
1390- Tokens, type, spacing, radius: Task 3.
1391- Components: Task 3 rules, Task 4 markup; flash family resolved as shared `.error`/`.notice` rules.
1392- Shell and layout: widths (Task 7), aside 18rem and stacking above (Task 3), rail (Task 3), header (Task 8), page head and filters (Tasks 3, 4), responsive (Tasks 3, 5).
1393- Reference pages: landing and register (Task 8), overview (Task 9), MR (Task 10), settings (Task 11), dashboard pinned (Task 12).
1394- Copy: Tasks 4, 8, 9, 13.
1395- Landing order: MRs 1 to 4. Verification: Tasks 5, 12, 14. Not in spec: filed as issues when MR 4 merges.
docs/specs/2026-09-17-web-design-foundation-design.md added +282
@@ -0,0 +1,282 @@
1# Web design foundation
2
3Closes #218. A rewrite of the stylesheet around a measured token set, one
4control family with authored states, content widths chosen per page, and
5four reference pages recomposed: landing, repository overview, merge
6request, repository settings. The copy fixes from the September 2026 UI
7review ride along. No JavaScript: the instance CSP is `script-src 'none'`
8and stays so.
9
10The review's findings came from dark-scheme screenshots. The stylesheet is
11light-first with dark through `prefers-color-scheme`. Both schemes are
12designed here, dark as the audited one, and every token is measured in
13each.
14
15Mockups of the four pages, with the tokens and components below, were
16reviewed in the browser before this was written. They are the visual
17reference for the implementation, not the templates: the implementation
18keeps the template structure and class names where a component survives.
19
20## Rules
21
22- **Two accents, two jobs.** Blue is what you can do: links, primary
23 buttons, focus, pressed toggles. Orange is where you are and what wants
24 you: the current tab's bar, rail counts, pending state, unverified
25 signatures. Orange is never a button ground.
26- **Links and fills are different tokens.** `--link` is text on a ground
27 and must clear 4.5:1 on canvas, surface and hover. `--fill` is a
28 button ground and must carry white at 4.5:1 and stand 3:1 against
29 surface. One blue for both fails one of the two jobs in each scheme.
30- **Every colour is a token on `:root`**, redefined in the dark media
31 query. No colour defined only in the media query. A test parses the
32 two blocks out of `style.css` and enforces the floors in the table
33 below; the hex values are starting points and the test is the contract.
34- **Every class in a template has a selector.** A test parses every
35 template for class names and fails on one with no rule in `style.css`.
36 Classes that chroma emits and classes tests pin are on an allowlist
37 only if they have no styling; today none qualifies.
38- **States are authored, never hover-only.** Hover, focus, active,
39 pressed, disabled, empty, success and error each have a rule. Essential
40 meaning is never in a `title` attribute alone.
41- **The product name is lowercase** in templates, docs and the
42 CHANGELOG. The instance title is `[web] title` in the daemon's
43 config; the Admin wiki page says to set it lowercase.
44
45## Tokens
46
47Colour. Light and dark per token; floors are WCAG contrast ratios.
48
49| Token | Role | Light | Dark | Floor |
50| --- | --- | --- | --- | --- |
51| `--canvas` | page | `#ffffff` | `#101114` | |
52| `--surface` | cards, lists, aside groups | `#f6f7f9` | `#17191d` | distinct from canvas: luminance ratio at least 1.15 |
53| `--inset` | inputs, code, terminal blocks | `#ffffff` | `#0b0c0e` | distinct from surface |
54| `--hover` | row and control hover | `#eef0f3` | `#1e2126` | |
55| `--line` | control and card borders | `#d0d5db` | `#2f343b` | 3:1 against canvas |
56| `--faint` | row separators | `#e6e9ed` | `#22252b` | |
57| `--fg` | text | `#1c1f24` | `#e9eaec` | 7:1 on canvas and surface |
58| `--muted` | metadata | `#5a6270` | `#a3a9b3` | 4.5:1 on canvas and surface |
59| `--link` | text links | `#0033d6` | `#8b9bff` | 4.5:1 on canvas, surface, hover |
60| `--fill` | primary button ground | `#0000f0` | `#3b4fe8` | white on it 4.5:1; 3:1 against surface |
61| `--mark` | current-tab bar, counts | `#e4572e` | `#ff6b3d` | 3:1 non-text |
62| `--warn` | the same hue at text size | `#b8400f` | `#ff8a63` | 4.5:1 |
63| `--ok` | success | `#0a7d3c` | `#3fce7a` | 4.5:1 on canvas and surface |
64| `--bad` | failure, destructive | `#c62828` | `#ff6b6b` | 4.5:1 on canvas and surface |
65| `--done` | merged | `#6b21a8` | `#c084fc` | 4.5:1 on canvas and surface |
66| `--neutral` | closed, draft, unknown | `--muted` | `--muted` | as muted |
67| `--focus` | ring | `--link` | `--link` | 3:1 against canvas |
68
69The rail keeps `--shell-*`: black ground and its own text, muted, line,
70hover and mark values in both schemes. The dark values above are neutral
71greys so the pane and the black rail read as two grounds.
72
73The syntax palettes and the diff tints stay as they are; the existing
74`TestSyntaxPaletteContrast` pins them. The chroma grounds it measures
75against become `--inset` and the diff tints.
76
77Type. Base 16px. Atkinson Hyperlegible Next and Mono, self-hosted, as
78now. Sizes `--fs-0` to `--fs-6`: 12, 13, 14, 16, 20, 24, 32px. Weights
79400, 500, 600 only. Body line-height 1.5, headings 1.25. Metadata that
80carries a decision, such as a branch, a timestamp or a check result, is
81never below 13px. Monospace is for commands, hashes, paths, refs and
82code; rail labels, dates and section labels use the sans face.
83
84Spacing. `--sp-1` to `--sp-7`: 4, 8, 12, 16, 24, 32, 48px. Between
85sections 32, within a group 8 or 12, page padding 24 by 32.
86
87Radius. 4px on controls and chips, 6px on cards, lists and code blocks.
88One shadow, on the ref menu dropdown only.
89
90## Components
91
92Buttons. All 32px tall at 14px, 500 weight, same horizontal padding, so
93they align in a row. Four treatments:
94
95- Primary: `--fill` ground, white text. One per form group, the action
96 the page exists for: Create account, Comment, Merge, the identity
97 form's Save.
98- Secondary (`.btn`): canvas ground, `--line` border, `--fg` text, hover
99 to `--hover`. Pin, Watch, Bookmark, Fork, Filter, per-field Save.
100- Quiet (`.linklike`): no ground or border, `--link` text, underline on
101 hover. Inline actions: clear, remove, make primary.
102- Destructive (`.danger`): canvas ground, `--bad` border and text; hover
103 fills `--bad` with white text. Delete, close without merging,
104 unprotect, archive. Beside the typed confirmation field where one
105 exists today.
106
107Toggles keep `aria-pressed`. Pressed: `--surface` ground, `--link` border
108and text, label in the participle as now.
109
110Links: `--link`, underline on hover; inside rendered prose always
111underlined. Row titles in lists stay `--fg` and turn `--link` on hover.
112
113Focus: one ring, 2px `--focus` with 2px offset, on every focusable
114element including inputs. The rail uses `--shell-mark`.
115
116Chips: 4px radius, 12px, 500 weight, 1px border at 40% of the state
117colour, 10% tint ground. Variants as now; topic chips use `--link`.
118
119Tabs: current tab `--fg` at 600 with the 2px `--mark` bar. Counts in
120tabs are plain `--muted` text. The rail's count keeps the orange ground.
121
122Forms: inputs on `--inset`, `--line` border, 32px tall, 14px. Label above
123the field at 500 weight; the hint in `--muted` under the label. Checkbox
12416px, label to its right on the same line. `::placeholder` in `--muted`.
125
126Feedback: `.error` and `.notice` become one `.flash` family with a
127`role`, a 3px left edge in `--bad`, `--ok` or `--warn`, a 6% tint ground
128and `--fg` text. `class="error"` stays on the error variant, since e2e
129tests pin it.
130
131Containers: one recipe for lists, tables and cards: `--surface` ground,
132`--line` border, 6px radius, `--faint` row separators, `--hover` row
133hover. Table headers 13px, 500, `--muted`. A card is a bounded group with
134a header: README, a comment, an aside group. Bordered boxes around whole
135page regions go.
136
137Code and diffs: `--inset` ground, existing gutters and chroma classes.
138
139## Shell and layout
140
141Rail: 14rem, black, same sections. Labels sans, 12px, uppercase, tracked.
142Under 52rem it becomes the top strip it is today, search kept, pinned
143group hidden.
144
145Content width: a class on `main`, set from the page struct, one of:
146
147- `wide`, no maximum: tree, blob, blame, log, commit and MR diffs,
148 builds, code search.
149- `reading`, 72rem: dashboard, issue and MR lists and pages, explore,
150 profile, releases, wiki, notifications, bookmarks, snippets. The
151 default.
152- `bounded`, 48rem: landing, login, register, new repository, new issue,
153 new MR, repository settings, account settings, admin.
154
155Two-column pages (issue, MR, dashboard): the aside is 18rem, the gap
15632px, its top aligned with the first content block below the page
157header. Prose in the conversation column is capped at 78ch. Under 62rem
158the aside stacks above the conversation. The dashboard's pinned group
159goes; the rail has it.
160
161Repository header: two levels. The identity row holds owner/name, the
162visibility and archived chips, and the toggles at the right. On the code
163tab it also shows the description with the topic chips on the same line,
164one `--muted` line for website and mirrors, and for a signed-in viewer
165one `--muted` line explaining Pin, Watch and Bookmark. On every other tab
166only the identity row and the tab bar render. The header is identical on
167every page within a tab.
168
169Page header: `h1` at 24px, page controls on the same row at the right,
17024px before content. Filters are secondary buttons in a group with the
171active one pressed, each a GET link; a text filter has an explicit Search
172button and a result count line above the list.
173
174Responsive: the four reference pages are checked at 375px, 768px, and
1751280px at 200% zoom. Tables under 40rem drop the last-commit column and
176scroll inside their wrapper. Clone commands are two labelled code blocks
177that wrap.
178
179## Reference pages
180
181Landing, `bounded`. Order: mark and name as the title; proposition;
182quickstart; picture; three facets; routes; sign-in line. Copy:
183
184- Proposition: "A git forge you drive from the terminal. Repositories,
185 issues, merge requests and CI over SSH, with a fast, readable web view
186 of the same state."
187- Quickstart: `ssh git@{host} help` with the comment "every command, no
188 client to install", and `git clone ssh://git@{host}/owner/repo.git`.
189- Picture: a `<picture>` with a dark source under
190 `(prefers-color-scheme: dark)` and a light `img`, of the krz/gitbay
191 merge request page at 1280 wide, captured from gitbay.org after the
192 redesign deploys. Files under `internal/web/static/img/`, routes
193 generated from the directory as fonts are, served with the same cache
194 headers as the stylesheet.
195- Facets. Read: "Browse and clone any public repository over HTTPS or
196 git://, no account. Every commit shows whether its signature
197 verified." Write: "Push over SSH with the key you already have. Create
198 a repository, file an issue, open and merge a request, all as
199 commands." Review: "Read a diff, comment on a line, approve, merge, in
200 the browser or the terminal."
201- Routes: primary "Explore repositories"; secondary "Create an account"
202 when registration is open.
203- Sign-in line, `--muted`: "Have an account? Sign in with an emailed
204 link, or run `ssh git@{host} web login`." When email login is off the
205 first clause is dropped.
206
207Register, `bounded`. Labels above fields, one primary button. Key field
208label "SSH public key", hint "Paste the contents of your public key
209file, usually ~/.ssh/id_ed25519.pub. It starts with ssh-ed25519 or
210ssh-rsa." and a link to the wiki page `SSH-keys`, new, which shows
211`ssh-keygen -t ed25519` and where the public half lands. The terminal
212alternative stays as one line at the bottom.
213
214Repository overview, `wide`. Under the header: the path bar with the ref
215menu, crumbs, and at the right Search code, History, Download as
216secondary buttons. The file table with the tip commit as its first row.
217The README card. Then, at reading width, two columns: Clone on the left
218as two labelled blocks, SSH first; About on the right with commits,
219branches, tags, contributors, licence, latest release, build state in a
220four-column grid, then the language bar with its legend. The Find file
221link becomes Search code, since the route searches contents.
222
223Merge request, `reading`. Title row: title, number in `--muted`. Next
224line: state chip, then "opened by X on DATE", "merged by X on DATE" or
225"closed without merging by X on DATE", then source into target in code.
226Subtabs as now with plain counts. Aside order: Merge, Checks, Reviews,
227Reviewers, Source and target, Milestone. The diff view's empty case
228renders "No changes between the source and target." followed, when the
229head is an ancestor of the target, by "The source branch was already
230merged or fast-forwarded into TARGET." A related-request link needs a
231data model and is not in this spec.
232
233Repository settings, `bounded`. A flash at the top after a save, "Saved
234the FIELD.", rendered on the redirected GET through the existing flash
235cookie; an error renders the flash error variant and the field keeps the
236submitted value. A row of anchor links to the sections. Each section an
237`h2` and a stack of rows. A row is a grid: label column 14rem with the
238hint beneath the label, control column bounded at 28rem, Save at the
239end. Every control with a consequence carries one hint sentence:
240visibility, git:// serving, default branch, each merge gate, require-MR,
241archive. The identity form's Save is primary; every other Save is
242secondary; Unprotect and Archive are destructive. Topics become one
243prefilled text field, comma separated.
244
245## Copy
246
247Buttons and headings in sentence case. "Sign in" and "Log out". "Search"
248as the rail field's placeholder and every search button's label. The
249homepage no longer calls `repo create` the whole onboarding. The wiki's
250Users page records the vocabulary.
251
252## Landing in order
253
254Four merge requests onto `main`, each green on bay1 before merging, each
255referencing #218:
256
2571. Stylesheet rewrite: tokens, components, shell, and every template
258 adjusted where a component's markup changes. The contrast test and
259 the class-coverage test land here. Every page family screenshotted at
260 1280 and 375 in both schemes before merge.
2612. Layout and pages: the `main` width class, the two-level repository
262 header, and the four reference pages. An e2e test posts a settings
263 form and checks the flash and the retained value on error.
2643. Copy and the `SSH-keys` wiki page. Rides with 2 if the diff stays
265 readable.
2664. Screenshots, captured after 2 deploys, committed with their routes
267 and the landing `<picture>`.
268
269Release v1.23.0. The CHANGELOG and the Admin wiki page note the
270lowercase title.
271
272## Verification
273
274Before each merge: build, vet, the unit tests of `internal/httpd` and
275`internal/web`, and the e2e tests that pin class names. Then in the
276browser: a keyboard walk of the four reference pages, a 200% zoom pass,
277and a comparison against the mockups.
278
279## Not in this spec
280
281Triage and builds (D01, D02, D04, D05, M01, M04), profiles (P01 to
282P03), and the benchmark (B08). Each is filed as its own issue.
internal/httpd/palette_test.go +3 −3
@@ -53,13 +53,13 @@ func TestSyntaxPaletteContrast(t *testing.T) {
53 }{ 53 }{
54 {lightStyle, map[string]chroma.Colour{ 54 {lightStyle, map[string]chroma.Colour{
55 "page": chroma.NewColour(0xff, 0xff, 0xff), 55 "page": chroma.NewColour(0xff, 0xff, 0xff),
56 "code": chroma.NewColour(0xf5, 0xf5, 0xf5), 56 "code": chroma.NewColour(0xff, 0xff, 0xff),
57 "add": chroma.NewColour(0xe4, 0xf6, 0xea), 57 "add": chroma.NewColour(0xe4, 0xf6, 0xea),
58 "del": chroma.NewColour(0xfd, 0xea, 0xea), 58 "del": chroma.NewColour(0xfd, 0xea, 0xea),
59 }, map[chroma.TokenType]bool{chroma.NameAttribute: true}}, 59 }, map[chroma.TokenType]bool{chroma.NameAttribute: true}},
60 {darkStyle, map[string]chroma.Colour{ 60 {darkStyle, map[string]chroma.Colour{
61 "page": chroma.NewColour(0x0a, 0x0a, 0x0a), 61 "page": chroma.NewColour(0x10, 0x11, 0x14),
62 "code": chroma.NewColour(0x05, 0x05, 0x05), 62 "code": chroma.NewColour(0x0b, 0x0c, 0x0e),
63 "add": chroma.NewColour(0x0d, 0x2a, 0x18), 63 "add": chroma.NewColour(0x0d, 0x2a, 0x18),
64 "del": chroma.NewColour(0x2c, 0x11, 0x13), 64 "del": chroma.NewColour(0x2c, 0x11, 0x13),
65 }, map[chroma.TokenType]bool{ 65 }, map[chroma.TokenType]bool{
internal/web/classes_test.go added +107
@@ -0,0 +1,107 @@
1package web
2
3import (
4 "io/fs"
5 "regexp"
6 "sort"
7 "strings"
8 "testing"
9)
10
11// TestEveryTemplateClassHasARule fails on a class a template uses that
12// no selector in style.css mentions. Template actions are replaced by a
13// marker and any class token carrying one (chip-{{.State}}, l{{.Level}})
14// is skipped; the rules for the concrete values cover those.
15func TestEveryTemplateClassHasARule(t *testing.T) {
16 css := string(StyleCSS)
17 // Every ".name" that appears in a selector position: outside braces.
18 depth := 0
19 var sel strings.Builder
20 for _, r := range css {
21 switch r {
22 case '{':
23 depth++
24 case '}':
25 depth--
26 default:
27 if depth == 0 {
28 sel.WriteRune(r)
29 }
30 }
31 }
32 // Media queries wrap rules one level deeper; strip their headers and
33 // scan again at depth one.
34 depth = 0
35 inMedia := false
36 for i := 0; i < len(css); i++ {
37 if strings.HasPrefix(css[i:], "@media") {
38 inMedia = true
39 }
40 switch css[i] {
41 case '{':
42 depth++
43 if depth == 1 && !inMedia {
44 // ordinary rule; already scanned above
45 }
46 case '}':
47 depth--
48 if depth == 0 {
49 inMedia = false
50 }
51 default:
52 if inMedia && depth == 1 {
53 sel.WriteByte(css[i])
54 }
55 }
56 }
57 classRe := regexp.MustCompile(`\.([a-zA-Z_][a-zA-Z0-9_-]*)`)
58 styled := map[string]bool{}
59 for _, m := range classRe.FindAllStringSubmatch(sel.String(), -1) {
60 styled[m[1]] = true
61 }
62
63 attrRe := regexp.MustCompile(`class="([^"]*)"`)
64 actionRe := regexp.MustCompile(`(?s)\{\{.*?\}\}`)
65 missing := map[string][]string{}
66 entries, err := fs.ReadDir(templateFS, "templates")
67 if err != nil {
68 t.Fatal(err)
69 }
70 for _, e := range entries {
71 src, err := templateFS.ReadFile("templates/" + e.Name())
72 if err != nil {
73 t.Fatal(err)
74 }
75 clean := actionRe.ReplaceAllString(string(src), "\x00")
76 for _, m := range attrRe.FindAllStringSubmatch(clean, -1) {
77 for _, c := range strings.Fields(m[1]) {
78 if strings.Contains(c, "\x00") {
79 continue // composed at render time; the concrete values have rules
80 }
81 if !styled[c] {
82 missing[c] = append(missing[c], e.Name())
83 }
84 }
85 }
86 }
87 var names []string
88 for c := range missing {
89 names = append(names, c)
90 }
91 sort.Strings(names)
92 for _, c := range names {
93 t.Errorf("class %q in %s has no rule in style.css", c, strings.Join(uniq(missing[c]), ", "))
94 }
95}
96
97func uniq(in []string) []string {
98 seen := map[string]bool{}
99 var out []string
100 for _, s := range in {
101 if !seen[s] {
102 seen[s] = true
103 out = append(out, s)
104 }
105 }
106 return out
107}
internal/web/static/style.css +1097 −1171
@@ -1,10 +1,11 @@
1/* gitbay stylesheet — Harbor: black and white ground, two accents with 1/* gitbay stylesheet. Tokens first, on :root and again in the dark media
2 separate jobs. Blue is what you can do (links, buttons, focus); orange 2 query; components use tokens only. Two accents with separate jobs:
3 is where you are and what wants you (active nav, counts, pending state, 3 blue is what you can do (links, primary buttons, focus, pressed
4 unverified signatures). Orange is never a button, so the two never 4 toggles), orange is where you are and what wants you (current tab,
5 compete. One file, light and dark via prefers-color-scheme. Tokens 5 rail counts, pending state, unverified signatures). Orange is never a
6 first; components use tokens only. No external requests: Atkinson 6 button ground. internal/web/tokens_test.go measures every token;
7 Hyperlegible is served from this origin. */ 7 internal/web/classes_test.go checks every template class has a rule.
8 No external requests: fonts are served from this origin. */
8 9
9@font-face { 10@font-face {
10 font-family: "Atkinson Hyperlegible Next"; 11 font-family: "Atkinson Hyperlegible Next";
@@ -33,35 +34,40 @@
33 scrollbars per scheme, and opt out of browser auto-darkening */ 34 scrollbars per scheme, and opt out of browser auto-darkening */
34 color-scheme: light dark; 35 color-scheme: light dark;
35 36
36 /* ground and ink */ 37 /* grounds: canvas, surface and inset are three steps, not one */
37 --bg: #ffffff; 38 --canvas: #ffffff;
38 --fg: #0d0d0d; 39 --surface: #ebedf1;
39 --muted: #595959; /* 7.00:1 on white */ 40 --inset: #ffffff;
40 --faint: #eaeaea; /* hairlines, row separators */ 41 --hover: #e1e4ea;
41 --line: #d4d4d4; /* control borders, table edges */ 42 --line: #8d939c; /* 3.09:1 on canvas — a border you can see */
42 --surface: #ffffff; 43 --faint: #d8dce2;
43 --hover: #f5f5f5; 44
44 --code-bg: #f5f5f5; 45 /* ink */
45 --fill-subtle: #ececec; /* hunk headers, summary hover */ 46 --fg: #1c1f24;
46 --diff-add: #e4f6ea; /* row grounds: tinted enough to scan, light 47 --muted: #5a6270;
47 enough that syntax colors stay legible */ 48 --neutral: var(--muted);
48 --diff-del: #fdeaea; 49
49 50 /* blue: what you can do. --link is text, --fill is a ground; one blue
50 /* blue: what you can do */ 51 cannot clear 4.5:1 as text and 3:1 as a ground in both schemes. */
51 --accent: #0000f0; /* links, focus — 9.30:1 on white */ 52 --link: #0033d6;
52 --accent-fg: #ffffff; 53 --fill: #0000f0;
53 --fill: #0000f0; /* button grounds; white on it is 9.30:1 */ 54 --fill-fg: #ffffff;
54 --fill-edge: transparent; /* light: the fill is its own boundary */ 55 --focus: var(--link);
55 56
56 /* orange: where you are, what wants you */ 57 /* orange: where you are, what wants you. Two cuts because the floor
57 --mark: #e4572e; /* bars and underlines — non-text, 3.0 bar */ 58 differs by size: --mark for bars, --warn at text size. */
58 --warn: #c2410c; /* the same orange at text size — 5.18:1 */ 59 --mark: #e4572e;
59 60 --warn: #b8400f;
60 /* semantic */ 61
61 --ok: #0a7d3c; 62 /* state */
63 --ok: #0a7536;
62 --bad: #c62828; 64 --bad: #c62828;
63 --done: #6b21a8; /* merged */ 65 --done: #6b21a8;
64 --neutral: #595959; 66
67 /* diff row tints: enough to scan, light enough that the syntax
68 palette stays legible on them (TestSyntaxPaletteContrast) */
69 --diff-add: #e4f6ea;
70 --diff-del: #fdeaea;
65 71
66 /* the rail is black in both schemes: the one brand constant */ 72 /* the rail is black in both schemes: the one brand constant */
67 --shell-bg: #000000; 73 --shell-bg: #000000;
@@ -69,98 +75,154 @@
69 --shell-muted: #8f8f8f; 75 --shell-muted: #8f8f8f;
70 --shell-line: #1c1c1c; 76 --shell-line: #1c1c1c;
71 --shell-hover: #141414; 77 --shell-hover: #141414;
72 --shell-mark: #ff6b3d; /* 7.42:1 on black, both schemes */ 78 --shell-mark: #ff6b3d;
79
80 /* the languages' own colours; the same in both schemes */
81 --lang-go: #00add8;
82 --lang-rust: #dea584;
83 --lang-c: #555555;
84 --lang-python: #3572a5;
85 --lang-javascript: #f1e05a;
86 --lang-typescript: #3178c6;
87 --lang-shell: #89e051;
88 --lang-ruby: #701516;
89 --lang-java: #b07219;
90 --lang-swift: #f05138;
91 --lang-html: #e34c26;
92 --lang-css: #563d7c;
93 --lang-org: #77aa99;
94 --lang-markdown: #083fa1;
95 --lang-emacs-lisp: #c065db;
96 --lang-nix: #7e7eff;
97 --lang-sql: #e38c00;
73 98
74 /* type */
75 --sans: "Atkinson Hyperlegible Next", -apple-system, BlinkMacSystemFont, 99 --sans: "Atkinson Hyperlegible Next", -apple-system, BlinkMacSystemFont,
76 "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; 100 "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
77 --mono: "Atkinson Hyperlegible Mono", ui-monospace, "SF Mono", 101 --mono: "Atkinson Hyperlegible Mono", ui-monospace, "SF Mono",
78 SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; 102 SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
79 --fs-0: 0.78rem; 103
80 --fs-1: 0.85rem; 104 /* 12, 13, 14, 16, 20, 24, 32 */
81 --fs-2: 0.92rem; 105 --fs-0: 0.75rem;
106 --fs-1: 0.8125rem;
107 --fs-2: 0.875rem;
82 --fs-3: 1rem; 108 --fs-3: 1rem;
83 --fs-4: 1.2rem; 109 --fs-4: 1.25rem;
84 --fs-5: 1.6rem; 110 --fs-5: 1.5rem;
85 --fs-6: 1.8rem; /* landing headline */ 111 --fs-6: 2rem;
86 --fs-7: 4rem; /* the 404 code */ 112
87 113 /* 4, 8, 12, 16, 24, 32, 48 */
88 /* spacing */ 114 --sp-1: 4px;
89 --sp-1: 0.25rem; 115 --sp-2: 8px;
90 --sp-2: 0.5rem; 116 --sp-3: 12px;
91 --sp-3: 0.75rem; 117 --sp-4: 16px;
92 --sp-4: 1rem; 118 --sp-5: 24px;
93 --sp-5: 1.5rem; 119 --sp-6: 32px;
94 --sp-6: 2.5rem; 120 --sp-7: 48px;
95 121
96 --chev: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23595959' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); 122 --r-ctl: 4px; /* controls and chips */
97 123 --r-card: 6px; /* cards, lists, code blocks */
98 /* radii: 2px everywhere — chart plates, not pills */
99 --r-sm: 2px;
100 --r-md: 2px;
101 --r-lg: 2px;
102 --r-pill: 2px;
103 --shadow: 0 4px 16px rgba(0, 0, 0, 0.12); 124 --shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
125
126 /* the native select arrow cannot be restyled, so draw our own;
127 --chev carries the stroke colour per scheme */
128 --chev: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%235a6270' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
104} 129}
105@media (prefers-color-scheme: dark) { 130@media (prefers-color-scheme: dark) {
106 :root { 131 :root {
107 --bg: #0a0a0a; 132 --canvas: #101114;
108 --fg: #f2f2f2; 133 --surface: #212429;
109 --muted: #a1a1a1; /* 7.66:1 on #0a0a0a */ 134 --inset: #0b0c0e;
110 --faint: #1c1c1c; 135 --hover: #2b2f36;
111 --line: #2e2e2e; 136 --line: #61666f;
112 --surface: #0f0f0f; 137 --faint: #2f333a;
113 --hover: #161616; 138
114 --code-bg: #050505; 139 --fg: #e9eaec;
115 --fill-subtle: #161616; 140 --muted: #a3a9b3;
116 --diff-add: #0d2a18; 141 --neutral: var(--muted);
117 --diff-del: #2c1113; 142
118 --accent: #6272ff; /* pure #0000f0 is 2.13:1 here. Picked against 143 --link: #8b9bff;
119 the lightest ground it lands on, not the page: 144 --fill: #4d61f0;
120 a link wrapping inline code sits on #161616, 145 --fill-fg: #ffffff;
121 where the old #5b6bff was 4.30:1. Now 4.63:1 146 --focus: var(--link);
122 there and 5.06:1 on the page. */ 147
123 --accent-fg: #ffffff;
124 --fill: #0000f0; /* fills stay pure; white on it clears 9:1 */
125 --fill-edge: #7d8cff; /* a button's boundary: #0000f0 on #0a0a0a is 1.3:1 (#120) */
126 --shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
127 --mark: #ff6b3d; 148 --mark: #ff6b3d;
128 --warn: #ff6b3d; 149 --warn: #ff8a63;
150
129 --ok: #3fce7a; 151 --ok: #3fce7a;
130 --bad: #ff6b6b; 152 --bad: #ff6b6b;
131 --done: #c084fc; 153 --done: #c084fc;
132 --neutral: #a1a1a1; 154
133 --chev: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23a1a1a1' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); 155 --diff-add: #0d2a18;
156 --diff-del: #2c1113;
157
158 --shell-bg: #000000;
159 --shell-fg: #f2f2f2;
160 --shell-muted: #8f8f8f;
161 --shell-line: #1c1c1c;
162 --shell-hover: #141414;
163 --shell-mark: #ff6b3d;
164
165 /* the languages' own colours; the same in both schemes */
166 --lang-go: #00add8;
167 --lang-rust: #dea584;
168 --lang-c: #555555;
169 --lang-python: #3572a5;
170 --lang-javascript: #f1e05a;
171 --lang-typescript: #3178c6;
172 --lang-shell: #89e051;
173 --lang-ruby: #701516;
174 --lang-java: #b07219;
175 --lang-swift: #f05138;
176 --lang-html: #e34c26;
177 --lang-css: #563d7c;
178 --lang-org: #77aa99;
179 --lang-markdown: #083fa1;
180 --lang-emacs-lisp: #c065db;
181 --lang-nix: #7e7eff;
182 --lang-sql: #e38c00;
183
184 --shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
185 --chev: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23a3a9b3' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
134 } 186 }
135} 187}
136 188
189/* ---- base ---- */
137* { box-sizing: border-box; } 190* { box-sizing: border-box; }
138html { font-size: 15px; -webkit-text-size-adjust: 100%; text-size-adjust: 100%; } 191html { font-size: 16px; -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
139body { 192body {
140 margin: 0; 193 margin: 0;
141 background: var(--bg); 194 background: var(--canvas);
142 color: var(--fg); 195 color: var(--fg);
143 font: var(--fs-3)/1.6 var(--sans); 196 font: var(--fs-3)/1.5 var(--sans);
144 min-height: 100vh; 197 min-height: 100vh;
145} 198}
146 199h1, h2, h3 { line-height: 1.25; font-weight: 600; }
147a { color: var(--accent); text-decoration: none; } 200h1 { font-size: var(--fs-5); margin: 0 0 var(--sp-3); text-wrap: balance; }
201h2 { font-size: var(--fs-4); margin: var(--sp-6) 0 var(--sp-3); }
202h3 { font-size: var(--fs-3); margin: var(--sp-5) 0 var(--sp-2); }
203p { margin: 0 0 var(--sp-3); }
204a { color: var(--link); text-decoration: none; }
148a:hover { text-decoration: underline; } 205a:hover { text-decoration: underline; }
206a.xref { color: var(--link); text-decoration: none; }
207
208code, pre, .mono, .code, td.mode, td.size {
209 font-family: var(--mono);
210 font-size: var(--fs-1);
211}
212.mono { overflow-wrap: anywhere; }
149 213
150/* one authored focus ring, so keyboard position is never the browser's 214/* ---- focus: one authored ring, so keyboard position is never the
151 guess. Blue, because focus is an interactive state. */ 215 browser's guess ---- */
152:focus-visible { 216:focus-visible {
153 outline: 2px solid var(--accent); 217 outline: 2px solid var(--focus);
154 outline-offset: 2px; 218 outline-offset: 2px;
155 border-radius: 1px; 219 border-radius: 1px;
156} 220}
157/* the shell is black in both schemes, and --accent is a dark blue in the 221/* the rail is black in both schemes and --focus is a dark blue in the
158 light one — a ring nobody could see. The shell's own mark clears 7:1 on 222 light one — a ring nobody could see there */
159 black either way. */
160.rail :focus-visible { outline-color: var(--shell-mark); } 223.rail :focus-visible { outline-color: var(--shell-mark); }
161 224
162/* visually hidden, still read aloud: for headings a sighted reader gets 225/* visually hidden, still read aloud */
163 from the layout but a screen reader would otherwise miss */
164.vh { 226.vh {
165 position: absolute; 227 position: absolute;
166 width: 1px; height: 1px; 228 width: 1px; height: 1px;
@@ -169,36 +231,23 @@ a:hover { text-decoration: underline; }
169 overflow: hidden; 231 overflow: hidden;
170 white-space: nowrap; 232 white-space: nowrap;
171} 233}
172/* the rail runs ahead of the content in source order, so give keyboard 234
173 and screen-reader users a way past it */ 235/* ---- skip link: the rail runs ahead of the content in source order ---- */
174a.skip { 236a.skip {
175 position: absolute; 237 position: absolute;
176 left: var(--sp-3); top: -3rem; 238 left: var(--sp-3); top: -3rem;
177 z-index: 50; 239 z-index: 50;
178 background: var(--fill); 240 background: var(--fill);
179 color: #ffffff; 241 color: var(--fill-fg);
180 padding: var(--sp-2) var(--sp-4); 242 padding: var(--sp-2) var(--sp-4);
181 font-weight: 600; 243 font-weight: 600;
182} 244}
183a.skip:focus { top: var(--sp-3); text-decoration: none; } 245a.skip:focus { top: var(--sp-3); text-decoration: none; }
184 246
185h1 { font-size: var(--fs-5); font-weight: 700; line-height: 1.18; 247/* ---- rail ---- */
186 margin: 0 0 var(--sp-3); letter-spacing: -0.025em; text-wrap: balance; }
187h2 { font-size: var(--fs-4); font-weight: 700; line-height: 1.3;
188 margin: var(--sp-5) 0 var(--sp-3); letter-spacing: -0.015em; }
189h3 { font-size: var(--fs-3); font-weight: 600; margin: var(--sp-5) 0 var(--sp-2); }
190
191code, pre, .code, td.mode, td.size {
192 font-family: var(--mono);
193 font-size: var(--fs-1);
194}
195
196/* ---- shell: black rail, content pane ---- */
197.shell { display: flex; align-items: stretch; min-height: 100vh; }
198
199.rail { 248.rail {
200 flex: none; 249 flex: none;
201 width: 13rem; 250 width: 14rem;
202 background: var(--shell-bg); 251 background: var(--shell-bg);
203 color: var(--shell-fg); 252 color: var(--shell-fg);
204 border-right: 1px solid var(--shell-line); 253 border-right: 1px solid var(--shell-line);
@@ -212,170 +261,173 @@ a.brand {
212 display: flex; 261 display: flex;
213 align-items: center; 262 align-items: center;
214 gap: var(--sp-2); 263 gap: var(--sp-2);
215 padding: var(--sp-3) var(--sp-4); 264 padding: var(--sp-4) var(--sp-4) var(--sp-3);
216 color: var(--shell-fg); 265 color: var(--shell-fg);
217 font-weight: 700; 266 font-weight: 600;
218 font-size: var(--fs-3); 267 font-size: var(--fs-3);
219 letter-spacing: -0.02em;
220 border-bottom: 1px solid var(--shell-line);
221} 268}
222a.brand:hover { text-decoration: none; color: #ffffff; } 269a.brand:hover { text-decoration: none; }
223a.brand svg.mark { display: block; flex: none; } 270a.brand svg.mark { display: block; flex: none; }
224a.brand span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } 271a.brand span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
225 272
226.railbody { flex: 1; overflow-y: auto; padding: var(--sp-3) 0; } 273.railbody { flex: 1; overflow-y: auto; padding: 0 var(--sp-3); }
227/* search is reachable from every page, so it sits above the rail's links 274/* search is reachable from every page, so it sits above the rail's links
228 rather than on one page of its own */ 275 rather than on one page of its own */
229form.railsearch { padding: 0 var(--sp-4) var(--sp-3); } 276form.railsearch { margin-bottom: var(--sp-3); }
230form.railsearch input[type="search"] { 277form.railsearch input[type="search"] {
231 width: 100%; 278 width: 100%;
232 padding: var(--sp-1) var(--sp-2);
233 font-size: var(--fs-1);
234 color: var(--shell-fg);
235 background: var(--shell-hover); 279 background: var(--shell-hover);
236 border: 1px solid var(--shell-line); 280 border: 1px solid var(--shell-line);
237 border-radius: var(--r-sm); 281 color: var(--shell-fg);
282 border-radius: var(--r-ctl);
283 padding: 6px 10px;
284 height: 32px;
285 font: inherit;
286 font-size: var(--fs-2);
238} 287}
239form.railsearch input[type="search"]::placeholder { color: var(--shell-muted); } 288form.railsearch input[type="search"]::placeholder { color: var(--shell-muted); }
240ul.raillist { list-style: none; margin: 0; padding: 0; } 289ul.raillist { list-style: none; margin: 0 0 var(--sp-4); padding: 0; }
241ul.raillist a { 290ul.raillist a {
242 display: block; 291 display: flex;
243 padding: var(--sp-1) var(--sp-4); 292 align-items: center;
293 gap: var(--sp-2);
294 padding: 5px var(--sp-2);
244 color: var(--shell-fg); 295 color: var(--shell-fg);
245 font-size: var(--fs-2); 296 font-size: var(--fs-2);
246 border-left: 3px solid transparent; 297 border-radius: var(--r-ctl);
247 overflow: hidden; text-overflow: ellipsis; white-space: nowrap; 298 overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
248} 299}
249ul.raillist a:hover { background: var(--shell-hover); text-decoration: none; } 300ul.raillist a:hover { background: var(--shell-hover); text-decoration: none; }
250/* orange marks position — the rail's only accent */ 301/* orange marks position — the rail's only accent */
251ul.raillist a[aria-current] { 302ul.raillist a[aria-current] {
252 border-left-color: var(--shell-mark);
253 background: var(--shell-hover); 303 background: var(--shell-hover);
304 box-shadow: inset 3px 0 0 var(--shell-mark);
254 font-weight: 600; 305 font-weight: 600;
255} 306}
256ul.raillist .owner { color: var(--shell-muted); } 307ul.raillist .owner { color: var(--shell-muted); }
257ul.raillist.wide a { white-space: normal; padding-top: var(--sp-1); padding-bottom: var(--sp-1); } 308ul.raillist.wide a { white-space: normal; display: block; }
258ul.raillist.wide b { display: block; font-weight: 500; font-size: var(--fs-1); } 309ul.raillist.wide b { display: block; font-weight: 500; font-size: var(--fs-1); }
259ul.raillist.wide .owner { font-size: var(--fs-0); } 310ul.raillist.wide .owner { display: block; font-size: var(--fs-0); }
260 311
312.railgroup { margin-bottom: var(--sp-4); }
261.raillabel { 313.raillabel {
262 display: flex; 314 display: flex;
263 align-items: center; 315 align-items: center;
264 gap: var(--sp-2); 316 gap: var(--sp-2);
265 margin: var(--sp-4) 0 var(--sp-1); 317 margin: 0 var(--sp-2) var(--sp-1);
266 padding: 0 var(--sp-4);
267 font-family: var(--mono);
268 font-size: var(--fs-0); 318 font-size: var(--fs-0);
269 font-weight: 500; 319 font-weight: 500;
270 letter-spacing: 0.1em; 320 letter-spacing: 0.08em;
271 text-transform: uppercase; 321 text-transform: uppercase;
272 color: var(--shell-muted); 322 color: var(--shell-muted);
273} 323}
274.raillabel .count, ul.raillist .count { 324.count {
275 font-family: var(--sans);
276 font-size: var(--fs-0); 325 font-size: var(--fs-0);
277 font-weight: 700; 326 font-weight: 600;
278 letter-spacing: 0; 327 letter-spacing: 0;
279 background: var(--shell-mark); 328 background: var(--shell-mark);
280 color: #000000; 329 color: var(--shell-bg);
281 padding: 0 0.35rem; 330 padding: 0 5px;
282 border-radius: var(--r-sm); 331 border-radius: var(--r-ctl);
332 margin-left: auto;
283} 333}
284/* pinned above the account cell: always reachable, never scrolled away */ 334/* pinned above the account cell: always reachable, never scrolled away */
285.railpinned { 335.railpinned {
286 flex: none; 336 flex: none;
287 padding: var(--sp-2) 0; 337 padding: var(--sp-2) var(--sp-3);
338 margin: 0;
288 border-top: 1px solid var(--shell-line); 339 border-top: 1px solid var(--shell-line);
289} 340}
290.railfoot { 341.railfoot {
291 flex: none; 342 flex: none;
292 border-top: 1px solid var(--shell-line); 343 border-top: 1px solid var(--shell-line);
293 /* same vertical rhythm as the page footer, so the two rules line up 344 padding: var(--sp-3) var(--sp-4);
294 when the page is scrolled to the bottom; horizontal padding stays on
295 the rail's own grid so the avatar aligns with the nav items above */
296 padding: var(--sp-4) var(--sp-4) var(--sp-5);
297 display: flex; 345 display: flex;
298 align-items: center; 346 align-items: center;
347 justify-content: space-between;
299 gap: var(--sp-3); 348 gap: var(--sp-3);
300 font-size: var(--fs-2); 349 font-size: var(--fs-2);
301 line-height: 1.4;
302} 350}
303.railfoot a { color: var(--shell-fg); } 351.railfoot a { color: var(--shell-fg); }
304a.railuser { display: inline-flex; align-items: center; gap: var(--sp-2); } 352a.railuser { display: inline-flex; align-items: center; gap: var(--sp-2); }
305/* a descendant cannot cancel an ancestor's underline, so the link carries 353/* a descendant cannot cancel an ancestor's underline, so the link carries
306 none and the name draws its own — the avatar square stays undecorated */ 354 none and the name draws its own */
307a.railuser:hover { text-decoration: none; } 355a.railuser:hover { text-decoration: none; }
308a.railuser:hover .uname { text-decoration: underline; } 356a.railuser:hover .uname { text-decoration: underline; }
309.railfoot form { margin-left: auto; } 357.railfoot form { margin-left: auto; }
310.railfoot .linklike { 358.railfoot button.linklike { color: var(--shell-muted); text-decoration: underline; }
311 background: none; border: 0; padding: 0; cursor: pointer; 359.railfoot button.linklike:hover { color: var(--shell-fg); }
312 color: var(--shell-muted); font: inherit; text-decoration: underline;
313}
314.railfoot .linklike:hover { color: var(--shell-fg); }
315.avatar { 360.avatar {
316 width: 1.4rem; height: 1.4rem; flex: none; 361 display: inline-grid;
317 display: inline-grid; place-items: center; 362 place-items: center;
363 width: 22px; height: 22px;
364 flex: none;
365 border-radius: 50%;
318 background: var(--fill); 366 background: var(--fill);
319 color: #ffffff; 367 color: var(--fill-fg);
320 font-size: var(--fs-0); 368 font-size: var(--fs-0);
321 font-weight: 700; 369 font-weight: 600;
322 border-radius: var(--r-sm);
323} 370}
324 371
372/* ---- shell ---- */
373.shell { display: flex; align-items: stretch; min-height: 100vh; }
325.pane { flex: 1; min-width: 0; display: flex; flex-direction: column; } 374.pane { flex: 1; min-width: 0; display: flex; flex-direction: column; }
326.content { 375main.content {
327 flex: 1; 376 flex: 1;
328 padding: var(--sp-5) var(--sp-5) var(--sp-6);
329 width: 100%; 377 width: 100%;
378 padding: var(--sp-5) var(--sp-6) var(--sp-7);
330} 379}
380main.wide { max-width: none; }
381main.reading { max-width: calc(72rem + 2 * var(--sp-6)); }
382main.bounded { max-width: calc(48rem + 2 * var(--sp-6)); }
383footer {
384 width: 100%;
385 padding: var(--sp-4) var(--sp-6);
386 border-top: 1px solid var(--faint);
387 color: var(--muted);
388 font-size: var(--fs-1);
389}
390footer p { margin: 0; }
391footer a { color: var(--muted); text-decoration: underline; }
392footer a:hover { color: var(--link); }
331 393
332/* ---- repo header: identity and tabs on every page, the rest once ---- */ 394/* ---- repository header ---- */
333.repohead { 395.repohead {
334 padding: var(--sp-4) var(--sp-5) 0; 396 padding: var(--sp-4) var(--sp-6) 0;
335 border-bottom: 1px solid var(--line); 397 border-bottom: 1px solid var(--line);
336} 398}
337.repohead .identity { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; } 399.identity { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
338.repohead .identity .grow { flex: 1; } 400.grow { flex: 1; }
339/* h1 on the repo home, p elsewhere so each page keeps its own h1 — but 401/* h1 on the repo home, p elsewhere so each page keeps its own h1 — but
340 identical in size, because the tabs sit below this and navigation must 402 identical in size, because the tabs sit below and navigation must not
341 not move when you use it */ 403 move when you use it */
342.repotitle { 404.repotitle {
343 margin: 0; 405 margin: 0;
344 font-size: var(--fs-4); 406 font-size: var(--fs-4);
345 font-weight: 700; 407 font-weight: 600;
346 letter-spacing: -0.02em; 408 line-height: 1.25;
347 line-height: 1.3;
348} 409}
349.repotitle a { color: var(--fg); } 410.repotitle a { color: var(--fg); }
350.repotitle a.owner { color: var(--muted); font-weight: 400; } 411.repotitle a.owner { color: var(--muted); font-weight: 400; }
351.repotitle a:hover { color: var(--accent); text-decoration: none; } 412.repotitle a:hover { color: var(--link); text-decoration: none; }
352.repotitle .sep { color: var(--muted); margin: 0 0.12em; font-weight: 400; } 413.repotitle .sep { color: var(--muted); margin: 0 2px; font-weight: 400; }
353.repodesc { margin: var(--sp-2) 0 0; color: var(--muted); font-size: var(--fs-2); max-width: 62ch; } 414.repodesc {
354.repometa { margin: var(--sp-1) 0 0; color: var(--muted); font-size: var(--fs-1); } 415 margin: var(--sp-2) 0 0;
355
356button.btn {
357 font: inherit;
358 font-size: var(--fs-1);
359 font-weight: 600;
360 padding: 0.15rem 0.7rem;
361 border: 1px solid var(--line);
362 border-radius: var(--r-sm);
363 background: var(--bg);
364 color: var(--fg); 416 color: var(--fg);
365 cursor: pointer; 417 font-size: var(--fs-2);
418 display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap;
366} 419}
367button.btn:hover { border-color: var(--accent); color: var(--accent); } 420.repohead .topics { margin: var(--sp-2) 0 0; }
368button.btn[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); } 421.repometa { margin: var(--sp-1) 0 0; color: var(--muted); font-size: var(--fs-1); }
369form.inline { display: inline; } 422.toggles { margin: var(--sp-2) 0 0; color: var(--muted); font-size: var(--fs-1); }
370input[name="confirm"] { width: auto; margin-right: var(--sp-2); }
371 423
372nav.tabs { 424nav.tabs {
373 display: flex; 425 display: flex;
374 gap: var(--sp-1); 426 gap: var(--sp-1);
375 margin-top: var(--sp-4); 427 margin-top: var(--sp-3);
376 overflow-x: auto; 428 overflow-x: auto;
377 /* with overflow-y left visible the browser computes it to auto, which 429 /* with overflow-y left visible the browser computes it to auto, which
378 is a vertical drag on touch; the strip scrolls sideways only. */ 430 is a vertical drag on touch; the strip scrolls sideways only */
379 overflow-y: hidden; 431 overflow-y: hidden;
380 scrollbar-width: none; 432 scrollbar-width: none;
381} 433}
@@ -383,8 +435,8 @@ nav.tabs::-webkit-scrollbar { display: none; }
383nav.tabs a { 435nav.tabs a {
384 display: inline-flex; 436 display: inline-flex;
385 align-items: center; 437 align-items: center;
386 gap: var(--sp-2); 438 gap: var(--sp-1);
387 padding: var(--sp-2) var(--sp-3) 0.6rem; 439 padding: var(--sp-2) var(--sp-3) 10px;
388 color: var(--muted); 440 color: var(--muted);
389 font-size: var(--fs-2); 441 font-size: var(--fs-2);
390 white-space: nowrap; 442 white-space: nowrap;
@@ -392,448 +444,536 @@ nav.tabs a {
392 margin-bottom: -1px; 444 margin-bottom: -1px;
393} 445}
394nav.tabs a:hover { color: var(--fg); text-decoration: none; } 446nav.tabs a:hover { color: var(--fg); text-decoration: none; }
395/* orange marks position here too */ 447nav.tabs a[aria-current] { color: var(--fg); font-weight: 600; border-bottom-color: var(--mark); }
396nav.tabs a[aria-current] { 448/* a count in a tab is plain metadata, not a badge */
397 color: var(--fg); 449nav.tabs a i { font-style: normal; color: var(--muted); margin-left: 4px; }
398 font-weight: 700;
399 border-bottom-color: var(--mark);
400}
401nav.tabs a i {
402 font-style: normal;
403 font-family: var(--mono);
404 font-size: var(--fs-0);
405 background: var(--hover);
406 border-radius: var(--r-sm);
407 padding: 0 0.35rem;
408}
409 450
410/* ---- mobile: the rail becomes a single strip, not a wall of links ---- */ 451/* ---- page heads ---- */
411@media (max-width: 52rem) { 452.pagehead, .listhead, .headrow {
412 .shell { display: block; } 453 display: flex;
413 .rail { 454 align-items: center;
414 width: auto; 455 gap: var(--sp-3);
415 height: auto; 456 flex-wrap: wrap;
416 position: static; 457 margin-bottom: var(--sp-5);
417 flex-direction: row;
418 align-items: center;
419 gap: var(--sp-2);
420 border-right: 0;
421 border-bottom: 1px solid var(--shell-line);
422 overflow-x: auto;
423 }
424 a.brand { border-bottom: 0; padding-right: var(--sp-2); }
425 .railbody { flex: 1; overflow: visible; padding: 0; display: flex; align-items: center; }
426 form.railsearch { padding: 0 var(--sp-2); }
427 form.railsearch input[type="search"] { min-width: 8rem; }
428 /* pinned repos and the review queue both live on the dashboard, so the
429 strip keeps only what has nowhere else to go */
430 .railgroup { display: none; }
431 ul.raillist { display: flex; }
432 ul.raillist a { border-left: 0; border-bottom: 2px solid transparent; padding: var(--sp-2) var(--sp-3); }
433 ul.raillist a[aria-current] { border-left: 0; border-bottom-color: var(--shell-mark); }
434 /* the rail is a horizontal strip here: keep the account cell compact */
435 .railfoot { border-top: 0; padding: var(--sp-2); }
436 .railpinned { border-top: 0; padding: 0; }
437 .content { padding: var(--sp-4) var(--sp-4) var(--sp-5); }
438 .repohead { padding: var(--sp-3) var(--sp-4) 0; }
439 /* seven tabs do not fit a phone in one row; a hidden scrollbar made
440 the ones past the edge look absent. Two rows show all of them. */
441 nav.tabs { flex-wrap: wrap; overflow: visible; }
442} 458}
459.pagehead h1, .listhead h1, .headrow h1, .listhead h2, .headrow h2 { margin: 0; }
460.pagehead .grow, .listhead .spacer, .headrow .spacer { flex: 1; }
461.issuetitle { margin: 0 0 var(--sp-2); font-size: var(--fs-5); }
462.issuenumber { color: var(--muted); font-weight: 400; }
463.issuemeta { color: var(--muted); font-size: var(--fs-2); margin: 0 0 var(--sp-4); }
464.commithead p { margin: var(--sp-1) 0; }
465.commithead h2 { margin-top: 0; margin-bottom: var(--sp-1); }
443 466
444/* tables */ 467nav.filters { display: inline-flex; gap: var(--sp-2); flex-wrap: wrap; }
445table { border-collapse: collapse; width: 100%; } 468nav.filters a {
446td { 469 display: inline-flex;
447 padding: var(--sp-2) var(--sp-4) var(--sp-2) 0; 470 align-items: center;
448 border-bottom: 1px solid var(--faint); 471 height: 32px;
449 vertical-align: top; 472 padding: 0 var(--sp-3);
473 background: var(--canvas);
474 border: 1px solid var(--line);
475 border-radius: var(--r-ctl);
476 color: var(--fg);
450 font-size: var(--fs-2); 477 font-size: var(--fs-2);
478 font-weight: 500;
451} 479}
452td:last-child { padding-right: 0; } 480nav.filters a:hover { background: var(--hover); text-decoration: none; }
453td.size, td.mode { color: var(--muted); white-space: nowrap; } 481nav.filters a.active { background: var(--surface); border-color: var(--link); color: var(--link); }
454 482
455/* path bar on tree and blob pages */ 483form.searchform, form.logfilter, form.compareform {
456.pathbar {
457 display: flex; 484 display: flex;
458 flex-wrap: wrap;
459 align-items: center; 485 align-items: center;
460 gap: var(--sp-2) var(--sp-3); 486 gap: var(--sp-2);
461 margin: var(--sp-3) 0; 487 flex-wrap: wrap;
462 font-size: var(--fs-2); 488 margin: 0 0 var(--sp-4);
463} 489}
464.pathbar .spacer { flex: 1; } 490form.searchform input[type="search"], form.logfilter input { flex: 1; min-width: 12rem; max-width: 32rem; }
465.refchip { 491form.searchform.compact { margin: 0; }
492form.searchform.compact input[type="search"] { min-width: 18rem; }
493form.compareform label { display: inline-flex; align-items: center; gap: var(--sp-2); margin: 0; }
494
495/* ---- buttons: one family, 32px tall, so a row of them lines up ---- */
496button, .button {
466 display: inline-flex; 497 display: inline-flex;
467 align-items: center; 498 align-items: center;
468 gap: 0.3rem; 499 justify-content: center;
469 background: var(--surface); 500 gap: 6px;
470 border: 1px solid var(--faint); 501 height: 32px;
471 border-radius: var(--r-md); 502 padding: 0 var(--sp-3);
472 padding: 0.1rem 0.55rem; 503 font: inherit;
473 font-family: var(--mono); 504 font-size: var(--fs-2);
474 font-size: var(--fs-1); 505 font-weight: 500;
506 border: 1px solid transparent;
507 border-radius: var(--r-ctl);
508 cursor: pointer;
509 text-decoration: none;
510 white-space: nowrap;
511 /* a bare <button> is the primary action of its form group */
512 background: var(--fill);
513 color: var(--fill-fg);
514}
515button.primary, .button.primary { background: var(--fill); color: var(--fill-fg); border-color: transparent; }
516button:hover, .button.primary:hover { filter: brightness(1.12); text-decoration: none; }
517button.btn, .button.btn {
518 background: var(--canvas);
475 color: var(--fg); 519 color: var(--fg);
520 border-color: var(--line);
521 filter: none;
476} 522}
477.pathbar .crumbs strong { color: var(--fg); } 523button.btn:hover, .button.btn:hover { background: var(--hover); filter: none; text-decoration: none; }
478details.refmenu { position: relative; } 524button.btn[aria-pressed="true"] { background: var(--surface); color: var(--link); border-color: var(--link); }
479details.refmenu summary { cursor: pointer; list-style: none; } 525button.linklike {
480details.refmenu summary::-webkit-details-marker { display: none; } 526 background: none;
481details.refmenu summary::after { content: " ▾"; color: var(--muted); } 527 border: 0;
482details.refmenu .refdrop { 528 padding: 0;
483 position: absolute; 529 height: auto;
484 z-index: 10; 530 color: var(--link);
485 top: calc(100% + 4px); 531 font-weight: 400;
486 left: 0; 532 filter: none;
487 min-width: 12rem;
488 max-height: 20rem;
489 overflow-y: auto;
490 background: var(--bg);
491 border: 1px solid var(--line);
492 border-radius: var(--r-md);
493 box-shadow: var(--shadow);
494 padding: var(--sp-1) 0;
495} 533}
496details.refmenu .refdrop a { 534button.linklike:hover { text-decoration: underline; filter: none; }
497 display: block; 535button.danger, .button.danger {
498 padding: var(--sp-1) var(--sp-3); 536 background: var(--canvas);
499 color: var(--fg); 537 color: var(--bad);
500 font-family: var(--mono); 538 border-color: var(--bad);
501 font-size: var(--fs-1); 539 filter: none;
502} 540}
503details.refmenu .refdrop a:hover { background: var(--hover); text-decoration: none; } 541/* --canvas, not --fill-fg: white on the dark scheme's --bad is 2.3:1 */
504details.refmenu .refdrop a.allrefs { color: var(--accent); font-family: var(--sans); border-top: 1px solid var(--faint); margin-top: var(--sp-1); } 542button.danger:hover, .button.danger:hover { background: var(--bad); color: var(--canvas); filter: none; }
505 543button:disabled { opacity: 0.55; cursor: default; filter: none; }
506/* file and refs tables: bordered cards, rows inside */ 544.btngroup { display: inline-flex; gap: var(--sp-2); flex-wrap: wrap; }
507/* wide listings scroll inside their own box; the page never does */ 545form.inline { display: inline; }
508.tablewrap { overflow-x: auto; max-width: 100%; } 546.act { text-align: right; }
509 547
510table.tree, table.refs { 548/* ---- forms ---- */
511 background: var(--surface); 549/* not button: it sets its own colour above, and this rule comes later at
550 the same specificity */
551input, textarea, select { font: inherit; color: inherit; }
552input[type="text"], input[type="search"], input[type="email"],
553input[type="password"], input[type="number"], input:not([type]), textarea, select {
554 background: var(--inset);
555 color: var(--fg);
512 border: 1px solid var(--line); 556 border: 1px solid var(--line);
513 border-radius: var(--r-lg); 557 border-radius: var(--r-ctl);
514 border-collapse: separate; 558 padding: 0 10px;
515 border-spacing: 0; 559 height: 32px;
516 overflow: hidden; 560 font-size: var(--fs-2);
517 margin-bottom: var(--sp-4); 561 line-height: 1.4;
518}
519table.tree td, table.refs td {
520 padding: var(--sp-2) var(--sp-4);
521 border-bottom: 1px solid var(--faint);
522} 562}
523table.tree tr:last-child td, table.refs tr:last-child td { border-bottom: none; } 563textarea {
524table.tree tr:hover td, table.refs tr:hover td { background: var(--hover); } 564 width: 100%;
525tr.cols th { 565 height: auto;
526 text-align: left; 566 padding: 8px 10px;
527 font-weight: 500; 567 resize: vertical;
528 font-size: var(--fs-1); 568 line-height: 1.5;
529 color: var(--muted);
530 padding: var(--sp-1) var(--sp-4);
531 border-bottom: 1px solid var(--line);
532 background: var(--bg);
533} 569}
534tr.cols:hover th { background: var(--bg); } 570textarea[name="content"] { font-family: var(--mono); font-size: var(--fs-1); }
535/* right-aligned columns keep their header with their values; this needs to 571select {
536 outrank the tr.cols th rule above, which is why it names both */ 572 appearance: none;
537tr.cols th.age, tr.cols th.size { text-align: right; } 573 padding-right: 1.9rem;
538table.tree th.size { text-align: right; } 574 background-image: var(--chev);
539table.tree td.name { width: 100%; } 575 background-repeat: no-repeat;
540table.tree td.name a { color: var(--fg); } 576 background-position: right 0.6rem center;
541table.tree td.name a:hover { color: var(--accent); } 577 background-size: 0.62rem;
542table.tree td.size { text-align: right; } 578 cursor: pointer;
543table.tree td.empty, table.refs td.empty, li.empty, ul.loglist li.empty { color: var(--muted); }
544
545/* refs page */
546table.refs th, table.assets th {
547 text-align: left;
548 font-size: var(--fs-0);
549 font-weight: 500;
550 color: var(--muted);
551 padding: var(--sp-1) var(--sp-2);
552 border-bottom: 1px solid var(--line);
553} 579}
554table.refs td.name { width: 100%; } 580input:hover, textarea:hover, select:hover { border-color: var(--muted); }
555table.refs td.sha { color: var(--muted); } 581input[type="radio"] { accent-color: var(--fill); }
582label { display: block; font-weight: 500; font-size: var(--fs-2); margin-bottom: 4px; }
583label:has(> input[type="radio"]), label:has(> input[type="checkbox"]),
584label:has(> select) { display: inline-flex; align-items: center; gap: var(--sp-2); }
585.hint { display: block; color: var(--muted); font-size: var(--fs-1); font-weight: 400; margin: 0 0 6px; }
586.field { margin-bottom: var(--sp-4); }
587.field input[type="text"], .field input[type="email"], .field select { width: 100%; }
588.check { display: flex; align-items: center; gap: var(--sp-2); font-weight: 400; }
589::placeholder { color: var(--muted); opacity: 1; }
590::selection { background: var(--fill); color: var(--fill-fg); }
591/* :user-invalid, not :invalid — a required field is invalid the moment
592 the page loads, and colouring it red before anyone has typed is a
593 scolding, not a hint */
594input:user-invalid, textarea:user-invalid { border-color: var(--bad); }
595input[name="confirm"] { width: auto; margin-right: var(--sp-2); }
556 596
557/* card header (README) */ 597/* A radio keeps the native control: a circle takes one answer, a square
558.cardhead { 598 takes any number, and that shape survives greyscale and colour
559 font-family: var(--mono); 599 blindness. The checkbox is drawn so it matches the rest of the family. */
560 font-size: var(--fs-1); 600input[type="checkbox"] {
561 color: var(--muted); 601 appearance: none;
562 border-bottom: 1px solid var(--faint); 602 flex: none;
563 margin: calc(-1 * var(--sp-4)) calc(-1 * var(--sp-5)) var(--sp-4); 603 width: 16px;
564 padding: var(--sp-2) var(--sp-5); 604 height: 16px;
565 background: var(--bg); 605 margin: 0;
566 border-radius: var(--r-lg) var(--r-lg) 0 0; 606 /* an empty box has nothing but its border to announce it */
607 border: 1px solid var(--muted);
608 border-radius: var(--r-ctl);
609 background: var(--inset);
610 cursor: pointer;
611}
612input[type="checkbox"]:not(:checked):hover { border-color: var(--fg); }
613input[type="checkbox"]:checked {
614 background: var(--fill);
615 border-color: var(--fill);
616 /* the mark sits on the blue ground, so it is white in both schemes */
617 background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 10'%3E%3Cpath d='M1 5l3.4 3.4L11 1.4' fill='none' stroke='%23ffffff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
618 background-repeat: no-repeat;
619 background-position: center;
620 background-size: 11px;
567} 621}
568 622
569/* log */ 623/* a segmented control for a small, mutually exclusive set: real radios
570ul.loglist { 624 underneath, so arrow keys and screen readers behave as they always did */
571 list-style: none; 625fieldset.segmented { border: 0; margin: 0 0 var(--sp-4); padding: 0; }
572 margin: var(--sp-3) 0; 626fieldset.segmented legend { padding: 0; margin-bottom: var(--sp-2); color: var(--muted); font-size: var(--fs-2); }
573 padding: 0; 627fieldset.segmented .options { display: inline-flex; }
574 background: var(--surface); 628fieldset.segmented label { gap: 0; margin: 0; }
629fieldset.segmented input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
630fieldset.segmented span {
631 display: flex;
632 align-items: center;
633 height: 32px;
634 padding: 0 var(--sp-4);
575 border: 1px solid var(--line); 635 border: 1px solid var(--line);
576 border-radius: var(--r-lg); 636 font-size: var(--fs-2);
577 overflow: hidden; 637 cursor: pointer;
578} 638}
579ul.loglist li { 639fieldset.segmented label + label span { border-left: 0; }
580 display: flex; 640fieldset.segmented label:first-child span { border-radius: var(--r-ctl) 0 0 var(--r-ctl); }
581 align-items: baseline; 641fieldset.segmented label:last-child span { border-radius: 0 var(--r-ctl) var(--r-ctl) 0; }
582 gap: var(--sp-3); 642fieldset.segmented span:hover { border-color: var(--muted); }
583 padding: var(--sp-2) var(--sp-4); 643fieldset.segmented input:checked + span {
584 border-bottom: 1px solid var(--faint); 644 background: var(--fill);
585} 645 border-color: var(--fill);
586ul.loglist li:last-child { border-bottom: none; } 646 color: var(--fill-fg);
587ul.loglist li:hover { background: var(--hover); } 647 font-weight: 600;
588ul.loglist .commitmain { flex: 1; min-width: 0; }
589ul.loglist p { margin: 0; }
590ul.loglist .subject a { color: var(--fg); font-weight: 550; }
591ul.loglist .subject a:hover { color: var(--accent); }
592ul.loglist .commitside {
593 display: flex;
594 align-items: baseline;
595 gap: var(--sp-2);
596 white-space: nowrap;
597} 648}
598p.pager { margin-top: var(--sp-4); } 649fieldset.segmented input:focus-visible + span { outline: 2px solid var(--focus); outline-offset: 2px; }
599 650
600/* commit page */ 651p.branchpick { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); }
601.commithead p { margin: var(--sp-1) 0; } 652p.branchpick label { color: var(--muted); font-size: var(--fs-2); margin: 0; }
602.commithead h2 { margin-bottom: var(--sp-1); } 653p.branchpick select { flex: 1 1 12rem; }
603code.fullsha { color: var(--muted); overflow-wrap: anywhere; } 654
604.empty-note { color: var(--muted); } 655form.signupform, form.editform { max-width: 40rem; }
605.crumbs { color: var(--muted); font-size: var(--fs-2); } 656form.signupform label, form.editform label { margin-bottom: 4px; }
606.desc, td.desc { color: var(--muted); } 657.editform textarea { width: 100%; }
607.error { 658.editform input[name="message"] { width: 100%; }
659
660form.commentform { margin: var(--sp-4) 0; max-width: 48rem; }
661form.commentform p:last-child { display: flex; align-items: center; gap: var(--sp-3); margin-bottom: 0; }
662
663details.editbox { margin: var(--sp-2) 0; max-width: 48rem; }
664details.editbox > summary {
665 display: inline-flex;
666 align-items: center;
667 height: 32px;
668 padding: 0 var(--sp-3);
669 background: var(--canvas);
670 border: 1px solid var(--line);
671 border-radius: var(--r-ctl);
608 color: var(--fg); 672 color: var(--fg);
609 border: 1px solid var(--bad); 673 font-size: var(--fs-2);
610 border-left-width: 3px; 674 font-weight: 500;
611 border-radius: var(--r-sm); 675 cursor: pointer;
612 padding: var(--sp-2) var(--sp-3); 676 list-style: none;
613 margin-bottom: var(--sp-4);
614} 677}
615/* settings: one row per control, label then input then its own button */ 678details.editbox > summary::-webkit-details-marker { display: none; }
679details.editbox > summary:hover { background: var(--hover); }
680details.editbox[open] > summary { margin-bottom: var(--sp-2); }
681details.editbox input[type="text"] { width: 100%; }
682
683/* ---- settings: one row per control, label then input then its own
684 button. The name stays setform because the templates carry it. ---- */
616form.setform { 685form.setform {
686 display: grid;
687 grid-template-columns: 14rem minmax(0, 28rem) auto;
688 gap: var(--sp-4);
689 align-items: start;
690 padding: var(--sp-3) 0;
691 border-bottom: 1px solid var(--faint);
692}
693form.setform label { margin-top: 6px; }
694form.setform .hint { margin: 2px 0 0; }
695form.setform input[type="text"], form.setform input[type="number"], form.setform select { width: 100%; }
696/* the third column takes the leftover width; the button keeps its own */
697form.setform > button, form.setform > .btngroup { justify-self: start; }
698form.setform.stack { grid-template-columns: 1fr; }
699form.setform.stack textarea, form.setform.stack select { width: 100%; max-width: 48rem; }
700nav.sections { display: flex; gap: var(--sp-4); flex-wrap: wrap; font-size: var(--fs-2); margin-bottom: var(--sp-5); }
701ul.protlist { list-style: none; margin: var(--sp-2) 0; padding: 0; }
702ul.protlist li {
617 display: flex; 703 display: flex;
618 flex-wrap: wrap;
619 align-items: center; 704 align-items: center;
620 gap: var(--sp-3); 705 gap: var(--sp-3);
621 padding: var(--sp-2) 0; 706 padding: var(--sp-2) 0;
622 border-bottom: 1px solid var(--faint); 707 border-bottom: 1px solid var(--faint);
623} 708}
624/* the label absorbs the free space, so a row whose control does not 709ul.protlist li:last-child { border-bottom: 0; }
625 stretch — a checkbox, a number — still finishes at the same right edge 710ul.protlist form { margin-left: auto; }
626 as the rows whose input fills the line */ 711ul.plain { list-style: none; margin: 0 0 var(--sp-3); padding: 0; }
627form.setform label { min-width: 12rem; margin-right: auto; color: var(--muted); font-size: var(--fs-2); } 712ul.plain li { padding: var(--sp-1) 0; display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
628form.setform input[type="text"], form.setform select { flex: 1 1 16rem; }
629form.setform input[type="number"] { width: 5rem; }
630ul.protlist { list-style: none; padding: 0; margin: var(--sp-2) 0; }
631ul.protlist li { padding: var(--sp-1) 0; display: flex; gap: var(--sp-3); align-items: baseline; }
632
633/* branch picker on the new merge request form */
634p.branchpick {
635 display: flex;
636 flex-wrap: wrap;
637 align-items: center;
638 gap: var(--sp-2);
639}
640p.branchpick label { color: var(--muted); font-size: var(--fs-2); }
641p.branchpick select { flex: 1 1 12rem; }
642 713
643/* merge request actions in the aside: stacked controls, full width */ 714/* ---- messages: .error and .notice are one family. The names stay
644.aside form.actions { 715 because e2e tests match class="error". ---- */
645 display: flex; 716.error, .notice {
646 flex-wrap: wrap; 717 border-left: 3px solid var(--bad);
647 gap: var(--sp-2); 718 background: color-mix(in srgb, var(--bad) 6%, transparent);
648 margin-top: var(--sp-2); 719 color: var(--fg);
720 border-radius: 0 var(--r-ctl) var(--r-ctl) 0;
721 padding: var(--sp-2) var(--sp-3);
722 margin: 0 0 var(--sp-4);
723 font-size: var(--fs-2);
649} 724}
650.aside form.actions button { flex: 1 1 auto; } 725.notice { border-left-color: var(--ok); background: color-mix(in srgb, var(--ok) 6%, transparent); }
651.aside form.actions select { width: 100%; } 726.empty-note, .none, .empty, li.empty, td.empty { color: var(--muted); }
652form.threadact { margin: var(--sp-1) 0 0; padding: 0 var(--sp-3) var(--sp-2); }
653
654.meta { color: var(--muted); font-size: var(--fs-1); }
655svg.icon { vertical-align: -0.125em; }
656.lede { font-size: var(--fs-3); margin: var(--sp-2) 0; }
657 727
658/* repo listings (explore, owner pages, dashboard): one row per repo */ 728/* ---- containers: one recipe for cards, lists and tables ---- */
659ul.repolist { 729.card, ul.list, ul.loglist, ul.repolist, ul.issuelist,
660 list-style: none; 730ul.milestonelist, .readme, .code, article.release, .blame, .teambody {
661 margin: var(--sp-3) 0 var(--sp-5);
662 padding: 0;
663 background: var(--surface); 731 background: var(--surface);
664 border: 1px solid var(--line); 732 border: 1px solid var(--line);
665 border-radius: var(--r-lg); 733 border-radius: var(--r-card);
666 overflow: hidden; 734 overflow: hidden;
667} 735}
668ul.repolist li { padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--faint); } 736.cardhead {
669/* pinned repos: a compact wrap of name pills, no description or meta */
670ul.pinlist {
671 list-style: none;
672 margin: var(--sp-3) 0 var(--sp-5);
673 padding: 0;
674 display: flex; 737 display: flex;
675 flex-wrap: wrap; 738 align-items: center;
676 gap: var(--sp-2); 739 gap: var(--sp-3);
740 padding: var(--sp-2) var(--sp-4);
741 border-bottom: 1px solid var(--line);
742 font-size: var(--fs-1);
743 color: var(--muted);
677} 744}
678ul.pinlist li { 745.cardhead b { color: var(--fg); font-weight: 500; }
746.cardbody { padding: var(--sp-4) var(--sp-5); }
747ul.list { list-style: none; margin: 0 0 var(--sp-4); padding: 0; }
748ul.list li { padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--faint); }
749ul.list li:last-child { border-bottom: 0; }
750ul.list li:hover { background: var(--hover); }
751
752table { border-collapse: collapse; width: 100%; font-size: var(--fs-2); }
753th {
754 text-align: left;
755 font-weight: 500;
756 font-size: var(--fs-1);
757 color: var(--muted);
758 padding: 6px var(--sp-4);
759 border-bottom: 1px solid var(--line);
760}
761td { padding: 7px var(--sp-4); border-bottom: 1px solid var(--faint); vertical-align: top; }
762tr:last-child td { border-bottom: 0; }
763tbody tr:hover td { background: var(--hover); }
764/* every header row a template writes, by the class it or its table
765 carries: TestHeaderRowsAreLeftAligned reads these selectors */
766tr.cols th, table.tree th, table.refs th, table.keys th, table.assets th { text-align: left; }
767tr.cols th { background: var(--canvas); }
768/* right-aligned columns keep their header with their values */
769tr.cols th.age, tr.cols th.size { text-align: right; }
770/* wide listings scroll inside their own box; the page never does. The
771 wrapper is the scroller only — the table inside carries the card. */
772.tablewrap { overflow-x: auto; max-width: 100%; }
773
774table.tree, table.refs {
679 background: var(--surface); 775 background: var(--surface);
680 border: 1px solid var(--line); 776 border: 1px solid var(--line);
681 border-radius: var(--r-md); 777 border-radius: var(--r-card);
682 padding: var(--sp-1) var(--sp-3); 778 /* collapsed borders defeat a radius on the table itself */
683} 779 border-collapse: separate;
684ul.pinlist li:hover { background: var(--hover); } 780 border-spacing: 0;
685ul.pinlist li a { color: var(--fg); } 781 overflow: hidden;
686ul.pinlist li a:hover { color: var(--accent); text-decoration: none; }
687ul.pinlist .sep { color: var(--muted); margin: 0 0.15em; }
688ul.repolist li:last-child { border-bottom: none; }
689ul.repolist li:hover { background: var(--hover); }
690ul.repolist li.empty { color: var(--muted); }
691ul.repolist p { margin: 0; }
692ul.repolist .reponame { font-size: var(--fs-3); }
693ul.repolist .reponame a { color: var(--fg); }
694ul.repolist .reponame a strong { color: var(--accent); font-weight: 600; }
695ul.repolist .reponame .sep { color: var(--muted); margin: 0 0.1em; }
696ul.repolist .desc { margin-top: 0.1rem; }
697ul.repolist .topics { margin-top: var(--sp-1); }
698ul.repolist .meta { margin-top: var(--sp-1); }
699
700/* owner profile header */
701.profilehead {
702 border-bottom: 1px solid var(--faint);
703 padding-bottom: var(--sp-4);
704 margin-bottom: var(--sp-4); 782 margin-bottom: var(--sp-4);
705} 783}
706.profilehead h1 { margin: var(--sp-4) 0 var(--sp-1); } 784table.tree tr:last-child td, table.refs tr:last-child td { border-bottom: 0; }
707.profilehead .lede { margin: 0 0 var(--sp-1); } 785table.tree tr:hover td, table.refs tr:hover td { background: var(--hover); }
708.profilehead .meta { margin: var(--sp-1) 0 0; } 786td.name a { color: var(--fg); }
709h2 .count { color: var(--muted); font-weight: 400; font-size: var(--fs-2); } 787td.name a:hover { color: var(--link); }
788table.tree td.name { width: 25%; white-space: nowrap; }
789table.tree td.name.dir a { color: var(--link); }
790table.tree th.lastcommit, table.tree td.lastcommit { width: 55%; }
791table.tree td.lastcommit a {
792 color: var(--muted);
793 display: block;
794 overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
795}
796table.tree td.lastcommit a:hover { color: var(--link); }
797td.age, th.age { text-align: right; white-space: nowrap; color: var(--muted); font-variant-numeric: tabular-nums; }
798td.size, td.mode { color: var(--muted); white-space: nowrap; }
799table.tree th.size, table.tree td.size { text-align: right; }
800table.refs td.name { width: 100%; }
801table.refs td.sha, td.sha { color: var(--muted); }
802
803/* these tables sit flush with the page text: no outer border, and the
804 header row drops the ground and the left padding */
805table.keys, table.assets { background: none; border: 0; border-radius: 0; }
806table.keys th, table.assets th, table.keys tr.cols th { background: transparent; padding-left: 0; }
807table.keys td { padding-left: 0; }
808table.keys td.act, table.assets td.size { text-align: right; }
809table.assets td.name { width: 100%; font-family: var(--mono); font-size: var(--fs-1); }
810table.assets { margin-top: var(--sp-2); }
710 811
711/* activity graph: 53 week columns x 7 day rows, accent-tinted levels */ 812/* the tip commit is the first row of the file table */
712.actgraph-scroll { overflow-x: auto; padding-bottom: var(--sp-1); } 813.tipbar {
713.actgraph { display: flex; gap: 3px; width: max-content; } 814 display: flex;
714.actweek { display: flex; flex-direction: column; gap: 3px; } 815 align-items: center;
715.actday { 816 gap: var(--sp-3);
716 width: 11px; 817 padding: var(--sp-2) var(--sp-4);
717 height: 11px; 818 background: var(--surface);
718 border-radius: 2px; 819 border: 1px solid var(--line);
719 background: var(--faint); 820 border-bottom: 0;
821 border-radius: var(--r-card) var(--r-card) 0 0;
822 font-size: var(--fs-2);
720} 823}
721.actday.pad { background: transparent; } 824.tipbar .who { font-weight: 600; }
722.actday.l1 { background: color-mix(in srgb, var(--accent) 25%, var(--bg)); } 825.tipbar .subject { color: var(--fg); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
723.actday.l2 { background: color-mix(in srgb, var(--accent) 50%, var(--bg)); } 826.tipbar .subject:hover { color: var(--link); }
724.actday.l3 { background: color-mix(in srgb, var(--accent) 75%, var(--bg)); } 827.tipbar .spacer { flex: 1; }
725.actday.l4 { background: var(--accent); } 828.tipbar .age { color: var(--muted); white-space: nowrap; }
829.tipbar + table.tree,
830.tipbar + .tablewrap table.tree { border-radius: 0 0 var(--r-card) var(--r-card); }
831
832.readme { margin-top: var(--sp-5); }
833.readme .cardbody { max-width: 78ch; }
834/* until every card wraps its body in .cardbody, the rendered block is
835 the body */
836.readme > .rendered { padding: var(--sp-4) var(--sp-5); }
837.code { background: var(--inset); padding: var(--sp-3) var(--sp-4); overflow-x: auto; }
838.code pre { margin: 0; background: transparent !important; border: 0; padding: 0; }
839pre.bigcode, .bigcode {
840 font-family: var(--mono);
841 /* the 404 numeral is a picture of a number, not body type */
842 font-size: 4rem;
843 color: var(--faint);
844 -webkit-text-stroke: 1px var(--line);
845 margin: 0;
846 line-height: 1;
847}
848.notfound { text-align: center; padding: var(--sp-6) 0; }
849.notfound h1 { margin-top: var(--sp-3); }
726 850
727/* heading row with actions on the right */ 851ul.loglist { list-style: none; margin: var(--sp-3) 0; padding: 0; }
728.headrow { 852ul.loglist li {
729 display: flex; 853 display: flex;
730 align-items: baseline; 854 align-items: baseline;
731 gap: var(--sp-4); 855 gap: var(--sp-3);
732 flex-wrap: wrap; 856 padding: var(--sp-3) var(--sp-4);
857 border-bottom: 1px solid var(--faint);
733} 858}
734.headrow .spacer { flex: 1; } 859ul.loglist li:last-child { border-bottom: 0; }
860ul.loglist li:hover { background: var(--hover); }
861ul.loglist p { margin: 0; }
862ul.loglist .commitmain { flex: 1 1 auto; min-width: 0; }
863ul.loglist .subject a { color: var(--fg); font-weight: 500; }
864ul.loglist .subject a:hover { color: var(--link); }
865ul.loglist .commitside { flex: none; display: flex; gap: var(--sp-2); align-items: center; white-space: nowrap; }
735 866
736/* member and org chips on owner pages */ 867ul.repolist { list-style: none; margin: var(--sp-3) 0 var(--sp-5); padding: 0; }
737a.memberchip { 868ul.repolist li { padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--faint); }
738 display: inline-block; 869ul.repolist li:last-child { border-bottom: 0; }
870ul.repolist li:hover { background: var(--hover); }
871ul.repolist p { margin: 0; }
872ul.repolist .reponame { font-size: var(--fs-3); }
873ul.repolist .reponame a { color: var(--fg); }
874ul.repolist .reponame a strong { color: var(--link); font-weight: 600; }
875ul.repolist .reponame .sep { color: var(--muted); margin: 0 2px; }
876ul.repolist .desc { margin-top: 2px; }
877ul.repolist .topics, ul.repolist .meta { margin-top: var(--sp-1); }
878
879/* pinned repos: a compact wrap of names, no description or meta */
880ul.pinlist { list-style: none; margin: var(--sp-3) 0 var(--sp-5); padding: 0; display: flex; flex-wrap: wrap; gap: var(--sp-2); }
881ul.pinlist li {
882 background: var(--surface);
739 border: 1px solid var(--line); 883 border: 1px solid var(--line);
740 border-radius: var(--r-pill); 884 border-radius: var(--r-ctl);
741 padding: 0.05rem 0.6rem; 885 padding: var(--sp-1) var(--sp-3);
742 color: var(--fg);
743 font-size: var(--fs-1);
744 margin: 0 0.15rem 0.15rem 0;
745} 886}
746a.memberchip:hover { text-decoration: none; border-color: var(--accent); color: var(--accent); } 887ul.pinlist li:hover { background: var(--hover); }
747a.memberchip .role { color: var(--muted); } 888ul.pinlist li a { color: var(--fg); }
889ul.pinlist li a:hover { color: var(--link); text-decoration: none; }
890ul.pinlist .sep { color: var(--muted); margin: 0 2px; }
748 891
749/* landing page */ 892ul.issuelist { list-style: none; margin: var(--sp-3) 0; padding: 0; }
750.landing { max-width: 44rem; padding: var(--sp-6) 0; } 893ul.issuelist li {
751.landing h1 { font-size: var(--fs-6); margin-bottom: var(--sp-3); } 894 display: flex;
752.landing .lede { font-size: var(--fs-4); color: var(--muted); line-height: 1.7; margin: 0 0 var(--sp-4); } 895 align-items: baseline;
753pre.quickstart { 896 gap: var(--sp-3);
754 background: var(--code-bg);
755 border: 1px solid var(--line);
756 border-radius: var(--r-md);
757 padding: var(--sp-3) var(--sp-4); 897 padding: var(--sp-3) var(--sp-4);
758 overflow-x: auto; 898 border-bottom: 1px solid var(--faint);
759 margin: 0 0 var(--sp-4);
760} 899}
761.landing .explorelink { font-weight: 550; } 900ul.issuelist li:last-child { border-bottom: 0; }
762.landing .facets { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--sp-5); margin: var(--sp-5) 0; } 901ul.issuelist li:hover { background: var(--hover); }
763.landing .facets h2 { font-size: var(--fs-3); margin: 0 0 var(--sp-1); } 902ul.issuelist li.empty { display: block; }
764.landing .facets p { margin: 0; color: var(--muted); font-size: var(--fs-2); line-height: 1.6; } 903ul.issuelist p { margin: 0; }
765@media (max-width: 40rem) { .landing .facets { grid-template-columns: 1fr; gap: var(--sp-4); } } 904ul.issuelist .issuemain { flex: 1 1 auto; min-width: 0; }
905ul.issuelist .title a { color: var(--fg); font-weight: 500; }
906ul.issuelist .title a:hover { color: var(--link); }
766 907
767/* index toolbar */ 908ul.milestonelist { list-style: none; margin: var(--sp-3) 0; padding: 0; }
768p.toolbar { color: var(--muted); font-size: var(--fs-2); margin-top: var(--sp-4); } 909ul.milestonelist li { padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--faint); }
769button.linklike { 910ul.milestonelist li:last-child { border-bottom: 0; }
770 background: none; 911ul.milestonelist li:hover { background: var(--hover); }
771 border: none; 912ul.milestonelist p { margin: 0; }
772 color: var(--accent); 913ul.milestonelist .msmain { min-width: 0; }
773 padding: 0; 914ul.milestonelist .title { font-weight: 500; }
774 font: inherit; 915.progress {
775 font-size: var(--fs-2); 916 height: 6px;
776 cursor: pointer; 917 max-width: 24rem;
918 margin-top: var(--sp-2);
919 background: var(--faint);
920 border-radius: var(--r-ctl);
921 overflow: hidden;
777} 922}
778button.linklike:hover { text-decoration: underline; filter: none; } 923.progress .bar { height: 100%; background: var(--ok); }
779 924
780/* cards: README, comments */ 925article.release { padding: var(--sp-4); margin: var(--sp-4) 0; }
781.readme, .code { 926.releasehead { background: none; border: 0; padding: 0; }
782 background: var(--surface); 927.releasehead h3 { margin: 0 0 var(--sp-1); }
928.releasehead .meta { margin: 0 0 var(--sp-2); }
929
930ul.matchlist { list-style: none; margin: var(--sp-3) 0; padding: 0; }
931ul.matchlist li { margin-bottom: var(--sp-3); }
932.matchpath { font-family: var(--mono); font-size: var(--fs-1); }
933pre.matchline {
934 background: var(--inset);
783 border: 1px solid var(--line); 935 border: 1px solid var(--line);
784 border-radius: var(--r-lg); 936 border-radius: var(--r-card);
785 padding: var(--sp-4) var(--sp-5); 937 padding: var(--sp-1) var(--sp-3);
786 margin-top: var(--sp-4); 938 margin: var(--sp-1) 0 0;
787 overflow-x: auto; 939 overflow-x: auto;
788} 940}
789/* Highlighting is class-based with a palette per scheme, so code blocks 941pre.matchline mark { background: color-mix(in srgb, var(--mark) 25%, transparent); color: inherit; border-radius: 2px; }
790 follow the site's own colors. */ 942p.pager { margin-top: var(--sp-4); }
791.code { background: var(--code-bg); color: var(--fg); padding: var(--sp-3) var(--sp-4); border-color: var(--line); } 943.snippetfile { margin-bottom: var(--sp-5); }
792.code pre { margin: 0; background: transparent !important; } 944p.filefacts { color: var(--muted); font-size: var(--fs-1); margin: 0 0 var(--sp-3); }
793/* rendered markdown / org / html */ 945pre.buildlog { max-height: 40rem; overflow: auto; }
794.rendered { max-width: 100%; overflow-wrap: anywhere; } 946
795/* A linked line: #f2-n17 on a diff, #L17 on a file (#118). */ 947/* ---- rendered prose ---- */
796.difftable tr:target td, .code .line:has(a:target) { background: color-mix(in srgb, var(--mark) 18%, transparent); } 948pre {
797.difftable tr:target td.ln, .code .line:has(a:target) .ln { border-left: 3px solid var(--mark); } 949 background: var(--inset);
798.thread:target { outline: 2px solid var(--mark); outline-offset: 2px; }
799/* GFM task lists: the box sits in the line, sized to the prose. */
800.rendered li input[type="checkbox"] { width: 0.9em; height: 0.9em; margin: 0 0.35em 0 0; vertical-align: -0.05em; accent-color: var(--accent); }
801.rendered li:has(> input[type="checkbox"]) { list-style: none; margin-left: -1.2em; }
802/* line length is a reading constraint, so it applies to prose only —
803 tables, code blocks and images keep the full column */
804.rendered > p,
805.rendered > ul,
806.rendered > ol,
807.rendered > blockquote { max-width: 78ch; }
808.rendered pre {
809 background: var(--code-bg);
810 border: 1px solid var(--line); 950 border: 1px solid var(--line);
951 border-radius: var(--r-card);
811 padding: var(--sp-3) var(--sp-4); 952 padding: var(--sp-3) var(--sp-4);
812 border-radius: var(--r-md); 953 margin: 0 0 var(--sp-4);
813 overflow-x: auto; 954 overflow-x: auto;
955 font-size: var(--fs-2);
956 line-height: 1.6;
814} 957}
815.rendered code { 958code {
816 background: var(--code-bg); 959 background: var(--inset);
817 border: 1px solid var(--line); 960 border: 1px solid var(--faint);
818 padding: 0.05rem 0.3rem; 961 border-radius: var(--r-ctl);
819 border-radius: var(--r-sm); 962 padding: 0 0.3em;
820} 963}
821.rendered pre code { background: transparent; border: none; padding: 0; } 964pre code { background: none; border: 0; padding: 0; font-size: inherit; }
822.rendered blockquote { 965code.fullsha { color: var(--muted); overflow-wrap: anywhere; }
823 border-left: 3px solid var(--line); 966pre.message {
824 margin-left: 0; 967 white-space: pre-wrap;
825 padding-left: var(--sp-4); 968 overflow-wrap: anywhere;
826 color: var(--muted);
827} 969}
970.rendered { max-width: 100%; overflow-wrap: anywhere; }
971.rendered a { text-decoration: underline; }
972.prose, .rendered > p, .rendered > ul, .rendered > ol, .rendered > blockquote { max-width: 78ch; }
973.rendered blockquote { border-left: 3px solid var(--line); margin-left: 0; padding-left: var(--sp-4); color: var(--muted); }
828.rendered img { max-width: 100%; } 974.rendered img { max-width: 100%; }
829.rendered h1, .rendered h2 { border-bottom: 1px solid var(--faint); padding-bottom: var(--sp-1); } 975.rendered h1, .rendered h2 { border-bottom: 1px solid var(--faint); padding-bottom: var(--sp-1); }
830.rendered table { 976.rendered table { border-collapse: collapse; margin: var(--sp-3) 0; display: block; max-width: 100%; overflow-x: auto; }
831 border-collapse: collapse;
832 margin: var(--sp-3) 0;
833 display: block;
834 max-width: 100%;
835 overflow-x: auto;
836}
837.rendered th, .rendered td { 977.rendered th, .rendered td {
838 border: 1px solid var(--line); 978 border: 1px solid var(--line);
839 padding: var(--sp-1) var(--sp-3); 979 padding: var(--sp-1) var(--sp-3);
@@ -841,129 +981,158 @@ button.linklike:hover { text-decoration: underline; filter: none; }
841 overflow-wrap: normal; /* scroll the table, don't shatter words */ 981 overflow-wrap: normal; /* scroll the table, don't shatter words */
842} 982}
843.rendered th { background: var(--surface); font-weight: 600; } 983.rendered th { background: var(--surface); font-weight: 600; }
984/* GFM task lists: the box sits in the line, sized to the prose */
985.rendered li input[type="checkbox"] { width: 0.9em; height: 0.9em; margin: 0 0.35em 0 0; vertical-align: -0.05em; background-size: 0.7em; }
986.rendered li:has(> input[type="checkbox"]) { list-style: none; margin-left: -1.2em; }
844.blobimage { text-align: center; } 987.blobimage { text-align: center; }
845.blobimage img { 988.blobimage img { max-width: 100%; border: 1px solid var(--line); border-radius: var(--r-card); background: var(--surface); }
846 max-width: 100%;
847 border: 1px solid var(--faint);
848 border-radius: var(--r-md);
849 background: var(--surface);
850}
851 989
852pre.message { 990/* ---- two-column: conversation and aside ---- */
853 background: var(--code-bg); 991.withaside { display: grid; grid-template-columns: minmax(0, 1fr) 18rem; gap: var(--sp-6); align-items: start; }
854 border: 1px solid var(--line); 992.withaside .mainside { min-width: 0; }
855 padding: var(--sp-3) var(--sp-4); 993aside.aside { font-size: var(--fs-2); }
856 border-radius: var(--r-md); 994.aside .grp { border-top: 1px solid var(--line); padding: var(--sp-3) 0; }
857 white-space: pre-wrap; 995.aside .grp:first-child { border-top: 0; padding-top: 0; }
858 overflow-wrap: anywhere; 996.aside h2 {
997 margin: 0 0 var(--sp-2);
998 font-size: var(--fs-0);
999 font-weight: 500;
1000 letter-spacing: 0.08em;
1001 text-transform: uppercase;
1002 color: var(--muted);
859} 1003}
860 1004.aside .row { display: flex; align-items: center; gap: 6px; margin: 0 0 var(--sp-1); flex-wrap: wrap; }
861/* repo facts: the counts a visitor sizes a project up with */ 1005.aside .row:last-child { margin-bottom: 0; }
862.teambody { padding: var(--sp-3); background: var(--bg); } 1006/* a row's second line: when it happened, under what it happened to */
863.teambody .memberchip { margin-right: var(--sp-2); } 1007.aside .row .sub { flex-basis: 100%; padding-left: 14px; color: var(--muted); font-size: var(--fs-1); }
864 1008.aside .row:has(.sub) { margin-bottom: var(--sp-2); }
865form.logfilter { 1009.aside .none { color: var(--muted); }
866 display: flex; align-items: center; gap: var(--sp-2); 1010.dot { display: inline-block; width: 8px; height: 8px; flex: none; border-radius: 50%; background: var(--neutral); margin-right: 2px; vertical-align: middle; }
867 margin: 0 0 var(--sp-4); flex-wrap: wrap; 1011.dot.ok { background: var(--ok); }
1012.dot.bad { background: var(--bad); }
1013.dot.pend { background: var(--mark); }
1014.ok { color: var(--ok); }
1015.bad { color: var(--bad); }
1016form.actions { display: flex; flex-wrap: wrap; gap: var(--sp-2); margin-top: var(--sp-2); }
1017form.actions button { flex: 1 1 auto; }
1018form.actions select, form.actions textarea { width: 100%; }
1019.feedline { margin-bottom: var(--sp-2); }
1020
1021/* ---- comments and threads ---- */
1022article.comment {
1023 max-width: 48rem;
1024 border: 1px solid var(--line);
1025 border-radius: var(--r-card);
1026 margin: var(--sp-4) 0;
1027 overflow: hidden;
868} 1028}
869form.logfilter input { flex: 1; min-width: 12rem; max-width: 28rem; } 1029article.comment .commenthead {
870 1030 display: flex;
871/* account settings */ 1031 gap: var(--sp-2);
872table.keys td { padding: var(--sp-2) var(--sp-4) var(--sp-2) 0; } 1032 flex-wrap: wrap;
873/* these tables sit flush with the page text, so the header row drops the 1033 background: var(--surface);
874 left padding the card-style listings use */ 1034 border-bottom: 1px solid var(--line);
875table.keys tr.cols th { padding-left: 0; background: transparent; } 1035 padding: var(--sp-2) var(--sp-4);
876table.keys td.mono, .mono { font-family: var(--mono); font-size: var(--fs-1); overflow-wrap: anywhere; } 1036 margin: 0;
877table.keys td.act { text-align: right; } 1037 font-size: var(--fs-1);
878ul.plain { list-style: none; margin: 0 0 var(--sp-3); padding: 0; } 1038 color: var(--muted);
879ul.plain li { padding: var(--sp-1) 0; display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
880form.setform.stack { display: block; }
881form.setform.stack textarea, form.setform.stack select { width: 100%; max-width: 48rem; display: block; margin-bottom: var(--sp-2); }
882p.notice {
883 border-left: 3px solid var(--ok);
884 background: var(--hover);
885 padding: var(--sp-2) var(--sp-3);
886 margin: 0 0 var(--sp-4);
887} 1039}
1040article.comment .commenthead b { color: var(--fg); font-weight: 500; }
1041article.comment .commenthead .when { color: var(--muted); }
1042article.comment .rendered { padding: var(--sp-3) var(--sp-4); }
1043article.comment .rendered > :first-child { margin-top: 0; }
1044article.comment .rendered > :last-child { margin-bottom: 0; }
1045a.authorlink { color: var(--fg); font-weight: 500; }
1046a.authorlink:hover { color: var(--link); }
888 1047
889.facts { margin: 0 0 var(--sp-4); } 1048.syscomment {
890.facts .counts { 1049 max-width: 48rem;
891 display: flex; flex-wrap: wrap; gap: var(--sp-1) var(--sp-4); 1050 color: var(--muted);
892 margin: 0 0 var(--sp-3); font-size: var(--fs-2); 1051 font-size: var(--fs-1);
1052 margin: 0 0 var(--sp-4) var(--sp-3);
1053 padding: var(--sp-2) 0 var(--sp-2) var(--sp-4);
1054 border-left: 2px solid var(--faint);
893} 1055}
894.facts .counts a { color: var(--fg); text-decoration: none; } 1056.syscomment p { display: inline; margin: 0; }
895.facts .counts a:hover { color: var(--accent); text-decoration: underline; }
896.facts .counts .fact { color: var(--muted); }
897.facts .counts strong { font-weight: 600; }
898 1057
899.langbar { 1058.thread {
900 display: flex; height: 0.5rem; margin: 0 0 var(--sp-2); 1059 border: 1px solid var(--faint);
901 border-radius: var(--r-sm); overflow: hidden; background: var(--faint); 1060 border-left: 3px solid var(--link);
1061 border-radius: var(--r-ctl);
1062 padding: var(--sp-2) var(--sp-4);
1063 margin: var(--sp-2) 0 var(--sp-2) var(--sp-6);
902} 1064}
903.langbar .lang { display: block; min-width: 2px; } 1065.thread.resolved { border-left-color: var(--ok); opacity: 0.75; }
904.langs { 1066/* an unsubmitted comment is the viewer's own note, not the conversation */
905 display: flex; flex-wrap: wrap; gap: var(--sp-1) var(--sp-4); 1067.thread.pending { border-left-color: var(--neutral); border-left-style: dashed; }
906 margin: 0 0 var(--sp-3); font-size: var(--fs-1); 1068.thread.stale { border-left-color: var(--warn); }
1069.thread.composing { border-left-color: var(--mark); }
1070.thread:target { outline: 2px solid var(--mark); outline-offset: 2px; }
1071.thread p.commenthead { font-size: var(--fs-2); margin: var(--sp-2) 0 0; }
1072.thread .when { color: var(--muted); }
1073.thread .rendered p { margin: var(--sp-1) 0; }
1074.thread .threadstate { color: var(--muted); font-size: var(--fs-1); margin: var(--sp-1) 0 0; }
1075.thread details.threadreply { margin: var(--sp-2) 0 0; }
1076.thread details.threadreply > summary { color: var(--link); font-size: var(--fs-1); cursor: pointer; }
1077.thread textarea { width: 100%; }
1078.thread.composing p, .thread details.threadreply p { margin: var(--sp-2) 0 0; }
1079form.threadact { margin: var(--sp-1) 0 0; padding: 0 var(--sp-3) var(--sp-2); }
1080
1081nav.subtabs {
1082 display: flex;
1083 gap: var(--sp-4);
1084 border-bottom: 1px solid var(--line);
1085 margin: 0 0 var(--sp-4);
907} 1086}
908.langs .lang-name { display: inline-flex; align-items: center; gap: var(--sp-1); } 1087nav.subtabs a {
909.langs .muted { color: var(--muted); } 1088 display: inline-flex;
910.langs .dot { width: 0.6rem; height: 0.6rem; border-radius: 50%; display: inline-block; } 1089 align-items: center;
911/* named where it reads as the language's own color, neutral otherwise */ 1090 gap: var(--sp-1);
912.lang { background: var(--neutral); } 1091 padding: var(--sp-2) 0 10px;
913.lang-go { background: #00add8; } 1092 color: var(--muted);
914.lang-rust { background: #dea584; } 1093 font-size: var(--fs-2);
915.lang-c, .lang-c- { background: #555555; } 1094 border-bottom: 2px solid transparent;
916.lang-python { background: #3572a5; } 1095 margin-bottom: -1px;
917.lang-javascript { background: #f1e05a; }
918.lang-typescript { background: #3178c6; }
919.lang-shell { background: #89e051; }
920.lang-ruby { background: #701516; }
921.lang-java { background: #b07219; }
922.lang-swift { background: #f05138; }
923.lang-html { background: #e34c26; }
924.lang-css { background: #563d7c; }
925.lang-org { background: #77aa99; }
926.lang-markdown { background: #083fa1; }
927.lang-emacs-lisp { background: #c065db; }
928.lang-nix { background: #7e7eff; }
929.lang-sql { background: #e38c00; }
930
931.contribs {
932 display: flex; flex-wrap: wrap; align-items: baseline;
933 gap: var(--sp-1) var(--sp-3); margin: 0; font-size: var(--fs-1);
934} 1096}
935.contribs .label { color: var(--muted); } 1097nav.subtabs a:hover { color: var(--fg); text-decoration: none; }
1098nav.subtabs a[aria-current] { color: var(--fg); font-weight: 600; border-bottom-color: var(--mark); }
1099nav.subtabs a i { font-style: normal; color: var(--muted); margin-left: 4px; }
1100
1101/* ---- diffs and code ---- */
1102p.diffstat { color: var(--muted); font-size: var(--fs-2); margin: 0 0 var(--sp-3); }
1103p.diffstat .add { color: var(--ok); }
1104p.diffstat .del { color: var(--bad); }
936 1105
937/* diffs: one foldable section per file, a table so the line-number
938 gutters stay put while the code scrolls */
939details.difffold { 1106details.difffold {
940 border: 1px solid var(--line); 1107 border: 1px solid var(--line);
941 border-radius: var(--r-md); 1108 border-radius: var(--r-card);
942 margin: 0 0 var(--sp-3); 1109 margin: 0 0 var(--sp-3);
943 background: var(--code-bg); 1110 background: var(--inset);
1111 overflow: hidden;
944} 1112}
945details.difffold summary { 1113details.difffold summary {
946 cursor: pointer;
947 display: flex; 1114 display: flex;
948 align-items: baseline; 1115 align-items: baseline;
949 gap: var(--sp-3); 1116 gap: var(--sp-3);
950 padding: var(--sp-2) var(--sp-3); 1117 padding: var(--sp-2) var(--sp-3);
951 list-style: none; 1118 background: var(--surface);
952 font-size: var(--fs-2); 1119 font-size: var(--fs-2);
1120 cursor: pointer;
1121 list-style: none;
953} 1122}
954details.difffold[open] summary { border-bottom: 1px solid var(--line); } 1123details.difffold[open] summary { border-bottom: 1px solid var(--line); }
955details.difffold summary::-webkit-details-marker { display: none; } 1124details.difffold summary::-webkit-details-marker { display: none; }
956/* flex summaries lose the native disclosure marker; draw our own */ 1125/* flex summaries lose the native disclosure marker; draw our own */
957details.difffold summary::before { content: "\25b8"; color: var(--muted); } 1126details.difffold summary::before { content: "\25b8"; color: var(--muted); }
958details.difffold[open] summary::before { content: "\25be"; } 1127details.difffold[open] summary::before { content: "\25be"; }
959details.difffold summary:hover { background: var(--fill-subtle); } 1128details.difffold summary:hover { background: var(--hover); }
960summary .fpath { font-family: var(--mono); overflow-wrap: anywhere; } 1129details.difffold summary .fpath { font-family: var(--mono); overflow-wrap: anywhere; }
961summary .fpath .was { color: var(--muted); } 1130details.difffold summary .fpath .was { color: var(--muted); }
962summary .fstat { margin-left: auto; font-family: var(--mono); white-space: nowrap; } 1131details.difffold summary .fstat { margin-left: auto; font-family: var(--mono); white-space: nowrap; }
963details.difffold summary .add { color: var(--ok); } 1132details.difffold summary .add { color: var(--ok); }
964details.difffold summary .del { color: var(--bad); } 1133details.difffold summary .del { color: var(--bad); }
965details.difffold > .none { padding: var(--sp-3); margin: 0; } 1134details.difffold > .none { padding: var(--sp-3); margin: 0; }
966details.difffold .tablewrap { border-radius: 0 0 var(--r-md) var(--r-md); } 1135details.difffold .tablewrap { background: none; border: 0; border-radius: 0; }
967 1136
968table.difftable { 1137table.difftable {
969 border-collapse: collapse; 1138 border-collapse: collapse;
@@ -972,12 +1141,8 @@ table.difftable {
972 font-size: var(--fs-1); 1141 font-size: var(--fs-1);
973 line-height: 1.5; 1142 line-height: 1.5;
974} 1143}
975table.difftable td { 1144table.difftable td { border: 0; padding: 0; vertical-align: top; font-size: inherit; }
976 border: 0; 1145table.difftable tbody tr:hover td { background: none; }
977 padding: 0;
978 vertical-align: top;
979 font-size: inherit;
980}
981table.difftable td.ln { 1146table.difftable td.ln {
982 width: 1%; 1147 width: 1%;
983 min-width: 2.5rem; 1148 min-width: 2.5rem;
@@ -988,13 +1153,9 @@ table.difftable td.ln {
988 border-right: 1px solid var(--line); 1153 border-right: 1px solid var(--line);
989 white-space: nowrap; 1154 white-space: nowrap;
990} 1155}
991/* not .code: that class is the blob/code-block style and carries its own 1156/* not .code: that class carries its own ground, which painted over the
992 background, which painted over every row tint */ 1157 row tints */
993table.difftable td.src { 1158table.difftable td.src { padding: 0 var(--sp-3); white-space: pre-wrap; overflow-wrap: anywhere; }
994 padding: 0 var(--sp-3);
995 white-space: pre-wrap;
996 overflow-wrap: anywhere;
997}
998/* the marker is decoration, so it lives in CSS and stays out of a copied 1159/* the marker is decoration, so it lives in CSS and stays out of a copied
999 selection */ 1160 selection */
1000table.difftable tr.add td.src::before { content: "+"; color: var(--ok); } 1161table.difftable tr.add td.src::before { content: "+"; color: var(--ok); }
@@ -1005,33 +1166,61 @@ table.difftable tr.del { background: var(--diff-del); }
1005table.difftable tr.hunk td, table.difftable tr.dmeta td { 1166table.difftable tr.hunk td, table.difftable tr.dmeta td {
1006 color: var(--muted); 1167 color: var(--muted);
1007 padding: var(--sp-1) var(--sp-3); 1168 padding: var(--sp-1) var(--sp-3);
1008 background: var(--fill-subtle); 1169 background: var(--surface);
1009 border-top: 1px solid var(--line); 1170 border-top: 1px solid var(--line);
1010 border-bottom: 1px solid var(--line); 1171 border-bottom: 1px solid var(--line);
1011} 1172}
1012table.difftable tr.hunk td.src { color: var(--accent); } 1173table.difftable tr.hunk td.src { color: var(--link); }
1013table.difftable tr.threadrow td { padding: var(--sp-2) var(--sp-3); background: var(--bg); } 1174table.difftable tr.threadrow td { padding: var(--sp-2) var(--sp-3); background: var(--canvas); }
1014/* a line number is the handle for commenting on that line; it stays a 1175/* a line number is the handle for commenting on that line; it stays a
1015 plain number until pointed at, so the gutter does not read as a wall of 1176 plain number until pointed at, so the gutter is not a wall of links */
1016 links */
1017table.difftable td.ln a.cmt { color: inherit; text-decoration: none; } 1177table.difftable td.ln a.cmt { color: inherit; text-decoration: none; }
1018table.difftable td.ln a.cmt:hover, 1178table.difftable td.ln a.cmt:hover,
1019table.difftable td.ln a.cmt:focus { color: var(--accent); text-decoration: underline; } 1179table.difftable td.ln a.cmt:focus { color: var(--link); text-decoration: underline; }
1180/* A linked line: f2-n17 on a diff, L17 on a file (see issue 118). */
1181.difftable tr:target td, .code .line:has(a:target) { background: color-mix(in srgb, var(--mark) 18%, transparent); }
1182.difftable tr:target td.ln, .code .line:has(a:target) .ln { border-left: 3px solid var(--mark); }
1183/* chroma writes the highlighted spans; the container only supplies the
1184 ground and the face */
1185.chroma { font-family: var(--mono); background: none; }
1186
1187.blamehunk { display: flex; gap: var(--sp-3); border-top: 1px solid var(--line); align-items: flex-start; }
1188.blamehunk:first-child { border-top: 0; }
1189.blameinfo { flex: none; width: 17rem; min-width: 0; padding: var(--sp-2) var(--sp-3); }
1190.blameinfo .subject { margin: 0; font-size: var(--fs-1); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1191.blameinfo .meta { margin: 0; font-size: var(--fs-1); color: var(--muted); }
1192.blamecode {
1193 flex: 1;
1194 margin: 0;
1195 padding: var(--sp-2) var(--sp-3) var(--sp-2) 0;
1196 background: var(--inset);
1197 border: 0;
1198 border-left: 1px solid var(--line);
1199 border-radius: 0;
1200 overflow-x: auto;
1201 align-self: stretch;
1202}
1203.lineno {
1204 display: inline-block;
1205 min-width: 3.5em;
1206 padding-right: 1em;
1207 text-align: right;
1208 color: var(--muted);
1209 user-select: none;
1210}
1020 1211
1021/* badges: signature and check states. Semantic colors stay distinct: 1212/* ---- chips and badges ---- */
1022 green verified/success, amber pending/stale, red bad/failure, muted 1213.chip, .badge {
1023 unsigned. */
1024.badge, .chip {
1025 --chip: var(--neutral); 1214 --chip: var(--neutral);
1026 display: inline-block; 1215 display: inline-block;
1027 padding: 0.05rem 0.55rem; 1216 padding: 1px 8px;
1028 border-radius: var(--r-pill); 1217 border-radius: var(--r-ctl);
1029 font-size: var(--fs-0); 1218 font-size: var(--fs-0);
1030 font-weight: 500; 1219 font-weight: 500;
1031 line-height: 1.5; 1220 line-height: 1.5;
1032 color: var(--chip); 1221 color: var(--chip);
1033 border: 1px solid color-mix(in srgb, var(--chip) 35%, transparent); 1222 border: 1px solid color-mix(in srgb, var(--chip) 40%, transparent);
1034 background: color-mix(in srgb, var(--chip) 7%, transparent); 1223 background: color-mix(in srgb, var(--chip) 10%, transparent);
1035 white-space: nowrap; 1224 white-space: nowrap;
1036 vertical-align: middle; 1225 vertical-align: middle;
1037} 1226}
@@ -1040,534 +1229,271 @@ table.difftable td.ln a.cmt:focus { color: var(--accent); text-decoration: under
1040.chip-merged, .chip-done { --chip: var(--done); } 1229.chip-merged, .chip-done { --chip: var(--done); }
1041.chip-neutral, .chip-source_gone { --chip: var(--neutral); } 1230.chip-neutral, .chip-source_gone { --chip: var(--neutral); }
1042.chip-stale { --chip: var(--warn); } 1231.chip-stale { --chip: var(--warn); }
1232.chip.topic { --chip: var(--link); }
1233.chip.topic:hover { text-decoration: none; background: color-mix(in srgb, var(--chip) 18%, transparent); }
1234/* a label carries its own colour inline, from the label's hex */
1043.chip.label { font-weight: 500; } 1235.chip.label { font-weight: 500; }
1044.chip.topic { --chip: var(--accent); }
1045.repohead .topics { margin: 0 0 var(--sp-2); }
1046.badge-verified { --chip: var(--ok); } 1236.badge-verified { --chip: var(--ok); }
1047.badge-unsigned { --chip: var(--neutral); } 1237.badge-unsigned { --chip: var(--neutral); }
1048.badge-signed_unknown_key { --chip: var(--warn); } 1238.badge-signed_unknown_key, .badge-signed_key_expired { --chip: var(--warn); }
1049.badge-signed_email_mismatch { --chip: var(--bad); } 1239.badge-signed_email_mismatch, .badge-signed_key_revoked, .badge-bad_signature { --chip: var(--bad); }
1050.badge-signed_key_expired { --chip: var(--warn); } 1240.badge.check-success, .chip.check-success { --chip: var(--ok); }
1051.badge-signed_key_revoked { --chip: var(--bad); } 1241.badge.check-pending, .chip.check-pending,
1052.badge-bad_signature { --chip: var(--bad); }
1053.badge.check-success { --chip: var(--ok); }
1054.badge.check-pending { --chip: var(--warn); }
1055.badge.check-failure, .badge.check-error { --chip: var(--bad); }
1056.badge.check-cancelled, .chip.check-cancelled { --chip: var(--neutral); }
1057.badge.check-skipped, .chip.check-skipped { --chip: var(--neutral); }
1058.badge.check-running, .chip.check-running { --chip: var(--warn); } 1242.badge.check-running, .chip.check-running { --chip: var(--warn); }
1059.chip.check-success { --chip: var(--ok); } 1243.badge.check-failure, .chip.check-failure,
1060.chip.check-pending { --chip: var(--warn); } 1244.badge.check-error, .chip.check-error { --chip: var(--bad); }
1061.chip.check-failure { --chip: var(--bad); } 1245.badge.check-cancelled, .chip.check-cancelled,
1062pre.buildlog { max-height: 40rem; overflow: auto; } 1246.badge.check-skipped, .chip.check-skipped { --chip: var(--neutral); }
1063span.check-success { color: var(--ok); } 1247span.check-success { color: var(--ok); }
1064span.check-pending { color: var(--warn); } 1248span.check-pending, span.check-running { color: var(--warn); }
1065span.check-failure, span.check-error { color: var(--bad); } 1249span.check-failure, span.check-error { color: var(--bad); }
1066 1250
1067/* issue and MR listings */ 1251a.memberchip {
1068.listhead { 1252 display: inline-block;
1069 display: flex;
1070 align-items: baseline;
1071 gap: var(--sp-4);
1072 flex-wrap: wrap;
1073}
1074.listhead h2 { margin-bottom: var(--sp-2); }
1075nav.filters { display: flex; gap: var(--sp-1); }
1076nav.filters a {
1077 color: var(--muted);
1078 font-size: var(--fs-2);
1079 padding: 0.05rem var(--sp-2);
1080 border-radius: var(--r-pill);
1081}
1082nav.filters a:hover { color: var(--fg); background: var(--hover); text-decoration: none; }
1083nav.filters a.active { color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, transparent); }
1084
1085ul.issuelist {
1086 list-style: none;
1087 margin: var(--sp-2) 0;
1088 padding: 0;
1089 background: var(--surface);
1090 border: 1px solid var(--line);
1091 border-radius: var(--r-lg);
1092 overflow: hidden;
1093}
1094ul.issuelist li {
1095 display: flex;
1096 align-items: baseline;
1097 gap: var(--sp-3);
1098 padding: var(--sp-3) var(--sp-4);
1099 border-bottom: 1px solid var(--faint);
1100}
1101ul.issuelist li:last-child { border-bottom: none; }
1102ul.issuelist li:hover { background: var(--hover); }
1103ul.issuelist li.empty { display: block; color: var(--muted); }
1104ul.issuelist .issuemain { flex: 1; min-width: 0; }
1105ul.issuelist p { margin: 0; }
1106ul.issuelist .title a { color: var(--fg); font-weight: 550; }
1107ul.issuelist .title a:hover { color: var(--accent); }
1108
1109/* releases */
1110article.release {
1111 background: var(--surface);
1112 border: 1px solid var(--line); 1253 border: 1px solid var(--line);
1113 border-radius: var(--r-lg); 1254 border-radius: var(--r-ctl);
1114 padding: var(--sp-3) var(--sp-4); 1255 padding: 1px 8px;
1115 margin: var(--sp-4) 0; 1256 margin: 0 2px 2px 0;
1257 color: var(--fg);
1258 font-size: var(--fs-1);
1116} 1259}
1117.releasehead { background: none; border: 0; padding: 0; } 1260a.memberchip:hover { text-decoration: none; border-color: var(--link); color: var(--link); }
1118.releasehead h3 { margin: 0 0 var(--sp-1); } 1261a.memberchip .role { color: var(--muted); }
1119.releasehead .meta { margin: 0 0 var(--sp-2); }
1120table.assets { margin-top: var(--sp-2); }
1121table.assets td.size { color: var(--muted); white-space: nowrap; text-align: right; }
1122table.assets td.name { width: 100%; font-family: var(--mono); font-size: var(--fs-1); }
1123 1262
1124/* milestones */ 1263.pathbar { display: flex; align-items: center; gap: var(--sp-2) var(--sp-3); margin: 0 0 var(--sp-3); flex-wrap: wrap; font-size: var(--fs-2); }
1125ul.milestonelist { 1264.pathbar .spacer { flex: 1; }
1126 list-style: none; 1265.pathbar .actions { display: flex; gap: var(--sp-2); }
1127 margin: var(--sp-2) 0; 1266.crumbs { color: var(--muted); }
1128 padding: 0; 1267.crumbs a { color: var(--link); }
1268.crumbs strong { color: var(--fg); }
1269.refchip {
1270 display: inline-flex;
1271 align-items: center;
1272 gap: 6px;
1273 height: 32px;
1274 padding: 0 10px;
1275 background: var(--canvas);
1129 border: 1px solid var(--line); 1276 border: 1px solid var(--line);
1130 border-radius: var(--r-lg); 1277 border-radius: var(--r-ctl);
1131 overflow: hidden; 1278 font-family: var(--mono);
1132}
1133ul.milestonelist li { padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--faint); }
1134ul.milestonelist li:last-child { border-bottom: none; }
1135ul.milestonelist li.empty { color: var(--muted); }
1136ul.milestonelist p { margin: 0; }
1137ul.milestonelist .title { font-weight: 550; }
1138.progress {
1139 height: 6px;
1140 background: var(--faint);
1141 border-radius: var(--r-pill);
1142 margin-top: var(--sp-2);
1143 overflow: hidden;
1144 max-width: 24rem;
1145}
1146.progress .bar { height: 100%; background: var(--ok); }
1147.listhead .spacer { flex: 1; }
1148
1149/* issue / MR detail */
1150h2.issuetitle { margin-bottom: var(--sp-1); font-size: var(--fs-5); }
1151.issuenumber { color: var(--muted); font-weight: 400; }
1152.issuemeta { color: var(--muted); font-size: var(--fs-2); margin-top: 0; }
1153.review { font-size: var(--fs-2); }
1154
1155/* comment cards; discussion stays at a readable measure */
1156article.comment, form.commentform {
1157 max-width: 48rem;
1158}
1159article.comment {
1160 border: 1px solid var(--faint);
1161 border-radius: var(--r-lg);
1162 margin: var(--sp-4) 0;
1163}
1164article.comment .commenthead {
1165 background: var(--surface);
1166 border-bottom: 1px solid var(--faint);
1167 border-radius: var(--r-lg) var(--r-lg) 0 0;
1168 padding: var(--sp-2) var(--sp-4);
1169 font-size: var(--fs-2);
1170}
1171article.comment .commenthead .when { color: var(--muted); }
1172article.comment .rendered { padding: 0 var(--sp-4); }
1173article.comment .rendered > :first-child { margin-top: var(--sp-3); }
1174article.comment .rendered > :last-child { margin-bottom: var(--sp-3); }
1175form.commentform { margin: var(--sp-4) 0; }
1176details.editbox { margin: var(--sp-2) 0; max-width: 48rem; }
1177details.editbox summary { cursor: pointer; color: var(--muted); font-size: var(--fs-2); }
1178details.editbox summary:hover { color: var(--accent); }
1179details.editbox input[type="text"] { width: 100%; }
1180.syscomment {
1181 max-width: 48rem;
1182 color: var(--muted);
1183 font-size: var(--fs-2); 1279 font-size: var(--fs-2);
1184 margin: var(--sp-3) 0; 1280 font-weight: 500;
1185 padding-left: var(--sp-4);
1186 border-left: 3px solid var(--faint);
1187}
1188.syscomment p { display: inline; margin: 0; }
1189
1190/* review threads under diff lines */
1191.thread {
1192 border: 1px solid var(--faint);
1193 border-left: 3px solid var(--accent);
1194 border-radius: var(--r-md);
1195 padding: var(--sp-2) var(--sp-4);
1196 margin: var(--sp-2) 0 var(--sp-2) var(--sp-6);
1197}
1198.thread.resolved { border-left-color: var(--ok); opacity: 0.75; }
1199/* an unsubmitted comment is the viewer's own note, not the conversation */
1200.thread.pending { border-left-color: var(--neutral); border-left-style: dashed; }
1201.thread.stale { border-left-color: var(--warn); }
1202.thread p.commenthead { font-size: var(--fs-2); margin: var(--sp-2) 0 0; }
1203.thread .when { color: var(--muted); }
1204.thread .rendered p { margin: var(--sp-1) 0; }
1205.thread .threadstate { color: var(--muted); font-size: var(--fs-1); margin: var(--sp-1) 0 0; }
1206.thread.composing { border-left-color: var(--mark); }
1207.thread details.threadreply { margin: var(--sp-2) 0 0; }
1208.thread details.threadreply > summary { color: var(--accent); font-size: var(--fs-1); cursor: pointer; }
1209.thread textarea { width: 100%; }
1210.thread.composing p, .thread details.threadreply p { margin: var(--sp-2) 0 0; }
1211
1212/* forms: every control shares one shell, so a select reads as a sibling
1213 of the text inputs beside it rather than as an OS control dropped in */
1214input, textarea, button, select { font: inherit; color: inherit; }
1215input[type="text"], input[type="search"], input[type="email"],
1216input[type="password"], input:not([type]), textarea, select {
1217 background: var(--bg);
1218 color: var(--fg); 1281 color: var(--fg);
1219 border: 1px solid var(--line);
1220 border-radius: var(--r-md);
1221 padding: var(--sp-2) var(--sp-3);
1222 line-height: 1.4;
1223}
1224textarea { width: 100%; resize: vertical; }
1225textarea[name="content"] { font-family: var(--mono); font-size: var(--fs-1); }
1226/* the native arrow cannot be restyled, so draw our own and match the
1227 inputs exactly; --chev carries the stroke colour per scheme */
1228select {
1229 appearance: none;
1230 padding-right: 1.9rem;
1231 background-image: var(--chev);
1232 background-repeat: no-repeat;
1233 background-position: right 0.6rem center;
1234 background-size: 0.62rem;
1235 cursor: pointer;
1236}
1237input:hover, textarea:hover, select:hover { border-color: var(--muted); }
1238/* the global :focus-visible ring covers these; only the border moves */
1239input:focus, textarea:focus, select:focus { border-color: var(--accent); }
1240input[type="radio"] { accent-color: var(--fill); }
1241label:has(> input[type="radio"]), label:has(> input[type="checkbox"]),
1242label:has(> select) { display: inline-flex; align-items: center; gap: var(--sp-2); }
1243
1244/* A radio keeps the native control: a circle takes one answer, a square
1245 takes any number, and that shape is the cue that survives greyscale,
1246 high contrast and colour blindness. The checkbox is drawn instead,
1247 because at the size the OS renders it it reads as an afterthought
1248 beside the button it shares a row with. The square is kept square —
1249 the size changes, the cue does not. */
1250input[type="checkbox"] {
1251 appearance: none;
1252 flex: none;
1253 /* the height of a text input, a select and a button, so a settings row
1254 ends in one column of controls rather than a button and a speck */
1255 width: 2.5rem;
1256 height: 2.5rem;
1257 /* an empty box has nothing but its border to announce it, so the border
1258 is --muted rather than the --line the sized controls can afford */
1259 border: 1px solid var(--muted);
1260 border-radius: var(--r-sm);
1261 background: var(--bg);
1262 cursor: pointer;
1263}
1264input[type="checkbox"]:not(:checked):hover { border-color: var(--fg); }
1265input[type="checkbox"]:checked {
1266 background: var(--fill);
1267 border-color: var(--fill);
1268 /* the mark sits on the blue ground, so it is white in both schemes */
1269 background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 10'%3E%3Cpath d='M1 5l3.4 3.4L11 1.4' fill='none' stroke='%23ffffff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
1270 background-repeat: no-repeat;
1271 background-position: center;
1272 background-size: 1.2rem;
1273} 1282}
1274 1283details.refmenu { position: relative; }
1275/* a segmented control for a small, mutually exclusive set: real radios 1284details.refmenu summary { cursor: pointer; list-style: none; }
1276 underneath, so arrow keys and screen readers behave as they always did */ 1285details.refmenu summary::-webkit-details-marker { display: none; }
1277fieldset.segmented { border: 0; margin: 0 0 var(--sp-4); padding: 0; } 1286details.refmenu summary::after { content: " \25be"; color: var(--muted); }
1278fieldset.segmented legend { 1287details.refmenu .refdrop {
1279 padding: 0;
1280 margin-bottom: var(--sp-2);
1281 color: var(--muted);
1282 font-size: var(--fs-2);
1283}
1284fieldset.segmented .options { display: inline-flex; }
1285fieldset.segmented label { gap: 0; }
1286fieldset.segmented input {
1287 position: absolute; 1288 position: absolute;
1288 width: 1px; 1289 z-index: 10;
1289 height: 1px; 1290 top: calc(100% + 4px);
1290 opacity: 0; 1291 left: 0;
1291 pointer-events: none; 1292 min-width: 12rem;
1292} 1293 max-height: 20rem;
1293fieldset.segmented span { 1294 overflow-y: auto;
1294 display: block; 1295 background: var(--canvas);
1295 padding: var(--sp-2) var(--sp-4);
1296 border: 1px solid var(--line); 1296 border: 1px solid var(--line);
1297 font-size: var(--fs-2); 1297 border-radius: var(--r-card);
1298 cursor: pointer; 1298 box-shadow: var(--shadow);
1299} 1299 padding: var(--sp-1) 0;
1300fieldset.segmented label + label span { border-left: 0; }
1301fieldset.segmented label:first-child span { border-radius: var(--r-sm) 0 0 var(--r-sm); }
1302fieldset.segmented label:last-child span { border-radius: 0 var(--r-sm) var(--r-sm) 0; }
1303fieldset.segmented span:hover { border-color: var(--muted); }
1304fieldset.segmented input:checked + span {
1305 background: var(--fill);
1306 border-color: var(--fill);
1307 color: #ffffff;
1308 font-weight: 600;
1309}
1310fieldset.segmented input:focus-visible + span {
1311 outline: 2px solid var(--accent);
1312 outline-offset: 2px;
1313}
1314
1315/* remaining input states */
1316::placeholder { color: var(--muted); opacity: 1; }
1317::selection { background: var(--fill); color: #ffffff; }
1318/* :user-invalid, not :invalid — a required field is invalid the moment
1319 the page loads, and colouring it red before anyone has typed is a
1320 scolding, not a hint */
1321input:user-invalid, textarea:user-invalid {
1322 border-color: var(--bad);
1323}
1324.hint { display: block; color: var(--muted); font-size: var(--fs-1); margin-top: var(--sp-1); }
1325button {
1326 background: var(--fill);
1327 color: #ffffff;
1328 border: 1px solid var(--fill-edge);
1329 border-radius: var(--r-md);
1330 padding: var(--sp-2) var(--sp-4);
1331 font-weight: 600;
1332 cursor: pointer;
1333} 1300}
1334button:hover { filter: brightness(1.15); } 1301details.refmenu .refdrop a {
1335 1302 display: block;
1336/* 404 */ 1303 padding: var(--sp-1) var(--sp-3);
1337.notfound { text-align: center; padding: var(--sp-6) 0; } 1304 color: var(--fg);
1338.notfound .bigcode {
1339 font-family: var(--mono); 1305 font-family: var(--mono);
1340 font-size: var(--fs-7); 1306 font-size: var(--fs-1);
1341 color: var(--faint);
1342 -webkit-text-stroke: 1px var(--line);
1343 margin: 0;
1344 line-height: 1;
1345} 1307}
1346.notfound h1 { margin-top: var(--sp-3); } 1308details.refmenu .refdrop a:hover { background: var(--hover); text-decoration: none; }
1347 1309details.refmenu .refdrop a.allrefs {
1348/* footer */ 1310 color: var(--link);
1349footer { 1311 font-family: var(--sans);
1350 border-top: 1px solid var(--faint); 1312 border-top: 1px solid var(--faint);
1351 padding: var(--sp-4) var(--sp-5) var(--sp-5); 1313 margin-top: var(--sp-1);
1352 width: 100%;
1353 color: var(--muted);
1354 font-size: var(--fs-1);
1355} 1314}
1356footer p { margin: 0; }
1357footer a { color: var(--muted); text-decoration: underline; }
1358footer a:hover { color: var(--accent); }
1359 1315
1360/* search */ 1316/* ---- repository facts ---- */
1361form.searchform { 1317.facts { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-5); margin: var(--sp-5) 0 var(--sp-4); max-width: 72rem; }
1362 display: flex; 1318.facts h2 {
1363 gap: var(--sp-2); 1319 margin: 0 0 var(--sp-2);
1364 margin: var(--sp-3) 0 var(--sp-4); 1320 font-size: var(--fs-1);
1365} 1321 font-weight: 500;
1366form.searchform input[type="search"] { flex: 1; max-width: 32rem; } 1322 letter-spacing: 0.08em;
1367form.searchform.compact { margin: 0; } 1323 text-transform: uppercase;
1368form.searchform.compact input[type="search"] { 1324 color: var(--muted);
1369 padding: var(--sp-1) var(--sp-3);
1370 font-size: var(--fs-2);
1371 min-width: 18rem;
1372}
1373ul.matchlist { list-style: none; margin: var(--sp-3) 0; padding: 0; }
1374ul.matchlist li { margin-bottom: var(--sp-3); }
1375ul.matchlist .matchpath { font-family: var(--mono); font-size: var(--fs-1); }
1376pre.matchline {
1377 background: var(--code-bg);
1378 border: 1px solid var(--faint);
1379 border-radius: var(--r-md);
1380 padding: var(--sp-1) var(--sp-3);
1381 margin: var(--sp-1) 0 0;
1382 overflow-x: auto;
1383}
1384pre.matchline mark {
1385 background: color-mix(in srgb, var(--warn) 25%, transparent);
1386 color: inherit;
1387 border-radius: 2px;
1388} 1325}
1326.clone { margin: 0 0 var(--sp-3); }
1327.clone label { font-size: var(--fs-1); color: var(--muted); font-weight: 500; margin-bottom: 2px; }
1328.clone pre { margin-bottom: var(--sp-3); user-select: all; }
1329.factgrid { display: grid; grid-template-columns: repeat(4, auto); gap: var(--sp-2) var(--sp-4); font-size: var(--fs-2); margin-bottom: var(--sp-3); }
1330.factgrid b { font-weight: 600; }
1331.factgrid a { color: var(--fg); }
1332.factgrid a:hover { color: var(--link); }
1333.counts { display: flex; flex-wrap: wrap; gap: var(--sp-1) var(--sp-4); margin: 0 0 var(--sp-3); font-size: var(--fs-2); }
1334.counts a { color: var(--fg); }
1335.counts a:hover { color: var(--link); }
1336.counts strong { font-weight: 600; }
1337.fact { color: var(--muted); }
1338.langbar { display: flex; height: 8px; margin: 0 0 var(--sp-2); border-radius: var(--r-ctl); overflow: hidden; gap: 1px; background: var(--faint); }
1339.langbar .lang { display: block; height: 100%; min-width: 2px; }
1340.langs { display: flex; flex-wrap: wrap; gap: var(--sp-1) var(--sp-3); margin: 0 0 var(--sp-3); font-size: var(--fs-1); color: var(--muted); }
1341.langs .lang-name { display: inline-flex; align-items: center; gap: var(--sp-1); }
1342.langs .dot { width: 8px; height: 8px; }
1343/* named where it reads as the language's own colour, neutral otherwise */
1344.lang { background: var(--neutral); }
1345.lang-go { background: var(--lang-go); }
1346.lang-rust { background: var(--lang-rust); }
1347.lang-c, .lang-c- { background: var(--lang-c); }
1348.lang-python { background: var(--lang-python); }
1349.lang-javascript { background: var(--lang-javascript); }
1350.lang-typescript { background: var(--lang-typescript); }
1351.lang-shell { background: var(--lang-shell); }
1352.lang-ruby { background: var(--lang-ruby); }
1353.lang-java { background: var(--lang-java); }
1354.lang-swift { background: var(--lang-swift); }
1355.lang-html { background: var(--lang-html); }
1356.lang-css { background: var(--lang-css); }
1357.lang-org { background: var(--lang-org); }
1358.lang-markdown { background: var(--lang-markdown); }
1359.lang-emacs-lisp { background: var(--lang-emacs-lisp); }
1360.lang-nix { background: var(--lang-nix); }
1361.lang-sql { background: var(--lang-sql); }
1362.contribs { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--sp-1) var(--sp-3); margin: 0; font-size: var(--fs-1); }
1363.contribs .label { color: var(--muted); }
1364.teambody { padding: var(--sp-3); }
1365.teambody .memberchip { margin-right: var(--sp-2); }
1389 1366
1390/* wiki */ 1367/* ---- landing ---- */
1368/* the width class on main carries this too; the cap here keeps the page
1369 readable on the templates that do not set one yet */
1370.landing { max-width: 48rem; padding: var(--sp-6) 0; }
1371.landing h1 { font-size: var(--fs-6); display: flex; align-items: center; gap: var(--sp-3); margin-bottom: var(--sp-3); }
1372.lede { font-size: var(--fs-4); color: var(--muted); line-height: 1.5; max-width: 40rem; margin: 0 0 var(--sp-5); }
1373pre.quickstart { margin: 0 0 var(--sp-4); }
1374.shot { border: 1px solid var(--line); border-radius: var(--r-card); overflow: hidden; margin: var(--sp-5) 0; }
1375.shot img { display: block; width: 100%; }
1376.facets { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--sp-5); margin: var(--sp-5) 0; }
1377.facets h2 { font-size: var(--fs-3); margin: 0 0 var(--sp-1); }
1378.facets p { color: var(--muted); font-size: var(--fs-2); margin: 0; }
1379.routes { display: flex; gap: var(--sp-3); align-items: center; margin: var(--sp-5) 0 var(--sp-3); }
1380.explorelink { font-weight: 500; }
1381
1382/* ---- profile ---- */
1383.profilehead { border-bottom: 1px solid var(--faint); padding-bottom: var(--sp-4); margin-bottom: var(--sp-4); }
1384.profilehead h1 { margin: 0 0 var(--sp-1); }
1385.profilehead .lede { font-size: var(--fs-3); margin: 0 0 var(--sp-1); }
1386.profilehead .meta { margin: var(--sp-1) 0 0; }
1387.activity { margin-bottom: var(--sp-5); }
1388h2 .count { background: none; color: var(--muted); font-weight: 400; font-size: var(--fs-2); margin-left: var(--sp-2); padding: 0; }
1389/* 53 week columns x 7 day rows, tinted with the link blue */
1390.actgraph-scroll { overflow-x: auto; padding-bottom: var(--sp-1); }
1391.actgraph { display: flex; gap: 3px; width: max-content; }
1392.actweek { display: flex; flex-direction: column; gap: 3px; }
1393.actday { width: 11px; height: 11px; border-radius: 2px; background: var(--faint); }
1394.actday.pad { background: transparent; }
1395.actday.l0 { background: var(--faint); }
1396.actday.l1 { background: color-mix(in srgb, var(--link) 25%, var(--canvas)); }
1397.actday.l2 { background: color-mix(in srgb, var(--link) 50%, var(--canvas)); }
1398.actday.l3 { background: color-mix(in srgb, var(--link) 75%, var(--canvas)); }
1399.actday.l4 { background: var(--link); }
1400
1401/* ---- wiki ---- */
1391.wikilayout { display: flex; gap: var(--sp-5); align-items: flex-start; } 1402.wikilayout { display: flex; gap: var(--sp-5); align-items: flex-start; }
1392.wikipage { flex: 1; min-width: 0; } 1403.wikipage { flex: 1; min-width: 0; }
1393.wikinav { flex: none; width: 13rem; padding-top: var(--sp-4); } 1404.wikinav { flex: none; width: 14rem; }
1394.wikinav ul { list-style: none; margin: var(--sp-1) 0 var(--sp-4); padding: 0; } 1405.wikinav ul { list-style: none; margin: var(--sp-1) 0 var(--sp-4); padding: 0; }
1395.wikinav li a { display: block; padding: 0.15rem 0; color: var(--fg); } 1406.wikinav li a {
1396.wikinav li a:hover { color: var(--accent); text-decoration: none; } 1407 display: block;
1397.wikinav li a.active { color: var(--accent); font-weight: 550; } 1408 padding: 5px var(--sp-2);
1398@media (max-width: 40rem) { 1409 border-radius: var(--r-ctl);
1399 /* Column direction + the desktop align-items:flex-start lets .wikipage 1410 color: var(--fg);
1400 take its content's intrinsic width, overflowing the viewport (and 1411 font-size: var(--fs-2);
1401 triggering iOS font inflation). Stretch pins it to the container. */
1402 .wikilayout { flex-direction: column; align-items: stretch; }
1403 .wikinav { width: auto; padding-top: 0; }
1404} 1412}
1413.wikinav li a:hover { background: var(--hover); color: var(--fg); text-decoration: none; }
1414.wikinav li a.active { background: var(--surface); color: var(--link); font-weight: 500; }
1405 1415
1406/* blame */ 1416/* ---- misc ---- */
1407.blame { 1417.meta { color: var(--muted); font-size: var(--fs-1); }
1408 border: 1px solid var(--line); 1418.muted { color: var(--muted); }
1409 border-radius: var(--r-md); 1419.desc, td.desc { color: var(--muted); }
1410 overflow: hidden; 1420.spacer { flex: 1; }
1411} 1421.compact { margin: 0; }
1412.blamehunk { 1422.name { min-width: 0; }
1413 display: flex; 1423svg.icon { vertical-align: -0.125em; }
1414 gap: var(--sp-3); 1424.sep { color: var(--muted); }
1415 border-top: 1px solid var(--line); 1425.owner { color: var(--muted); }
1416 align-items: flex-start; 1426.title { margin: 0; }
1417} 1427.subject { min-width: 0; }
1418.blamehunk:first-child { border-top: none; } 1428.sha { font-family: var(--mono); }
1419.blameinfo { 1429.when { color: var(--muted); }
1420 flex: none; 1430.who { font-weight: 500; }
1421 width: 17rem; 1431.was { color: var(--muted); }
1422 padding: var(--sp-2) var(--sp-3); 1432.role { color: var(--muted); }
1423 min-width: 0; 1433.age { color: var(--muted); }
1424} 1434.size { color: var(--muted); }
1425.blameinfo .subject { 1435.review { font-size: var(--fs-2); }
1426 margin: 0; 1436
1427 font-size: var(--fs-1); 1437/* ---- breakpoints ---- */
1428 white-space: nowrap; 1438@media (max-width: 62rem) {
1429 overflow: hidden; 1439 .withaside { grid-template-columns: 1fr; }
1430 text-overflow: ellipsis; 1440 .withaside .aside { order: -1; }
1431}
1432.blameinfo .meta { margin: 0; font-size: var(--fs-1); color: var(--muted); }
1433.blamecode {
1434 flex: 1;
1435 margin: 0;
1436 padding: var(--sp-2) var(--sp-3) var(--sp-2) 0;
1437 overflow-x: auto;
1438 border-left: 1px solid var(--line);
1439 align-self: stretch;
1440} 1441}
1441.lineno { 1442
1442 display: inline-block; 1443@media (max-width: 52rem) {
1443 min-width: 3.5em; 1444 /* the rail becomes a single strip, not a wall of links */
1444 padding-right: 1em; 1445 .shell { display: block; }
1445 text-align: right; 1446 .rail {
1446 color: var(--muted); 1447 width: auto;
1447 user-select: none; 1448 height: auto;
1449 position: static;
1450 flex-direction: row;
1451 align-items: center;
1452 gap: var(--sp-2);
1453 border-right: 0;
1454 border-bottom: 1px solid var(--shell-line);
1455 overflow-x: auto;
1456 }
1457 a.brand { padding: var(--sp-2) var(--sp-3); }
1458 .railbody { flex: 1; overflow: visible; padding: 0; display: flex; align-items: center; }
1459 form.railsearch { margin: 0 var(--sp-2); }
1460 form.railsearch input[type="search"] { min-width: 8rem; }
1461 /* pinned repos and the review queue both live on the dashboard, so the
1462 strip keeps only what has nowhere else to go */
1463 .railgroup { display: none; }
1464 ul.raillist { display: flex; margin: 0; }
1465 ul.raillist a { border-radius: 0; border-bottom: 2px solid transparent; padding: var(--sp-2) var(--sp-3); }
1466 ul.raillist a[aria-current] { box-shadow: none; border-bottom-color: var(--shell-mark); }
1467 .railfoot { border-top: 0; padding: var(--sp-2); }
1468 .railpinned { border-top: 0; padding: 0; }
1469 main.content { padding: var(--sp-4) var(--sp-4) var(--sp-5); }
1470 footer { padding: var(--sp-4); }
1471 .repohead { padding: var(--sp-3) var(--sp-4) 0; }
1472 /* seven tabs do not fit a phone in one row; a hidden scrollbar made the
1473 ones past the edge look absent. Two rows show all of them. */
1474 nav.tabs { flex-wrap: wrap; overflow: visible; }
1475 .facts { grid-template-columns: 1fr; }
1448} 1476}
1449 1477
1450/* mobile */
1451@media (max-width: 40rem) { 1478@media (max-width: 40rem) {
1452 .thread { margin-left: var(--sp-3); } 1479 .facets { grid-template-columns: 1fr; gap: var(--sp-4); }
1453 /* the commit subject is the widest thing in the listing and the one 1480 .wikilayout { flex-direction: column; align-items: stretch; }
1481 .wikinav { width: auto; }
1482 form.setform { grid-template-columns: 1fr; gap: var(--sp-2); }
1483 form.setform label { margin-top: 0; }
1484 /* the commit subject is the widest thing in a file table and the one
1454 column a phone can do without: the tipbar above already names the 1485 column a phone can do without: the tipbar above already names the
1455 latest commit. */ 1486 latest commit */
1456 table.tree td.lastcommit, table.tree th.lastcommit { display: none; } 1487 table.tree td.lastcommit, table.tree th.lastcommit { display: none; }
1457 table.tree td.mode, table.tree th.mode { display: none; } 1488 table.tree td.mode, table.tree th.mode { display: none; }
1458 .clone, .pathbar { overflow-wrap: anywhere; } 1489 .clone pre { white-space: pre-wrap; word-break: break-all; }
1459 .readme, .code { padding: var(--sp-3); } 1490 .pathbar { overflow-wrap: anywhere; }
1491 .readme .cardbody, .code { padding: var(--sp-3); }
1492 .thread { margin-left: var(--sp-3); }
1460 .blamehunk { flex-direction: column; gap: 0; } 1493 .blamehunk { flex-direction: column; gap: 0; }
1461 .blameinfo { width: auto; } 1494 .blameinfo { width: auto; }
1462 .blamecode { border-left: none; padding-left: var(--sp-3); width: 100%; } 1495 .blamecode { border-left: 0; padding-left: var(--sp-3); width: 100%; }
1463 .cardhead { 1496 .factgrid { grid-template-columns: repeat(2, auto); }
1464 margin: calc(-1 * var(--sp-3)) calc(-1 * var(--sp-3)) var(--sp-3); 1497 ul.loglist li { flex-wrap: wrap; }
1465 padding: var(--sp-2) var(--sp-3); 1498 ul.loglist .commitside { width: 100%; justify-content: flex-start; }
1466 }
1467}
1468
1469/* tree listing: the last commit is what a file table is scanned for, so
1470 it leads. The file's own facts moved to the blob page. */
1471.tipbar {
1472 display: flex;
1473 align-items: center;
1474 gap: var(--sp-3);
1475 padding: var(--sp-2) var(--sp-4);
1476 background: var(--hover);
1477 border: 1px solid var(--line);
1478 border-bottom: 0;
1479 border-radius: var(--r-lg) var(--r-lg) 0 0;
1480 font-size: var(--fs-2);
1481}
1482.tipbar .who { font-weight: 600; }
1483.tipbar .subject { color: var(--fg); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1484.tipbar .subject:hover { color: var(--accent); }
1485.tipbar .spacer { flex: 1; }
1486.tipbar .age { color: var(--muted); white-space: nowrap; }
1487.tipbar + table.tree { border-radius: 0 0 var(--r-lg) var(--r-lg); }
1488table.tree th.lastcommit, table.tree td.lastcommit { width: 55%; }
1489table.tree td.lastcommit a { color: var(--muted); display: block; overflow: hidden;
1490 text-overflow: ellipsis; white-space: nowrap; }
1491table.tree td.lastcommit a:hover { color: var(--accent); }
1492table.tree th.age, table.tree td.age {
1493 text-align: right;
1494 white-space: nowrap;
1495 color: var(--muted);
1496 font-variant-numeric: tabular-nums;
1497}
1498table.tree td.name { width: 25%; white-space: nowrap; }
1499table.tree td.name.dir a { color: var(--accent); }
1500p.clone { margin: 0 0 var(--sp-3); }
1501p.filefacts { color: var(--muted); font-size: var(--fs-1); margin: 0 0 var(--sp-3); }
1502.pathbar .actions { font-size: var(--fs-1); color: var(--muted); }
1503.snippetfile { margin-bottom: var(--sp-5); }
1504
1505/* merge request: a two-column split, so state has somewhere to live that
1506 is not a run-on sentence under the title */
1507.withaside {
1508 display: grid;
1509 grid-template-columns: minmax(0, 1fr) 15rem;
1510 gap: var(--sp-5);
1511 align-items: start;
1512}
1513.withaside .mainside { min-width: 0; }
1514@media (max-width: 62rem) { .withaside { grid-template-columns: 1fr; } }
1515
1516.aside { font-size: var(--fs-2); }
1517.aside .grp { border-top: 1px solid var(--line); padding: var(--sp-3) 0; }
1518.aside .grp:first-child { border-top: 0; padding-top: 0; }
1519.aside h2 {
1520 margin: 0 0 var(--sp-2);
1521 font-family: var(--mono);
1522 font-size: var(--fs-0);
1523 font-weight: 500;
1524 letter-spacing: 0.1em;
1525 text-transform: uppercase;
1526 color: var(--muted);
1527} 1499}
1528.aside .row { display: flex; align-items: center; gap: var(--sp-2); margin: 0 0 var(--sp-1); flex-wrap: wrap; }
1529.aside .row:last-child { margin-bottom: 0; }
1530.aside .none { color: var(--muted); }
1531/* a row's second line: when it happened, under what it happened to. The
1532 extra gap keeps the pair reading as one entry. */
1533.aside .row .sub { flex-basis: 100%; color: var(--muted); font-size: var(--fs-0); }
1534.aside .row:has(.sub) { margin-bottom: var(--sp-2); }
1535.aside .dot { width: 0.45rem; height: 0.45rem; flex: none; border-radius: 50%; background: var(--muted); }
1536.aside .dot.ok { background: var(--ok); }
1537.aside .dot.bad { background: var(--bad); }
1538.aside .dot.pend { background: var(--mark); }
1539
1540/* sub-navigation within an item; orange marks position as it does above */
1541nav.subtabs {
1542 display: flex;
1543 gap: var(--sp-4);
1544 border-bottom: 1px solid var(--line);
1545 margin: 0 0 var(--sp-4);
1546}
1547nav.subtabs a {
1548 display: inline-flex;
1549 align-items: center;
1550 gap: var(--sp-2);
1551 padding: 0 0 var(--sp-2);
1552 color: var(--muted);
1553 font-size: var(--fs-2);
1554 border-bottom: 2px solid transparent;
1555 margin-bottom: -1px;
1556}
1557nav.subtabs a:hover { color: var(--fg); text-decoration: none; }
1558nav.subtabs a[aria-current] { color: var(--fg); font-weight: 700; border-bottom-color: var(--mark); }
1559nav.subtabs a i {
1560 font-style: normal;
1561 font-family: var(--mono);
1562 font-size: var(--fs-0);
1563 background: var(--hover);
1564 border-radius: var(--r-sm);
1565 padding: 0 0.35rem;
1566}
1567p.diffstat { color: var(--muted); font-size: var(--fs-2); margin: 0 0 var(--sp-3); }
1568p.diffstat .add { color: var(--ok); }
1569p.diffstat .del { color: var(--bad); }
1570
1571/* file edit form */
1572.editform textarea { width: 100%; }
1573.editform input[name="message"] { width: 60%; }
internal/web/templates/account.html +10 −10
@@ -17,7 +17,7 @@
17 <label for="p-about">About</label> 17 <label for="p-about">About</label>
18 <textarea id="p-about" name="about" rows="8" placeholder="longer, shown below your repositories">{{.Profile.About}}</textarea> 18 <textarea id="p-about" name="about" rows="8" placeholder="longer, shown below your repositories">{{.Profile.About}}</textarea>
19 {{template "formatpicker" .Profile.AboutFormat}} 19 {{template "formatpicker" .Profile.AboutFormat}}
20 <button type="submit">Save profile</button> 20 <button type="submit" class="btn">Save profile</button>
21</form> 21</form>
22 22
23<h2>SSH keys</h2> 23<h2>SSH keys</h2>
@@ -31,7 +31,7 @@ a CI checkout wants.</p>
31 <td class="mono">{{.Fingerprint}}</td> 31 <td class="mono">{{.Fingerprint}}</td>
32 <td>{{.Algo}}</td> 32 <td>{{.Algo}}</td>
33 <td>{{.Scope}}</td> 33 <td>{{.Scope}}</td>
34 <td class="act"><form method="post" action="/settings"><input type="hidden" name="field" value="key-remove"><input type="hidden" name="fingerprint" value="{{.Fingerprint}}">{{template "confirmfield" .Confirm}} <button type="submit" class="linklike">Remove</button></form></td> 34 <td class="act"><form method="post" action="/settings"><input type="hidden" name="field" value="key-remove"><input type="hidden" name="fingerprint" value="{{.Fingerprint}}">{{template "confirmfield" .Confirm}} <button type="submit" class="danger">Remove</button></form></td>
35</tr> 35</tr>
36{{end}}</table></div> 36{{end}}</table></div>
37{{else}}<p class="none">No SSH keys — which cannot be right, since you signed in.</p>{{end}} 37{{else}}<p class="none">No SSH keys — which cannot be right, since you signed in.</p>{{end}}
@@ -48,7 +48,7 @@ a CI checkout wants.</p>
48 <option value="full">full — commands and git</option> 48 <option value="full">full — commands and git</option>
49 <option value="git">git — git transport only</option> 49 <option value="git">git — git transport only</option>
50 </select> 50 </select>
51 <button type="submit">Add key</button> 51 <button type="submit" class="btn">Add key</button>
52 </form> 52 </form>
53</details> 53</details>
54 54
@@ -61,7 +61,7 @@ account, and where notifications go.</p>
61 {{if .Verified}}<span class="badge badge-verified">verified{{with .VerifiedBy}} · {{.}}{{end}}</span> 61 {{if .Verified}}<span class="badge badge-verified">verified{{with .VerifiedBy}} · {{.}}{{end}}</span>
62 {{else}}<span class="badge badge-unsigned">unverified</span>{{end}} 62 {{else}}<span class="badge badge-unsigned">unverified</span>{{end}}
63 {{if not .Primary}}{{if .Verified}}<form method="post" action="/settings" class="inline"><input type="hidden" name="field" value="email-primary"><input type="hidden" name="address" value="{{.Address}}"><button type="submit" class="linklike">Make primary</button></form>{{end}} 63 {{if not .Primary}}{{if .Verified}}<form method="post" action="/settings" class="inline"><input type="hidden" name="field" value="email-primary"><input type="hidden" name="address" value="{{.Address}}"><button type="submit" class="linklike">Make primary</button></form>{{end}}
64 <form method="post" action="/settings" class="inline"><input type="hidden" name="field" value="email-remove"><input type="hidden" name="address" value="{{.Address}}">{{template "confirmfield" .Address}} <button type="submit" class="linklike">Remove</button></form>{{end}}</li> 64 <form method="post" action="/settings" class="inline"><input type="hidden" name="field" value="email-remove"><input type="hidden" name="address" value="{{.Address}}">{{template "confirmfield" .Address}} <button type="submit" class="danger">Remove</button></form>{{end}}</li>
65{{end}}</ul>{{end}} 65{{end}}</ul>{{end}}
66<details class="editbox"> 66<details class="editbox">
67 <summary>Add an address</summary> 67 <summary>Add an address</summary>
@@ -69,13 +69,13 @@ account, and where notifications go.</p>
69 <input type="hidden" name="field" value="email-add"> 69 <input type="hidden" name="field" value="email-add">
70 <label for="address">Address</label> 70 <label for="address">Address</label>
71 <input type="email" id="address" name="address" required placeholder="you@example.org"> 71 <input type="email" id="address" name="address" required placeholder="you@example.org">
72 <button type="submit">Send code</button> 72 <button type="submit" class="btn">Send code</button>
73 </form> 73 </form>
74 <form method="post" action="/settings" class="setform"> 74 <form method="post" action="/settings" class="setform">
75 <input type="hidden" name="field" value="email-verify"> 75 <input type="hidden" name="field" value="email-verify">
76 <label for="code">Verification code</label> 76 <label for="code">Verification code</label>
77 <input type="text" id="code" name="code" required placeholder="from the mail"> 77 <input type="text" id="code" name="code" required placeholder="from the mail">
78 <button type="submit">Verify</button> 78 <button type="submit" class="btn">Verify</button>
79 </form> 79 </form>
80</details> 80</details>
81 81
@@ -87,7 +87,7 @@ account, and where notifications go.</p>
87{{range .PGP}}<tr> 87{{range .PGP}}<tr>
88 <td class="mono">{{.Fingerprint}}</td> 88 <td class="mono">{{.Fingerprint}}</td>
89 <td>{{range .UIDs}}{{.}}<br>{{end}}{{if .Revoked}}<span class="badge badge-bad_signature">revoked</span>{{else if .Expired}}<span class="badge badge-signed_key_expired">expired</span>{{end}}</td> 89 <td>{{range .UIDs}}{{.}}<br>{{end}}{{if .Revoked}}<span class="badge badge-bad_signature">revoked</span>{{else if .Expired}}<span class="badge badge-signed_key_expired">expired</span>{{end}}</td>
90 <td class="act"><form method="post" action="/settings"><input type="hidden" name="field" value="pgp-remove"><input type="hidden" name="fingerprint" value="{{.Fingerprint}}">{{template "confirmfield" .Confirm}} <button type="submit" class="linklike">Remove</button></form></td> 90 <td class="act"><form method="post" action="/settings"><input type="hidden" name="field" value="pgp-remove"><input type="hidden" name="fingerprint" value="{{.Fingerprint}}">{{template "confirmfield" .Confirm}} <button type="submit" class="danger">Remove</button></form></td>
91</tr> 91</tr>
92{{end}}</table></div> 92{{end}}</table></div>
93{{else}}<p class="none">No OpenPGP keys.</p>{{end}} 93{{else}}<p class="none">No OpenPGP keys.</p>{{end}}
@@ -97,7 +97,7 @@ account, and where notifications go.</p>
97 <input type="hidden" name="field" value="pgp-add"> 97 <input type="hidden" name="field" value="pgp-add">
98 <label for="pgpkey">Armored public key</label> 98 <label for="pgpkey">Armored public key</label>
99 <textarea id="pgpkey" name="key" rows="6" required placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----"></textarea> 99 <textarea id="pgpkey" name="key" rows="6" required placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----"></textarea>
100 <button type="submit">Add key</button> 100 <button type="submit" class="btn">Add key</button>
101 </form> 101 </form>
102</details> 102</details>
103 103
@@ -106,14 +106,14 @@ account, and where notifications go.</p>
106 <input type="hidden" name="field" value="notify-mail"> 106 <input type="hidden" name="field" value="notify-mail">
107 <label for="notify-mail">Activity by mail</label> 107 <label for="notify-mail">Activity by mail</label>
108 <input type="checkbox" id="notify-mail" name="mail" value="on"{{if .MailOn}} checked{{end}}> 108 <input type="checkbox" id="notify-mail" name="mail" value="on"{{if .MailOn}} checked{{end}}>
109 <button type="submit">Save</button> 109 <button type="submit" class="btn">Save</button>
110</form> 110</form>
111<p class="meta">The inbox is filed either way; this is the mail half. Login links are not activity and still arrive.</p> 111<p class="meta">The inbox is filed either way; this is the mail half. Login links are not activity and still arrive.</p>
112<form method="post" action="/settings" class="setform"> 112<form method="post" action="/settings" class="setform">
113 <input type="hidden" name="field" value="notify-watch"> 113 <input type="hidden" name="field" value="notify-watch">
114 <label for="notify-watch">Watch repositories you can write to</label> 114 <label for="notify-watch">Watch repositories you can write to</label>
115 <input type="checkbox" id="notify-watch" name="watch" value="on"{{if .WatchOn}} checked{{end}}> 115 <input type="checkbox" id="notify-watch" name="watch" value="on"{{if .WatchOn}} checked{{end}}>
116 <button type="submit">Save</button> 116 <button type="submit" class="btn">Save</button>
117</form> 117</form>
118<p class="meta">Every issue and merge request on those repositories, as if you had watched each. A watch or mute on a repository still wins.</p> 118<p class="meta">Every issue and merge request on those repositories, as if you had watched each. A watch or mute on a repository still wins.</p>
119 119
internal/web/templates/build.html +1 −1
@@ -6,7 +6,7 @@
6<span class="spacer"></span> 6<span class="spacer"></span>
7{{if and .CanWrite (or (eq .Build.Status "pending") (eq .Build.Status "running"))}} 7{{if and .CanWrite (or (eq .Build.Status "pending") (eq .Build.Status "running"))}}
8<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/builds/{{.Build.Number}}/cancel" class="actions"> 8<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/builds/{{.Build.Number}}/cancel" class="actions">
9 <button type="submit">{{if eq .Build.Status "running"}}Cancel (stops at the runner's next check){{else}}Cancel{{end}}</button> 9 <button type="submit" class="btn">{{if eq .Build.Status "running"}}Cancel (stops at the runner's next check){{else}}Cancel{{end}}</button>
10</form> 10</form>
11{{end}} 11{{end}}
12</div> 12</div>
internal/web/templates/builds.html +1 −1
@@ -8,7 +8,7 @@
8 <select id="job" name="job"> 8 <select id="job" name="job">
9 {{range .Jobs}}<option value="{{.Name}}">{{.Name}}{{if .Schedule}} (schedule {{.Schedule}}){{else if .Tags}} (tags {{.Tags}}){{end}}</option>{{end}} 9 {{range .Jobs}}<option value="{{.Name}}">{{.Name}}{{if .Schedule}} (schedule {{.Schedule}}){{else if .Tags}} (tags {{.Tags}}){{end}}</option>{{end}}
10 </select> 10 </select>
11 <button type="submit">Run</button> 11 <button type="submit" class="btn">Run</button>
12</form> 12</form>
13{{end}} 13{{end}}
14<details class="editbox"><summary>Status badge</summary> 14<details class="editbox"><summary>Status badge</summary>
internal/web/templates/explore.html +1
@@ -4,6 +4,7 @@
4<h1>Explore</h1> 4<h1>Explore</h1>
5<form method="get" action="/explore" class="searchform compact"> 5<form method="get" action="/explore" class="searchform compact">
6 <input type="search" name="q" aria-label="Filter repositories" value="{{.Query}}" placeholder="filter by name, description, topic"> 6 <input type="search" name="q" aria-label="Filter repositories" value="{{.Query}}" placeholder="filter by name, description, topic">
7 <button type="submit" class="btn">Search</button>
7</form> 8</form>
8<span class="spacer"></span> 9<span class="spacer"></span>
9</div> 10</div>
internal/web/templates/globalsearch.html +1 −1
@@ -13,7 +13,7 @@
13<form method="get" action="/search" class="searchform"> 13<form method="get" action="/search" class="searchform">
14 <input type="search" name="q" aria-label="Search" value="{{.Query}}" placeholder="repository names and topics, issue and merge request text" autofocus> 14 <input type="search" name="q" aria-label="Search" value="{{.Query}}" placeholder="repository names and topics, issue and merge request text" autofocus>
15 {{if .Kind}}<input type="hidden" name="kind" value="{{.Kind}}">{{end}} 15 {{if .Kind}}<input type="hidden" name="kind" value="{{.Kind}}">{{end}}
16 <button type="submit">search</button> 16 <button type="submit" class="btn">Search</button>
17</form> 17</form>
18{{if .QueryErr}}<p class="error" role="alert">{{.QueryErr}}</p> 18{{if .QueryErr}}<p class="error" role="alert">{{.QueryErr}}</p>
19{{else if .Query}} 19{{else if .Query}}
internal/web/templates/issue.html +5 −5
@@ -15,7 +15,7 @@
15<p><input type="text" name="title" aria-label="Title" value="{{.Issue.Title}}" required></p> 15<p><input type="text" name="title" aria-label="Title" value="{{.Issue.Title}}" required></p>
16<p><textarea name="body" aria-label="Description" rows="8">{{.Issue.Body}}</textarea></p> 16<p><textarea name="body" aria-label="Description" rows="8">{{.Issue.Body}}</textarea></p>
17<p><input type="text" name="labels" aria-label="Labels" value="{{range $i, $l := .Issue.Labels}}{{if $i}} {{end}}{{$l}}{{end}}" placeholder="labels, space-separated (write access)"></p> 17<p><input type="text" name="labels" aria-label="Labels" value="{{range $i, $l := .Issue.Labels}}{{if $i}} {{end}}{{$l}}{{end}}" placeholder="labels, space-separated (write access)"></p>
18<p><button type="submit">save</button></p> 18<p><button type="submit" class="btn">Save</button></p>
19</form></details>{{end}} 19</form></details>{{end}}
20{{if .BodyHTML}}<article class="comment"> 20{{if .BodyHTML}}<article class="comment">
21 <header class="commenthead"><strong><a href="/{{.Issue.Author}}">{{.Issue.Author}}</a></strong> <span class="when">{{when .Issue.CreatedAt}}</span></header> 21 <header class="commenthead"><strong><a href="/{{.Issue.Author}}">{{.Issue.Author}}</a></strong> <span class="when">{{when .Issue.CreatedAt}}</span></header>
@@ -31,7 +31,7 @@
31{{if .Viewer}} 31{{if .Viewer}}
32<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/{{.Issue.Number}}/comment" class="commentform"> 32<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/{{.Issue.Number}}/comment" class="commentform">
33<p><textarea name="body" aria-label="Comment" rows="4" placeholder="Comment as {{.Viewer}}"></textarea></p> 33<p><textarea name="body" aria-label="Comment" rows="4" placeholder="Comment as {{.Viewer}}"></textarea></p>
34<p><button type="submit">comment</button></p> 34<p><button type="submit" class="btn">Comment</button></p>
35</form> 35</form>
36{{end}} 36{{end}}
37</div> 37</div>
@@ -55,7 +55,7 @@
55 <form method="post" action="{{$base}}/label" class="actions"> 55 <form method="post" action="{{$base}}/label" class="actions">
56 <input type="text" name="add" aria-label="Add labels" placeholder="add, space-separated"> 56 <input type="text" name="add" aria-label="Add labels" placeholder="add, space-separated">
57 <input type="text" name="remove" aria-label="Remove labels" placeholder="remove"> 57 <input type="text" name="remove" aria-label="Remove labels" placeholder="remove">
58 <button type="submit">Apply</button> 58 <button type="submit" class="btn">Apply</button>
59 </form> 59 </form>
60 {{end}} 60 {{end}}
61 </div> 61 </div>
@@ -67,7 +67,7 @@
67 <form method="post" action="{{$base}}/assign" class="actions"> 67 <form method="post" action="{{$base}}/assign" class="actions">
68 <input type="text" name="add" aria-label="Add assignees" placeholder="add, space-separated"> 68 <input type="text" name="add" aria-label="Add assignees" placeholder="add, space-separated">
69 <input type="text" name="remove" aria-label="Remove assignees" placeholder="remove"> 69 <input type="text" name="remove" aria-label="Remove assignees" placeholder="remove">
70 <button type="submit">Apply</button> 70 <button type="submit" class="btn">Apply</button>
71 </form> 71 </form>
72 {{end}} 72 {{end}}
73 </div> 73 </div>
@@ -82,7 +82,7 @@
82 <option value="none">No milestone</option> 82 <option value="none">No milestone</option>
83 {{range .Milestones}}<option value="{{.Title}}"{{if eq .Title $.Issue.Milestone}} selected{{end}}>{{.Title}}</option>{{end}} 83 {{range .Milestones}}<option value="{{.Title}}"{{if eq .Title $.Issue.Milestone}} selected{{end}}>{{.Title}}</option>{{end}}
84 </select> 84 </select>
85 <button type="submit">Apply</button> 85 <button type="submit" class="btn">Apply</button>
86 </form> 86 </form>
87 {{end}} 87 {{end}}
88 </div> 88 </div>
internal/web/templates/issues.html +1
@@ -10,6 +10,7 @@
10 {{range .Filters}}<p class="meta">{{.Key}}: {{if eq .Key "label"}}<span class="chip label" style="{{index $.LabelColors .Value}}">{{.Value}}</span>{{else}}<b>{{.Value}}</b>{{end}} <a href="{{.Clear}}">clear</a></p>{{end}} 10 {{range .Filters}}<p class="meta">{{.Key}}: {{if eq .Key "label"}}<span class="chip label" style="{{index $.LabelColors .Value}}">{{.Value}}</span>{{else}}<b>{{.Value}}</b>{{end}} <a href="{{.Clear}}">clear</a></p>{{end}}
11 <form method="get" class="searchform compact"> 11 <form method="get" class="searchform compact">
12 <input type="search" name="q" aria-label="Search issues" value="{{.Query}}" placeholder="search title and body"> 12 <input type="search" name="q" aria-label="Search issues" value="{{.Query}}" placeholder="search title and body">
13 <button type="submit" class="btn">Search</button>
13 <input type="hidden" name="state" value="{{.State}}"> 14 <input type="hidden" name="state" value="{{.State}}">
14 </form> 15 </form>
15 <span class="spacer"></span> 16 <span class="spacer"></span>
internal/web/templates/labels.html +2 −2
@@ -16,7 +16,7 @@
16 <input type="hidden" name="action" value="remove"> 16 <input type="hidden" name="action" value="remove">
17 <input type="hidden" name="name" value="{{.Name}}"> 17 <input type="hidden" name="name" value="{{.Name}}">
18 {{template "confirmfield" .Name}} 18 {{template "confirmfield" .Name}}
19 <button type="submit" class="linklike">Remove</button> 19 <button type="submit" class="danger">Remove</button>
20 </form>{{else if .Org}}<a href="/{{$.Repo.OwnerName}}/-/labels">org</a>{{end}}</td> 20 </form>{{else if .Org}}<a href="/{{$.Repo.OwnerName}}/-/labels">org</a>{{end}}</td>
21</tr> 21</tr>
22{{end}}</table></div> 22{{end}}</table></div>
@@ -29,7 +29,7 @@
29 <input type="text" id="labelname" name="name" maxlength="50" required> 29 <input type="text" id="labelname" name="name" maxlength="50" required>
30 <label for="labelcolor">Colour</label> 30 <label for="labelcolor">Colour</label>
31 <input type="text" id="labelcolor" name="color" placeholder="rrggbb, or blank for one picked from the name"> 31 <input type="text" id="labelcolor" name="color" placeholder="rrggbb, or blank for one picked from the name">
32 <button type="submit">Create label</button> 32 <button type="submit" class="btn">Create label</button>
33 </form> 33 </form>
34</details> 34</details>
35<p class="meta">Removing a label takes it off every issue that carries it.</p> 35<p class="meta">Removing a label takes it off every issue that carries it.</p>
internal/web/templates/layout.html +3 −3
@@ -162,8 +162,8 @@
162 <input type="hidden" name="line" value="{{if eq .Class "del"}}{{.OldLine}}{{else}}{{.NewLine}}{{end}}"> 162 <input type="hidden" name="line" value="{{if eq .Class "del"}}{{.OldLine}}{{else}}{{.NewLine}}{{end}}">
163 <input type="hidden" name="side" value="{{if eq .Class "del"}}old{{else}}new{{end}}"> 163 <input type="hidden" name="side" value="{{if eq .Class "del"}}old{{else}}new{{end}}">
164 <p><textarea name="body" aria-label="Comment on {{.Path}}" rows="3" placeholder="Comment on this line" autofocus></textarea></p> 164 <p><textarea name="body" aria-label="Comment on {{.Path}}" rows="3" placeholder="Comment on this line" autofocus></textarea></p>
165 <p><button type="submit">Comment</button> 165 <p><button type="submit" class="btn">Comment</button>
166 <button type="submit" name="pending" value="on">Add to review</button> 166 <button type="submit" name="pending" value="on" class="btn">Add to review</button>
167 <a href="{{$base}}?view=diff">Cancel</a></p> 167 <a href="{{$base}}?view=diff">Cancel</a></p>
168 </form></td></tr> 168 </form></td></tr>
169 {{end}}{{end}}{{range .Threads}}<tr class="threadrow"><td colspan="3">{{template "thread" dict "T" . "Base" $base "Viewer" $viewer "Class" ""}}</td></tr> 169 {{end}}{{end}}{{range .Threads}}<tr class="threadrow"><td colspan="3">{{template "thread" dict "T" . "Base" $base "Viewer" $viewer "Class" ""}}</td></tr>
@@ -186,7 +186,7 @@
186<form method="post" action="{{.Base}}/diff-comment"> 186<form method="post" action="{{.Base}}/diff-comment">
187 <input type="hidden" name="reply" value="{{$t.ID}}"> 187 <input type="hidden" name="reply" value="{{$t.ID}}">
188 <p><textarea name="body" aria-label="Reply to thread {{$t.ID}}" rows="2" placeholder="Reply as {{.Viewer}}"></textarea></p> 188 <p><textarea name="body" aria-label="Reply to thread {{$t.ID}}" rows="2" placeholder="Reply as {{.Viewer}}"></textarea></p>
189 <p><button type="submit">Reply</button></p> 189 <p><button type="submit" class="btn">Reply</button></p>
190</form></details>{{end}} 190</form></details>{{end}}
191{{if $t.CanResolve}}<form method="post" action="{{.Base}}/thread" class="threadact"><input type="hidden" name="thread" value="{{$t.ID}}"><button type="submit" name="action" value="{{if $t.Resolved}}unresolve{{else}}resolve{{end}}" class="linklike">{{if $t.Resolved}}Reopen thread{{else}}Resolve thread{{end}}</button></form>{{end}} 191{{if $t.CanResolve}}<form method="post" action="{{.Base}}/thread" class="threadact"><input type="hidden" name="thread" value="{{$t.ID}}"><button type="submit" name="action" value="{{if $t.Resolved}}unresolve{{else}}resolve{{end}}" class="linklike">{{if $t.Resolved}}Reopen thread{{else}}Resolve thread{{end}}</button></form>{{end}}
192</div>{{end}} 192</div>{{end}}
internal/web/templates/log.html +1 −1
@@ -4,7 +4,7 @@
4<form method="get" class="logfilter" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log/{{.Ref}}"> 4<form method="get" class="logfilter" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log/{{.Ref}}">
5 <label for="path">Path</label> 5 <label for="path">Path</label>
6 <input type="text" id="path" name="path" value="{{.FilePath}}" placeholder="filter to a file or directory"> 6 <input type="text" id="path" name="path" value="{{.FilePath}}" placeholder="filter to a file or directory">
7 <button type="submit">Filter</button> 7 <button type="submit" class="btn">Filter</button>
8 {{if .FilePath}}<a class="act" href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log/{{.Ref}}">Clear</a>{{end}} 8 {{if .FilePath}}<a class="act" href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log/{{.Ref}}">Clear</a>{{end}}
9</form> 9</form>
10{{if .FilePath}}<p class="meta">history of <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/blob/{{.Ref}}/{{.FilePath}}"><code>{{.FilePath}}</code></a></p>{{end}} 10{{if .FilePath}}<p class="meta">history of <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/blob/{{.Ref}}/{{.FilePath}}"><code>{{.FilePath}}</code></a></p>{{end}}
internal/web/templates/login.html +1 −1
@@ -1,6 +1,6 @@
1{{define "title"}}login · {{.Site}}{{end}} 1{{define "title"}}login · {{.Site}}{{end}}
2{{define "content"}} 2{{define "content"}}
3<h1>Log in</h1> 3<h1>Sign in</h1>
4{{if .Next}}<p class="meta">Log in to continue to <code>{{.Next}}</code>.</p>{{end}} 4{{if .Next}}<p class="meta">Log in to continue to <code>{{.Next}}</code>.</p>{{end}}
5{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}} 5{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
6{{if .Sent}} 6{{if .Sent}}
internal/web/templates/mr.html +9 −9
@@ -34,7 +34,7 @@
34<form method="post" action="{{$base}}/edit" class="commentform"> 34<form method="post" action="{{$base}}/edit" class="commentform">
35<p><input type="text" name="title" aria-label="Title" value="{{.MR.Title}}" required></p> 35<p><input type="text" name="title" aria-label="Title" value="{{.MR.Title}}" required></p>
36<p><textarea name="body" aria-label="Description" rows="8">{{.MR.Body}}</textarea></p> 36<p><textarea name="body" aria-label="Description" rows="8">{{.MR.Body}}</textarea></p>
37<p><button type="submit">Save</button></p> 37<p><button type="submit" class="btn">Save</button></p>
38</form></details>{{end}} 38</form></details>{{end}}
39{{if .BodyHTML}}<article class="comment"> 39{{if .BodyHTML}}<article class="comment">
40 <header class="commenthead"><strong><a href="/{{.MR.Author}}">{{.MR.Author}}</a></strong></header> 40 <header class="commenthead"><strong><a href="/{{.MR.Author}}">{{.MR.Author}}</a></strong></header>
@@ -52,7 +52,7 @@
52{{if .Viewer}} 52{{if .Viewer}}
53<form method="post" action="{{$base}}/comment" class="commentform"> 53<form method="post" action="{{$base}}/comment" class="commentform">
54<p><textarea name="body" aria-label="Comment" rows="4" placeholder="Comment as {{.Viewer}}"></textarea></p> 54<p><textarea name="body" aria-label="Comment" rows="4" placeholder="Comment as {{.Viewer}}"></textarea></p>
55<p><button type="submit">Comment</button></p> 55<p><button type="submit" class="btn">Comment</button></p>
56</form> 56</form>
57{{end}} 57{{end}}
58 58
@@ -86,8 +86,8 @@
86 <div class="grp"> 86 <div class="grp">
87 <h2>Review</h2> 87 <h2>Review</h2>
88 <form method="post" action="{{$base}}/review" class="actions"> 88 <form method="post" action="{{$base}}/review" class="actions">
89 <button type="submit" name="verdict" value="approve">Approve</button> 89 <button type="submit" name="verdict" value="approve" class="btn">Approve</button>
90 <button type="submit" name="verdict" value="request-changes">Request changes</button> 90 <button type="submit" name="verdict" value="request-changes" class="btn">Request changes</button>
91 </form> 91 </form>
92 </div> 92 </div>
93 <div class="grp"> 93 <div class="grp">
@@ -105,11 +105,11 @@
105 <button type="submit">Merge</button> 105 <button type="submit">Merge</button>
106 </form> 106 </form>
107 <form method="post" action="{{$base}}/close" class="actions"> 107 <form method="post" action="{{$base}}/close" class="actions">
108 <button type="submit">Close without merging</button> 108 <button type="submit" class="danger">Close without merging</button>
109 </form> 109 </form>
110 <form method="post" action="{{$base}}/draft" class="actions"> 110 <form method="post" action="{{$base}}/draft" class="actions">
111 {{if .MR.Draft}}<button type="submit">Ready for review</button> 111 {{if .MR.Draft}}<button type="submit" class="btn">Ready for review</button>
112 {{else}}<input type="hidden" name="draft" value="on"><button type="submit">Convert to draft</button>{{end}} 112 {{else}}<input type="hidden" name="draft" value="on"><button type="submit" class="btn">Convert to draft</button>{{end}}
113 </form> 113 </form>
114 </div> 114 </div>
115 {{end}} 115 {{end}}
@@ -121,7 +121,7 @@
121 <select id="target" name="target"> 121 <select id="target" name="target">
122 {{range .Branches}}<option value="{{.Name}}"{{if eq .Name $.MR.TargetRef}} selected{{end}}>{{.Name}}</option>{{end}} 122 {{range .Branches}}<option value="{{.Name}}"{{if eq .Name $.MR.TargetRef}} selected{{end}}>{{.Name}}</option>{{end}}
123 </select> 123 </select>
124 <button type="submit">Retarget</button> 124 <button type="submit" class="btn">Retarget</button>
125 </form> 125 </form>
126 <p class="row none">Retargeting stales existing reviews.</p> 126 <p class="row none">Retargeting stales existing reviews.</p>
127 </div> 127 </div>
@@ -134,7 +134,7 @@
134 <form method="post" action="{{$base}}/review-request" class="actions"> 134 <form method="post" action="{{$base}}/review-request" class="actions">
135 <input type="text" name="add" aria-label="Add reviewers" placeholder="add, space-separated"> 135 <input type="text" name="add" aria-label="Add reviewers" placeholder="add, space-separated">
136 <input type="text" name="remove" aria-label="Remove reviewers" placeholder="remove"> 136 <input type="text" name="remove" aria-label="Remove reviewers" placeholder="remove">
137 <button type="submit">Apply</button> 137 <button type="submit" class="btn">Apply</button>
138 </form> 138 </form>
139 {{end}} 139 {{end}}
140 </div> 140 </div>
internal/web/templates/mrs.html +1
@@ -10,6 +10,7 @@
10 </nav> 10 </nav>
11 <form method="get" class="searchform compact"> 11 <form method="get" class="searchform compact">
12 <input type="search" name="q" aria-label="Search merge requests" value="{{.Query}}" placeholder="search title and body"> 12 <input type="search" name="q" aria-label="Search merge requests" value="{{.Query}}" placeholder="search title and body">
13 <button type="submit" class="btn">Search</button>
13 <input type="hidden" name="state" value="{{.State}}"> 14 <input type="hidden" name="state" value="{{.State}}">
14 </form> 15 </form>
15 {{range .Filters}}<p class="meta">{{.Key}}: <b>{{.Value}}</b> <a href="{{.Clear}}">clear</a></p>{{end}} 16 {{range .Filters}}<p class="meta">{{.Key}}: <b>{{.Value}}</b> <a href="{{.Clear}}">clear</a></p>{{end}}
internal/web/templates/owner.html +12 −12
@@ -34,7 +34,7 @@
34 <input type="hidden" name="field" value="org-create"> 34 <input type="hidden" name="field" value="org-create">
35 <label for="orgname">Name</label> 35 <label for="orgname">Name</label>
36 <input type="text" id="orgname" name="name" required> 36 <input type="text" id="orgname" name="name" required>
37 <button type="submit">Create</button> 37 <button type="submit" class="btn">Create</button>
38 </form> 38 </form>
39</details> 39</details>
40{{end}} 40{{end}}
@@ -52,7 +52,7 @@
52{{end}}</table></div> 52{{end}}</table></div>
53<details class="editbox"> 53<details class="editbox">
54 <summary>Add or change a member</summary> 54 <summary>Add or change a member</summary>
55 <form method="post" action="/{{$org}}" class="setform"> 55 <form method="post" action="/{{$org}}" class="setform stack">
56 <input type="hidden" name="field" value="member-add"> 56 <input type="hidden" name="field" value="member-add">
57 <label for="member">Username</label> 57 <label for="member">Username</label>
58 <input type="text" id="member" name="user" required> 58 <input type="text" id="member" name="user" required>
@@ -61,7 +61,7 @@
61 <option value="member">member</option> 61 <option value="member">member</option>
62 <option value="admin">admin</option> 62 <option value="admin">admin</option>
63 </select> 63 </select>
64 <button type="submit">Apply</button> 64 <button type="submit" class="btn">Apply</button>
65 </form> 65 </form>
66</details> 66</details>
67 67
@@ -75,14 +75,14 @@ of a team get its role on every repository it is granted.</p>
75 <div class="teambody"> 75 <div class="teambody">
76 <p class="meta">members: {{range .Members}}<a class="memberchip" href="/{{.}}">{{.}}</a> {{else}}<span class="none">none yet</span>{{end}}</p> 76 <p class="meta">members: {{range .Members}}<a class="memberchip" href="/{{.}}">{{.}}</a> {{else}}<span class="none">none yet</span>{{end}}</p>
77 <p class="meta">repositories: {{range .Grants}}<span class="memberchip"><a href="/{{.RepoPath}}">{{.RepoPath}}</a> <span class="role">{{.Role}}</span></span> {{else}}<span class="none">none yet</span>{{end}}</p> 77 <p class="meta">repositories: {{range .Grants}}<span class="memberchip"><a href="/{{.RepoPath}}">{{.RepoPath}}</a> <span class="role">{{.Role}}</span></span> {{else}}<span class="none">none yet</span>{{end}}</p>
78 <form method="post" action="/{{$org}}" class="setform"> 78 <form method="post" action="/{{$org}}" class="setform stack">
79 <input type="hidden" name="team" value="{{.Name}}"> 79 <input type="hidden" name="team" value="{{.Name}}">
80 <label class="none" for="tm-{{.Name}}">Members</label> 80 <label class="none" for="tm-{{.Name}}">Members</label>
81 <input type="text" id="tm-{{.Name}}" name="user" placeholder="usernames, space-separated"> 81 <input type="text" id="tm-{{.Name}}" name="user" placeholder="usernames, space-separated">
82 <button type="submit" name="field" value="team-add">Add</button> 82 <button type="submit" name="field" value="team-add" class="btn">Add</button>
83 <button type="submit" name="field" value="team-remove">Remove</button> 83 <button type="submit" name="field" value="team-remove" class="btn">Remove</button>
84 </form> 84 </form>
85 <form method="post" action="/{{$org}}" class="setform"> 85 <form method="post" action="/{{$org}}" class="setform stack">
86 <input type="hidden" name="team" value="{{.Name}}"> 86 <input type="hidden" name="team" value="{{.Name}}">
87 <label class="none" for="tr-{{.Name}}">Repository</label> 87 <label class="none" for="tr-{{.Name}}">Repository</label>
88 <input type="text" id="tr-{{.Name}}" name="repo" placeholder="owner/name"> 88 <input type="text" id="tr-{{.Name}}" name="repo" placeholder="owner/name">
@@ -92,14 +92,14 @@ of a team get its role on every repository it is granted.</p>
92 <option value="write">write</option> 92 <option value="write">write</option>
93 <option value="admin">admin</option> 93 <option value="admin">admin</option>
94 </select> 94 </select>
95 <button type="submit" name="field" value="team-grant">Grant</button> 95 <button type="submit" name="field" value="team-grant" class="btn">Grant</button>
96 <button type="submit" name="field" value="team-revoke">Revoke</button> 96 <button type="submit" name="field" value="team-revoke" class="btn">Revoke</button>
97 </form> 97 </form>
98 <form method="post" action="/{{$org}}" class="setform"> 98 <form method="post" action="/{{$org}}" class="setform">
99 <input type="hidden" name="field" value="team-delete"> 99 <input type="hidden" name="field" value="team-delete">
100 <input type="hidden" name="team" value="{{.Name}}"> 100 <input type="hidden" name="team" value="{{.Name}}">
101 {{template "confirmfield" .Name}} 101 {{template "confirmfield" .Name}}
102 <button type="submit" class="linklike">Delete this team</button> 102 <button type="submit" class="danger">Delete this team</button>
103 </form> 103 </form>
104 </div> 104 </div>
105</details> 105</details>
@@ -110,7 +110,7 @@ of a team get its role on every repository it is granted.</p>
110 <input type="hidden" name="field" value="team-create"> 110 <input type="hidden" name="field" value="team-create">
111 <label for="newteam">Name</label> 111 <label for="newteam">Name</label>
112 <input type="text" id="newteam" name="team" required> 112 <input type="text" id="newteam" name="team" required>
113 <button type="submit">Create</button> 113 <button type="submit" class="btn">Create</button>
114 </form> 114 </form>
115</details> 115</details>
116 116
@@ -121,7 +121,7 @@ of a team get its role on every repository it is granted.</p>
121 <input type="hidden" name="field" value="org-rename"> 121 <input type="hidden" name="field" value="org-rename">
122 <label for="orgrename">New name</label> 122 <label for="orgrename">New name</label>
123 <input type="text" id="orgrename" name="name" value="{{$org}}" required> 123 <input type="text" id="orgrename" name="name" value="{{$org}}" required>
124 <button type="submit">Rename</button> 124 <button type="submit" class="btn">Rename</button>
125 </form> 125 </form>
126 <p class="meta">Every clone URL under this organization changes with the name.</p> 126 <p class="meta">Every clone URL under this organization changes with the name.</p>
127</details> 127</details>
internal/web/templates/register.html +2 −2
@@ -6,9 +6,9 @@
6{{else}}<p class="lede">Open registration — your account activates once you verify your email.</p>{{end}} 6{{else}}<p class="lede">Open registration — your account activates once you verify your email.</p>{{end}}
7{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}} 7{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
8<form method="post" action="/register" class="signupform"> 8<form method="post" action="/register" class="signupform">
9<p><label>username<br><input type="text" name="username" value="{{.Username}}" required autofocus></label></p> 9<p><label>Username<br><input type="text" name="username" value="{{.Username}}" required autofocus></label></p>
10{{if eq .Mode "invite"}}<p><label>invite code<br><input type="text" name="invite" required></label></p> 10{{if eq .Mode "invite"}}<p><label>invite code<br><input type="text" name="invite" required></label></p>
11{{else}}<p><label>email<br><input type="text" name="email" required></label></p>{{end}} 11{{else}}<p><label>Email<br><input type="text" name="email" required></label></p>{{end}}
12<p><label>SSH public key — your key is your identity; paste e.g. <code>~/.ssh/id_ed25519.pub</code><br> 12<p><label>SSH public key — your key is your identity; paste e.g. <code>~/.ssh/id_ed25519.pub</code><br>
13<textarea name="key" aria-label="SSH public key" rows="3" required placeholder="ssh-ed25519 AAAA... you@host"></textarea></label></p> 13<textarea name="key" aria-label="SSH public key" rows="3" required placeholder="ssh-ed25519 AAAA... you@host"></textarea></label></p>
14<p><button type="submit">Create account</button></p> 14<p><button type="submit">Create account</button></p>
internal/web/templates/releases.html +3 −3
@@ -13,7 +13,7 @@
13 </p> 13 </p>
14 <p><input type="text" name="title" aria-label="Title" placeholder="title (defaults to the tag)"></p> 14 <p><input type="text" name="title" aria-label="Title" placeholder="title (defaults to the tag)"></p>
15 <p><textarea name="notes" aria-label="Notes" rows="6" placeholder="what changed"></textarea></p> 15 <p><textarea name="notes" aria-label="Notes" rows="6" placeholder="what changed"></textarea></p>
16 <p><button type="submit">Create release</button></p> 16 <p><button type="submit" class="btn">Create release</button></p>
17</form> 17</form>
18</details> 18</details>
19{{else if .CanWrite}}<p class="meta">Push a tag to create a release from it.</p>{{end}} 19{{else if .CanWrite}}<p class="meta">Push a tag to create a release from it.</p>{{end}}
@@ -30,13 +30,13 @@
30 <input type="hidden" name="tag" value="{{$rel.Tag}}"> 30 <input type="hidden" name="tag" value="{{$rel.Tag}}">
31 <p><input type="text" name="title" aria-label="Title" value="{{$rel.Title}}"></p> 31 <p><input type="text" name="title" aria-label="Title" value="{{$rel.Title}}"></p>
32 <p><textarea name="notes" aria-label="Notes" rows="6">{{$rel.Notes}}</textarea></p> 32 <p><textarea name="notes" aria-label="Notes" rows="6">{{$rel.Notes}}</textarea></p>
33 <p><button type="submit">Save</button></p> 33 <p><button type="submit" class="btn">Save</button></p>
34 </form> 34 </form>
35 {{if $.CanAdmin}}<form method="post" action="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/releases" class="commentform"> 35 {{if $.CanAdmin}}<form method="post" action="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/releases" class="commentform">
36 <input type="hidden" name="action" value="delete"> 36 <input type="hidden" name="action" value="delete">
37 <input type="hidden" name="tag" value="{{$rel.Tag}}"> 37 <input type="hidden" name="tag" value="{{$rel.Tag}}">
38 <p class="meta">Deleting is permanent and takes the assets with it. The tag stays.</p> 38 <p class="meta">Deleting is permanent and takes the assets with it. The tag stays.</p>
39 <p>{{template "confirmfield" $rel.Tag}} <button type="submit" class="linklike">Delete release</button></p> 39 <p>{{template "confirmfield" $rel.Tag}} <button type="submit" class="danger">Delete release</button></p>
40 </form>{{end}}</details>{{end}} 40 </form>{{end}}</details>{{end}}
41 {{if $rel.Assets}}<table class="assets"> 41 {{if $rel.Assets}}<table class="assets">
42 <thead><tr><th scope="col">File</th><th scope="col">Bytes</th><th scope="col">SHA-256</th></tr></thead> 42 <thead><tr><th scope="col">File</th><th scope="col">Bytes</th><th scope="col">SHA-256</th></tr></thead>
internal/web/templates/search.html +1 −1
@@ -3,7 +3,7 @@
3<h1>Search</h1> 3<h1>Search</h1>
4<form method="get" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/search" class="searchform"> 4<form method="get" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/search" class="searchform">
5 <input type="search" name="q" aria-label="Search file contents" value="{{.Query}}" placeholder="search file contents on {{.Ref}}" autofocus> 5 <input type="search" name="q" aria-label="Search file contents" value="{{.Query}}" placeholder="search file contents on {{.Ref}}" autofocus>
6 <button type="submit">search</button> 6 <button type="submit" class="btn">Search</button>
7</form> 7</form>
8{{if .QueryErr}}<p class="error" role="alert">{{.QueryErr}}</p> 8{{if .QueryErr}}<p class="error" role="alert">{{.QueryErr}}</p>
9{{else if .Query}} 9{{else if .Query}}
internal/web/templates/settings.html +18 −18
@@ -9,13 +9,13 @@
9 <input type="hidden" name="field" value="description"> 9 <input type="hidden" name="field" value="description">
10 <label for="description">Description</label> 10 <label for="description">Description</label>
11 <input type="text" id="description" name="description" value="{{.Desc}}" placeholder="one line, shown in listings"> 11 <input type="text" id="description" name="description" value="{{.Desc}}" placeholder="one line, shown in listings">
12 <button type="submit">Save description</button> 12 <button type="submit" class="btn">Save description</button>
13</form> 13</form>
14<form method="post" action="{{$base}}" class="setform"> 14<form method="post" action="{{$base}}" class="setform">
15 <input type="hidden" name="field" value="website"> 15 <input type="hidden" name="field" value="website">
16 <label for="website">Website</label> 16 <label for="website">Website</label>
17 <input type="text" id="website" name="website" value="{{.Repo.Settings.Website}}" placeholder="https://example.org"> 17 <input type="text" id="website" name="website" value="{{.Repo.Settings.Website}}" placeholder="https://example.org">
18 <button type="submit">Save website</button> 18 <button type="submit" class="btn">Save website</button>
19</form> 19</form>
20{{if .Branches}}<form method="post" action="{{$base}}" class="setform"> 20{{if .Branches}}<form method="post" action="{{$base}}" class="setform">
21 <input type="hidden" name="field" value="default-branch"> 21 <input type="hidden" name="field" value="default-branch">
@@ -23,15 +23,15 @@
23 <select id="default-branch" name="default-branch"> 23 <select id="default-branch" name="default-branch">
24 {{$cur := .Repo.DefaultBranch}}{{range .Branches}}<option value="{{.Name}}"{{if eq .Name $cur}} selected{{end}}>{{.Name}}</option>{{end}} 24 {{$cur := .Repo.DefaultBranch}}{{range .Branches}}<option value="{{.Name}}"{{if eq .Name $cur}} selected{{end}}>{{.Name}}</option>{{end}}
25 </select> 25 </select>
26 <button type="submit">Save default branch</button> 26 <button type="submit" class="btn">Save default branch</button>
27</form> 27</form>
28{{end}}<form method="post" action="{{$base}}" class="setform"> 28{{end}}<form method="post" action="{{$base}}" class="setform stack">
29 <input type="hidden" name="field" value="topics"> 29 <input type="hidden" name="field" value="topics">
30 <label for="topics-add">Topics to add</label> 30 <label for="topics-add">Topics to add</label>
31 <input type="text" id="topics-add" name="add" placeholder="add, space-separated"> 31 <input type="text" id="topics-add" name="add" placeholder="add, space-separated">
32 <label for="topics-remove">Topics to remove</label> 32 <label for="topics-remove">Topics to remove</label>
33 <input type="text" id="topics-remove" name="remove" placeholder="remove"> 33 <input type="text" id="topics-remove" name="remove" placeholder="remove">
34 <button type="submit">Apply</button> 34 <button type="submit" class="btn">Apply</button>
35</form> 35</form>
36{{if .Topics}}<p class="meta">{{range .Topics}}<span class="chip topic">{{.}}</span> {{end}}</p>{{end}} 36{{if .Topics}}<p class="meta">{{range .Topics}}<span class="chip topic">{{.}}</span> {{end}}</p>{{end}}
37 37
@@ -43,13 +43,13 @@
43 <option value="public"{{if eq .Repo.Visibility "public"}} selected{{end}}>Public</option> 43 <option value="public"{{if eq .Repo.Visibility "public"}} selected{{end}}>Public</option>
44 <option value="private"{{if eq .Repo.Visibility "private"}} selected{{end}}>Private</option> 44 <option value="private"{{if eq .Repo.Visibility "private"}} selected{{end}}>Private</option>
45 </select> 45 </select>
46 <button type="submit">Save visibility</button> 46 <button type="submit" class="btn">Save visibility</button>
47</form> 47</form>
48<form method="post" action="{{$base}}" class="setform"> 48<form method="post" action="{{$base}}" class="setform">
49 <input type="hidden" name="field" value="git-daemon"> 49 <input type="hidden" name="field" value="git-daemon">
50 <label for="git-daemon">Serve over git://</label> 50 <label for="git-daemon">Serve over git://</label>
51 <input type="checkbox" id="git-daemon" name="git-daemon" value="on"{{if .Repo.Settings.GitDaemon}} checked{{end}}> 51 <input type="checkbox" id="git-daemon" name="git-daemon" value="on"{{if .Repo.Settings.GitDaemon}} checked{{end}}>
52 <button type="submit">Save git://</button> 52 <button type="submit" class="btn">Save git://</button>
53</form> 53</form>
54 54
55<h2>Merge gates</h2> 55<h2>Merge gates</h2>
@@ -58,31 +58,31 @@
58 <input type="hidden" name="field" value="require-checks"> 58 <input type="hidden" name="field" value="require-checks">
59 <label for="require-checks">Require green checks</label> 59 <label for="require-checks">Require green checks</label>
60 <input type="checkbox" id="require-checks" name="require-checks" value="on"{{if .Repo.Settings.RequireChecks}} checked{{end}}> 60 <input type="checkbox" id="require-checks" name="require-checks" value="on"{{if .Repo.Settings.RequireChecks}} checked{{end}}>
61 <button type="submit">Save checks</button> 61 <button type="submit" class="btn">Save checks</button>
62</form> 62</form>
63<form method="post" action="{{$base}}" class="setform"> 63<form method="post" action="{{$base}}" class="setform">
64 <input type="hidden" name="field" value="require-approvals"> 64 <input type="hidden" name="field" value="require-approvals">
65 <label for="approvals">Required approvals</label> 65 <label for="approvals">Required approvals</label>
66 <input type="number" id="approvals" name="approvals" min="0" max="10" value="{{.Repo.Settings.RequireApprovals}}"> 66 <input type="number" id="approvals" name="approvals" min="0" max="10" value="{{.Repo.Settings.RequireApprovals}}">
67 <button type="submit">Save approvals</button> 67 <button type="submit" class="btn">Save approvals</button>
68</form> 68</form>
69<form method="post" action="{{$base}}" class="setform"> 69<form method="post" action="{{$base}}" class="setform">
70 <input type="hidden" name="field" value="require-resolved"> 70 <input type="hidden" name="field" value="require-resolved">
71 <label for="require-resolved">Require resolved threads</label> 71 <label for="require-resolved">Require resolved threads</label>
72 <input type="checkbox" id="require-resolved" name="require-resolved" value="on"{{if .Repo.Settings.RequireResolved}} checked{{end}}> 72 <input type="checkbox" id="require-resolved" name="require-resolved" value="on"{{if .Repo.Settings.RequireResolved}} checked{{end}}>
73 <button type="submit">Save threads</button> 73 <button type="submit" class="btn">Save threads</button>
74</form> 74</form>
75<form method="post" action="{{$base}}" class="setform"> 75<form method="post" action="{{$base}}" class="setform">
76 <input type="hidden" name="field" value="require-codeowners"> 76 <input type="hidden" name="field" value="require-codeowners">
77 <label for="require-codeowners">Require CODEOWNERS approval</label> 77 <label for="require-codeowners">Require CODEOWNERS approval</label>
78 <input type="checkbox" id="require-codeowners" name="require-codeowners" value="on"{{if .Repo.Settings.RequireCodeowners}} checked{{end}}> 78 <input type="checkbox" id="require-codeowners" name="require-codeowners" value="on"{{if .Repo.Settings.RequireCodeowners}} checked{{end}}>
79 <button type="submit">Save CODEOWNERS</button> 79 <button type="submit" class="btn">Save CODEOWNERS</button>
80</form> 80</form>
81<form method="post" action="{{$base}}" class="setform"> 81<form method="post" action="{{$base}}" class="setform">
82 <input type="hidden" name="field" value="require-signed"> 82 <input type="hidden" name="field" value="require-signed">
83 <label for="require-signed">Require signed commits</label> 83 <label for="require-signed">Require signed commits</label>
84 <input type="checkbox" id="require-signed" name="require-signed" value="on"{{if .Repo.Settings.RequireSignedCommits}} checked{{end}}> 84 <input type="checkbox" id="require-signed" name="require-signed" value="on"{{if .Repo.Settings.RequireSignedCommits}} checked{{end}}>
85 <button type="submit">Save signing</button> 85 <button type="submit" class="btn">Save signing</button>
86</form> 86</form>
87 87
88<h2>Protected branches</h2> 88<h2>Protected branches</h2>
@@ -103,13 +103,13 @@
103 <select id="branch" name="branch"> 103 <select id="branch" name="branch">
104 {{range .Branches}}<option value="{{.Name}}">{{.Name}}</option>{{end}} 104 {{range .Branches}}<option value="{{.Name}}">{{.Name}}</option>{{end}}
105 </select> 105 </select>
106 <button type="submit">Protect</button> 106 <button type="submit" class="btn">Protect</button>
107</form> 107</form>
108<form method="post" action="{{$base}}" class="setform"> 108<form method="post" action="{{$base}}" class="setform">
109 <input type="hidden" name="field" value="require-mr"> 109 <input type="hidden" name="field" value="require-mr">
110 <label for="require-mr">Merge requests only</label> 110 <label for="require-mr">Merge requests only</label>
111 <input type="checkbox" id="require-mr" name="require-mr" value="on"{{if .Repo.Settings.RequireMR}} checked{{end}}> 111 <input type="checkbox" id="require-mr" name="require-mr" value="on"{{if .Repo.Settings.RequireMR}} checked{{end}}>
112 <button type="submit">Save merge-only</button> 112 <button type="submit" class="btn">Save merge-only</button>
113</form> 113</form>
114<p class="meta">With merge requests only, a protected branch refuses every direct push once it exists; the merge gates above are then what a change has to pass.</p> 114<p class="meta">With merge requests only, a protected branch refuses every direct push once it exists; the merge gates above are then what a change has to pass.</p>
115 115
@@ -129,7 +129,7 @@
129 <input type="hidden" name="field" value="protect-tag"> 129 <input type="hidden" name="field" value="protect-tag">
130 <label for="glob">Protect tags matching</label> 130 <label for="glob">Protect tags matching</label>
131 <input type="text" id="glob" name="glob" placeholder="v*"> 131 <input type="text" id="glob" name="glob" placeholder="v*">
132 <button type="submit">Protect</button> 132 <button type="submit" class="btn">Protect</button>
133</form> 133</form>
134 134
135<h2>Dependencies</h2> 135<h2>Dependencies</h2>
@@ -137,7 +137,7 @@
137 <input type="hidden" name="field" value="deps"> 137 <input type="hidden" name="field" value="deps">
138 <label for="deps">Check for updates</label> 138 <label for="deps">Check for updates</label>
139 <input type="checkbox" id="deps" name="deps" value="on"{{if .DepsEnabled}} checked{{end}}> 139 <input type="checkbox" id="deps" name="deps" value="on"{{if .DepsEnabled}} checked{{end}}>
140 <button type="submit">Save dependency checks</button> 140 <button type="submit" class="btn">Save dependency checks</button>
141</form> 141</form>
142<p class="meta">Compares the manifests on <code>{{.Repo.DefaultBranch}}</code> against 142<p class="meta">Compares the manifests on <code>{{.Repo.DefaultBranch}}</code> against
143proxy.golang.org, npm, crates.io, and PyPI once a day, and tracks what is behind 143proxy.golang.org, npm, crates.io, and PyPI once a day, and tracks what is behind
@@ -173,7 +173,7 @@ depends on.</p>
173 <input type="hidden" name="field" value="runner-add"> 173 <input type="hidden" name="field" value="runner-add">
174 <label for="runner-key">Attach a runner</label> 174 <label for="runner-key">Attach a runner</label>
175 <textarea id="runner-key" name="key" rows="3" placeholder="ssh-ed25519 AAAA… (from gitbay-runner init)"></textarea> 175 <textarea id="runner-key" name="key" rows="3" placeholder="ssh-ed25519 AAAA… (from gitbay-runner init)"></textarea>
176 <button type="submit">Attach</button> 176 <button type="submit" class="btn">Attach</button>
177</form> 177</form>
178<p class="meta">Install <code>gitbay-runner</code>, run <code>gitbay-runner init</code>, and paste the key it prints. The runner builds your commits with the repository's secrets; merge requests from forks wait unless it runs with <code>-untrusted</code>.</p> 178<p class="meta">Install <code>gitbay-runner</code>, run <code>gitbay-runner init</code>, and paste the key it prints. The runner builds your commits with the repository's secrets; merge requests from forks wait unless it runs with <code>-untrusted</code>.</p>
179 179
@@ -182,7 +182,7 @@ depends on.</p>
182 <input type="hidden" name="field" value="archive"> 182 <input type="hidden" name="field" value="archive">
183 <label for="archive">Archived (read-only)</label> 183 <label for="archive">Archived (read-only)</label>
184 <input type="checkbox" id="archive" name="archive" value="on"{{if .Repo.Settings.Archived}} checked{{end}}> 184 <input type="checkbox" id="archive" name="archive" value="on"{{if .Repo.Settings.Archived}} checked{{end}}>
185 <button type="submit">Save archive</button> 185 <button type="submit" class="btn">Save archive</button>
186</form> 186</form>
187<p class="meta">Deleting or transferring a repository is a CLI operation: 187<p class="meta">Deleting or transferring a repository is a CLI operation:
188<code>gitbay repo delete {{.Repo.OwnerName}}/{{.Repo.Name}} --yes</code></p> 188<code>gitbay repo delete {{.Repo.OwnerName}}/{{.Repo.Name}} --yes</code></p>
internal/web/templates/snippet.html +5 −5
@@ -17,11 +17,11 @@
17<form method="post" action="/{{$.Owner}}/-/snippets/{{$.Snippet.PublicID}}/file" class="commentform"> 17<form method="post" action="/{{$.Owner}}/-/snippets/{{$.Snippet.PublicID}}/file" class="commentform">
18<input type="hidden" name="name" value="{{.Name}}"> 18<input type="hidden" name="name" value="{{.Name}}">
19<p><textarea name="content" aria-label="Content of {{.Name}}" rows="12">{{.Content}}</textarea></p> 19<p><textarea name="content" aria-label="Content of {{.Name}}" rows="12">{{.Content}}</textarea></p>
20<p><button type="submit">Save</button></p> 20<p><button type="submit" class="btn">Save</button></p>
21</form> 21</form>
22<form method="post" action="/{{$.Owner}}/-/snippets/{{$.Snippet.PublicID}}/file/remove"> 22<form method="post" action="/{{$.Owner}}/-/snippets/{{$.Snippet.PublicID}}/file/remove">
23<input type="hidden" name="name" value="{{.Name}}"> 23<input type="hidden" name="name" value="{{.Name}}">
24<p>{{template "confirmfield" .Name}} <button type="submit">Remove {{.Name}}</button></p> 24<p>{{template "confirmfield" .Name}} <button type="submit" class="danger">Remove {{.Name}}</button></p>
25</form> 25</form>
26</details>{{end}} 26</details>{{end}}
27</section> 27</section>
@@ -31,7 +31,7 @@
31<form method="post" action="/{{.Owner}}/-/snippets/{{.Snippet.PublicID}}/file" class="commentform"> 31<form method="post" action="/{{.Owner}}/-/snippets/{{.Snippet.PublicID}}/file" class="commentform">
32<p><input type="text" name="name" aria-label="File name" placeholder="filename" required></p> 32<p><input type="text" name="name" aria-label="File name" placeholder="filename" required></p>
33<p><textarea name="content" aria-label="Content" rows="12" required></textarea></p> 33<p><textarea name="content" aria-label="Content" rows="12" required></textarea></p>
34<p><button type="submit">Add file</button></p> 34<p><button type="submit" class="btn">Add file</button></p>
35</form></details> 35</form></details>
36<details class="editbox"><summary>settings</summary> 36<details class="editbox"><summary>settings</summary>
37<form method="post" action="/{{.Owner}}/-/snippets/{{.Snippet.PublicID}}/edit"> 37<form method="post" action="/{{.Owner}}/-/snippets/{{.Snippet.PublicID}}/edit">
@@ -41,10 +41,10 @@
41<option value="unlisted"{{if eq .Snippet.Visibility "unlisted"}} selected{{end}}>unlisted</option> 41<option value="unlisted"{{if eq .Snippet.Visibility "unlisted"}} selected{{end}}>unlisted</option>
42<option value="private"{{if eq .Snippet.Visibility "private"}} selected{{end}}>private</option> 42<option value="private"{{if eq .Snippet.Visibility "private"}} selected{{end}}>private</option>
43</select></p> 43</select></p>
44<p><button type="submit">Save</button></p> 44<p><button type="submit" class="btn">Save</button></p>
45</form> 45</form>
46<form method="post" action="/{{.Owner}}/-/snippets/{{.Snippet.PublicID}}/delete"> 46<form method="post" action="/{{.Owner}}/-/snippets/{{.Snippet.PublicID}}/delete">
47<p>{{template "confirmfield" .Snippet.PublicID}} <button type="submit">Delete snippet</button></p> 47<p>{{template "confirmfield" .Snippet.PublicID}} <button type="submit" class="danger">Delete snippet</button></p>
48</form> 48</form>
49</details> 49</details>
50{{end}} 50{{end}}
internal/web/tokens_test.go added +133
@@ -0,0 +1,133 @@
1package web
2
3import (
4 "math"
5 "regexp"
6 "strconv"
7 "strings"
8 "testing"
9)
10
11// parseTokens returns the --name: value pairs of the light :root block
12// and of the :root block inside the dark media query. A value that is
13// itself a var() is resolved one level deep within the same scheme.
14func parseTokens(css []byte) (light, dark map[string]string) {
15 s := string(css)
16 rootRe := regexp.MustCompile(`(?s):root\s*\{(.*?)\}`)
17 darkIdx := strings.Index(s, "@media (prefers-color-scheme: dark)")
18 if darkIdx < 0 {
19 return nil, nil
20 }
21 parse := func(block string) map[string]string {
22 m := map[string]string{}
23 lineRe := regexp.MustCompile(`--([a-z0-9-]+)\s*:\s*([^;]+);`)
24 for _, mm := range lineRe.FindAllStringSubmatch(block, -1) {
25 v := strings.TrimSpace(mm[2])
26 if i := strings.Index(v, "/*"); i >= 0 {
27 v = strings.TrimSpace(v[:i])
28 }
29 m[mm[1]] = v
30 }
31 for k, v := range m {
32 if strings.HasPrefix(v, "var(--") {
33 ref := strings.TrimSuffix(strings.TrimPrefix(v, "var(--"), ")")
34 if rv, ok := m[ref]; ok {
35 m[k] = rv
36 }
37 }
38 }
39 return m
40 }
41 lm := rootRe.FindStringSubmatch(s[:darkIdx])
42 dm := rootRe.FindStringSubmatch(s[darkIdx:])
43 if lm == nil || dm == nil {
44 return nil, nil
45 }
46 light = parse(lm[1])
47 dark = parse(dm[1])
48 for k, v := range light {
49 if _, ok := dark[k]; !ok && strings.HasPrefix(v, "#") {
50 dark[k] = v // a light-only colour is a bug; keep it visible below
51 }
52 }
53 return light, dark
54}
55
56func hexChannel(h string) float64 {
57 n, _ := strconv.ParseUint(h, 16, 8)
58 c := float64(n) / 255
59 if c <= 0.03928 {
60 return c / 12.92
61 }
62 return math.Pow((c+0.055)/1.055, 2.4)
63}
64
65func luminanceHex(hex string) float64 {
66 hex = strings.TrimPrefix(hex, "#")
67 if len(hex) == 3 {
68 hex = string([]byte{hex[0], hex[0], hex[1], hex[1], hex[2], hex[2]})
69 }
70 return 0.2126*hexChannel(hex[0:2]) + 0.7152*hexChannel(hex[2:4]) + 0.0722*hexChannel(hex[4:6])
71}
72
73func contrastHex(a, b string) float64 {
74 la, lb := luminanceHex(a), luminanceHex(b)
75 if la < lb {
76 la, lb = lb, la
77 }
78 return (la + 0.05) / (lb + 0.05)
79}
80
81// TestTokenContrast is the contract for the colour tokens in style.css:
82// every text colour clears WCAG AA on every ground it lands on, in both
83// schemes. The hex values in the stylesheet are free to move as long as
84// this passes.
85func TestTokenContrast(t *testing.T) {
86 light, dark := parseTokens(StyleCSS)
87 if light == nil || dark == nil {
88 t.Fatal("could not find the light and dark :root blocks in style.css")
89 }
90 type check struct {
91 fg, bg string
92 floor float64
93 }
94 checks := []check{
95 {"fg", "canvas", 7}, {"fg", "surface", 7},
96 {"muted", "canvas", 4.5}, {"muted", "surface", 4.5},
97 {"link", "canvas", 4.5}, {"link", "surface", 4.5}, {"link", "hover", 4.5},
98 {"fill-fg", "fill", 4.5}, {"fill", "surface", 3},
99 {"line", "canvas", 3},
100 {"mark", "canvas", 3},
101 {"warn", "canvas", 4.5}, {"warn", "surface", 4.5},
102 {"ok", "canvas", 4.5}, {"ok", "surface", 4.5},
103 {"bad", "canvas", 4.5}, {"bad", "surface", 4.5},
104 {"done", "canvas", 4.5}, {"done", "surface", 4.5},
105 {"focus", "canvas", 3},
106 {"shell-fg", "shell-bg", 7}, {"shell-muted", "shell-bg", 4.5}, {"shell-mark", "shell-bg", 3},
107 }
108 for name, scheme := range map[string]map[string]string{"light": light, "dark": dark} {
109 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"} {
110 v, ok := scheme[tok]
111 if !ok || !strings.HasPrefix(v, "#") {
112 t.Errorf("%s: --%s is missing or not a hex colour (%q)", name, tok, v)
113 }
114 }
115 if t.Failed() {
116 continue
117 }
118 for _, c := range checks {
119 if got := contrastHex(scheme[c.fg], scheme[c.bg]); got < c.floor {
120 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)
121 }
122 }
123 // The surface ladder must be visible: canvas, surface and inset
124 // are three grounds, not one.
125 lc, ls, _ := luminanceHex(scheme["canvas"]), luminanceHex(scheme["surface"]), luminanceHex(scheme["inset"])
126 if r := (math.Max(lc, ls) + 0.05) / (math.Min(lc, ls) + 0.05); r < 1.15 {
127 t.Errorf("%s: canvas %s and surface %s are %.2f apart, want >= 1.15", name, scheme["canvas"], scheme["surface"], r)
128 }
129 if scheme["inset"] == scheme["surface"] {
130 t.Errorf("%s: inset and surface are the same colour %s", name, scheme["inset"])
131 }
132 }
133}