~gitpocket/gitpocket

1

feat(settings): add a dark theme

commit
8fb214d48b8f62e01985d15a40d62863b8c82985 browse tree
parent
ff1bef987ecef51d6a27bb2709c291418fbc4a0d
author
obscurejames <354576047+obscurejames@users.noreply.github.com>
signature
Verified ~james signed with a key registered by ~james
date
2026-11-04 21:55 UTC

10 files changed, +151 −59

README.md modified

@@ -22,6 +22,7 @@ The web interface is then on `127.0.0.1:3200` and wants a reverse proxy with TLS
2222 - Profiles with a pinned repository list, an activity graph and a README from your `~you/you` repository
2323 - SSH and PGP commit verification, TOTP, access tokens and Atom feeds for commits, issues and pull requests
2424 - Site search over repositories and people with language facets
25+- Light and dark themes that follow the device or a choice saved under settings
2526 - Open, invite only or closed registration and an `/admin` page for accounts, invites, the front page and storage
2627
2728 There is no wiki and no CI and neither is planned.

admin.go modified

@@ -1,6 +1,7 @@
11 package main
22
33 import (
4+ "errors"
45 "net/http"
56 "net/url"
67 "os"
@@ -76,6 +77,21 @@ func (s *Store) IsAdmin(id int64) bool {
7677 return admin
7778 }
7879
80+var themes = []string{"", "light", "dark"}
81+
82+func (s *Store) Viewer(id int64) (admin bool, theme string) {
83+ s.db.QueryRow(`select admin and not banned, theme from users where id = ?`, id).Scan(&admin, &theme)
84+ return
85+}
86+
87+func (s *Store) SetTheme(id int64, theme string) error {
88+ if !slices.Contains(themes, theme) {
89+ return errors.New("Unknown theme")
90+ }
91+ _, err := s.db.Exec(`update users set theme = ? where id = ?`, theme, id)
92+ return err
93+}
94+
7995 func (s *Store) SetAdmin(name string, admin bool) error {
8096 res, err := s.db.Exec(`update users set admin = ? where name = ? and org = 0`, admin, name)
8197 if err != nil {

flow_test.go modified

@@ -366,3 +366,25 @@ func TestBranchPushAndPullMerge(t *testing.T) {
366366 t.Error("pull request not shown as merged")
367367 }
368368 }
369+
370+func TestAppearance(t *testing.T) {
371+ s, h := testServer(t)
372+ alice, _ := s.store.CreateUser("alice", "correct horse")
373+ c, _ := s.store.CreateSession(alice.ID)
374+ if strings.Contains(get(h, "/", c).Body.String(), "data-theme") {
375+ t.Error("theme set before choosing one")
376+ }
377+ if w := post(h, "/settings/appearance", url.Values{"theme": {"dark"}}, c); w.Code != http.StatusSeeOther {
378+ t.Fatalf("saving theme gave %d", w.Code)
379+ }
380+ if !strings.Contains(get(h, "/", c).Body.String(), `data-theme="dark"`) {
381+ t.Error("dark theme not applied")
382+ }
383+ post(h, "/settings/appearance", url.Values{"theme": {`x" onload="`}}, c)
384+ if !strings.Contains(get(h, "/", c).Body.String(), `data-theme="dark"`) {
385+ t.Error("unknown theme replaced the saved one")
386+ }
387+ if strings.Contains(get(h, "/", "").Body.String(), "data-theme") {
388+ t.Error("theme leaked to logged out visitors")
389+ }
390+}

highlight.go modified

@@ -2,8 +2,10 @@ package main
22
33 import (
44 "bytes"
5+ "fmt"
56 "html/template"
67 "path/filepath"
8+ "strings"
79
810 "github.com/alecthomas/chroma/v2"
911 "github.com/alecthomas/chroma/v2/formatters/html"
@@ -48,8 +50,26 @@ func highlight(name string, content []byte) (template.HTML, bool) {
4850 return template.HTML(buf.String()), true
4951 }
5052
53+func scoped(rules []string, scope string) string {
54+ var b strings.Builder
55+ for _, r := range rules {
56+ b.WriteString(scope + r + "\n")
57+ }
58+ return b.String()
59+}
60+
5161 func highlightCSS() []byte {
52- var buf bytes.Buffer
62+ var buf, dark bytes.Buffer
5363 formatter.WriteCSS(&buf, highlightStyle)
64+ formatter.WriteCSS(&dark, styles.Get("github-dark"))
65+ var rules []string
66+ for line := range strings.Lines(dark.String()) {
67+ _, rule, ok := strings.Cut(line, "*/ ")
68+ if !ok || !strings.HasPrefix(rule, ".chroma .") || strings.Contains(rule, "background-color") {
69+ continue
70+ }
71+ rules = append(rules, strings.TrimSpace(rule))
72+ }
73+ fmt.Fprintf(&buf, "%s\n@media (prefers-color-scheme:dark){\n%s}\n", scoped(rules, ":root[data-theme=dark] "), scoped(rules, ":root:not([data-theme=light]) "))
5474 return buf.Bytes()
5575 }

pull.go modified

@@ -4,8 +4,8 @@ import (
44 "database/sql"
55 "errors"
66 "fmt"
7- "log"
87 "html/template"
8+ "log"
99 "net/http"
1010 "os"
1111 "path/filepath"

static/style.css modified

@@ -2,11 +2,11 @@
22 @font-face{font-family:"Source Sans 3";font-style:italic;font-weight:400;font-display:swap;src:url(/static/fonts/sans-400-italic.woff2) format('woff2')}
33 @font-face{font-family:"Source Sans 3";font-style:normal;font-weight:600;font-display:swap;src:url(/static/fonts/sans-600-normal.woff2) format('woff2')}
44 @font-face{font-family:"Source Sans 3";font-style:normal;font-weight:700;font-display:swap;src:url(/static/fonts/sans-700-normal.woff2) format('woff2')}
5-:root{--accent:#44467d;--accentdark:#35366b;--accentsoft:#f3f4fd;--focus:#c4c8e8;--heat0:#ebedf0;--heat1:#c4c8e8;--heat2:#959acb;--heat3:#6a6ea8;--heat4:#44467d;--fg:#212529;--muted:#6c757d;--line:#dee2e6;--soft:#f8f9fa;--bar:#ddd;--add:#1a7f37;--del:#c0392b;--addbg:#e6ffec;--delbg:#ffebe9;--code:#f8f9fa}
5+:root{--accent:#44467d;--accentdark:#35366b;--accentsoft:#f3f4fd;--focus:#c4c8e8;--heat0:#ebedf0;--heat1:#c4c8e8;--heat2:#959acb;--heat3:#6a6ea8;--heat4:#44467d;--fg:#212529;--muted:#6c757d;--line:#dee2e6;--soft:#f8f9fa;--bar:#ddd;--add:#1a7f37;--del:#c0392b;--addbg:#e6ffec;--delbg:#ffebe9;--code:#f8f9fa;--bg:#fff;--raised:#e9ecef;--raisedhover:#dde1e5;--edge:#ced4da;--hl:#fff8c5;--warnbg:#fff3cd;--warnedge:#ffeeba;--warnfg:#5c4b00;--merged:#6f42c1;--dotedge:rgba(0,0,0,.12);--link:var(--accent);--tip:#16181b;--tipfg:#fff;color-scheme:light}
66 *{box-sizing:border-box}
7-html{font:15px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","DejaVu Sans",Arial,sans-serif;background:#fff;color:var(--fg)}
7+html{font:15px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","DejaVu Sans",Arial,sans-serif;background:var(--bg);color:var(--fg)}
88 body{margin:0}
9-a{color:var(--accent);text-decoration:none}
9+a{color:var(--link);text-decoration:none}
1010 a:hover{text-decoration:underline}
1111 code,pre,.hash,.mode,.blob,.hunk,.code,textarea{font-family:"DejaVu Sans Mono",Menlo,Consolas,"Liberation Mono","SF Mono",monospace;font-size:13px}
1212 pre{background:var(--code);padding:.6rem .8rem;overflow-x:auto;border:1px solid var(--line)}
@@ -20,7 +20,7 @@ h4 a{color:var(--fg)}
2020 .muted,.when{color:var(--muted)}
2121 .small{font-size:.85rem}
2222 .tag{font-size:.75rem;border:1px solid var(--line);padding:0 .3rem;color:var(--muted);vertical-align:middle}
23-.error{color:var(--del);border:1px solid var(--del);padding:.4rem .6rem;background:#fff}
23+.error{color:var(--del);border:1px solid var(--del);padding:.4rem .6rem;background:var(--bg)}
2424 .notice{border:1px solid var(--add);padding:.4rem .6rem;background:var(--addbg)}
2525 .inner,nav.site,nav.subtabs,main{max-width:1140px;margin:0 auto;padding-left:1rem;padding-right:1rem}
2626 header{margin-bottom:1rem}
@@ -38,14 +38,14 @@ nav.site button{background:none;border:0;color:var(--muted);cursor:pointer;font:
3838 nav.subtabs{display:flex;gap:0;padding-top:.2rem;border-bottom:1px solid var(--bar);max-width:none}
3939 nav.subtabs a:first-child{margin-left:max(1rem,calc((100% - 1140px)/2 + 1rem))}
4040 nav.subtabs a,nav.tabs a{color:var(--muted);padding:.3rem .9rem}
41-nav.subtabs a.active{background:#e9ecef;color:var(--fg)}
41+nav.subtabs a.active{background:var(--raised);color:var(--fg)}
4242 nav.subtabs a:hover,nav.tabs a:hover{text-decoration:none;color:var(--fg)}
4343 .repohead{margin-top:-1rem}
4444 .repohead h2{padding:.4rem 0 .3rem;font-size:1.9rem}
4545 .tabbar{background:var(--bar)}
4646 nav.tabs{display:flex}
4747 nav.tabs a{padding:.25rem .9rem}
48-nav.tabs a.active{background:#fff;color:var(--fg)}
48+nav.tabs a.active{background:var(--bg);color:var(--fg)}
4949 .repohead .desc{margin:0 0 .4rem;color:var(--muted)}
5050 .repohead+main{margin-top:1rem}
5151 .repohead+main{margin-top:0;padding-top:.6rem}
@@ -56,7 +56,7 @@ body.fluid main.file{padding-top:.9rem}
5656 .summary{margin-top:.2rem;display:grid;grid-template-columns:4fr 1.5fr 2.6fr;gap:2rem;margin-bottom:1.5rem;align-items:start}
5757 .summary .event{background:var(--soft);padding:.4rem .5rem .5rem;margin-bottom:.6rem}
5858 .summary .event .row{display:flex;justify-content:space-between;gap:1rem}
59-.summary .event .when{font-size:.85rem;color:var(--accent);text-decoration:underline}
59+.summary .event .when{font-size:.85rem;color:var(--link);text-decoration:underline}
6060 .summary .event .hash{text-decoration:underline}
6161 .summary .subject{margin:.3rem 0 0;background:none;border:0;padding:0;white-space:pre-wrap}
6262 .summary h3{font-weight:400;font-size:1.25rem;margin:0 0 .5rem}
@@ -73,22 +73,22 @@ main{padding-bottom:3rem}
7373 .panel.narrow{max-width:34rem;margin:1rem auto}
7474 .panel .btn{margin-top:.6rem}
7575 .panel p{margin:.4rem 0 .6rem}
76-.btn{display:inline-block;font:inherit;font-size:.9rem;background:#e9ecef;color:var(--fg);border:1px solid #ccc;padding:.25rem .7rem;cursor:pointer;line-height:1.4}
77-.btn:hover{text-decoration:none;background:#dde1e5}
76+.btn{display:inline-block;font:inherit;font-size:.9rem;background:var(--raised);color:var(--fg);border:1px solid var(--edge);padding:.25rem .7rem;cursor:pointer;line-height:1.4}
77+.btn:hover{text-decoration:none;background:var(--raisedhover)}
7878 .btn.primary{background:var(--accent);color:#fff;border-color:var(--accent)}
7979 .btn.primary:hover{background:var(--accentdark);border-color:var(--accentdark)}
80-.btn.danger{background:#fff;color:var(--del);border-color:var(--del)}
80+.btn.danger{background:var(--bg);color:var(--del);border-color:var(--del)}
8181 .events .event{background:var(--soft);padding:.25rem .5rem .4rem;margin-bottom:.6rem;border-bottom:0}
8282 .event h4{font-size:1rem;font-weight:400;margin:0 0 .2rem;padding-bottom:.15rem;border-bottom:1px solid var(--line)}
83-.event h4 a{color:var(--accent)}
83+.event h4 a{color:var(--link)}
8484 .event p{margin:0;font-size:.95rem}
8585 .columns{display:grid;grid-template-columns:minmax(0,1fr) 18rem;gap:2.5rem;align-items:start}
8686 .columns.user{grid-template-columns:230px minmax(0,1fr);gap:2rem}
8787 .userhead{display:flex;align-items:flex-end;gap:1.2rem;border-bottom:1px solid var(--bar);margin:-.35rem 0 1rem;padding:0}
8888 .userhead h2{font-size:1.7rem;padding-bottom:.2rem}
89-nav.tabs.small{background:#e9ecef;align-self:flex-end;margin-bottom:.3rem}
89+nav.tabs.small{background:var(--raised);align-self:flex-end;margin-bottom:.3rem}
9090 nav.tabs.small a{padding:.1rem .8rem}
91-nav.tabs.small a.active{background:#ddd}
91+nav.tabs.small a.active{background:var(--bar)}
9292 .profile .avatar{display:block;width:230px;height:230px;margin-bottom:.9rem;border-radius:6px}
9393 .profile p{margin:.2rem 0}
9494 .profile .name{font-weight:400;font-size:1.3rem;line-height:1.25;margin:0;color:var(--fg)}
@@ -112,7 +112,7 @@ dl.meta dd{margin:0;overflow-wrap:anywhere}
112112 .filebar .spacer{flex:1}
113113 .filebar a.active{color:var(--fg);border-bottom:2px solid var(--fg);padding-bottom:.2rem}
114114 .filebar a{color:var(--muted)}
115-.filebar .crumbs a{color:var(--accent);text-decoration:underline}
115+.filebar .crumbs a{color:var(--link);text-decoration:underline}
116116 .filebar .mode{color:var(--muted);font-family:inherit;font-size:inherit}
117117 .filebar .lastcommit{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:40rem}
118118 .readme,.bio,.comment .readme{font-family:"Source Sans 3",system-ui,sans-serif;font-size:17px;line-height:1.55}
@@ -141,11 +141,11 @@ table.refs td,table.keys td{padding:.3rem 1rem .3rem 0;border-bottom:1px solid v
141141 table.blob{width:100%}
142142 table.blob td,table.hunk td,.code td{padding:0 .5rem;white-space:pre;vertical-align:top}
143143 td.n{text-align:right;color:var(--muted);user-select:none;width:1%}
144-td.n a{color:var(--accent);text-decoration:underline}
144+td.n a{color:var(--link);text-decoration:underline}
145145 table.blob td.n{border-right:1px solid var(--bar);padding:0 .5rem 0 .6rem}
146146 td.l{white-space:pre-wrap;overflow-wrap:anywhere;width:100%}
147-tr:target td{background:#fff8c5}
148-.code pre,.code .chroma{background:#fff;border:0;padding:0;margin:0}
147+tr:target td{background:var(--hl)}
148+.code pre,.code .chroma{background:var(--bg);border:0;padding:0;margin:0}
149149 .code .chroma table.lntable{width:auto}
150150 .code .lntable{margin-top:0}
151151 .code .lntd:first-child{width:auto;user-select:none;border-right:1px solid var(--bar);padding-top:.4rem;vertical-align:top}
@@ -153,10 +153,10 @@ tr:target td{background:#fff8c5}
153153 .code .lntd:first-child{min-height:70vh}
154154 .code .lnlinks{text-decoration:underline}
155155 .code .lnt{padding-right:.5rem}
156-.code .lnt,.code .ln{color:var(--accent);margin-right:0;padding:0 .5rem 0 .4rem}
157-.code .lntd a{color:var(--accent);text-decoration:none}
156+.code .lnt,.code .ln{color:var(--link);margin-right:0;padding:0 .5rem 0 .4rem}
157+.code .lntd a{color:var(--link);text-decoration:none}
158158 .code .lntd:last-child{padding-left:.6rem}
159-.code .line:target,.code .line:target .cl{background:#fff8c5}
159+.code .line:target,.code .line:target .cl{background:var(--hl)}
160160 table.hunk{width:100%;border:1px solid var(--line)}
161161 .hunk .head td{color:var(--muted);background:var(--soft)}
162162 .hunk tr.add td{background:var(--addbg)}
@@ -173,14 +173,14 @@ form.stack label{display:flex;flex-direction:column;gap:.25rem}
173173 form.stack label.check{flex-direction:row;align-items:center;gap:.5rem}
174174 form.stack small{color:var(--muted)}
175175 .pair{display:grid;grid-template-columns:1fr 1fr;gap:1rem}
176-input,textarea{font:inherit;color:var(--fg);background:#fff;border:1px solid #ced4da;padding:.35rem .55rem}
177-input:disabled{background:#e9ecef}
176+input,textarea{font:inherit;color:var(--fg);background:var(--bg);border:1px solid var(--edge);padding:.35rem .55rem}
177+input:disabled{background:var(--raised)}
178178 input:focus,textarea:focus{outline:2px solid var(--focus);border-color:var(--accent)}
179179 input[type=file]{border:0;padding:0}
180180 .actions{display:flex;justify-content:flex-end;gap:.5rem}
181181 .actions.left{justify-content:flex-start}
182182 .avatarform{display:grid;grid-template-columns:160px 1fr;gap:1.2rem;margin-bottom:1.2rem;align-items:start}
183-.avatarform .avatarbox{width:160px;height:160px;border:1px solid var(--line);background:#fff}
183+.avatarform .avatarbox{width:160px;height:160px;border:1px solid var(--line);background:var(--bg)}
184184 .avatarform .avatar{width:158px;height:158px;border:0}
185185 .avatarform label{display:block;margin-bottom:.3rem}
186186 .avatarform small{display:block;color:var(--muted);margin-bottom:.5rem}
@@ -203,14 +203,14 @@ h3.plain{border:0;font-size:1.25rem}
203203 .btn.block{display:block;text-align:center}
204204 .titlerow{display:flex;align-items:center;gap:1rem}
205205 .titlerow form{margin:0}
206-.count{background:#e9ecef;border-radius:.6rem;padding:0 .4rem;font-size:.8rem;color:var(--fg)}
206+.count{background:var(--raised);border-radius:.6rem;padding:0 .4rem;font-size:.8rem;color:var(--fg)}
207207 .pullhead{display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem}
208208 nav.filters a{margin-right:1rem;color:var(--muted)}
209209 nav.filters a.active{color:var(--fg);font-weight:600}
210210 .state{display:inline-block;font-size:.75rem;padding:0 .4rem;border-radius:3px;color:#fff;vertical-align:middle;text-transform:lowercase;margin-right:.45rem}
211-.state.open{background:#1a7f37}
212-.state.merged{background:#6f42c1}
213-.state.closed{background:#c0392b}
211+.state.open{background:var(--add)}
212+.state.merged{background:var(--merged)}
213+.state.closed{background:var(--del)}
214214 .pulltitle{margin-bottom:1rem}
215215 .pulltitle p{margin:.2rem 0}
216216 .columns.pullbody{grid-template-columns:minmax(0,1fr) 20rem;margin-bottom:1.5rem}
@@ -220,27 +220,27 @@ nav.filters a.active{color:var(--fg);font-weight:600}
220220 .comment .readme p{margin:.3rem 0}
221221 .btn.block+form{margin-top:.4rem}
222222 .btn:disabled{opacity:.5;cursor:default}
223-select{font:inherit;padding:.35rem .5rem;border:1px solid #ced4da;background:#fff}
223+select{font:inherit;padding:.35rem .5rem;border:1px solid var(--edge);background:var(--bg)}
224224 .mergeform{max-width:none;margin:.4rem 0 .8rem}
225225 .mergeform input,.mergeform textarea{width:100%}
226226 .mergeform .actions{gap:.4rem}
227227 .panel a.btn.block{margin-top:.4rem}
228-.verified{display:inline-block;font-size:.72rem;border:1px solid #1a7f37;color:#1a7f37;border-radius:3px;padding:0 .3rem;vertical-align:middle;margin-left:.3rem}
228+.verified{display:inline-block;font-size:.72rem;border:1px solid var(--add);color:var(--add);border-radius:3px;padding:0 .3rem;vertical-align:middle;margin-left:.3rem}
229229 .verified.unknown{border-color:var(--muted);color:var(--muted)}
230230 .auth{max-width:920px;margin:0 auto}
231231 .auth h3 small{font-size:.9rem;font-weight:400;margin-left:.5rem}
232232 .auth p{margin:.6rem 0 1rem}
233233 form.stack.centred{margin:0 auto 1.2rem;max-width:34rem}
234234 form.stack.centred input{width:100%}
235-.privacy{background:#fff3cd;border:1px solid #ffeeba;color:#5c4b00;padding:.6rem .8rem}
235+.privacy{background:var(--warnbg);border:1px solid var(--warnedge);color:var(--warnfg);padding:.6rem .8rem}
236236 .filesearch input{padding:.15rem .4rem;font-size:.9rem;width:12rem}
237237 .hits{margin-bottom:1rem}
238238 .hits h4{margin:0 0 .2rem}
239239 .hits table.blob{border:1px solid var(--line)}
240-mark{background:#fff3b0;padding:0}
241-.hunk td.n a{color:var(--accent);text-decoration:none}
240+mark{background:var(--hl);padding:0}
241+.hunk td.n a{color:var(--link);text-decoration:none}
242242 .hunk td.n a:hover{text-decoration:underline;font-weight:600}
243-.hunk tr.inline td{white-space:normal;padding:.4rem .6rem;background:#fff;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}
243+.hunk tr.inline td{white-space:normal;padding:.4rem .6rem;background:var(--bg);border-top:1px solid var(--line);border-bottom:1px solid var(--line)}
244244 .hunk tr.inline .comment{margin:0;max-width:60rem}
245245 .hunk tr.inline.resolved .comment{opacity:.7}
246246 .comment .who{display:flex;gap:.5rem;align-items:center}
@@ -253,12 +253,12 @@ mark{background:#fff3b0;padding:0}
253253 .comment.outdated{opacity:.8}
254254 .mergeform label.radio{flex-direction:row;flex-wrap:wrap;align-items:center;gap:.4rem;margin-bottom:.1rem}
255255 .mergeform label.radio small{flex-basis:100%;color:var(--muted);padding-left:1.4rem}
256-.btn.starred{background:var(--accentsoft);border-color:var(--focus);color:var(--accent)}
257-.btn.starred:hover{background:#e8eaf8}
256+.btn.starred{background:var(--accentsoft);border-color:var(--focus);color:var(--link)}
257+.btn.starred:hover{background:var(--accentsoft)}
258258 .staricon{width:13px;height:13px;fill:none;stroke:currentColor;stroke-width:1.4;vertical-align:-2px;margin-right:.3rem}
259259 .btn.starred .staricon{fill:currentColor}
260260 .btn.static{cursor:default}
261-.archived{background:#fff3cd;border:1px solid #ffeeba;color:#5c4b00;padding:.3rem .6rem;margin:.4rem 0 0}
261+.archived{background:var(--warnbg);border:1px solid var(--warnedge);color:var(--warnfg);padding:.3rem .6rem;margin:.4rem 0 0}
262262 .compareform{display:flex;gap:.6rem;align-items:flex-end;margin-bottom:1rem;flex-wrap:wrap}
263263 .compareform label{display:flex;flex-direction:column;font-size:.85rem;color:var(--muted)}
264264 table.blame{width:100%}
@@ -277,7 +277,7 @@ table.blame td.n{border-right:1px solid var(--bar)}
277277 .event.repo p.tags{margin-top:.15rem}
278278 .event p.repometa{display:flex;gap:.9rem;color:var(--muted);font-size:.78rem;margin:.3rem 0 0}
279279 .repometa span{display:inline-flex;align-items:center}
280-.repometa .dot{width:10px;height:10px;border-radius:50%;margin-right:.35rem;border:1px solid rgba(0,0,0,.12)}
280+.repometa .dot{width:10px;height:10px;border-radius:50%;margin-right:.35rem;border:1px solid var(--dotedge)}
281281 .repometa svg{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:1.3;margin-right:.25rem}
282282 .label{display:inline-block;color:#fff;font-size:.75rem;padding:0 .45rem;border-radius:.6rem;vertical-align:middle;line-height:1.4;font-weight:500;text-shadow:0 0 2px rgba(0,0,0,.3);margin-left:.35rem}
283283 .labelbar .label,.metaform .label,table .label{margin-left:0}
@@ -285,11 +285,11 @@ a.label{opacity:.75}a.label.on,a.label:hover{opacity:1;text-decoration:none}
285285 .labelbar{margin:-.4rem 0 1rem}
286286 .state.draft{background:var(--muted)}
287287 .verdict{font-size:.8rem;padding:0 .35rem;border-radius:3px;border:1px solid}
288-.verdict.approved{color:#1a7f37;border-color:#1a7f37}
289-.verdict.changes{color:#c0392b;border-color:#c0392b}
288+.verdict.approved{color:var(--add);border-color:var(--add)}
289+.verdict.changes{color:var(--del);border-color:var(--del)}
290290 .verdict.comment{color:var(--muted);border-color:var(--line)}
291-.comment.review.approved .who{background:#e6ffec}
292-.comment.review.changes .who{background:#ffebe9}
291+.comment.review.approved .who{background:var(--addbg)}
292+.comment.review.changes .who{background:var(--delbg)}
293293 .reviewverdict{margin-top:1.2rem;border-top:1px solid var(--line);padding-top:.6rem}
294294 .reviewverdict h4{margin:0 0 .3rem}
295295 .reviewverdict label.radio{flex-direction:row;flex-wrap:wrap;align-items:center;gap:.4rem;margin-bottom:.1rem}
@@ -310,9 +310,9 @@ h3.featured{font-size:1.3rem;font-weight:400;margin:2rem 0 .5rem}
310310 .featuredgrid{display:grid;grid-template-columns:1fr 1fr;gap:.9rem 1rem}
311311 .feature{background:var(--soft);padding:.5rem .5rem .9rem}
312312 .feature h4{margin:0 0 .4rem;font-weight:700}
313-.feature h4 a{color:var(--accent)}
313+.feature h4 a{color:var(--link)}
314314 .feature h4 a.owner{color:var(--muted)}
315-.feature blockquote{margin:0 0 .5rem;padding:0 0 0 .5rem;border-left:2px solid #aaa}
315+.feature blockquote{margin:0 0 .5rem;padding:0 0 0 .5rem;border-left:2px solid var(--muted)}
316316 .feature .tags{margin:0}
317317 .feature .tags a{color:var(--muted);text-decoration:underline;margin-right:.75rem}
318318 .landing .more{display:flex;justify-content:flex-end;margin-top:.9rem}
@@ -321,19 +321,19 @@ h3.featured{font-size:1.3rem;font-weight:400;margin:2rem 0 .5rem}
321321 .readme video,.readme img{max-width:100%;height:auto}
322322 .auth h3{white-space:nowrap}
323323 .pickaxe{display:flex;gap:.5rem;margin:0 0 1rem;flex-wrap:wrap}.pickaxe input{flex:1;min-width:14rem}
324-.pickaxe select{font:inherit;color:var(--fg);background:#fff;border:1px solid #ced4da;padding:.3rem .4rem}
324+.pickaxe select{font:inherit;color:var(--fg);background:var(--bg);border:1px solid var(--edge);padding:.3rem .4rem}
325325 .feedlink{margin-right:.6rem}
326326 .revisions{display:flex;flex-direction:column;gap:.2rem;margin:0 0 1rem}
327327 .revpick{display:flex;gap:.4rem;flex-wrap:wrap;margin:0 0 .8rem}
328328 .revpick a{padding:.15rem .5rem;border:1px solid var(--line);color:var(--fg)}
329-.revpick a.active{background:var(--soft);border-color:#999}
329+.revpick a.active{background:var(--soft);border-color:var(--muted)}
330330 pre.rangediff{line-height:1.45}
331331 .rangediff span{display:block}
332332 .rangediff .add{background:var(--addbg)}
333333 .rangediff .del{background:var(--delbg)}
334334 .rangediff .hunk{color:var(--muted)}
335335 .rangediff .same{color:var(--muted)}
336-.rangediff .changed{font-weight:700;color:#8a5a00}
336+.rangediff .changed{font-weight:700;color:var(--warnfg)}
337337 .rangediff .new{font-weight:700;color:var(--add)}
338338 .rangediff .gone{font-weight:700;color:var(--del)}
339339 table.hunk td.l,.hunk .head td{white-space:pre-wrap;overflow-wrap:anywhere;width:100%}
@@ -353,7 +353,7 @@ pre.rangediff,pre.body{white-space:pre-wrap;overflow-wrap:anywhere}
353353 .heat .cells{grid-column:2;grid-row:2;display:grid;grid-auto-flow:column;grid-template-rows:repeat(7,auto);grid-template-columns:repeat(var(--weeks),1fr);gap:2px}
354354 .heat .c,.heat .pad{aspect-ratio:1;border-radius:2px;position:relative}
355355 .heat .c:hover,.heat .c:focus{outline:1px solid var(--fg);outline-offset:0}
356-.heat .c:hover::after,.heat .c:focus::after{content:attr(data-tip);position:absolute;bottom:calc(100% + 6px);left:50%;transform:translateX(-50%);background:#16181b;color:#fff;font-size:12px;padding:.2rem .45rem;border-radius:3px;white-space:nowrap;z-index:5;pointer-events:none}
356+.heat .c:hover::after,.heat .c:focus::after{content:attr(data-tip);position:absolute;bottom:calc(100% + 6px);left:50%;transform:translateX(-50%);background:var(--tip);color:var(--tipfg);font-size:12px;padding:.2rem .45rem;border-radius:3px;white-space:nowrap;z-index:5;pointer-events:none}
357357 .heat .c.l:hover::after,.heat .c.l:focus::after{left:0;transform:none}
358358 .heat .c.r:hover::after,.heat .c.r:focus::after{left:auto;right:0;transform:none}
359359 .h0{background:var(--heat0)}.h1{background:var(--heat1)}.h2{background:var(--heat2)}.h3{background:var(--heat3)}.h4{background:var(--heat4)}
@@ -372,7 +372,7 @@ pre.rangediff,pre.body{white-space:pre-wrap;overflow-wrap:anywhere}
372372 .navsearch{flex:0 1 20rem;margin-left:.5rem}
373373 .navsearch input{width:100%;padding:.2rem .55rem;font-size:.9rem}
374374 .searchpage{display:grid;grid-template-columns:15rem 1fr;gap:2rem;align-items:start}
375-.dot{display:inline-block;width:.7rem;height:.7rem;border-radius:50%;margin-right:.4rem;vertical-align:-.05rem;border:1px solid rgba(0,0,0,.12)}
375+.dot{display:inline-block;width:.7rem;height:.7rem;border-radius:50%;margin-right:.4rem;vertical-align:-.05rem;border:1px solid var(--dotedge)}
376376 .bigsearch{display:flex;gap:.5rem;margin:0 0 1.2rem 17rem}
377377 .bigsearch input{flex:1;font-size:1rem;padding:.45rem .7rem}
378378 .resulthead{display:flex;justify-content:space-between;align-items:baseline;gap:1rem;flex-wrap:wrap;border-bottom:1px solid var(--line);padding-bottom:.4rem;margin-bottom:.2rem}
@@ -382,7 +382,7 @@ pre.rangediff,pre.body{white-space:pre-wrap;overflow-wrap:anywhere}
382382 .sorts a.active{color:var(--fg);font-weight:600}
383383 .hit{padding:.9rem 0;border-bottom:1px solid var(--line)}
384384 .hit h4{margin:0 0 .25rem;font-size:1.05rem;font-weight:600}
385-.hit h4 a{color:var(--accent)}
385+.hit h4 a{color:var(--link)}
386386 .hit h4 a.owner{color:var(--muted)}
387387 .hit p{margin:.15rem 0}
388388 .hit .tags a{color:var(--muted);text-decoration:underline;margin-right:.6rem;font-size:.85rem}
@@ -401,9 +401,9 @@ pre.rangediff,pre.body{white-space:pre-wrap;overflow-wrap:anywhere}
401401 .facetlist{display:flex;flex-direction:column;gap:1px}
402402 .facetlist a{display:flex;align-items:center;gap:.55rem;padding:.35rem .45rem;color:var(--fg);border-radius:4px;font-size:.93rem}
403403 .facetlist a:hover{background:var(--soft);text-decoration:none}
404-.facetlist a.active{background:var(--accentsoft);color:var(--accent);font-weight:600;box-shadow:inset 2px 0 0 var(--accent);border-radius:0 4px 4px 0}
404+.facetlist a.active{background:var(--accentsoft);color:var(--link);font-weight:600;box-shadow:inset 2px 0 0 var(--accent);border-radius:0 4px 4px 0}
405405 .facetlist svg{width:16px;height:16px;flex:none;fill:none;stroke:currentColor;stroke-width:1.4;stroke-linecap:round;stroke-linejoin:round;color:var(--muted)}
406-.facetlist a.active svg{color:var(--accent)}
406+.facetlist a.active svg{color:var(--link)}
407407 .facetlist .dot{margin:0 .1rem 0 .15rem;width:.65rem;height:.65rem}
408408 .facetlist .fname{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
409409 .facetlist .count{background:none;padding:0;color:var(--muted);font-size:.8rem;font-variant-numeric:tabular-nums;font-weight:400}
@@ -462,15 +462,18 @@ nav.site .me form{margin:0}
462462 .introhead a{color:var(--muted)}
463463 .forkicon{width:13px;height:13px;fill:none;stroke:currentColor;stroke-width:1.4;vertical-align:-2px;margin-right:.3rem}
464464 .pushnote{display:flex;align-items:center;gap:.6rem;background:var(--accentsoft);border:1px solid var(--focus);padding:.45rem .7rem;margin:0 0 1rem;font-size:.92rem}
465-.pushnote svg{width:15px;height:15px;fill:none;stroke:var(--accent);stroke-width:1.4;flex-shrink:0}
465+.pushnote svg{width:15px;height:15px;fill:none;stroke:var(--link);stroke-width:1.4;flex-shrink:0}
466466 .pushnote span{flex:1}
467467 .pushnote .btn{white-space:nowrap}
468468 .branchpick{position:relative}
469-.branchpick summary{list-style:none;cursor:pointer;display:inline-flex;align-items:center;gap:.3rem;border:1px solid var(--line);background:#fff;padding:.05rem .5rem;font-size:.88rem}
469+.branchpick summary{list-style:none;cursor:pointer;display:inline-flex;align-items:center;gap:.3rem;border:1px solid var(--line);background:var(--bg);padding:.05rem .5rem;font-size:.88rem}
470470 .branchpick summary::-webkit-details-marker{display:none}
471471 .branchpick summary svg{width:13px;height:13px;fill:none;stroke:currentColor;stroke-width:1.4}
472-.branchpick>div{position:absolute;z-index:10;top:calc(100% + 4px);left:0;min-width:12rem;max-height:18rem;overflow:auto;background:#fff;border:1px solid var(--line);box-shadow:0 4px 12px rgba(0,0,0,.08);display:flex;flex-direction:column}
472+.branchpick>div{position:absolute;z-index:10;top:calc(100% + 4px);left:0;min-width:12rem;max-height:18rem;overflow:auto;background:var(--bg);border:1px solid var(--line);box-shadow:0 4px 12px rgba(0,0,0,.08);display:flex;flex-direction:column}
473473 .branchpick>div a{padding:.3rem .7rem;color:var(--fg);font-size:.9rem}
474474 .branchpick>div a:hover{background:var(--soft);text-decoration:none}
475-.branchpick>div a.current{font-weight:600;color:var(--accent)}
475+.branchpick>div a.current{font-weight:600;color:var(--link)}
476476 label.radio input{width:auto;margin:0}
477+:root[data-theme=dark]{--accent:#5b5fa8;--accentdark:#4a4d91;--accentsoft:#24263d;--focus:#4a4d91;--link:#a3a7ee;--heat0:#262626;--heat1:#2f3262;--heat2:#454991;--heat3:#666bc4;--heat4:#9ca0f0;--fg:#f4f4f4;--muted:#a8a8a8;--line:#393939;--soft:#1f1f1f;--bar:#393939;--add:#42be65;--del:#fa4d56;--addbg:#0e2a17;--delbg:#3a1518;--code:#1f1f1f;--bg:#161616;--raised:#262626;--raisedhover:#333;--edge:#525252;--hl:#3d3412;--warnbg:#2d2510;--warnedge:#5c4b10;--warnfg:#f1c21b;--merged:#a56eff;--dotedge:rgba(255,255,255,.15);--tip:#f4f4f4;--tipfg:#161616;color-scheme:dark}
478+:root[data-theme=dark] nav.site .brand img{filter:brightness(2.2) saturate(.9)}
479+@media (prefers-color-scheme:dark){:root:not([data-theme=light]){--accent:#5b5fa8;--accentdark:#4a4d91;--accentsoft:#24263d;--focus:#4a4d91;--link:#a3a7ee;--heat0:#262626;--heat1:#2f3262;--heat2:#454991;--heat3:#666bc4;--heat4:#9ca0f0;--fg:#f4f4f4;--muted:#a8a8a8;--line:#393939;--soft:#1f1f1f;--bar:#393939;--add:#42be65;--del:#fa4d56;--addbg:#0e2a17;--delbg:#3a1518;--code:#1f1f1f;--bg:#161616;--raised:#262626;--raisedhover:#333;--edge:#525252;--hl:#3d3412;--warnbg:#2d2510;--warnedge:#5c4b10;--warnfg:#f1c21b;--merged:#a56eff;--dotedge:rgba(255,255,255,.15);--tip:#f4f4f4;--tipfg:#161616;color-scheme:dark}:root:not([data-theme=light]) nav.site .brand img{filter:brightness(2.2) saturate(.9)}}

store.go modified

@@ -81,6 +81,7 @@ var migrations = []string{
8181 `alter table users add column org integer not null default 0`,
8282 `alter table users add column admin integer not null default 0`,
8383 `alter table repos add column pinned integer not null default 0`,
84+ `alter table users add column theme text not null default ''`,
8485 }
8586
8687 const extraSchema = `

templates/appearance.html added

@@ -0,0 +1,9 @@
1+{{define "content"}}
2+<div class="panel"><h3>Theme</h3>
3+<form method="post" action="/settings/appearance" class="stack">
4+<label class="radio"><input type="radio" name="theme" value=""{{if eq .Theme ""}} checked{{end}}> System <small>Follow the light or dark setting of your device</small></label>
5+<label class="radio"><input type="radio" name="theme" value="light"{{if eq .Theme "light"}} checked{{end}}> Light</label>
6+<label class="radio"><input type="radio" name="theme" value="dark"{{if eq .Theme "dark"}} checked{{end}}> Dark</label>
7+<div class="actions left"><button class="btn primary">Save ▸</button></div>
8+</form></div>
9+{{end}}

templates/layout.html modified

@@ -1,5 +1,5 @@
11 <!doctype html>
2-<html lang="en">
2+<html lang="en"{{with .Theme}} data-theme="{{.}}"{{end}}>
33 <head>
44 <link rel="icon" type="image/png" href="/static/{{if .Icon}}site/{{end}}icon.png?v={{.Version}}">
55 <meta charset="utf-8">
@@ -30,6 +30,7 @@
3030 <a href="/settings/security"{{if eq . "security"}} class="active"{{end}}>security</a>
3131 <a href="/settings/keys"{{if eq . "keys"}} class="active"{{end}}>keys</a>
3232 <a href="/settings/tokens"{{if eq . "tokens"}} class="active"{{end}}>tokens</a>
33+<a href="/settings/appearance"{{if eq . "appearance"}} class="active"{{end}}>appearance</a>
3334 </nav>
3435 {{end}}{{end}}
3536 </header>

web.go modified

@@ -74,6 +74,7 @@ type page struct {
7474 Admin bool
7575 Logo bool
7676 Icon bool
77+ Theme string
7778 }
7879
7980 func newServer(store *Store, root, host, name string, sshUser string) *Server {
@@ -180,7 +181,7 @@ func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, p p
180181 p.User = s.current(r)
181182 }
182183 if p.User != nil {
183- p.Admin = s.store.IsAdmin(p.User.ID)
184+ p.Admin, p.Theme = s.store.Viewer(p.User.ID)
184185 }
185186 t, ok := s.pages[name]
186187 if !ok {
@@ -264,6 +265,8 @@ func (s *Server) handler() http.Handler {
264265 mux.HandleFunc("GET /settings", s.settings)
265266 mux.HandleFunc("GET /settings/security", s.security)
266267 mux.HandleFunc("GET /settings/keys", s.keysPage)
268+ mux.HandleFunc("GET /settings/appearance", s.appearance)
269+ mux.HandleFunc("POST /settings/appearance", s.appearance)
267270 mux.HandleFunc("GET /settings/tokens", s.tokensPage)
268271 mux.HandleFunc("POST /settings/sessions", s.endSessions)
269272 mux.HandleFunc("POST /settings/keys", s.addKey)
@@ -658,6 +661,22 @@ func (s *Server) tokensPage(w http.ResponseWriter, r *http.Request) {
658661 s.render(w, r, "tokens", page{Title: "Tokens", User: u, Error: r.URL.Query().Get("error"), Data: map[string]any{"Settings": "tokens", "Tokens": tokens, "NewToken": r.URL.Query().Get("token")}})
659662 }
660663
664+func (s *Server) appearance(w http.ResponseWriter, r *http.Request) {
665+ u := s.require(w, r)
666+ if u == nil {
667+ return
668+ }
669+ if r.Method == "POST" {
670+ if err := s.store.SetTheme(u.ID, r.FormValue("theme")); err != nil {
671+ http.Redirect(w, r, "/settings/appearance?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
672+ return
673+ }
674+ http.Redirect(w, r, "/settings/appearance", http.StatusSeeOther)
675+ return
676+ }
677+ s.render(w, r, "appearance", page{Title: "Appearance", User: u, Error: r.URL.Query().Get("error"), Data: map[string]any{"Settings": "appearance"}})
678+}
679+
661680 func (s *Server) endSessions(w http.ResponseWriter, r *http.Request) {
662681 u := s.require(w, r)
663682 if u == nil {