web: the ten findings from the forge comparison !430

merged merged by cmc on 2026-09-19 14:26 UTC · krz/gitbay:web-findings into main

29 files changed, +724 −42

Layout: unified · split

.gitbay/wiki/Parity.org +1
@@ -308,6 +308,7 @@ client has no use for one (krz/gitbay#57).
308308| notification inbox | yes | yes | yes |
309309| activity mail on, off | yes | yes | yes |
310310| watch writable repos | yes | yes | yes |
311| web colour scheme | yes | yes | n/a |
311312| API token mint | yes | no | no |
312313| account export bundle | yes | yes | n/a |
313314| profile set | yes | yes | yes |
.gitbay/wiki/Users.org +5
@@ -653,6 +653,11 @@ id with its creation and expiry, and =gitbay web sessions revoke <id>=
653653or =--all= ends them from the terminal, which is where a lost laptop is
654654handled.
655655
656=web theme set light= or =dark= fixes the web UI's colour scheme for
657your account; =system=, the default, follows the browser's own
658preference. =web theme show= prints it. The account page has the same
659control under Appearance.
660
656661* Notifications
657662
658663When the instance has SMTP configured, activity mails you as well as
CHANGELOG.org +25
@@ -4,6 +4,31 @@ Versioning follows semver from v0.1.0. Database migrations run
44automatically on daemon start; upgrade notes appear per release when
55anything beyond "replace the binary and restart" is needed.
66
7* v1.30.0 — unreleased
8
9The web findings from the forge comparison (#232).
10
11- Links inside running text are underlined: the meta lines on issues,
12 merge requests and releases no longer tell a link apart by colour
13 alone.
14- File view line numbers are 24px targets and keep the focus ring;
15 chroma's LineLink rule, which set =outline: none=, is dropped from the
16 served palette.
17- Every rendered =<pre>= and the landing page's quickstart block take
18 keyboard focus, so a block that scrolls sideways can be reached.
19- On one column the issue and merge request aside follows the thread
20 instead of preceding it.
21- The file tree at 320px keeps its last column inside the card.
22- A path no route matches renders the 404 page instead of a plain-text
23 body.
24- An org image link with no description gets its file name as alt text,
25 so a README badge is a named link.
26- Release titles are h2.
27- Text responses are gzipped for clients that accept it.
28- =web theme set system|light|dark= and an Appearance section on the
29 account page fix the colour scheme per account. Migration 0057 adds
30 =users.theme=.
31
732* v1.29.0 — 2026-09-19
833
934The icon nav is a top bar at every width.
cmd/gitbay/main.go +4
@@ -647,6 +647,10 @@ func webCmd() *cobra.Command {
647647 pass("list", "list your browser sessions", passOpts{server: []string{"web", "sessions", "list"}}),
648648 pass("revoke", "end a browser session: <id>|--all", passOpts{server: []string{"web", "sessions", "revoke"}}),
649649 ),
650 group("theme", "the colour scheme the web UI uses for you",
651 pass("show", "show your colour scheme", passOpts{server: []string{"web", "theme", "show"}}),
652 pass("set", "follow the browser, or force one: system|light|dark", passOpts{server: []string{"web", "theme", "set"}}),
653 ),
650654 )
651655}
652656
e2e/readonly_test.go +1
@@ -97,6 +97,7 @@ func TestReadOnlyCommandsWriteNothing(t *testing.T) {
9797 "pgp list": {},
9898 "token list": {},
9999 "web sessions list": {},
100 "web theme show": {},
100101 "account export": {},
101102 "org list": {},
102103 "repo list": {},
e2e/theme_test.go added +44
@@ -0,0 +1,44 @@
1package e2e
2
3import (
4 "net/url"
5 "strings"
6 "testing"
7)
8
9// web theme set fixes the colour scheme the layout stamps on <html>; the
10// account page shows the same setting and changes it (#232).
11func TestWebTheme(t *testing.T) {
12 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
13 key := inst.newKey(t, "alice")
14 inst.admin(t, "admin", "user", "create", "alice", "--key", key+".pub")
15
16 if out, _, code := inst.ssh(t, key, "", "web", "theme", "show", "--json"); code != 0 || !strings.Contains(out, `"theme":"system"`) {
17 t.Fatalf("theme show: %d %s", code, out)
18 }
19 if _, _, code := inst.ssh(t, key, "", "web", "theme", "set", "blue"); code != 2 {
20 t.Fatalf("bad value accepted: %d", code)
21 }
22 if out, _, code := inst.ssh(t, key, "", "web", "theme", "set", "dark", "--json"); code != 0 || !strings.Contains(out, `"theme":"dark"`) {
23 t.Fatalf("theme set: %d %s", code, out)
24 }
25
26 alice := inst.login(t, key)
27 set := inst.base() + "/settings"
28 _, body := browserGet(t, alice, set)
29 if !strings.Contains(body, `<html lang="en" data-theme="dark">`) {
30 t.Fatalf("page is not stamped dark:\n%s", body[:200])
31 }
32 if !strings.Contains(body, `<option value="dark" selected>`) {
33 t.Fatalf("account page does not show dark selected")
34 }
35 if status, _ := browserPost(t, alice, set, url.Values{"field": {"theme"}, "theme": {"system"}}); status != 200 {
36 t.Fatalf("settings post: %d", status)
37 }
38 if out, _, _ := inst.ssh(t, key, "", "web", "theme", "show", "--json"); !strings.Contains(out, `"theme":"system"`) {
39 t.Fatalf("web form did not reset the theme: %s", out)
40 }
41 if _, body := browserGet(t, alice, set); strings.Contains(body, "data-theme") {
42 t.Fatalf("system stamps the page")
43 }
44}
internal/control/theme.go added +49
@@ -0,0 +1,49 @@
1package control
2
3import (
4 "fmt"
5 "io"
6
7 "gitbay.org/gitbay/internal/protocol"
8)
9
10func init() {
11 register(Command{Path: []string{"web", "theme", "show"},
12 Summary: "the colour scheme the web UI uses for you",
13 Usage: "web theme show",
14 ReadOnly: true, Run: runWebThemeShow})
15 register(Command{Path: []string{"web", "theme", "set"},
16 Summary: "follow the browser's scheme, or force light or dark",
17 Usage: "web theme set system|light|dark", Run: runWebThemeSet})
18}
19
20// themes are the values the layout knows how to stamp. system is the
21// default and stamps nothing: the stylesheet's media query decides.
22var themes = map[string]bool{"system": true, "light": true, "dark": true}
23
24func runWebThemeShow(c *Ctx, args []string) int {
25 if len(args) != 0 {
26 return c.usage()
27 }
28 return emitTheme(c)
29}
30
31func runWebThemeSet(c *Ctx, args []string) int {
32 if len(args) != 1 || !themes[args[0]] {
33 return c.usage()
34 }
35 if err := c.Store.SetTheme(c.User.ID, args[0]); err != nil {
36 return c.fail(protocol.ExitFailure, "%v", err)
37 }
38 return emitTheme(c)
39}
40
41func emitTheme(c *Ctx) int {
42 theme, err := c.Store.Theme(c.User.ID)
43 if err != nil {
44 return c.fail(protocol.ExitFailure, "%v", err)
45 }
46 return c.emit(map[string]string{"theme": theme}, func(w io.Writer) {
47 fmt.Fprintf(w, "theme: %s\n", theme)
48 })
49}
internal/control/theme_test.go added +36
@@ -0,0 +1,36 @@
1package control
2
3import (
4 "bytes"
5 "io"
6 "strings"
7 "testing"
8
9 "gitbay.org/gitbay/internal/protocol"
10 "gitbay.org/gitbay/internal/store"
11)
12
13// web theme show reports system for a new account, set refuses anything
14// but the three schemes, and a set value is what show reports next.
15func TestWebTheme(t *testing.T) {
16 st, _, uid := newQueueTestRepo(t)
17 var buf bytes.Buffer
18 c := &Ctx{User: store.User{ID: uid, Username: "alice"}, Store: st, Stdout: &buf, Stderr: io.Discard, JSON: true}
19
20 if code := runWebThemeShow(c, nil); code != protocol.ExitOK || !strings.Contains(buf.String(), `"theme":"system"`) {
21 t.Fatalf("show: %d %s", code, buf.String())
22 }
23 if code := runWebThemeSet(c, []string{"blue"}); code != protocol.ExitUsage {
24 t.Fatalf("bad value exited %d", code)
25 }
26 if code := runWebThemeSet(c, nil); code != protocol.ExitUsage {
27 t.Fatalf("no value exited %d", code)
28 }
29 buf.Reset()
30 if code := runWebThemeSet(c, []string{"dark"}); code != protocol.ExitOK || !strings.Contains(buf.String(), `"theme":"dark"`) {
31 t.Fatalf("set: %d %s", code, buf.String())
32 }
33 if got, _ := st.Theme(uid); got != "dark" {
34 t.Fatalf("stored theme: %q", got)
35 }
36}
internal/httpd/account.go +20 −12
@@ -59,22 +59,24 @@ func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.Use
5959 s.runControlInto(u, []string{"profile", "show"}, &profile)
6060 mailOn, _ := s.st.MailEnabled(u.ID)
6161 watchOn, _ := s.st.WatchEnabled(u.ID)
62 theme, _ := s.st.Theme(u.ID)
6263
6364 s.render(w, "account.html", struct {
6465 basePage
65 Tab string // marks the rail's Settings row as current
66 Keys []accountKey
67 PGP []accountPGP
68 Emails []store.Email
69 Profile control.ProfileOut
70 LinksText string
71 Host string
72 Notice string
73 Message string
74 MailOn bool
75 WatchOn bool
66 Tab string // marks the rail's Settings row as current
67 Keys []accountKey
68 PGP []accountPGP
69 Emails []store.Email
70 Profile control.ProfileOut
71 LinksText string
72 Host string
73 Notice string
74 Message string
75 MailOn bool
76 WatchOn bool
77 ThemeSetting string // system, light or dark: the form's selected option
7678 }{s.baseFor(u), "account", keys, pgp, emails, profile, profileLinksText(profile.Links), s.cfg.SiteHost(),
77 s.takeFlash(w, r), r.URL.Query().Get("m"), mailOn, watchOn})
79 s.takeFlash(w, r), r.URL.Query().Get("m"), mailOn, watchOn, theme})
7880}
7981
8082// accountExport hands the browser the same bundle `account export`
@@ -219,6 +221,12 @@ func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.U
219221 return
220222 }
221223 back("", "primary address changed")
224 case "theme":
225 if _, msg, ok := s.runControl(u, []string{"web", "theme", "set", r.FormValue("theme")}); !ok {
226 back(msg, "")
227 return
228 }
229 back("", "colour scheme saved")
222230 case "notify-mail", "notify-watch":
223231 pref := strings.TrimPrefix(r.FormValue("field"), "notify-")
224232 state := "off"
internal/httpd/compress.go added +85
@@ -0,0 +1,85 @@
1package httpd
2
3import (
4 "compress/gzip"
5 "net/http"
6 "strings"
7)
8
9// compressed gzips text responses for clients that accept it. Every body
10// the forge writes itself is text — pages, the stylesheet, feeds, JSON —
11// and none is precompressed; the stylesheet alone went from 67 KB to 15 KB
12// (#232). The decision is made when the headers are final, on the content
13// type, so git transport, LFS and binary downloads pass through untouched.
14func compressed(next http.Handler) http.Handler {
15 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
16 if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
17 next.ServeHTTP(w, r)
18 return
19 }
20 cw := &gzipWriter{ResponseWriter: w}
21 defer cw.Close()
22 next.ServeHTTP(cw, r)
23 })
24}
25
26var compressibleTypes = []string{"text/", "application/json", "application/atom+xml", "image/svg+xml"}
27
28func compressible(contentType string) bool {
29 for _, p := range compressibleTypes {
30 if strings.HasPrefix(contentType, p) {
31 return true
32 }
33 }
34 return false
35}
36
37// gzipWriter decides on the first WriteHeader or Write whether the body
38// is compressed, then either wraps the underlying writer or steps aside.
39type gzipWriter struct {
40 http.ResponseWriter
41 gz *gzip.Writer
42 decided bool
43}
44
45func (g *gzipWriter) decide(status int) {
46 if g.decided {
47 return
48 }
49 g.decided = true
50 h := g.Header()
51 if status == http.StatusNotModified || status == http.StatusNoContent ||
52 h.Get("Content-Encoding") != "" || !compressible(h.Get("Content-Type")) {
53 return
54 }
55 h.Del("Content-Length")
56 h.Set("Content-Encoding", "gzip")
57 h.Add("Vary", "Accept-Encoding")
58 g.gz = gzip.NewWriter(g.ResponseWriter)
59}
60
61func (g *gzipWriter) WriteHeader(status int) {
62 g.decide(status)
63 g.ResponseWriter.WriteHeader(status)
64}
65
66func (g *gzipWriter) Write(b []byte) (int, error) {
67 if !g.decided {
68 // net/http would sniff the type on this write; do it first so the
69 // decision sees it.
70 if g.Header().Get("Content-Type") == "" {
71 g.Header().Set("Content-Type", http.DetectContentType(b))
72 }
73 g.decide(http.StatusOK)
74 }
75 if g.gz == nil {
76 return g.ResponseWriter.Write(b)
77 }
78 return g.gz.Write(b)
79}
80
81func (g *gzipWriter) Close() {
82 if g.gz != nil {
83 g.gz.Close()
84 }
85}
internal/httpd/compress_test.go added +81
@@ -0,0 +1,81 @@
1package httpd
2
3import (
4 "bytes"
5 "compress/gzip"
6 "io"
7 "net/http"
8 "net/http/httptest"
9 "testing"
10
11 "gitbay.org/gitbay/internal/config"
12)
13
14func plainServer() *Server {
15 cfg := config.Default()
16 cfg.Server.SiteURL = "https://forge.test/"
17 return &Server{cfg: cfg}
18}
19
20func get(t *testing.T, h http.Handler, path string, hdr map[string]string) *httptest.ResponseRecorder {
21 t.Helper()
22 r := httptest.NewRequest("GET", path, nil)
23 r.Host = "forge.test"
24 for k, v := range hdr {
25 r.Header.Set(k, v)
26 }
27 w := httptest.NewRecorder()
28 h.ServeHTTP(w, r)
29 return w
30}
31
32// The stylesheet is gzipped for a client that accepts it and served as-is
33// for one that does not; both bodies are the same bytes (#232).
34func TestStylesheetIsCompressed(t *testing.T) {
35 h := plainServer().Handler()
36 plain := get(t, h, "/static/style.css", nil)
37 if plain.Code != 200 || plain.Header().Get("Content-Encoding") != "" {
38 t.Fatalf("identity: %d %q", plain.Code, plain.Header().Get("Content-Encoding"))
39 }
40 zipped := get(t, h, "/static/style.css", map[string]string{"Accept-Encoding": "gzip, br"})
41 if zipped.Code != 200 || zipped.Header().Get("Content-Encoding") != "gzip" || zipped.Header().Get("Vary") != "Accept-Encoding" {
42 t.Fatalf("gzip: %d %q vary=%q", zipped.Code, zipped.Header().Get("Content-Encoding"), zipped.Header().Get("Vary"))
43 }
44 if zipped.Body.Len() >= plain.Body.Len()/2 {
45 t.Fatalf("gzip body %d bytes, plain %d", zipped.Body.Len(), plain.Body.Len())
46 }
47 zr, err := gzip.NewReader(zipped.Body)
48 if err != nil {
49 t.Fatal(err)
50 }
51 body, _ := io.ReadAll(zr)
52 if !bytes.Equal(body, plain.Body.Bytes()) {
53 t.Fatal("gunzipped body differs from the identity body")
54 }
55 if zipped.Header().Get("ETag") != plain.Header().Get("ETag") {
56 t.Fatal("ETag changed with encoding")
57 }
58 // A 304 carries no body to compress and no encoding header.
59 notMod := get(t, h, "/static/style.css", map[string]string{"Accept-Encoding": "gzip", "If-None-Match": plain.Header().Get("ETag")})
60 if notMod.Code != 304 || notMod.Header().Get("Content-Encoding") != "" {
61 t.Fatalf("304: %d %q", notMod.Code, notMod.Header().Get("Content-Encoding"))
62 }
63}
64
65// A binary type is not touched: the font route keeps its bytes and no
66// encoding header.
67func TestBinaryResponsesPassThrough(t *testing.T) {
68 h := plainServer().Handler()
69 w := get(t, h, "/favicon.svg", map[string]string{"Accept-Encoding": "gzip"})
70 if w.Code != 200 || w.Header().Get("Content-Encoding") != "gzip" {
71 t.Fatalf("svg is text and should gzip: %d %q", w.Code, w.Header().Get("Content-Encoding"))
72 }
73 rec := httptest.NewRecorder()
74 compressed(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
75 w.Header().Set("Content-Type", "application/x-git-upload-pack-result")
76 w.Write([]byte("0000"))
77 })).ServeHTTP(rec, httptest.NewRequest("POST", "/x", nil))
78 if rec.Header().Get("Content-Encoding") != "" || rec.Body.String() != "0000" {
79 t.Fatalf("git transport touched: %q %q", rec.Header().Get("Content-Encoding"), rec.Body.String())
80 }
81}
internal/httpd/notfound_test.go added +26
@@ -0,0 +1,26 @@
1package httpd
2
3import (
4 "strings"
5 "testing"
6)
7
8// A path no route matches renders the 404 page, the same one a missing
9// record gets, instead of net/http's plain-text body (#232).
10func TestUnmatchedPathRendersNotFoundPage(t *testing.T) {
11 h := plainServer().Handler()
12 // One and two segment paths are owner and repository routes, which
13 // need a store; these fall past every pattern.
14 for _, p := range []string{"/krz/gitbay/mrs/315/files", "/krz/gitbay/nothing/at/all"} {
15 w := get(t, h, p, nil)
16 if w.Code != 404 {
17 t.Errorf("%s: status %d", p, w.Code)
18 }
19 if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
20 t.Errorf("%s: content type %q", p, ct)
21 }
22 if !strings.Contains(w.Body.String(), "Page not found") {
23 t.Errorf("%s: body is not the 404 page:\n%s", p, w.Body.String())
24 }
25 }
26}
internal/httpd/orgrender_test.go +1 −1
@@ -107,7 +107,7 @@ func TestOrgRenderingIsUnaffectedByTheIncludeGuard(t *testing.T) {
107107 // The source block is chroma-highlighted, so its text is split across spans;
108108 // check the block and a token rather than the joined source line.
109109 for _, want := range []string{"Heading", "<em>emphasis</em>", "<code>code</code>",
110 `<pre class="chroma">`, "Println"} {
110 `<pre tabindex="0" class="chroma">`, "Println"} {
111111 if !strings.Contains(out, want) {
112112 t.Errorf("expected %q in output:\n%s", want, out)
113113 }
internal/httpd/page.go +6
@@ -31,6 +31,9 @@ type basePage struct {
3131 Viewer string
3232 Admin bool // the viewer is an instance admin: the rail shows /admin
3333 Rail rail
34 // Theme is stamped on <html> as data-theme: light or dark when the
35 // viewer chose one, empty when the browser's own scheme decides.
36 Theme string
3437}
3538
3639// base builds the layout-wide data for a request that has not already
@@ -52,6 +55,9 @@ func (s *Server) baseFor(viewer store.User) basePage {
5255 b.Viewer = viewer.Username
5356 b.Admin = viewer.IsAdmin
5457 b.Rail = s.railFor(viewer)
58 if theme, err := s.st.Theme(viewer.ID); err == nil && theme != "system" {
59 b.Theme = theme
60 }
5561 return b
5662}
5763
internal/httpd/rendered.go added +51
@@ -0,0 +1,51 @@
1package httpd
2
3import (
4 "html/template"
5 "path"
6 "regexp"
7 "strings"
8)
9
10var (
11 preTag = regexp.MustCompile(`<pre\b[^>]*>`)
12 imgTag = regexp.MustCompile(`<img\b[^>]*>`)
13 srcAttr = regexp.MustCompile(`\ssrc="([^"]*)"`)
14)
15
16// focusableBlocks gives every <pre> a tab stop. A block wider than its
17// column scrolls sideways, and without one a keyboard has no way to reach
18// the end of a long line (#232).
19func focusableBlocks(h template.HTML) template.HTML {
20 return template.HTML(preTag.ReplaceAllStringFunc(string(h), func(m string) string {
21 if strings.Contains(m, "tabindex") {
22 return m
23 }
24 return `<pre tabindex="0"` + m[len("<pre"):]
25 }))
26}
27
28// imageAlt names an image that arrived without alt text after its file.
29// go-org writes none for a bare image link, so a README badge was an image
30// with no text inside a link with no name (#232). Markdown always carries
31// alt, empty or not, and an empty alt is left alone: it means decorative.
32func imageAlt(h template.HTML) template.HTML {
33 return template.HTML(imgTag.ReplaceAllStringFunc(string(h), func(m string) string {
34 if strings.Contains(m, " alt=") {
35 return m
36 }
37 name := ""
38 if sub := srcAttr.FindStringSubmatch(m); sub != nil {
39 src := sub[1]
40 if i := strings.IndexAny(src, "?#"); i >= 0 {
41 src = src[:i]
42 }
43 name = path.Base(src)
44 name = strings.TrimSuffix(name, path.Ext(name))
45 if name == "." || name == "/" {
46 name = ""
47 }
48 }
49 return `<img alt="` + template.HTMLEscapeString(name) + `"` + m[len("<img"):]
50 }))
51}
internal/httpd/rendered_test.go added +44
@@ -0,0 +1,44 @@
1package httpd
2
3import (
4 "html/template"
5 "strings"
6 "testing"
7)
8
9func TestFocusableBlocks(t *testing.T) {
10 got := string(focusableBlocks(`<pre class="chroma">x</pre><pre>y</pre><pre tabindex="-1">z</pre>`))
11 want := `<pre tabindex="0" class="chroma">x</pre><pre tabindex="0">y</pre><pre tabindex="-1">z</pre>`
12 if got != want {
13 t.Fatalf("got %s\nwant %s", got, want)
14 }
15}
16
17func TestImageAlt(t *testing.T) {
18 cases := map[string]string{
19 `<a href="/b"><img src="https://forge.test/krz/gitbay/badge/build.svg"/></a>`: `<a href="/b"><img alt="build" src="https://forge.test/krz/gitbay/badge/build.svg"/></a>`,
20 `<img src="pic.png?v=2">`: `<img alt="pic" src="pic.png?v=2">`,
21 `<img alt="" src="pic.png">`: `<img alt="" src="pic.png">`,
22 `<img alt="a chart" src="c.png">`: `<img alt="a chart" src="c.png">`,
23 `<img>`: `<img alt="">`,
24 }
25 for in, want := range cases {
26 if got := string(imageAlt(template.HTML(in))); got != want {
27 t.Errorf("%s\n got %s\nwant %s", in, got, want)
28 }
29 }
30}
31
32// A README badge written as a bare org image link renders as a named
33// image, and an org source block takes keyboard focus.
34func TestOrgReadmeBadgeAndBlocks(t *testing.T) {
35 out := string(renderReadme("README.org", []byte("[[https://forge.test/krz/gitbay/builds][https://forge.test/krz/gitbay/badge/build.svg]]\n\n#+begin_src go\npackage main\n#+end_src\n")))
36 for _, want := range []string{`alt="build"`, `<pre tabindex="0"`} {
37 if !strings.Contains(out, want) {
38 t.Errorf("lacks %s:\n%s", want, out)
39 }
40 }
41 if got := string(renderReadme("README.md", []byte("```go\npackage main\n```\n"))); !strings.Contains(got, `<pre tabindex="0"`) {
42 t.Errorf("markdown fence has no tab stop:\n%s", got)
43 }
44}
internal/httpd/routes.go +4 −1
@@ -230,13 +230,16 @@ func (s *Server) Handler() http.Handler {
230230 for _, r := range s.Routes() {
231231 mux.HandleFunc(r.Method+" "+r.Pattern, r.Handler)
232232 }
233 // A path no pattern matches gets the 404 page, not net/http's
234 // plain-text body (#232).
235 mux.HandleFunc("/", s.notFound)
233236 var h http.Handler = mux
234237 if len(s.cfg.GoImport) > 0 {
235238 h = s.goImportHandler(mux)
236239 }
237240 // Always wrapped: custom pages domains work with or without the
238241 // built-in [pages] domain.
239 return s.pagesRouter(s.securityHeaders(h))
242 return compressed(s.pagesRouter(s.securityHeaders(h)))
240243}
241244
242245// securityHeaders sets defensive response headers on every reply. The CSP
internal/httpd/theme.go added +61
@@ -0,0 +1,61 @@
1package httpd
2
3import (
4 "regexp"
5 "strings"
6
7 "gitbay.org/gitbay/internal/web"
8)
9
10// themedCSS derives the served stylesheet from style.css. The source keeps
11// one dark token block under the media query, which the token tests read;
12// served, that block is guarded so a page stamped data-theme="light" keeps
13// the light tokens under a dark OS, and a copy of it applies, outside any
14// media query, when the page is stamped data-theme="dark" (#232).
15func themedCSS(src []byte) []byte {
16 const open = "@media (prefers-color-scheme: dark) {\n :root {"
17 const close = "\n }\n}"
18 s := string(src)
19 i := strings.Index(s, open)
20 if i < 0 {
21 return src
22 }
23 start := i + len(open)
24 n := strings.Index(s[start:], close)
25 if n < 0 {
26 return src
27 }
28 body := s[start : start+n]
29 var b strings.Builder
30 b.WriteString(s[:i])
31 b.WriteString("@media (prefers-color-scheme: dark) {\n :root:not([data-theme=\"light\"]) {")
32 b.WriteString(body)
33 b.WriteString(close)
34 b.WriteString("\n:root[data-theme=\"dark\"] {")
35 b.WriteString(body)
36 b.WriteString("\n}")
37 b.WriteString(s[start+n+len(close):])
38 return []byte(b.String())
39}
40
41// styleCSS is what /static/style.css serves, minus the syntax palettes.
42var styleCSS = themedCSS(web.StyleCSS)
43
44// chromaRule matches the start of one rule in chroma's generated CSS: an
45// optional token-name comment, then the .chroma or .bg selector.
46var chromaRule = regexp.MustCompile(`(?m)^((?:/\*[^*]*\*/ )?)(\.chroma|\.bg)`)
47
48// scopeChroma prefixes every rule of a chroma palette with a selector, so
49// the palette applies only under that scheme. The LineLink rule is dropped:
50// it sets outline: none on the line-number links, which hid the focus ring;
51// style.css carries the rest of it.
52func scopeChroma(css, prefix string) string {
53 var out []string
54 for _, line := range strings.Split(css, "\n") {
55 if strings.Contains(line, ".lnlinks") {
56 continue
57 }
58 out = append(out, chromaRule.ReplaceAllString(line, "${1}"+prefix+" $2"))
59 }
60 return strings.Join(out, "\n")
61}
internal/httpd/theme_test.go added +64
@@ -0,0 +1,64 @@
1package httpd
2
3import (
4 "strings"
5 "testing"
6
7 "gitbay.org/gitbay/internal/web"
8)
9
10// The served stylesheet guards the dark media block against an explicit
11// light choice and repeats it, verbatim, for an explicit dark one (#232).
12func TestThemedCSSCarriesBothDarkBlocks(t *testing.T) {
13 css := string(themedCSS(web.StyleCSS))
14 const guarded = "@media (prefers-color-scheme: dark) {\n :root:not([data-theme=\"light\"]) {"
15 const stamped = "\n:root[data-theme=\"dark\"] {"
16 gi, si := strings.Index(css, guarded), strings.Index(css, stamped)
17 if gi < 0 || si < 0 {
18 t.Fatalf("guarded %d stamped %d", gi, si)
19 }
20 body := func(from int) string {
21 rest := css[from:]
22 return rest[:strings.Index(rest, "\n }\n}")]
23 }
24 guardedBody := body(gi + len(guarded))
25 rest := css[si+len(stamped):]
26 stampedBody := rest[:strings.Index(rest, "\n}")]
27 if guardedBody != stampedBody {
28 t.Fatalf("dark blocks differ:\n%s\n---\n%s", guardedBody, stampedBody)
29 }
30 if !strings.Contains(guardedBody, "--canvas: #101114;") {
31 t.Fatalf("dark block lacks the canvas token:\n%s", guardedBody)
32 }
33 if strings.Contains(css, "@media (prefers-color-scheme: dark) {\n :root {") {
34 t.Fatal("the dark token block is served unguarded")
35 }
36 // The source keeps the plain block, which the token tests parse.
37 if !strings.Contains(string(web.StyleCSS), "@media (prefers-color-scheme: dark) {\n :root {") {
38 t.Fatal("style.css no longer has the plain dark block")
39 }
40}
41
42// Each syntax palette is served four ways: under its media query unless
43// the other theme is stamped, and unconditionally under its own stamp.
44// chroma's LineLink rule, which set outline: none, is not served at all.
45func TestChromaPalettesAreScoped(t *testing.T) {
46 css := string(chromaCSS)
47 for _, want := range []string{
48 `:root:not([data-theme="dark"]) .chroma .na { color: #6f5a21 }`,
49 `:root:not([data-theme="light"]) .chroma `,
50 `:root[data-theme="light"] .chroma `,
51 `:root[data-theme="dark"] .chroma `,
52 `:root[data-theme="dark"] .bg `,
53 } {
54 if !strings.Contains(css, want) {
55 t.Errorf("lacks %s", want)
56 }
57 }
58 if strings.Contains(css, "lnlinks") {
59 t.Error("chroma's LineLink rule is served")
60 }
61 if strings.Contains(css, "\n.chroma .k ") {
62 t.Error("an unscoped palette rule is served")
63 }
64}
internal/httpd/web.go +34 −22
@@ -70,7 +70,7 @@ func (s *Server) siteName() string {
7070// changes the bytes (#132).
7171var stylesheetETag = func() string {
7272 h := sha256.New()
73 h.Write(web.StyleCSS)
73 h.Write(styleCSS)
7474 h.Write(chromaCSS)
7575 return `"` + hex.EncodeToString(h.Sum(nil))[:16] + `"`
7676}()
@@ -83,7 +83,7 @@ func (s *Server) stylesheet(w http.ResponseWriter, r *http.Request) {
8383 return
8484 }
8585 w.Header().Set("Content-Type", "text/css; charset=utf-8")
86 w.Write(web.StyleCSS)
86 w.Write(styleCSS)
8787 w.Write(chromaCSS)
8888}
8989
@@ -953,9 +953,9 @@ func highlightWith(formatter *html.Formatter, filePath string, data []byte) temp
953953 }
954954 var buf bytes.Buffer
955955 if err := formatter.Format(&buf, styles.Get(lightStyle), iterator); err != nil {
956 return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
956 return focusableBlocks(template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>"))
957957 }
958 return template.HTML(buf.String())
958 return focusableBlocks(template.HTML(buf.String()))
959959}
960960
961961// chromaCSS is both syntax palettes, each scoped to the scheme it is for.
@@ -974,21 +974,31 @@ const (
974974)
975975
976976var chromaCSS = func() []byte {
977 var buf bytes.Buffer
978 buf.WriteString("@media (prefers-color-scheme: light) {\n")
979 chromaFormatter.WriteCSS(&buf, styles.Get(lightStyle))
977 var light, dark bytes.Buffer
978 chromaFormatter.WriteCSS(&light, styles.Get(lightStyle))
980979 // xcode's NameAttribute is its one token under 4.5:1 against the diff
981980 // tints (4.51 on additions, 4.38 on deletions); darkened it clears both.
982 buf.WriteString(".chroma .na { color: #6f5a21 }\n")
981 light.WriteString(".chroma .na { color: #6f5a21 }\n")
982 chromaFormatter.WriteCSS(&dark, styles.Get(darkStyle))
983 // Each palette applies under its media query unless the page is
984 // stamped with the other theme, and again, outside any media query,
985 // when the page is stamped with its own (#232).
986 var buf bytes.Buffer
987 buf.WriteString("@media (prefers-color-scheme: light) {\n")
988 buf.WriteString(scopeChroma(light.String(), `:root:not([data-theme="dark"])`))
983989 buf.WriteString("}\n@media (prefers-color-scheme: dark) {\n")
984 chromaFormatter.WriteCSS(&buf, styles.Get(darkStyle))
985 buf.WriteString("}\n.chroma, .bg { background: transparent !important; }\n")
990 buf.WriteString(scopeChroma(dark.String(), `:root:not([data-theme="light"])`))
991 buf.WriteString("}\n")
992 buf.WriteString(scopeChroma(light.String(), `:root[data-theme="light"]`))
993 buf.WriteString(scopeChroma(dark.String(), `:root[data-theme="dark"]`))
994 buf.WriteString(".chroma, .bg { background: transparent !important; }\n")
986995 // Line numbers take the site's own gutter colour in both schemes. Left
987996 // alone they are github-dark's #6e7681 (4.31:1 on the page) in dark and
988997 // chroma's built-in #7f7f7f (3.67:1 on a code block) in light — the
989998 // latter is a formatter fallback, not a style entry, so no palette test
990 // can see it.
991 buf.WriteString(".chroma .lnt, .chroma .ln { color: var(--muted) }\n")
999 // can see it. !important because the scoped palette rules above outrank
1000 // a bare .chroma .ln.
1001 buf.WriteString(".chroma .lnt, .chroma .ln { color: var(--muted) !important }\n")
9921002 return buf.Bytes()
9931003}()
9941004
@@ -1088,9 +1098,9 @@ func mdHTML(raw string) template.HTML {
10881098 }
10891099 var buf bytes.Buffer
10901100 if markdown.Convert([]byte(raw), &buf) != nil {
1091 return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>")
1101 return focusableBlocks(template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>"))
10921102 }
1093 return template.HTML(buf.String())
1103 return focusableBlocks(template.HTML(buf.String()))
10941104}
10951105
10961106// aboutHTML renders a profile's about text. It has no filename to
@@ -1163,9 +1173,9 @@ type ugcRenderer func(raw, format string) template.HTML
11631173// rather than growing a second org renderer to keep in step.
11641174func ugcHTML(raw, format string) template.HTML {
11651175 if format == "org" {
1166 return renderOrg("body.org", []byte(raw), false, func() template.HTML {
1176 return focusableBlocks(renderOrg("body.org", []byte(raw), false, func() template.HTML {
11671177 return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>")
1168 })
1178 }))
11691179 }
11701180 return mdHTML(raw)
11711181}
@@ -1265,7 +1275,7 @@ func renderOrg(name string, raw []byte, contents bool, fallback func() template.
12651275 if err != nil {
12661276 return fallback()
12671277 }
1268 return template.HTML(ugcPolicy.Sanitize(out))
1278 return imageAlt(template.HTML(ugcPolicy.Sanitize(out)))
12691279}
12701280
12711281// orgWriter overrides go-org's autolink rendering. go-org ends a bare URL
@@ -1332,20 +1342,22 @@ func renderReadme(name string, raw []byte) template.HTML {
13321342 if gitutil.IsBinary(raw) {
13331343 return ""
13341344 }
1345 var out template.HTML
13351346 switch path.Ext(strings.ToLower(name)) {
13361347 case ".md", ".markdown":
13371348 var buf bytes.Buffer
13381349 if markdown.Convert(raw, &buf) != nil {
1339 return plain()
1350 return focusableBlocks(plain())
13401351 }
1341 return demoteHeadings(template.HTML(buf.String()))
1352 out = demoteHeadings(template.HTML(buf.String()))
13421353 case ".org":
1343 return demoteHeadings(renderOrg(name, raw, true, plain))
1354 out = demoteHeadings(renderOrg(name, raw, true, plain))
13441355 case ".html", ".htm":
1345 return template.HTML(ugcPolicy.Sanitize(string(raw)))
1356 out = template.HTML(ugcPolicy.Sanitize(string(raw)))
13461357 default:
1347 return plain()
1358 out = plain()
13481359 }
1360 return focusableBlocks(out)
13491361}
13501362
13511363type diffThread struct {
internal/store/migrations/0057_users_theme.down.sql added +1
@@ -0,0 +1 @@
1ALTER TABLE users DROP COLUMN theme;
internal/store/migrations/0057_users_theme.up.sql added +3
@@ -0,0 +1,3 @@
1-- The web colour scheme the account asked for. system follows the
2-- browser's prefers-color-scheme; light and dark override it (#232).
3ALTER TABLE users ADD COLUMN theme TEXT NOT NULL DEFAULT 'system';
internal/store/theme_test.go added +27
@@ -0,0 +1,27 @@
1package store
2
3import "testing"
4
5// A new account follows the system scheme; a set value reads back.
6func TestTheme(t *testing.T) {
7 s := open(t)
8 if err := s.MigrateUp(); err != nil {
9 t.Fatal(err)
10 }
11 uid, err := s.CreateUser("cmc", false)
12 if err != nil {
13 t.Fatal(err)
14 }
15 if got, err := s.Theme(uid); err != nil || got != "system" {
16 t.Fatalf("default theme: %q %v", got, err)
17 }
18 if err := s.SetTheme(uid, "dark"); err != nil {
19 t.Fatal(err)
20 }
21 if got, _ := s.Theme(uid); got != "dark" {
22 t.Fatalf("theme after set: %q", got)
23 }
24 if _, err := s.Theme(uid + 1); err != ErrNotFound {
25 t.Fatalf("missing user: %v", err)
26 }
27}
internal/store/users.go +16
@@ -237,6 +237,22 @@ func (s *Store) SetWatchEnabled(userID int64, on bool) error {
237237 return err
238238}
239239
240// Theme is the web colour scheme the account chose: system, light or
241// dark (#232).
242func (s *Store) Theme(userID int64) (string, error) {
243 var theme string
244 err := s.DB.QueryRow("SELECT theme FROM users WHERE id = ?", userID).Scan(&theme)
245 if errors.Is(err, sql.ErrNoRows) {
246 return "", ErrNotFound
247 }
248 return theme, err
249}
250
251func (s *Store) SetTheme(userID int64, theme string) error {
252 _, err := s.DB.Exec("UPDATE users SET theme = ? WHERE id = ?", theme, userID)
253 return err
254}
255
240256func (s *Store) UserByID(id int64) (User, error) {
241257 var u User
242258 var admin, pending, disabled int
internal/web/static/style.css +19 −3
@@ -209,6 +209,12 @@ h3 { font-size: var(--fs-3); margin: var(--sp-5) 0 var(--sp-2); }
209209p { margin: 0 0 var(--sp-3); }
210210a { color: var(--link); text-decoration: none; }
211211a:hover { text-decoration: underline; }
212/* a link inside running text is told apart by its underline: link and
213 muted text are 1.07:1 apart in dark, and colour alone is not a cue
214 (#232). Navigation, chips and buttons keep position as their cue. */
215p a, .syscomment a { text-decoration: underline; }
216/* a title or a path is the whole line: position is the cue */
217p a.button, p a.btn, p a.chip, .repotitle a, p.title a, .pathbar a, .pager a { text-decoration: none; }
212218a.xref { color: var(--link); text-decoration: none; }
213219
214220code, pre, .mono, .code, td.mode, td.size {
@@ -919,7 +925,7 @@ ul.milestonelist .title { font-weight: 500; }
919925
920926article.release { padding: var(--sp-4); margin: var(--sp-4) 0; }
921927.releasehead { background: none; border: 0; padding: 0; }
922.releasehead h3 { margin: 0 0 var(--sp-1); }
928.releasehead h2 { margin: 0 0 var(--sp-1); font-size: var(--fs-3); }
923929.releasehead .meta { margin: 0 0 var(--sp-2); }
924930
925931ul.matchlist { list-style: none; margin: var(--sp-3) 0; padding: 0; }
@@ -1188,6 +1194,11 @@ table.difftable td.ln a.cmt:focus { color: var(--link); text-decoration: underli
11881194/* chroma writes the highlighted spans; the container only supplies the
11891195 ground and the face */
11901196.chroma { font-family: var(--mono); background: none; }
1197/* chroma's own LineLink rule is dropped from the served palette: it set
1198 outline: none and hid the focus ring. This is that rule without it, on
1199 a 24px box so a line number is a target and not a glyph (#232) */
1200.chroma .lnlinks { display: inline-block; min-width: 24px; min-height: 24px; line-height: 24px; text-decoration: none; color: inherit; }
1201.chroma .line { line-height: 24px; }
11911202
11921203.blamehunk { display: flex; gap: var(--sp-3); border-top: 1px solid var(--line); align-items: flex-start; }
11931204.blamehunk:first-child { border-top: 0; }
@@ -1268,6 +1279,8 @@ a.memberchip .role { color: var(--muted); }
12681279.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); }
12691280.pathbar .spacer { flex: 1; }
12701281.pathbar .actions { display: flex; gap: var(--sp-2); }
1282/* history · blame · raw were 21px tall and "raw" 22px wide (#232) */
1283.pathbar .actions a { display: inline-block; padding: 6px 4px; }
12711284.pathbar .acts { display: flex; gap: var(--sp-2); }
12721285.crumbs { color: var(--muted); }
12731286.crumbs a { color: var(--link); }
@@ -1460,9 +1473,9 @@ svg.icon { vertical-align: -0.125em; }
14601473
14611474/* ---- breakpoints ---- */
14621475@media (max-width: 62rem) {
1476 /* one column, the aside after the thread: a phone shows the
1477 description first, checks and reviewers after it (#232) */
14631478 .withaside { grid-template-columns: 1fr; }
1464 /* checks and reviewers before a long thread */
1465 .withaside .aside { order: -1; }
14661479}
14671480
14681481@media (max-width: 52rem) {
@@ -1486,6 +1499,9 @@ svg.icon { vertical-align: -0.125em; }
14861499 latest commit */
14871500 table.tree td.lastcommit, table.tree th.lastcommit { display: none; }
14881501 table.tree td.mode, table.tree th.mode { display: none; }
1502 /* at 320px a nowrap name plus the age column overran the card by the
1503 cell padding and the card clipped the age; a long name wraps (#232) */
1504 table.tree td.name { white-space: normal; overflow-wrap: anywhere; }
14891505 .clone pre { white-space: pre-wrap; word-break: break-all; }
14901506 .pathbar { overflow-wrap: anywhere; }
14911507 .readme .cardbody, .code { padding: var(--sp-3); }
internal/web/templates/account.html +13
@@ -118,6 +118,19 @@ account, and where notifications go.</p>
118118</form>
119119<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>
120120
121<h2>Appearance</h2>
122<form method="post" action="/settings" class="setform">
123 <input type="hidden" name="field" value="theme">
124 <label for="theme">Colour scheme</label>
125 <select id="theme" name="theme">
126 <option value="system"{{if eq .ThemeSetting "system"}} selected{{end}}>follow the browser</option>
127 <option value="light"{{if eq .ThemeSetting "light"}} selected{{end}}>light</option>
128 <option value="dark"{{if eq .ThemeSetting "dark"}} selected{{end}}>dark</option>
129 </select>
130 <button type="submit" class="btn">Save</button>
131</form>
132<p class="meta">The same setting as <code>gitbay web theme set</code>. Following the browser takes its light or dark preference.</p>
133
121134<h2>Export</h2>
122135<p class="meta">Your profile, repositories, issues and merge requests as one
123136JSON bundle, the same one <code>gitbay account export</code> writes. Keys are
internal/web/templates/landing.html +1 −1
@@ -4,7 +4,7 @@
44<div class="landing">
55<h1>{{template "mark"}}{{.Site}}</h1>
66<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>
7<pre class="quickstart">ssh git@{{.Host}} help # every command, no client to install
7<pre class="quickstart" tabindex="0">ssh git@{{.Host}} help # every command, no client to install
88git clone ssh://git@{{.Host}}/owner/repo.git</pre>
99<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>
1010<div class="shot"><picture>
internal/web/templates/layout.html +1 −1
@@ -1,5 +1,5 @@
11{{define "layout"}}<!DOCTYPE html>
2<html lang="en">
2<html lang="en"{{with field . "Theme"}} data-theme="{{.}}"{{end}}>
33<head>
44<meta charset="utf-8">
55<meta name="viewport" content="width=device-width, initial-scale=1">
internal/web/templates/releases.html +1 −1
@@ -20,7 +20,7 @@
2020{{range $rel := .Releases}}
2121<article class="release">
2222 <header class="releasehead">
23 <h3>{{$rel.Title}}</h3>
23 <h2>{{$rel.Title}}</h2>
2424 <p class="meta"><span class="refchip">{{$rel.Tag}}</span> {{if $rel.Author}}{{$rel.Author}} · {{end}}{{when $rel.CreatedAt}} · <a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/archive/{{$rel.Tag}}.tar.gz">source tar.gz</a></p>
2525 </header>
2626 {{if $rel.NotesHTML}}<div class="rendered">{{$rel.NotesHTML}}</div>{{end}}