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.
2626- Repository settings are bounded rows with a consequence beside each
2727 control, a saved flash, a rejected value kept, and topics as one field.
2828- 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/=).
3033
3134* v1.22.1 — 2026-09-13
3235
e2e/design_test.go +2
@@ -163,6 +163,8 @@ func TestLandingRoutes(t *testing.T) {
163163 `class="button primary" href="/explore">Explore repositories</a>`,
164164 `class="button btn" href="/register">Create an account</a>`,
165165 "web login</code>",
166 "/static/img/mr-dark.png",
167 "<picture>",
166168 } {
167169 if !strings.Contains(body, want) {
168170 t.Errorf("landing lacks %q", want)
internal/httpd/fonts_test.go +34
@@ -1,9 +1,11 @@
11package httpd
22
33import (
4 "io/fs"
45 "net/http"
56 "net/http/httptest"
67 "regexp"
8 "strings"
79 "testing"
810
911 "gitbay.org/gitbay/internal/config"
@@ -43,3 +45,35 @@ func TestStylesheetFontsAreServed(t *testing.T) {
4345 }
4446 }
4547}
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 {
5353 for _, f := range fonts {
5454 routes = append(routes, Route{Method: "GET", Pattern: "/static/fonts/" + f.Name(), Handler: s.font})
5555 }
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 }
5660 routes = append(routes,
5761 Route{Method: "GET", Pattern: "/favicon.svg", Handler: s.favicon},
5862 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
8282// settingsSubmit routes one form to its command. Keeping the mapping in
8383// one place makes what the page can reach obvious.
8484func (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 }
8589 repo := r.PathValue("owner") + "/" + r.PathValue("repo")
8690 v := func(k string) string { return strings.TrimSpace(r.FormValue(k)) }
8791 field := r.FormValue("field")
@@ -131,11 +135,6 @@ func (s *Server) settingsSubmit(w http.ResponseWriter, r *http.Request, u store.
131135 }
132136 argv = []string{"repo", verb, repo}
133137 case "topics":
134 row, err := s.st.RepoByPath(repo)
135 if err != nil {
136 http.NotFound(w, r)
137 return
138 }
139138 want := map[string]bool{}
140139 var order []string
141140 for _, t := range strings.Split(v("topics"), ",") {
@@ -160,11 +159,13 @@ func (s *Server) settingsSubmit(w http.ResponseWriter, r *http.Request, u store.
160159 remove = append(remove, t)
161160 }
162161 }
162 removed := false
163163 if len(remove) > 0 {
164164 if _, msg, ok := s.runControl(u, append([]string{"repo", "topics", "remove", repo}, remove...)); !ok {
165165 s.settingsFormWith(w, r, u, msg, r.Form)
166166 return
167167 }
168 removed = true
168169 }
169170 if len(add) > 0 {
170171 argv = append([]string{"repo", "topics", "add", repo}, add...)
@@ -172,6 +173,14 @@ func (s *Server) settingsSubmit(w http.ResponseWriter, r *http.Request, u store.
172173 s.settingsRedirect(w, r, "Saved the topics.")
173174 return
174175 }
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 }
175184 case "runner-add":
176185 body := v("key")
177186 if body == "" {
@@ -223,7 +232,7 @@ func fieldLabel(field string) string {
223232 case "require-codeowners":
224233 return "CODEOWNERS"
225234 case "require-mr":
226 return "require-MR"
235 return "merge request requirement"
227236 case "require-signed":
228237 return "signed commits"
229238 case "protect", "unprotect":
@@ -233,7 +242,7 @@ func fieldLabel(field string) string {
233242 case "deps":
234243 return "dependency scanning"
235244 case "archive":
236 return "archive"
245 return "archived state"
237246 case "topics":
238247 return "topics"
239248 case "runner-add", "runner-remove":
internal/httpd/web.go +20 −12
@@ -8,6 +8,7 @@ import (
88 "fmt"
99 "hash/fnv"
1010 "io"
11 "io/fs"
1112 "log"
1213 "math"
1314 "os"
@@ -105,6 +106,18 @@ func (s *Server) font(w http.ResponseWriter, r *http.Request) {
105106 w.Write(data)
106107}
107108
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
108121// notFound renders the designed 404 page with a 404 status. Falls back to
109122// the stock plain-text response if the template fails.
110123func (s *Server) notFound(w http.ResponseWriter, r *http.Request) {
@@ -172,18 +185,14 @@ func (s *Server) index(w http.ResponseWriter, r *http.Request) {
172185}
173186
174187// landingPicture says whether the landing page's screenshot images exist to
175// show. Task 15 replaces this with a check of the embedded images.
176var landingPicture = false
188// show, checked once against the embedded images.
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}()
177194
178195func (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 }
187196 mrs, _ := s.st.DashboardMRs(viewer.ID)
188197 issues, _ := s.st.DashboardIssues(viewer.ID)
189198 reviews, _ := s.st.ReviewQueue(viewer.ID)
@@ -191,13 +200,12 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store.
191200 events, _ := s.st.RecentEvents(viewer.ID, 20, 0)
192201 s.render(w, "dashboard.html", struct {
193202 basePage
194 Pinned []store.Repo
195203 Reviews []store.DashboardItem
196204 Assigned []store.DashboardItem
197205 MRs []store.DashboardItem
198206 Issues []store.DashboardItem
199207 Feed []feedLine
200 }{s.baseFor(viewer), visible, reviews, assigned, mrs, issues, feedLines(events)})
208 }{s.baseFor(viewer), reviews, assigned, mrs, issues, feedLines(events)})
201209}
202210
203211func (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
12251225 line-height: 1.5;
12261226 color: var(--chip);
12271227 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));
12291229 white-space: nowrap;
12301230 vertical-align: middle;
12311231}
@@ -1380,7 +1380,7 @@ details.refmenu .refdrop a.allrefs {
13801380.lede { font-size: var(--fs-4); color: var(--muted); line-height: 1.5; max-width: 40rem; margin: 0 0 var(--sp-5); }
13811381pre.quickstart { margin: 0 0 var(--sp-4); }
13821382.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; }
13841384.facets { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--sp-5); margin: var(--sp-5) 0; }
13851385.facets h2 { font-size: var(--fs-3); margin: 0 0 var(--sp-1); }
13861386.facets p { color: var(--muted); font-size: var(--fs-2); margin: 0; }
@@ -1465,8 +1465,9 @@ svg.icon { vertical-align: -0.125em; }
14651465 .railbody { flex: 1; overflow: visible; padding: 0; display: flex; align-items: center; }
14661466 form.railsearch { margin: 0 var(--sp-2); }
14671467 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 */
14701471 .railgroup { display: none; }
14711472 ul.raillist { display: flex; margin: 0; }
14721473 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 @@
3131{{if .Viewer}}
3232<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/{{.Issue.Number}}/comment" class="commentform">
3333<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>
3535</form>
3636{{end}}
3737</div>
internal/web/templates/layout.html +1 −1
@@ -75,7 +75,7 @@
7575 tabs only the identity row and the tab bar render, and the header
7676 is identical on every page within a tab. */}}
7777 {{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}}
7979 {{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}}
8080 {{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}}
8181 {{end}}
internal/web/templates/login.html +1 −1
@@ -2,7 +2,7 @@
22{{define "title"}}login · {{.Site}}{{end}}
33{{define "content"}}
44<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}}
66{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
77{{if .Sent}}
88<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 @@
77 {{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}}
88 {{else if or (eq .MR.State "merged") (eq .MR.State "closed")}}{{if eq .MR.State "merged"}}merged{{else}}closed without merging{{end}}
99 {{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>
1111{{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}}
1212{{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}}
1313
@@ -46,7 +46,7 @@
4646{{if .Viewer}}
4747<form method="post" action="{{$base}}/comment" class="commentform">
4848<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>
5050</form>
5151{{end}}
5252</div>
internal/web/templates/register.html +1 −1
@@ -10,7 +10,7 @@
1010{{if eq .Mode "invite"}}<div class="field"><label for="invite">Invite code</label><input type="text" id="invite" name="invite" required></div>
1111{{else}}<div class="field"><label for="email">Email</label><input type="text" id="email" name="email" required></div>{{end}}
1212<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>
1414<textarea id="key" name="key" rows="3" required placeholder="ssh-ed25519 AAAA... you@host"></textarea></div>
1515<p><button type="submit">Create account</button></p>
1616</form>
internal/web/templates/settings.html +2 −2
@@ -4,7 +4,7 @@
44{{$base := printf "/%s/%s/settings" .Repo.OwnerName .Repo.Name}}
55<h1>Settings</h1>
66{{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>
88
99<section id="identity"><h2>Identity</h2>
1010<form method="post" action="{{$base}}" class="setform">
@@ -179,7 +179,7 @@
179179 <input type="hidden" name="field" value="archive">
180180 <div><label for="archive">Archived</label><p class="hint">Read-only for everyone. Issues and requests close to new activity. Reversible.</p></div>
181181 <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>
183183</form>
184184<p class="meta">Deleting or transferring a repository is a CLI operation:
185185<code>gitbay repo delete {{.Repo.OwnerName}}/{{.Repo.Name}} --yes</code></p>
internal/web/tokens_test.go +23 −5
@@ -1,6 +1,7 @@
11package web
22
33import (
4 "fmt"
45 "math"
56 "regexp"
67 "strconv"
@@ -45,11 +46,8 @@ func parseTokens(css []byte) (light, dark map[string]string) {
4546 }
4647 light = parse(lm[1])
4748 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.
5351 return light, dark
5452}
5553
@@ -78,6 +76,18 @@ func contrastHex(a, b string) float64 {
7876 return (la + 0.05) / (lb + 0.05)
7977}
8078
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
8191// TestTokenContrast is the contract for the colour tokens in style.css:
8292// every text colour clears WCAG AA on every ground it lands on, in both
8393// schemes. The hex values in the stylesheet are free to move as long as
@@ -120,6 +130,14 @@ func TestTokenContrast(t *testing.T) {
120130 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)
121131 }
122132 }
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 }
123141 // The surface ladder must be visible: canvas, surface and inset
124142 // are three grounds, not one.
125143 lc, ls, _ := luminanceHex(scheme["canvas"]), luminanceHex(scheme["surface"]), luminanceHex(scheme["inset"])
internal/web/web.go +3
@@ -29,6 +29,9 @@ var FaviconSVG []byte
2929//go:embed static/fonts/*.woff2
3030var FontFS embed.FS
3131
32//go:embed static/img/*.png
33var ImageFS embed.FS
34
3235// version returns the short VCS revision baked into the binary, or "" when
3336// built outside a checkout. Used by the layout footer.
3437var version = sync.OnceValue(func() string {
internal/web/web_test.go +3 −3
@@ -84,9 +84,9 @@ func TestWhenNamesTheZone(t *testing.T) {
8484 }
8585}
8686
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.
9090func TestMainWidthClass(t *testing.T) {
9191 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}
9292 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}