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