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).
308| notification inbox | yes | yes | yes | 308| notification inbox | yes | yes | yes |
309| activity mail on, off | yes | yes | yes | 309| activity mail on, off | yes | yes | yes |
310| watch writable repos | yes | yes | yes | 310| watch writable repos | yes | yes | yes |
311| web colour scheme | yes | yes | n/a |
311| API token mint | yes | no | no | 312| API token mint | yes | no | no |
312| account export bundle | yes | yes | n/a | 313| account export bundle | yes | yes | n/a |
313| profile set | yes | yes | yes | 314| 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>=
653or =--all= ends them from the terminal, which is where a lost laptop is 653or =--all= ends them from the terminal, which is where a lost laptop is
654handled. 654handled.
655 655
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
656* Notifications 661* Notifications
657 662
658When the instance has SMTP configured, activity mails you as well as 663When 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
4automatically on daemon start; upgrade notes appear per release when 4automatically on daemon start; upgrade notes appear per release when
5anything beyond "replace the binary and restart" is needed. 5anything beyond "replace the binary and restart" is needed.
6 6
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
7* v1.29.0 — 2026-09-19 32* v1.29.0 — 2026-09-19
8 33
9The icon nav is a top bar at every width. 34The icon nav is a top bar at every width.
cmd/gitbay/main.go +4
@@ -647,6 +647,10 @@ func webCmd() *cobra.Command {
647 pass("list", "list your browser sessions", passOpts{server: []string{"web", "sessions", "list"}}), 647 pass("list", "list your browser sessions", passOpts{server: []string{"web", "sessions", "list"}}),
648 pass("revoke", "end a browser session: <id>|--all", passOpts{server: []string{"web", "sessions", "revoke"}}), 648 pass("revoke", "end a browser session: <id>|--all", passOpts{server: []string{"web", "sessions", "revoke"}}),
649 ), 649 ),
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 ),
650 ) 654 )
651} 655}
652 656
e2e/readonly_test.go +1
@@ -97,6 +97,7 @@ func TestReadOnlyCommandsWriteNothing(t *testing.T) {
97 "pgp list": {}, 97 "pgp list": {},
98 "token list": {}, 98 "token list": {},
99 "web sessions list": {}, 99 "web sessions list": {},
100 "web theme show": {},
100 "account export": {}, 101 "account export": {},
101 "org list": {}, 102 "org list": {},
102 "repo list": {}, 103 "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
59 s.runControlInto(u, []string{"profile", "show"}, &profile) 59 s.runControlInto(u, []string{"profile", "show"}, &profile)
60 mailOn, _ := s.st.MailEnabled(u.ID) 60 mailOn, _ := s.st.MailEnabled(u.ID)
61 watchOn, _ := s.st.WatchEnabled(u.ID) 61 watchOn, _ := s.st.WatchEnabled(u.ID)
62 theme, _ := s.st.Theme(u.ID)
62 63
63 s.render(w, "account.html", struct { 64 s.render(w, "account.html", struct {
64 basePage 65 basePage
65 Tab string // marks the rail's Settings row as current 66 Tab string // marks the rail's Settings row as current
66 Keys []accountKey 67 Keys []accountKey
67 PGP []accountPGP 68 PGP []accountPGP
68 Emails []store.Email 69 Emails []store.Email
69 Profile control.ProfileOut 70 Profile control.ProfileOut
70 LinksText string 71 LinksText string
71 Host string 72 Host string
72 Notice string 73 Notice string
73 Message string 74 Message string
74 MailOn bool 75 MailOn bool
75 WatchOn bool 76 WatchOn bool
77 ThemeSetting string // system, light or dark: the form's selected option
76 }{s.baseFor(u), "account", keys, pgp, emails, profile, profileLinksText(profile.Links), s.cfg.SiteHost(), 78 }{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})
78} 80}
79 81
80// accountExport hands the browser the same bundle `account export` 82// 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
219 return 221 return
220 } 222 }
221 back("", "primary address changed") 223 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")
222 case "notify-mail", "notify-watch": 230 case "notify-mail", "notify-watch":
223 pref := strings.TrimPrefix(r.FormValue("field"), "notify-") 231 pref := strings.TrimPrefix(r.FormValue("field"), "notify-")
224 state := "off" 232 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) {
107 // The source block is chroma-highlighted, so its text is split across spans; 107 // The source block is chroma-highlighted, so its text is split across spans;
108 // check the block and a token rather than the joined source line. 108 // check the block and a token rather than the joined source line.
109 for _, want := range []string{"Heading", "<em>emphasis</em>", "<code>code</code>", 109 for _, want := range []string{"Heading", "<em>emphasis</em>", "<code>code</code>",
110 `<pre class="chroma">`, "Println"} { 110 `<pre tabindex="0" class="chroma">`, "Println"} {
111 if !strings.Contains(out, want) { 111 if !strings.Contains(out, want) {
112 t.Errorf("expected %q in output:\n%s", want, out) 112 t.Errorf("expected %q in output:\n%s", want, out)
113 } 113 }
internal/httpd/page.go +6
@@ -31,6 +31,9 @@ type basePage struct {
31 Viewer string 31 Viewer string
32 Admin bool // the viewer is an instance admin: the rail shows /admin 32 Admin bool // the viewer is an instance admin: the rail shows /admin
33 Rail rail 33 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
34} 37}
35 38
36// base builds the layout-wide data for a request that has not already 39// 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 {
52 b.Viewer = viewer.Username 55 b.Viewer = viewer.Username
53 b.Admin = viewer.IsAdmin 56 b.Admin = viewer.IsAdmin
54 b.Rail = s.railFor(viewer) 57 b.Rail = s.railFor(viewer)
58 if theme, err := s.st.Theme(viewer.ID); err == nil && theme != "system" {
59 b.Theme = theme
60 }
55 return b 61 return b
56} 62}
57 63
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 {
230 for _, r := range s.Routes() { 230 for _, r := range s.Routes() {
231 mux.HandleFunc(r.Method+" "+r.Pattern, r.Handler) 231 mux.HandleFunc(r.Method+" "+r.Pattern, r.Handler)
232 } 232 }
233 // A path no pattern matches gets the 404 page, not net/http's
234 // plain-text body (#232).
235 mux.HandleFunc("/", s.notFound)
233 var h http.Handler = mux 236 var h http.Handler = mux
234 if len(s.cfg.GoImport) > 0 { 237 if len(s.cfg.GoImport) > 0 {
235 h = s.goImportHandler(mux) 238 h = s.goImportHandler(mux)
236 } 239 }
237 // Always wrapped: custom pages domains work with or without the 240 // Always wrapped: custom pages domains work with or without the
238 // built-in [pages] domain. 241 // built-in [pages] domain.
239 return s.pagesRouter(s.securityHeaders(h)) 242 return compressed(s.pagesRouter(s.securityHeaders(h)))
240} 243}
241 244
242// securityHeaders sets defensive response headers on every reply. The CSP 245// 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 {
70// changes the bytes (#132). 70// changes the bytes (#132).
71var stylesheetETag = func() string { 71var stylesheetETag = func() string {
72 h := sha256.New() 72 h := sha256.New()
73 h.Write(web.StyleCSS) 73 h.Write(styleCSS)
74 h.Write(chromaCSS) 74 h.Write(chromaCSS)
75 return `"` + hex.EncodeToString(h.Sum(nil))[:16] + `"` 75 return `"` + hex.EncodeToString(h.Sum(nil))[:16] + `"`
76}() 76}()
@@ -83,7 +83,7 @@ func (s *Server) stylesheet(w http.ResponseWriter, r *http.Request) {
83 return 83 return
84 } 84 }
85 w.Header().Set("Content-Type", "text/css; charset=utf-8") 85 w.Header().Set("Content-Type", "text/css; charset=utf-8")
86 w.Write(web.StyleCSS) 86 w.Write(styleCSS)
87 w.Write(chromaCSS) 87 w.Write(chromaCSS)
88} 88}
89 89
@@ -953,9 +953,9 @@ func highlightWith(formatter *html.Formatter, filePath string, data []byte) temp
953 } 953 }
954 var buf bytes.Buffer 954 var buf bytes.Buffer
955 if err := formatter.Format(&buf, styles.Get(lightStyle), iterator); err != nil { 955 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>"))
957 } 957 }
958 return template.HTML(buf.String()) 958 return focusableBlocks(template.HTML(buf.String()))
959} 959}
960 960
961// chromaCSS is both syntax palettes, each scoped to the scheme it is for. 961// chromaCSS is both syntax palettes, each scoped to the scheme it is for.
@@ -974,21 +974,31 @@ const (
974) 974)
975 975
976var chromaCSS = func() []byte { 976var chromaCSS = func() []byte {
977 var buf bytes.Buffer 977 var light, dark bytes.Buffer
978 buf.WriteString("@media (prefers-color-scheme: light) {\n") 978 chromaFormatter.WriteCSS(&light, styles.Get(lightStyle))
979 chromaFormatter.WriteCSS(&buf, styles.Get(lightStyle))
980 // xcode's NameAttribute is its one token under 4.5:1 against the diff 979 // xcode's NameAttribute is its one token under 4.5:1 against the diff
981 // tints (4.51 on additions, 4.38 on deletions); darkened it clears both. 980 // 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"])`))
983 buf.WriteString("}\n@media (prefers-color-scheme: dark) {\n") 989 buf.WriteString("}\n@media (prefers-color-scheme: dark) {\n")
984 chromaFormatter.WriteCSS(&buf, styles.Get(darkStyle)) 990 buf.WriteString(scopeChroma(dark.String(), `:root:not([data-theme="light"])`))
985 buf.WriteString("}\n.chroma, .bg { background: transparent !important; }\n") 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")
986 // Line numbers take the site's own gutter colour in both schemes. Left 995 // Line numbers take the site's own gutter colour in both schemes. Left
987 // alone they are github-dark's #6e7681 (4.31:1 on the page) in dark and 996 // alone they are github-dark's #6e7681 (4.31:1 on the page) in dark and
988 // chroma's built-in #7f7f7f (3.67:1 on a code block) in light — the 997 // chroma's built-in #7f7f7f (3.67:1 on a code block) in light — the
989 // latter is a formatter fallback, not a style entry, so no palette test 998 // latter is a formatter fallback, not a style entry, so no palette test
990 // can see it. 999 // can see it. !important because the scoped palette rules above outrank
991 buf.WriteString(".chroma .lnt, .chroma .ln { color: var(--muted) }\n") 1000 // a bare .chroma .ln.
1001 buf.WriteString(".chroma .lnt, .chroma .ln { color: var(--muted) !important }\n")
992 return buf.Bytes() 1002 return buf.Bytes()
993}() 1003}()
994 1004
@@ -1088,9 +1098,9 @@ func mdHTML(raw string) template.HTML {
1088 } 1098 }
1089 var buf bytes.Buffer 1099 var buf bytes.Buffer
1090 if markdown.Convert([]byte(raw), &buf) != nil { 1100 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>"))
1092 } 1102 }
1093 return template.HTML(buf.String()) 1103 return focusableBlocks(template.HTML(buf.String()))
1094} 1104}
1095 1105
1096// aboutHTML renders a profile's about text. It has no filename to 1106// aboutHTML renders a profile's about text. It has no filename to
@@ -1163,9 +1173,9 @@ type ugcRenderer func(raw, format string) template.HTML
1163// rather than growing a second org renderer to keep in step. 1173// rather than growing a second org renderer to keep in step.
1164func ugcHTML(raw, format string) template.HTML { 1174func ugcHTML(raw, format string) template.HTML {
1165 if format == "org" { 1175 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 {
1167 return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>") 1177 return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>")
1168 }) 1178 }))
1169 } 1179 }
1170 return mdHTML(raw) 1180 return mdHTML(raw)
1171} 1181}
@@ -1265,7 +1275,7 @@ func renderOrg(name string, raw []byte, contents bool, fallback func() template.
1265 if err != nil { 1275 if err != nil {
1266 return fallback() 1276 return fallback()
1267 } 1277 }
1268 return template.HTML(ugcPolicy.Sanitize(out)) 1278 return imageAlt(template.HTML(ugcPolicy.Sanitize(out)))
1269} 1279}
1270 1280
1271// orgWriter overrides go-org's autolink rendering. go-org ends a bare URL 1281// 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 {
1332 if gitutil.IsBinary(raw) { 1342 if gitutil.IsBinary(raw) {
1333 return "" 1343 return ""
1334 } 1344 }
1345 var out template.HTML
1335 switch path.Ext(strings.ToLower(name)) { 1346 switch path.Ext(strings.ToLower(name)) {
1336 case ".md", ".markdown": 1347 case ".md", ".markdown":
1337 var buf bytes.Buffer 1348 var buf bytes.Buffer
1338 if markdown.Convert(raw, &buf) != nil { 1349 if markdown.Convert(raw, &buf) != nil {
1339 return plain() 1350 return focusableBlocks(plain())
1340 } 1351 }
1341 return demoteHeadings(template.HTML(buf.String())) 1352 out = demoteHeadings(template.HTML(buf.String()))
1342 case ".org": 1353 case ".org":
1343 return demoteHeadings(renderOrg(name, raw, true, plain)) 1354 out = demoteHeadings(renderOrg(name, raw, true, plain))
1344 case ".html", ".htm": 1355 case ".html", ".htm":
1345 return template.HTML(ugcPolicy.Sanitize(string(raw))) 1356 out = template.HTML(ugcPolicy.Sanitize(string(raw)))
1346 default: 1357 default:
1347 return plain() 1358 out = plain()
1348 } 1359 }
1360 return focusableBlocks(out)
1349} 1361}
1350 1362
1351type diffThread struct { 1363type 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 {
237 return err 237 return err
238} 238}
239 239
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
240func (s *Store) UserByID(id int64) (User, error) { 256func (s *Store) UserByID(id int64) (User, error) {
241 var u User 257 var u User
242 var admin, pending, disabled int 258 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); }
209p { margin: 0 0 var(--sp-3); } 209p { margin: 0 0 var(--sp-3); }
210a { color: var(--link); text-decoration: none; } 210a { color: var(--link); text-decoration: none; }
211a:hover { text-decoration: underline; } 211a: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; }
212a.xref { color: var(--link); text-decoration: none; } 218a.xref { color: var(--link); text-decoration: none; }
213 219
214code, pre, .mono, .code, td.mode, td.size { 220code, pre, .mono, .code, td.mode, td.size {
@@ -919,7 +925,7 @@ ul.milestonelist .title { font-weight: 500; }
919 925
920article.release { padding: var(--sp-4); margin: var(--sp-4) 0; } 926article.release { padding: var(--sp-4); margin: var(--sp-4) 0; }
921.releasehead { background: none; border: 0; padding: 0; } 927.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); }
923.releasehead .meta { margin: 0 0 var(--sp-2); } 929.releasehead .meta { margin: 0 0 var(--sp-2); }
924 930
925ul.matchlist { list-style: none; margin: var(--sp-3) 0; padding: 0; } 931ul.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
1188/* chroma writes the highlighted spans; the container only supplies the 1194/* chroma writes the highlighted spans; the container only supplies the
1189 ground and the face */ 1195 ground and the face */
1190.chroma { font-family: var(--mono); background: none; } 1196.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; }
1191 1202
1192.blamehunk { display: flex; gap: var(--sp-3); border-top: 1px solid var(--line); align-items: flex-start; } 1203.blamehunk { display: flex; gap: var(--sp-3); border-top: 1px solid var(--line); align-items: flex-start; }
1193.blamehunk:first-child { border-top: 0; } 1204.blamehunk:first-child { border-top: 0; }
@@ -1268,6 +1279,8 @@ a.memberchip .role { color: var(--muted); }
1268.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); } 1279.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); }
1269.pathbar .spacer { flex: 1; } 1280.pathbar .spacer { flex: 1; }
1270.pathbar .actions { display: flex; gap: var(--sp-2); } 1281.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; }
1271.pathbar .acts { display: flex; gap: var(--sp-2); } 1284.pathbar .acts { display: flex; gap: var(--sp-2); }
1272.crumbs { color: var(--muted); } 1285.crumbs { color: var(--muted); }
1273.crumbs a { color: var(--link); } 1286.crumbs a { color: var(--link); }
@@ -1460,9 +1473,9 @@ svg.icon { vertical-align: -0.125em; }
1460 1473
1461/* ---- breakpoints ---- */ 1474/* ---- breakpoints ---- */
1462@media (max-width: 62rem) { 1475@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) */
1463 .withaside { grid-template-columns: 1fr; } 1478 .withaside { grid-template-columns: 1fr; }
1464 /* checks and reviewers before a long thread */
1465 .withaside .aside { order: -1; }
1466} 1479}
1467 1480
1468@media (max-width: 52rem) { 1481@media (max-width: 52rem) {
@@ -1486,6 +1499,9 @@ svg.icon { vertical-align: -0.125em; }
1486 latest commit */ 1499 latest commit */
1487 table.tree td.lastcommit, table.tree th.lastcommit { display: none; } 1500 table.tree td.lastcommit, table.tree th.lastcommit { display: none; }
1488 table.tree td.mode, table.tree th.mode { display: none; } 1501 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; }
1489 .clone pre { white-space: pre-wrap; word-break: break-all; } 1505 .clone pre { white-space: pre-wrap; word-break: break-all; }
1490 .pathbar { overflow-wrap: anywhere; } 1506 .pathbar { overflow-wrap: anywhere; }
1491 .readme .cardbody, .code { padding: var(--sp-3); } 1507 .readme .cardbody, .code { padding: var(--sp-3); }
internal/web/templates/account.html +13
@@ -118,6 +118,19 @@ account, and where notifications go.</p>
118</form> 118</form>
119<p class="meta">Every issue and merge request on those repositories, as if you had watched each. A watch or mute on a repository still wins.</p> 119<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>
120 120
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
121<h2>Export</h2> 134<h2>Export</h2>
122<p class="meta">Your profile, repositories, issues and merge requests as one 135<p class="meta">Your profile, repositories, issues and merge requests as one
123JSON bundle, the same one <code>gitbay account export</code> writes. Keys are 136JSON bundle, the same one <code>gitbay account export</code> writes. Keys are
internal/web/templates/landing.html +1 −1
@@ -4,7 +4,7 @@
4<div class="landing"> 4<div class="landing">
5<h1>{{template "mark"}}{{.Site}}</h1> 5<h1>{{template "mark"}}{{.Site}}</h1>
6<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> 6<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
8git clone ssh://git@{{.Host}}/owner/repo.git</pre> 8git clone ssh://git@{{.Host}}/owner/repo.git</pre>
9<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> 9<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>
10<div class="shot"><picture> 10<div class="shot"><picture>
internal/web/templates/layout.html +1 −1
@@ -1,5 +1,5 @@
1{{define "layout"}}<!DOCTYPE html> 1{{define "layout"}}<!DOCTYPE html>
2<html lang="en"> 2<html lang="en"{{with field . "Theme"}} data-theme="{{.}}"{{end}}>
3<head> 3<head>
4<meta charset="utf-8"> 4<meta charset="utf-8">
5<meta name="viewport" content="width=device-width, initial-scale=1"> 5<meta name="viewport" content="width=device-width, initial-scale=1">
internal/web/templates/releases.html +1 −1
@@ -20,7 +20,7 @@
20{{range $rel := .Releases}} 20{{range $rel := .Releases}}
21<article class="release"> 21<article class="release">
22 <header class="releasehead"> 22 <header class="releasehead">
23 <h3>{{$rel.Title}}</h3> 23 <h2>{{$rel.Title}}</h2>
24 <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> 24 <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>
25 </header> 25 </header>
26 {{if $rel.NotesHTML}}<div class="rendered">{{$rel.NotesHTML}}</div>{{end}} 26 {{if $rel.NotesHTML}}<div class="rendered">{{$rel.NotesHTML}}</div>{{end}}