web: landing picture !398

merged merged by cmc on 2026-09-18 02:44 UTC · krz/gitbay:design-picture into main

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- Repository settings are bounded rows with a consequence beside each 26- Repository settings are bounded rows with a consequence beside each
27 control, a saved flash, a rejected value kept, and topics as one field. 27 control, a saved flash, a rejected value kept, and topics as one field.
28- Landing copy says what the product is and where to go; the register 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* v1.22.1 — 2026-09-13 34* v1.22.1 — 2026-09-13
32 35
e2e/design_test.go +2
@@ -163,6 +163,8 @@ func TestLandingRoutes(t *testing.T) {
163 `class="button primary" href="/explore">Explore repositories</a>`, 163 `class="button primary" href="/explore">Explore repositories</a>`,
164 `class="button btn" href="/register">Create an account</a>`, 164 `class="button btn" href="/register">Create an account</a>`,
165 "web login</code>", 165 "web login</code>",
166 "/static/img/mr-dark.png",
167 "<picture>",
166 } { 168 } {
167 if !strings.Contains(body, want) { 169 if !strings.Contains(body, want) {
168 t.Errorf("landing lacks %q", want) 170 t.Errorf("landing lacks %q", want)
internal/httpd/fonts_test.go +34
@@ -1,9 +1,11 @@
1package httpd 1package httpd
2 2
3import ( 3import (
4 "io/fs"
4 "net/http" 5 "net/http"
5 "net/http/httptest" 6 "net/http/httptest"
6 "regexp" 7 "regexp"
8 "strings"
7 "testing" 9 "testing"
8 10
9 "gitbay.org/gitbay/internal/config" 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.
51func 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 for _, f := range fonts { 53 for _, f := range fonts {
54 routes = append(routes, Route{Method: "GET", Pattern: "/static/fonts/" + f.Name(), Handler: s.font}) 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 routes = append(routes, 60 routes = append(routes,
57 Route{Method: "GET", Pattern: "/favicon.svg", Handler: s.favicon}, 61 Route{Method: "GET", Pattern: "/favicon.svg", Handler: s.favicon},
58 Route{Method: "GET", Pattern: "/{owner}", Handler: s.ownerPage}, 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// settingsSubmit routes one form to its command. Keeping the mapping in 82// settingsSubmit routes one form to its command. Keeping the mapping in
83// one place makes what the page can reach obvious. 83// one place makes what the page can reach obvious.
84func (s *Server) settingsSubmit(w http.ResponseWriter, r *http.Request, u store.User) { 84func (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 repo := r.PathValue("owner") + "/" + r.PathValue("repo") 89 repo := r.PathValue("owner") + "/" + r.PathValue("repo")
86 v := func(k string) string { return strings.TrimSpace(r.FormValue(k)) } 90 v := func(k string) string { return strings.TrimSpace(r.FormValue(k)) }
87 field := r.FormValue("field") 91 field := r.FormValue("field")
@@ -131,11 +135,6 @@ func (s *Server) settingsSubmit(w http.ResponseWriter, r *http.Request, u store.
131 } 135 }
132 argv = []string{"repo", verb, repo} 136 argv = []string{"repo", verb, repo}
133 case "topics": 137 case "topics":
134 row, err := s.st.RepoByPath(repo)
135 if err != nil {
136 http.NotFound(w, r)
137 return
138 }
139 want := map[string]bool{} 138 want := map[string]bool{}
140 var order []string 139 var order []string
141 for _, t := range strings.Split(v("topics"), ",") { 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 remove = append(remove, t) 159 remove = append(remove, t)
161 } 160 }
162 } 161 }
162 removed := false
163 if len(remove) > 0 { 163 if len(remove) > 0 {
164 if _, msg, ok := s.runControl(u, append([]string{"repo", "topics", "remove", repo}, remove...)); !ok { 164 if _, msg, ok := s.runControl(u, append([]string{"repo", "topics", "remove", repo}, remove...)); !ok {
165 s.settingsFormWith(w, r, u, msg, r.Form) 165 s.settingsFormWith(w, r, u, msg, r.Form)
166 return 166 return
167 } 167 }
168 removed = true
168 } 169 }
169 if len(add) > 0 { 170 if len(add) > 0 {
170 argv = append([]string{"repo", "topics", "add", repo}, add...) 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 s.settingsRedirect(w, r, "Saved the topics.") 173 s.settingsRedirect(w, r, "Saved the topics.")
173 return 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 case "runner-add": 184 case "runner-add":
176 body := v("key") 185 body := v("key")
177 if body == "" { 186 if body == "" {
@@ -223,7 +232,7 @@ func fieldLabel(field string) string {
223 case "require-codeowners": 232 case "require-codeowners":
224 return "CODEOWNERS" 233 return "CODEOWNERS"
225 case "require-mr": 234 case "require-mr":
226 return "require-MR" 235 return "merge request requirement"
227 case "require-signed": 236 case "require-signed":
228 return "signed commits" 237 return "signed commits"
229 case "protect", "unprotect": 238 case "protect", "unprotect":
@@ -233,7 +242,7 @@ func fieldLabel(field string) string {
233 case "deps": 242 case "deps":
234 return "dependency scanning" 243 return "dependency scanning"
235 case "archive": 244 case "archive":
236 return "archive" 245 return "archived state"
237 case "topics": 246 case "topics":
238 return "topics" 247 return "topics"
239 case "runner-add", "runner-remove": 248 case "runner-add", "runner-remove":
internal/httpd/web.go +20 −12
@@ -8,6 +8,7 @@ import (
8 "fmt" 8 "fmt"
9 "hash/fnv" 9 "hash/fnv"
10 "io" 10 "io"
11 "io/fs"
11 "log" 12 "log"
12 "math" 13 "math"
13 "os" 14 "os"
@@ -105,6 +106,18 @@ func (s *Server) font(w http.ResponseWriter, r *http.Request) {
105 w.Write(data) 106 w.Write(data)
106} 107}
107 108
109// image serves the embedded landing pictures with the font cache policy.
110func (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// notFound renders the designed 404 page with a 404 status. Falls back to 121// notFound renders the designed 404 page with a 404 status. Falls back to
109// the stock plain-text response if the template fails. 122// the stock plain-text response if the template fails.
110func (s *Server) notFound(w http.ResponseWriter, r *http.Request) { 123func (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// landingPicture says whether the landing page's screenshot images exist to 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. 188// show, checked once against the embedded images.
176var landingPicture = false 189var 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
178func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store.User) { 195func (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 mrs, _ := s.st.DashboardMRs(viewer.ID) 196 mrs, _ := s.st.DashboardMRs(viewer.ID)
188 issues, _ := s.st.DashboardIssues(viewer.ID) 197 issues, _ := s.st.DashboardIssues(viewer.ID)
189 reviews, _ := s.st.ReviewQueue(viewer.ID) 198 reviews, _ := s.st.ReviewQueue(viewer.ID)
@@ -191,13 +200,12 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store.
191 events, _ := s.st.RecentEvents(viewer.ID, 20, 0) 200 events, _ := s.st.RecentEvents(viewer.ID, 20, 0)
192 s.render(w, "dashboard.html", struct { 201 s.render(w, "dashboard.html", struct {
193 basePage 202 basePage
194 Pinned []store.Repo
195 Reviews []store.DashboardItem 203 Reviews []store.DashboardItem
196 Assigned []store.DashboardItem 204 Assigned []store.DashboardItem
197 MRs []store.DashboardItem 205 MRs []store.DashboardItem
198 Issues []store.DashboardItem 206 Issues []store.DashboardItem
199 Feed []feedLine 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
203func (s *Server) explore(w http.ResponseWriter, r *http.Request) { 211func (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 line-height: 1.5; 1225 line-height: 1.5;
1226 color: var(--chip); 1226 color: var(--chip);
1227 border: 1px solid color-mix(in srgb, var(--chip) 40%, transparent); 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 white-space: nowrap; 1229 white-space: nowrap;
1230 vertical-align: middle; 1230 vertical-align: middle;
1231} 1231}
@@ -1380,7 +1380,7 @@ details.refmenu .refdrop a.allrefs {
1380.lede { font-size: var(--fs-4); color: var(--muted); line-height: 1.5; max-width: 40rem; margin: 0 0 var(--sp-5); } 1380.lede { font-size: var(--fs-4); color: var(--muted); line-height: 1.5; max-width: 40rem; margin: 0 0 var(--sp-5); }
1381pre.quickstart { margin: 0 0 var(--sp-4); } 1381pre.quickstart { margin: 0 0 var(--sp-4); }
1382.shot { border: 1px solid var(--line); border-radius: var(--r-card); overflow: hidden; margin: var(--sp-5) 0; } 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.facets { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--sp-5); margin: var(--sp-5) 0; } 1384.facets { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--sp-5); margin: var(--sp-5) 0; }
1385.facets h2 { font-size: var(--fs-3); margin: 0 0 var(--sp-1); } 1385.facets h2 { font-size: var(--fs-3); margin: 0 0 var(--sp-1); }
1386.facets p { color: var(--muted); font-size: var(--fs-2); margin: 0; } 1386.facets p { color: var(--muted); font-size: var(--fs-2); margin: 0; }
@@ -1465,8 +1465,9 @@ svg.icon { vertical-align: -0.125em; }
1465 .railbody { flex: 1; overflow: visible; padding: 0; display: flex; align-items: center; } 1465 .railbody { flex: 1; overflow: visible; padding: 0; display: flex; align-items: center; }
1466 form.railsearch { margin: 0 var(--sp-2); } 1466 form.railsearch { margin: 0 var(--sp-2); }
1467 form.railsearch input[type="search"] { min-width: 8rem; } 1467 form.railsearch input[type="search"] { min-width: 8rem; }
1468 /* pinned repos and the review queue both live on the dashboard, so the 1468 /* the strip hides the pinned group and the review queue: pinned repos
1469 strip keeps only what has nowhere else to go */ 1469 are reachable from the repository pages and the review queue from
1470 the dashboard's review section, per the spec */
1470 .railgroup { display: none; } 1471 .railgroup { display: none; }
1471 ul.raillist { display: flex; margin: 0; } 1472 ul.raillist { display: flex; margin: 0; }
1472 ul.raillist a { border-radius: 0; border-bottom: 2px solid transparent; padding: var(--sp-2) var(--sp-3); } 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{{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" class="btn">Comment</button></p> 34<p><button type="submit">Comment</button></p>
35</form> 35</form>
36{{end}} 36{{end}}
37</div> 37</div>
internal/web/templates/layout.html +1 −1
@@ -75,7 +75,7 @@
75 tabs only the identity row and the tab bar render, and the header 75 tabs only the identity row and the tab bar render, and the header
76 is identical on every page within a tab. */}} 76 is identical on every page within a tab. */}}
77 {{if eq $top "code"}} 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 {{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}} 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 {{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}} 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 {{end}} 81 {{end}}
internal/web/templates/login.html +1 −1
@@ -2,7 +2,7 @@
2{{define "title"}}login · {{.Site}}{{end}} 2{{define "title"}}login · {{.Site}}{{end}}
3{{define "content"}} 3{{define "content"}}
4<h1>Sign in</h1> 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{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}} 6{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
7{{if .Sent}} 7{{if .Sent}}
8<p>If that account exists, a login link is on its way. It works once and 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 {{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}} 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 {{else if or (eq .MR.State "merged") (eq .MR.State "closed")}}{{if eq .MR.State "merged"}}merged{{else}}closed without merging{{end}} 8 {{else if or (eq .MR.State "merged") (eq .MR.State "closed")}}{{if eq .MR.State "merged"}}merged{{else}}closed without merging{{end}}
9 {{else}}opened by <a href="/{{.MR.Author}}">{{.MR.Author}}</a> on {{when .MR.CreatedAt}}{{end}} 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{{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}} 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{{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}} 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{{if .Viewer}} 46{{if .Viewer}}
47<form method="post" action="{{$base}}/comment" class="commentform"> 47<form method="post" action="{{$base}}/comment" class="commentform">
48<p><textarea name="body" aria-label="Comment" rows="4" placeholder="Comment as {{.Viewer}}"></textarea></p> 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</form> 50</form>
51{{end}} 51{{end}}
52</div> 52</div>
internal/web/templates/register.html +1 −1
@@ -10,7 +10,7 @@
10{{if eq .Mode "invite"}}<div class="field"><label for="invite">Invite code</label><input type="text" id="invite" name="invite" required></div> 10{{if eq .Mode "invite"}}<div class="field"><label for="invite">Invite code</label><input type="text" id="invite" name="invite" required></div>
11{{else}}<div class="field"><label for="email">Email</label><input type="text" id="email" name="email" required></div>{{end}} 11{{else}}<div class="field"><label for="email">Email</label><input type="text" id="email" name="email" required></div>{{end}}
12<div class="field"><label for="key">SSH public key</label> 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<textarea id="key" name="key" rows="3" required placeholder="ssh-ed25519 AAAA... you@host"></textarea></div> 14<textarea id="key" name="key" rows="3" required placeholder="ssh-ed25519 AAAA... you@host"></textarea></div>
15<p><button type="submit">Create account</button></p> 15<p><button type="submit">Create account</button></p>
16</form> 16</form>
internal/web/templates/settings.html +2 −2
@@ -4,7 +4,7 @@
4{{$base := printf "/%s/%s/settings" .Repo.OwnerName .Repo.Name}} 4{{$base := printf "/%s/%s/settings" .Repo.OwnerName .Repo.Name}}
5<h1>Settings</h1> 5<h1>Settings</h1>
6{{if .Notice}}{{if .Saved}}<p class="notice" role="status">{{.Notice}}</p>{{else}}<p class="error" role="alert">{{.Notice}}</p>{{end}}{{end}} 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<section id="identity"><h2>Identity</h2> 9<section id="identity"><h2>Identity</h2>
10<form method="post" action="{{$base}}" class="setform"> 10<form method="post" action="{{$base}}" class="setform">
@@ -179,7 +179,7 @@
179 <input type="hidden" name="field" value="archive"> 179 <input type="hidden" name="field" value="archive">
180 <div><label for="archive">Archived</label><p class="hint">Read-only for everyone. Issues and requests close to new activity. Reversible.</p></div> 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 <div class="check"><input type="checkbox" id="archive" name="archive" value="on"{{if .Repo.Settings.Archived}} checked{{end}}></div> 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</form> 183</form>
184<p class="meta">Deleting or transferring a repository is a CLI operation: 184<p class="meta">Deleting or transferring a repository is a CLI operation:
185<code>gitbay repo delete {{.Repo.OwnerName}}/{{.Repo.Name}} --yes</code></p> 185<code>gitbay repo delete {{.Repo.OwnerName}}/{{.Repo.Name}} --yes</code></p>
internal/web/tokens_test.go +23 −5
@@ -1,6 +1,7 @@
1package web 1package web
2 2
3import ( 3import (
4 "fmt"
4 "math" 5 "math"
5 "regexp" 6 "regexp"
6 "strconv" 7 "strconv"
@@ -45,11 +46,8 @@ func parseTokens(css []byte) (light, dark map[string]string) {
45 } 46 }
46 light = parse(lm[1]) 47 light = parse(lm[1])
47 dark = parse(dm[1]) 48 dark = parse(dm[1])
48 for k, v := range light { 49 // A token missing from the dark block is a bug, not a fallback to the
49 if _, ok := dark[k]; !ok && strings.HasPrefix(v, "#") { 50 // light value: the missing-token loop in TestTokenContrast reports it.
50 dark[k] = v // a light-only colour is a bug; keep it visible below
51 }
52 }
53 return light, dark 51 return light, dark
54} 52}
55 53
@@ -78,6 +76,18 @@ func contrastHex(a, b string) float64 {
78 return (la + 0.05) / (lb + 0.05) 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).
81func 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// TestTokenContrast is the contract for the colour tokens in style.css: 91// 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 92// 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 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 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) 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 // The surface ladder must be visible: canvas, surface and inset 141 // The surface ladder must be visible: canvas, surface and inset
124 // are three grounds, not one. 142 // are three grounds, not one.
125 lc, ls, _ := luminanceHex(scheme["canvas"]), luminanceHex(scheme["surface"]), luminanceHex(scheme["inset"]) 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//go:embed static/fonts/*.woff2 29//go:embed static/fonts/*.woff2
30var FontFS embed.FS 30var FontFS embed.FS
31 31
32//go:embed static/img/*.png
33var ImageFS embed.FS
34
32// version returns the short VCS revision baked into the binary, or "" when 35// version returns the short VCS revision baked into the binary, or "" when
33// built outside a checkout. Used by the layout footer. 36// built outside a checkout. Used by the layout footer.
34var version = sync.OnceValue(func() string { 37var 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 87// TestMainWidthClass checks each template's source for the width define
88// checks main carries exactly one width class, and that the pages the 88// the spec assigns it (wide or bounded), and that a reading page defines
89// spec calls wide or bounded say so. 89// none.
90func TestMainWidthClass(t *testing.T) { 90func TestMainWidthClass(t *testing.T) {
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} 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 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} 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}