web: landing picture !398
18 files changed, +122 −40
Layout: unified · split
CHANGELOG.org +4 −1
| @@ -26,7 +26,10 @@ title= to =gitbay= in lower case; the templates already are. | ||
| 26 | 26 | - Repository settings are bounded rows with a consequence beside each |
| 27 | 27 | control, a saved flash, a rejected value kept, and topics as one field. |
| 28 | 28 | - Landing copy says what the product is and where to go; the register |
| 29 | form says to paste the contents of the key file and links [[SSH-keys]]. | |
| 29 | form says to paste the contents of the key file and links the SSH keys | |
| 30 | wiki page. | |
| 31 | - The landing page shows the merge request page, light and dark, | |
| 32 | captured from gitbay.org (two embedded PNGs under =/static/img/=). | |
| 30 | 33 | |
| 31 | 34 | * v1.22.1 — 2026-09-13 |
| 32 | 35 | |
e2e/design_test.go +2
| @@ -163,6 +163,8 @@ func TestLandingRoutes(t *testing.T) { | ||
| 163 | 163 | `class="button primary" href="/explore">Explore repositories</a>`, |
| 164 | 164 | `class="button btn" href="/register">Create an account</a>`, |
| 165 | 165 | "web login</code>", |
| 166 | "/static/img/mr-dark.png", | |
| 167 | "<picture>", | |
| 166 | 168 | } { |
| 167 | 169 | if !strings.Contains(body, want) { |
| 168 | 170 | t.Errorf("landing lacks %q", want) |
internal/httpd/fonts_test.go +34
| @@ -1,9 +1,11 @@ | ||
| 1 | 1 | package httpd |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | "io/fs" | |
| 4 | 5 | "net/http" |
| 5 | 6 | "net/http/httptest" |
| 6 | 7 | "regexp" |
| 8 | "strings" | |
| 7 | 9 | "testing" |
| 8 | 10 | |
| 9 | 11 | "gitbay.org/gitbay/internal/config" |
| @@ -43,3 +45,35 @@ func TestStylesheetFontsAreServed(t *testing.T) { | ||
| 43 | 45 | } |
| 44 | 46 | } |
| 45 | 47 | } |
| 48 | ||
| 49 | // TestLandingImagesAreServed: every file under static/img has a route | |
| 50 | // that answers 200 with an image type and the stylesheet's cache policy. | |
| 51 | func TestLandingImagesAreServed(t *testing.T) { | |
| 52 | s := New(config.Default(), nil) | |
| 53 | byPattern := map[string]http.HandlerFunc{} | |
| 54 | for _, r := range s.Routes() { | |
| 55 | if r.Method == "GET" { | |
| 56 | byPattern[r.Pattern] = r.Handler | |
| 57 | } | |
| 58 | } | |
| 59 | entries, err := fs.ReadDir(web.ImageFS, "static/img") | |
| 60 | if err != nil { | |
| 61 | t.Fatal(err) | |
| 62 | } | |
| 63 | if len(entries) == 0 { | |
| 64 | t.Fatal("no images embedded") | |
| 65 | } | |
| 66 | for _, e := range entries { | |
| 67 | u := "/static/img/" + e.Name() | |
| 68 | h, ok := byPattern[u] | |
| 69 | if !ok { | |
| 70 | t.Errorf("%s: no route", u) | |
| 71 | continue | |
| 72 | } | |
| 73 | rec := httptest.NewRecorder() | |
| 74 | h(rec, httptest.NewRequest("GET", u, nil)) | |
| 75 | if rec.Code != http.StatusOK || !strings.HasPrefix(rec.Header().Get("Content-Type"), "image/") { | |
| 76 | t.Errorf("%s: %d %s", e.Name(), rec.Code, rec.Header().Get("Content-Type")) | |
| 77 | } | |
| 78 | } | |
| 79 | } | |
internal/httpd/routes.go +4
| @@ -53,6 +53,10 @@ func (s *Server) Routes() []Route { | ||
| 53 | 53 | for _, f := range fonts { |
| 54 | 54 | routes = append(routes, Route{Method: "GET", Pattern: "/static/fonts/" + f.Name(), Handler: s.font}) |
| 55 | 55 | } |
| 56 | images, _ := fs.ReadDir(web.ImageFS, "static/img") | |
| 57 | for _, f := range images { | |
| 58 | routes = append(routes, Route{Method: "GET", Pattern: "/static/img/" + f.Name(), Handler: s.image}) | |
| 59 | } | |
| 56 | 60 | routes = append(routes, |
| 57 | 61 | Route{Method: "GET", Pattern: "/favicon.svg", Handler: s.favicon}, |
| 58 | 62 | Route{Method: "GET", Pattern: "/{owner}", Handler: s.ownerPage}, |
internal/httpd/settings.go +16 −7
| @@ -82,6 +82,10 @@ func (s *Server) settingsRedirect(w http.ResponseWriter, r *http.Request, msg st | ||
| 82 | 82 | // settingsSubmit routes one form to its command. Keeping the mapping in |
| 83 | 83 | // one place makes what the page can reach obvious. |
| 84 | 84 | func (s *Server) settingsSubmit(w http.ResponseWriter, r *http.Request, u store.User) { |
| 85 | row, ok := s.repoForUser(w, r, u, policyCanAdmin) | |
| 86 | if !ok { | |
| 87 | return | |
| 88 | } | |
| 85 | 89 | repo := r.PathValue("owner") + "/" + r.PathValue("repo") |
| 86 | 90 | v := func(k string) string { return strings.TrimSpace(r.FormValue(k)) } |
| 87 | 91 | field := r.FormValue("field") |
| @@ -131,11 +135,6 @@ func (s *Server) settingsSubmit(w http.ResponseWriter, r *http.Request, u store. | ||
| 131 | 135 | } |
| 132 | 136 | argv = []string{"repo", verb, repo} |
| 133 | 137 | case "topics": |
| 134 | row, err := s.st.RepoByPath(repo) | |
| 135 | if err != nil { | |
| 136 | http.NotFound(w, r) | |
| 137 | return | |
| 138 | } | |
| 139 | 138 | want := map[string]bool{} |
| 140 | 139 | var order []string |
| 141 | 140 | for _, t := range strings.Split(v("topics"), ",") { |
| @@ -160,11 +159,13 @@ func (s *Server) settingsSubmit(w http.ResponseWriter, r *http.Request, u store. | ||
| 160 | 159 | remove = append(remove, t) |
| 161 | 160 | } |
| 162 | 161 | } |
| 162 | removed := false | |
| 163 | 163 | if len(remove) > 0 { |
| 164 | 164 | if _, msg, ok := s.runControl(u, append([]string{"repo", "topics", "remove", repo}, remove...)); !ok { |
| 165 | 165 | s.settingsFormWith(w, r, u, msg, r.Form) |
| 166 | 166 | return |
| 167 | 167 | } |
| 168 | removed = true | |
| 168 | 169 | } |
| 169 | 170 | if len(add) > 0 { |
| 170 | 171 | argv = append([]string{"repo", "topics", "add", repo}, add...) |
| @@ -172,6 +173,14 @@ func (s *Server) settingsSubmit(w http.ResponseWriter, r *http.Request, u store. | ||
| 172 | 173 | s.settingsRedirect(w, r, "Saved the topics.") |
| 173 | 174 | return |
| 174 | 175 | } |
| 176 | if removed { | |
| 177 | if _, msg, ok := s.runControl(u, argv); !ok { | |
| 178 | s.settingsFormWith(w, r, u, "Removed "+strings.Join(remove, ", ")+"; "+msg, r.Form) | |
| 179 | return | |
| 180 | } | |
| 181 | s.settingsRedirect(w, r, "Saved the "+fieldLabel(field)+".") | |
| 182 | return | |
| 183 | } | |
| 175 | 184 | case "runner-add": |
| 176 | 185 | body := v("key") |
| 177 | 186 | if body == "" { |
| @@ -223,7 +232,7 @@ func fieldLabel(field string) string { | ||
| 223 | 232 | case "require-codeowners": |
| 224 | 233 | return "CODEOWNERS" |
| 225 | 234 | case "require-mr": |
| 226 | return "require-MR" | |
| 235 | return "merge request requirement" | |
| 227 | 236 | case "require-signed": |
| 228 | 237 | return "signed commits" |
| 229 | 238 | case "protect", "unprotect": |
| @@ -233,7 +242,7 @@ func fieldLabel(field string) string { | ||
| 233 | 242 | case "deps": |
| 234 | 243 | return "dependency scanning" |
| 235 | 244 | case "archive": |
| 236 | return "archive" | |
| 245 | return "archived state" | |
| 237 | 246 | case "topics": |
| 238 | 247 | return "topics" |
| 239 | 248 | case "runner-add", "runner-remove": |
internal/httpd/web.go +20 −12
| @@ -8,6 +8,7 @@ import ( | ||
| 8 | 8 | "fmt" |
| 9 | 9 | "hash/fnv" |
| 10 | 10 | "io" |
| 11 | "io/fs" | |
| 11 | 12 | "log" |
| 12 | 13 | "math" |
| 13 | 14 | "os" |
| @@ -105,6 +106,18 @@ func (s *Server) font(w http.ResponseWriter, r *http.Request) { | ||
| 105 | 106 | w.Write(data) |
| 106 | 107 | } |
| 107 | 108 | |
| 109 | // image serves the embedded landing pictures with the font cache policy. | |
| 110 | func (s *Server) image(w http.ResponseWriter, r *http.Request) { | |
| 111 | data, err := web.ImageFS.ReadFile("static" + r.URL.Path[len("/static"):]) | |
| 112 | if err != nil { | |
| 113 | http.NotFound(w, r) | |
| 114 | return | |
| 115 | } | |
| 116 | w.Header().Set("Content-Type", "image/png") | |
| 117 | w.Header().Set("Cache-Control", "public, max-age=604800, immutable") | |
| 118 | w.Write(data) | |
| 119 | } | |
| 120 | ||
| 108 | 121 | // notFound renders the designed 404 page with a 404 status. Falls back to |
| 109 | 122 | // the stock plain-text response if the template fails. |
| 110 | 123 | func (s *Server) notFound(w http.ResponseWriter, r *http.Request) { |
| @@ -172,18 +185,14 @@ func (s *Server) index(w http.ResponseWriter, r *http.Request) { | ||
| 172 | 185 | } |
| 173 | 186 | |
| 174 | 187 | // landingPicture says whether the landing page's screenshot images exist to |
| 175 | // show. Task 15 replaces this with a check of the embedded images. | |
| 176 | var landingPicture = false | |
| 188 | // show, checked once against the embedded images. | |
| 189 | var landingPicture = func() bool { | |
| 190 | _, e1 := fs.Stat(web.ImageFS, "static/img/mr-dark.png") | |
| 191 | _, e2 := fs.Stat(web.ImageFS, "static/img/mr-light.png") | |
| 192 | return e1 == nil && e2 == nil | |
| 193 | }() | |
| 177 | 194 | |
| 178 | 195 | func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store.User) { |
| 179 | pinned, _ := s.st.PinnedRepos(viewer.ID) | |
| 180 | var visible []store.Repo | |
| 181 | for _, rp := range pinned { | |
| 182 | grant, _ := s.st.AccessRole(rp.ID, viewer.ID) | |
| 183 | if policy.CanRead(viewer, rp, grant) { | |
| 184 | visible = append(visible, rp) | |
| 185 | } | |
| 186 | } | |
| 187 | 196 | mrs, _ := s.st.DashboardMRs(viewer.ID) |
| 188 | 197 | issues, _ := s.st.DashboardIssues(viewer.ID) |
| 189 | 198 | reviews, _ := s.st.ReviewQueue(viewer.ID) |
| @@ -191,13 +200,12 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store. | ||
| 191 | 200 | events, _ := s.st.RecentEvents(viewer.ID, 20, 0) |
| 192 | 201 | s.render(w, "dashboard.html", struct { |
| 193 | 202 | basePage |
| 194 | Pinned []store.Repo | |
| 195 | 203 | Reviews []store.DashboardItem |
| 196 | 204 | Assigned []store.DashboardItem |
| 197 | 205 | MRs []store.DashboardItem |
| 198 | 206 | Issues []store.DashboardItem |
| 199 | 207 | Feed []feedLine |
| 200 | }{s.baseFor(viewer), visible, reviews, assigned, mrs, issues, feedLines(events)}) | |
| 208 | }{s.baseFor(viewer), reviews, assigned, mrs, issues, feedLines(events)}) | |
| 201 | 209 | } |
| 202 | 210 | |
| 203 | 211 | func (s *Server) explore(w http.ResponseWriter, r *http.Request) { |
internal/web/static/img/mr-dark.png added
Binary file not shown.
internal/web/static/img/mr-light.png added
Binary file not shown.
internal/web/static/style.css +5 −4
| @@ -1225,7 +1225,7 @@ table.difftable td.ln a.cmt:focus { color: var(--link); text-decoration: underli | ||
| 1225 | 1225 | line-height: 1.5; |
| 1226 | 1226 | color: var(--chip); |
| 1227 | 1227 | border: 1px solid color-mix(in srgb, var(--chip) 40%, transparent); |
| 1228 | background: color-mix(in srgb, var(--chip) 10%, transparent); | |
| 1228 | background: color-mix(in srgb, var(--chip) 10%, var(--canvas)); | |
| 1229 | 1229 | white-space: nowrap; |
| 1230 | 1230 | vertical-align: middle; |
| 1231 | 1231 | } |
| @@ -1380,7 +1380,7 @@ details.refmenu .refdrop a.allrefs { | ||
| 1380 | 1380 | .lede { font-size: var(--fs-4); color: var(--muted); line-height: 1.5; max-width: 40rem; margin: 0 0 var(--sp-5); } |
| 1381 | 1381 | pre.quickstart { margin: 0 0 var(--sp-4); } |
| 1382 | 1382 | .shot { border: 1px solid var(--line); border-radius: var(--r-card); overflow: hidden; margin: var(--sp-5) 0; } |
| 1383 | .shot img { display: block; width: 100%; } | |
| 1383 | .shot img { display: block; width: 100%; height: auto; } | |
| 1384 | 1384 | .facets { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--sp-5); margin: var(--sp-5) 0; } |
| 1385 | 1385 | .facets h2 { font-size: var(--fs-3); margin: 0 0 var(--sp-1); } |
| 1386 | 1386 | .facets p { color: var(--muted); font-size: var(--fs-2); margin: 0; } |
| @@ -1465,8 +1465,9 @@ svg.icon { vertical-align: -0.125em; } | ||
| 1465 | 1465 | .railbody { flex: 1; overflow: visible; padding: 0; display: flex; align-items: center; } |
| 1466 | 1466 | form.railsearch { margin: 0 var(--sp-2); } |
| 1467 | 1467 | form.railsearch input[type="search"] { min-width: 8rem; } |
| 1468 | /* pinned repos and the review queue both live on the dashboard, so the | |
| 1469 | strip keeps only what has nowhere else to go */ | |
| 1468 | /* the strip hides the pinned group and the review queue: pinned repos | |
| 1469 | are reachable from the repository pages and the review queue from | |
| 1470 | the dashboard's review section, per the spec */ | |
| 1470 | 1471 | .railgroup { display: none; } |
| 1471 | 1472 | ul.raillist { display: flex; margin: 0; } |
| 1472 | 1473 | ul.raillist a { border-radius: 0; border-bottom: 2px solid transparent; padding: var(--sp-2) var(--sp-3); } |
internal/web/templates/issue.html +1 −1
| @@ -31,7 +31,7 @@ | ||
| 31 | 31 | {{if .Viewer}} |
| 32 | 32 | <form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/{{.Issue.Number}}/comment" class="commentform"> |
| 33 | 33 | <p><textarea name="body" aria-label="Comment" rows="4" placeholder="Comment as {{.Viewer}}"></textarea></p> |
| 34 | <p><button type="submit" class="btn">Comment</button></p> | |
| 34 | <p><button type="submit">Comment</button></p> | |
| 35 | 35 | </form> |
| 36 | 36 | {{end}} |
| 37 | 37 | </div> |
internal/web/templates/layout.html +1 −1
| @@ -75,7 +75,7 @@ | ||
| 75 | 75 | tabs only the identity row and the tab bar render, and the header |
| 76 | 76 | is identical on every page within a tab. */}} |
| 77 | 77 | {{if eq $top "code"}} |
| 78 | <p class="repodesc">{{with field $ "Desc"}}{{.}}{{end}} {{with field $ "Topics"}}{{range .}}<a class="chip topic" href="/explore?q={{.}}">{{.}}</a> {{end}}{{end}}</p> | |
| 78 | {{if or (field $ "Desc") (field $ "Topics")}}<p class="repodesc">{{with field $ "Desc"}}{{.}}{{end}} {{with field $ "Topics"}}{{range .}}<a class="chip topic" href="/explore?q={{.}}">{{.}}</a> {{end}}{{end}}</p>{{end}} | |
| 79 | 79 | {{if or $.Repo.Settings.Website (field $ "Mirrors")}}<p class="repometa">{{with $.Repo.Settings.Website}}<a href="{{.}}" rel="nofollow">{{.}}</a>{{end}}{{range $i, $m := field $ "Mirrors"}}{{if or $i $.Repo.Settings.Website}} · {{end}}{{if eq $m.Direction "push"}}mirrors to{{else}}mirrors from{{end}} <a href="{{$m.URL}}" rel="nofollow">{{$m.Target}}</a>{{if $m.Error}}, <span class="bad">sync error: {{$m.Error}}</span>{{else if $m.Synced}}, synced {{$m.Synced}}{{end}}{{end}}</p>{{end}} |
| 80 | 80 | {{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}} |
| 81 | 81 | {{end}} |
internal/web/templates/login.html +1 −1
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | {{define "title"}}login · {{.Site}}{{end}} |
| 3 | 3 | {{define "content"}} |
| 4 | 4 | <h1>Sign in</h1> |
| 5 | {{if .Next}}<p class="meta">Log in to continue to <code>{{.Next}}</code>.</p>{{end}} | |
| 5 | {{if .Next}}<p class="meta">Sign in to continue to <code>{{.Next}}</code>.</p>{{end}} | |
| 6 | 6 | {{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}} |
| 7 | 7 | {{if .Sent}} |
| 8 | 8 | <p>If that account exists, a login link is on its way. It works once and |
internal/web/templates/mr.html +2 −2
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | {{else if and (eq .MR.State "closed") .MR.ClosedBy}}closed without merging by <a href="/{{.MR.ClosedBy}}">{{.MR.ClosedBy}}</a>{{with .MR.ClosedAt}} on {{when .}}{{end}} |
| 8 | 8 | {{else if or (eq .MR.State "merged") (eq .MR.State "closed")}}{{if eq .MR.State "merged"}}merged{{else}}closed without merging{{end}} |
| 9 | 9 | {{else}}opened by <a href="/{{.MR.Author}}">{{.MR.Author}}</a> on {{when .MR.CreatedAt}}{{end}} |
| 10 | · <code>{{.MR.SourceRef}}</code> into <code>{{.MR.TargetRef}}</code></p> | |
| 10 | · <code>{{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}}</code> into <code>{{.MR.TargetRef}}</code></p> | |
| 11 | 11 | {{with field . "StackedOn"}}<p class="meta">Stacked on <a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/mrs/{{.Number}}">!{{.Number}} {{.Title}}</a>: merges into its branch until that lands, then onto its target.</p>{{end}} |
| 12 | 12 | {{with field . "Stacked"}}<p class="meta">Builds on this: {{range $i, $k := .}}{{if $i}}, {{end}}<a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/mrs/{{$k.Number}}">!{{$k.Number}} {{$k.Title}}</a>{{end}}. Merging with squash or rebase is refused while they are open.</p>{{end}} |
| 13 | 13 | |
| @@ -46,7 +46,7 @@ | ||
| 46 | 46 | {{if .Viewer}} |
| 47 | 47 | <form method="post" action="{{$base}}/comment" class="commentform"> |
| 48 | 48 | <p><textarea name="body" aria-label="Comment" rows="4" placeholder="Comment as {{.Viewer}}"></textarea></p> |
| 49 | <p><button type="submit" class="btn">Comment</button></p> | |
| 49 | <p><button type="submit">Comment</button></p> | |
| 50 | 50 | </form> |
| 51 | 51 | {{end}} |
| 52 | 52 | </div> |
internal/web/templates/register.html +1 −1
| @@ -10,7 +10,7 @@ | ||
| 10 | 10 | {{if eq .Mode "invite"}}<div class="field"><label for="invite">Invite code</label><input type="text" id="invite" name="invite" required></div> |
| 11 | 11 | {{else}}<div class="field"><label for="email">Email</label><input type="text" id="email" name="email" required></div>{{end}} |
| 12 | 12 | <div class="field"><label for="key">SSH public key</label> |
| 13 | <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> | |
| 13 | <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="https://gitbay.org/krz/gitbay/wiki/SSH-keys">Make one</a>.</p> | |
| 14 | 14 | <textarea id="key" name="key" rows="3" required placeholder="ssh-ed25519 AAAA... you@host"></textarea></div> |
| 15 | 15 | <p><button type="submit">Create account</button></p> |
| 16 | 16 | </form> |
internal/web/templates/settings.html +2 −2
| @@ -4,7 +4,7 @@ | ||
| 4 | 4 | {{$base := printf "/%s/%s/settings" .Repo.OwnerName .Repo.Name}} |
| 5 | 5 | <h1>Settings</h1> |
| 6 | 6 | {{if .Notice}}{{if .Saved}}<p class="notice" role="status">{{.Notice}}</p>{{else}}<p class="error" role="alert">{{.Notice}}</p>{{end}}{{end}} |
| 7 | <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> | |
| 7 | <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><a href="#deps">Dependencies</a><a href="#runners">Runners</a><a href="#lifecycle">Lifecycle</a></nav> | |
| 8 | 8 | |
| 9 | 9 | <section id="identity"><h2>Identity</h2> |
| 10 | 10 | <form method="post" action="{{$base}}" class="setform"> |
| @@ -179,7 +179,7 @@ | ||
| 179 | 179 | <input type="hidden" name="field" value="archive"> |
| 180 | 180 | <div><label for="archive">Archived</label><p class="hint">Read-only for everyone. Issues and requests close to new activity. Reversible.</p></div> |
| 181 | 181 | <div class="check"><input type="checkbox" id="archive" name="archive" value="on"{{if .Repo.Settings.Archived}} checked{{end}}></div> |
| 182 | <div><button type="submit" class="danger">Save</button></div> | |
| 182 | <div><button type="submit" {{if .Repo.Settings.Archived}}class="btn"{{else}}class="danger"{{end}}>Save</button></div> | |
| 183 | 183 | </form> |
| 184 | 184 | <p class="meta">Deleting or transferring a repository is a CLI operation: |
| 185 | 185 | <code>gitbay repo delete {{.Repo.OwnerName}}/{{.Repo.Name}} --yes</code></p> |
internal/web/tokens_test.go +23 −5
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | package web |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | "fmt" | |
| 4 | 5 | "math" |
| 5 | 6 | "regexp" |
| 6 | 7 | "strconv" |
| @@ -45,11 +46,8 @@ func parseTokens(css []byte) (light, dark map[string]string) { | ||
| 45 | 46 | } |
| 46 | 47 | light = parse(lm[1]) |
| 47 | 48 | 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 | } | |
| 49 | // A token missing from the dark block is a bug, not a fallback to the | |
| 50 | // light value: the missing-token loop in TestTokenContrast reports it. | |
| 53 | 51 | return light, dark |
| 54 | 52 | } |
| 55 | 53 | |
| @@ -78,6 +76,18 @@ func contrastHex(a, b string) float64 { | ||
| 78 | 76 | return (la + 0.05) / (lb + 0.05) |
| 79 | 77 | } |
| 80 | 78 | |
| 79 | // mixHex blends two hex colours per channel, t of a and 1-t of b, matching | |
| 80 | // CSS's color-mix(in srgb, a <t*100>%, b). | |
| 81 | func mixHex(a, b string, t float64) string { | |
| 82 | a, b = strings.TrimPrefix(a, "#"), strings.TrimPrefix(b, "#") | |
| 83 | channel := func(ca, cb string) string { | |
| 84 | na, _ := strconv.ParseUint(ca, 16, 8) | |
| 85 | nb, _ := strconv.ParseUint(cb, 16, 8) | |
| 86 | return fmt.Sprintf("%02x", int(math.Round(t*float64(na)+(1-t)*float64(nb)))) | |
| 87 | } | |
| 88 | return "#" + channel(a[0:2], b[0:2]) + channel(a[2:4], b[2:4]) + channel(a[4:6], b[4:6]) | |
| 89 | } | |
| 90 | ||
| 81 | 91 | // TestTokenContrast is the contract for the colour tokens in style.css: |
| 82 | 92 | // every text colour clears WCAG AA on every ground it lands on, in both |
| 83 | 93 | // schemes. The hex values in the stylesheet are free to move as long as |
| @@ -120,6 +130,14 @@ func TestTokenContrast(t *testing.T) { | ||
| 120 | 130 | 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 | 131 | } |
| 122 | 132 | } |
| 133 | // The chip/badge ground is opaque (color-mix against canvas, not | |
| 134 | // transparent), so its ratio does not depend on the row behind it. | |
| 135 | for _, chip := range []string{"ok", "bad", "warn", "done", "neutral", "link"} { | |
| 136 | ground := mixHex(scheme[chip], scheme["canvas"], 0.1) | |
| 137 | if got := contrastHex(scheme[chip], ground); got < 4.5 { | |
| 138 | t.Errorf("%s: chip --%s (%s) on its ground %s is %.2f:1, want >= 4.5", name, chip, scheme[chip], ground, got) | |
| 139 | } | |
| 140 | } | |
| 123 | 141 | // The surface ladder must be visible: canvas, surface and inset |
| 124 | 142 | // are three grounds, not one. |
| 125 | 143 | lc, ls, _ := luminanceHex(scheme["canvas"]), luminanceHex(scheme["surface"]), luminanceHex(scheme["inset"]) |
internal/web/web.go +3
| @@ -29,6 +29,9 @@ var FaviconSVG []byte | ||
| 29 | 29 | //go:embed static/fonts/*.woff2 |
| 30 | 30 | var FontFS embed.FS |
| 31 | 31 | |
| 32 | //go:embed static/img/*.png | |
| 33 | var ImageFS embed.FS | |
| 34 | ||
| 32 | 35 | // version returns the short VCS revision baked into the binary, or "" when |
| 33 | 36 | // built outside a checkout. Used by the layout footer. |
| 34 | 37 | var version = sync.OnceValue(func() string { |
internal/web/web_test.go +3 −3
| @@ -84,9 +84,9 @@ func TestWhenNamesTheZone(t *testing.T) { | ||
| 84 | 84 | } |
| 85 | 85 | } |
| 86 | 86 | |
| 87 | // TestMainWidthClass renders every page against an empty struct and | |
| 88 | // checks main carries exactly one width class, and that the pages the | |
| 89 | // spec calls wide or bounded say so. | |
| 87 | // TestMainWidthClass checks each template's source for the width define | |
| 88 | // the spec assigns it (wide or bounded), and that a reading page defines | |
| 89 | // none. | |
| 90 | 90 | func TestMainWidthClass(t *testing.T) { |
| 91 | 91 | 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} |
| 92 | 92 | 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} |