Add light/dark/system theme
Every CSS custom property gets a light-mode value (media query for automatic, :root[data-theme] override for the manual nav switcher), persisted via a cookie — same server-rendered pattern as the existing EN/FR language switcher, no client JS. Fixed a real bug found while wiring the new template field: the CSP script hash was computed from a second, separate shellTpl execution that didn't get the new Theme field, silently failing and pinning the hash of an empty string — would have broken every page's inline script in production.
9 files changed
+114 −19
M
CHANGELOG.md
+4 −0
M
ROADMAP.md
+0 −7
M
cmd/gitfed-web/render.go
+47 −6
M
cmd/gitfed-web/routes.go
+1 −0
M
cmd/gitfed-web/security_headers.go
+3 −3
M
cmd/gitfed-web/security_headers_test.go
+3 −3
A
cmd/gitfed-web/theme.go
+50 −0
M
internal/i18n/strings_en.go
+3 −0
M
internal/i18n/strings_fr.go
+3 −0
CHANGELOG.md
@@ -2,6 +2,10 @@
A bullet starting with `**BREAKING:**` flags a change gitfed-ctl's update wizard makes you acknowledge individually before it will let you upgrade past that version.
+## 1.2.16
+
+- Light/dark/system theme. Every CSS token gets a light-mode value, applied automatically via `prefers-color-scheme` or forced via a new nav switcher (Auto/Clair/Sombre) that persists across visits in a cookie — same server-rendered pattern as the existing EN/FR language switcher, no client-side JS. Found and fixed a real bug while wiring the new template field through: the CSP script hash was computed from a second, separate template execution that didn't get the new field, silently failing and pinning the hash of an *empty* string — would have broken every page's inline script under the CSP in production.
+
## 1.2.15
- Fixed a real command-injection bug found during a security review: `gitfed-ctl` and `gitfed-install` both built `sh -c "docker save '<tag>' | k3s ctr images import -"` with `shellQuote`, a bare wrap in single quotes that doesn't escape an embedded single quote — a `VERSION` file (or a cloned source's VERSION) containing one could break out of the quoting and run arbitrary shell commands as root. Both now pipe `docker save`/`k3s ctr images import` directly in Go (`runPiped`, no shell at all), so there's nothing to escape. Drop-in fix, no action needed.
ROADMAP.md
@@ -102,13 +102,6 @@ celui-là la prochaine fois qu'on rouvre ce document.
*(effort élevé — indexation à construire, faible valeur tant que le
nombre de dépôts reste petit)*.
-### Personnalisation
-
-- **Thème clair/sombre/système** — aujourd'hui une seule palette sombre
- fixe (`render.go`), aucune bascule. Bien cadré (redéfinir chaque
- token en version claire, mécanisme de bascule, persistance) et sans
- risque architectural. *(effort moyen)*.
-
### Fédération & import
- **Mirroring continu** depuis un dépôt externe (re-fetch périodique,
cmd/gitfed-web/render.go
@@ -220,7 +220,7 @@ func roleLabel(lang i18n.Lang, role string) string {
}
const shellHeadSrc = `<!doctype html>
-<html>
+<html{{if .Theme}} data-theme="{{.Theme}}"{{end}}>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -239,6 +239,42 @@ const shellHeadSrc = `<!doctype html>
--violet-bg: rgba(167,139,250,0.14); --violet-fg: #b9a3f7;
--mono: "SF Mono", "IBM Plex Mono", ui-monospace, Menlo, Consolas, monospace;
}
+ /* Light theme: automatic (OS preference) ... */
+ @media (prefers-color-scheme: light) {
+ :root {
+ --canvas: #f4f5f7; --surface: #ffffff; --surface-2: #edeff2; --surface-3: #e1e4e9;
+ --border: #dde1e6; --border-strong: #c5cbd3;
+ --text: #1a1d23; --text-dim: #565d6b; --text-faint: #868fa0;
+ --accent: #3f6fd1; --accent-dim: #d7e3fb; --accent-ink: #ffffff;
+ --ok-bg: rgba(30,122,76,0.12); --ok-fg: #1e7a4c;
+ --pending-bg: rgba(150,101,10,0.12); --pending-fg: #96650a;
+ --danger-bg: rgba(178,58,46,0.12); --danger-fg: #b23a2e;
+ --violet-bg: rgba(104,66,194,0.12); --violet-fg: #6842c2;
+ }
+ }
+ /* ... and forced, via the nav switcher (see theme.go) — these win over
+ the media query above (and over each other vs. the base :root) purely
+ by selector specificity, regardless of source order. */
+ :root[data-theme="light"] {
+ --canvas: #f4f5f7; --surface: #ffffff; --surface-2: #edeff2; --surface-3: #e1e4e9;
+ --border: #dde1e6; --border-strong: #c5cbd3;
+ --text: #1a1d23; --text-dim: #565d6b; --text-faint: #868fa0;
+ --accent: #3f6fd1; --accent-dim: #d7e3fb; --accent-ink: #ffffff;
+ --ok-bg: rgba(30,122,76,0.12); --ok-fg: #1e7a4c;
+ --pending-bg: rgba(150,101,10,0.12); --pending-fg: #96650a;
+ --danger-bg: rgba(178,58,46,0.12); --danger-fg: #b23a2e;
+ --violet-bg: rgba(104,66,194,0.12); --violet-fg: #6842c2;
+ }
+ :root[data-theme="dark"] {
+ --canvas: #0d0f13; --surface: #161a21; --surface-2: #1c212a; --surface-3: #232933;
+ --border: #262c36; --border-strong: #333a46;
+ --text: #e8eaed; --text-dim: #9aa1ac; --text-faint: #6b7280;
+ --accent: #6c9df5; --accent-dim: #3a5a8f; --accent-ink: #0d0f13;
+ --ok-bg: rgba(95,191,143,0.14); --ok-fg: #7bd6a8;
+ --pending-bg: rgba(224,179,78,0.14); --pending-fg: #e0b34e;
+ --danger-bg: rgba(242,139,130,0.14); --danger-fg: #f28b82;
+ --violet-bg: rgba(167,139,250,0.14); --violet-fg: #b9a3f7;
+ }
* { box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; margin: 0; background: var(--canvas); color: var(--text); font-size: 15px; line-height: 1.5; -webkit-font-smoothing: antialiased; }
a { color: inherit; }
@@ -709,6 +745,11 @@ const shellHeadSrc = `<!doctype html>
</div>
</form>
<div class="gf-nav-right">
+ <div class="gf-lang">
+ <a href="/theme/system?next={{.NextPath}}"{{if not .Theme}} class="active"{{end}}>{{t .Lang "theme.system"}}</a>
+ <a href="/theme/light?next={{.NextPath}}"{{if eq .Theme "light"}} class="active"{{end}}>{{t .Lang "theme.light"}}</a>
+ <a href="/theme/dark?next={{.NextPath}}"{{if eq .Theme "dark"}} class="active"{{end}}>{{t .Lang "theme.dark"}}</a>
+ </div>
<div class="gf-lang">
<a href="/lang/en?next={{.NextPath}}"{{if eq .Lang "en"}} class="active"{{end}}>EN</a>
<a href="/lang/fr?next={{.NextPath}}"{{if eq .Lang "fr"}} class="active"{{end}}>FR</a>
@@ -856,13 +897,13 @@ func (s *server) render(w http.ResponseWriter, r *http.Request, title, active st
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = shellTpl.Execute(w, struct {
- Title, Domain, Active, Username, Version, Initials, SearchQuery, Lang, NextPath string
- LoggedIn, IsAdmin bool
- PendingNotifs, PendingTrust int
- Body, BrandMark, IconSprite template.HTML
+ Title, Domain, Active, Username, Version, Initials, SearchQuery, Lang, Theme, NextPath string
+ LoggedIn, IsAdmin bool
+ PendingNotifs, PendingTrust int
+ Body, BrandMark, IconSprite template.HTML
}{
title, s.domain, active, sess.Username, version.Version, initials(sess.Username), r.URL.Query().Get("q"),
- string(s.lang(r)), r.URL.RequestURI(),
+ string(s.lang(r)), s.theme(r), r.URL.RequestURI(),
loggedIn, sess.IsAdmin, pendingNotifs, pendingTrust, body, template.HTML(brandMark), template.HTML(iconSprite),
})
}
cmd/gitfed-web/routes.go
@@ -20,6 +20,7 @@ func (s *server) routes(mux *http.ServeMux) {
mux.HandleFunc("GET /explore", s.handleExplore)
mux.HandleFunc("GET /u/{username}", s.handleUserProfile)
mux.HandleFunc("GET /lang/{lang}", s.handleSetLang)
+ mux.HandleFunc("GET /theme/{theme}", s.handleSetTheme)
// Anonymous read-only git-over-HTTPS for public repos (clone/fetch
// only — no receive-pack). Deliberately at the site root, not under a
cmd/gitfed-web/security_headers.go
@@ -26,9 +26,9 @@ var scriptHash = "sha256-" + func() string {
func renderedShellScript() string {
var buf bytes.Buffer
_ = shellTpl.Execute(&buf, struct {
- Title, Domain, Active, Username, Version, Initials, SearchQuery, Lang, NextPath string
- LoggedIn, IsAdmin bool
- Body, BrandMark, IconSprite template.HTML
+ Title, Domain, Active, Username, Version, Initials, SearchQuery, Lang, Theme, NextPath string
+ LoggedIn, IsAdmin bool
+ Body, BrandMark, IconSprite template.HTML
}{Lang: "en"})
html := buf.String()
open := strings.Index(html, "<script>")
cmd/gitfed-web/security_headers_test.go
@@ -17,9 +17,9 @@ import (
func TestCSPScriptHashMatchesRenderedScript(t *testing.T) {
var buf bytes.Buffer
err := shellTpl.Execute(&buf, struct {
- Title, Domain, Active, Username, Version, Initials, SearchQuery, Lang, NextPath string
- LoggedIn, IsAdmin bool
- Body, BrandMark, IconSprite template.HTML
+ Title, Domain, Active, Username, Version, Initials, SearchQuery, Lang, Theme, NextPath string
+ LoggedIn, IsAdmin bool
+ Body, BrandMark, IconSprite template.HTML
}{Title: "t", Lang: "en"})
if err != nil {
t.Fatalf("execute shell: %v", err)
cmd/gitfed-web/theme.go
@@ -0,0 +1,50 @@
+package main
+
+import (
+ "net/http"
+ "strings"
+ "time"
+)
+
+const themeCookieName = "gitfed_theme"
+
+// theme resolves the UI theme for a request from its cookie (set via the
+// nav switcher) — "light" or "dark" force that theme regardless of the
+// browser's own preference, "" (unset, or any other value) means "system":
+// no data-theme attribute is rendered at all, so the page's
+// prefers-color-scheme media query decides instead. There's no
+// Accept-Language-style fallback here on purpose — unlike language, "system"
+// is a legitimate, common default, not a missing signal to guess around.
+func (s *server) theme(r *http.Request) string {
+ if c, err := r.Cookie(themeCookieName); err == nil && (c.Value == "light" || c.Value == "dark") {
+ return c.Value
+ }
+ return ""
+}
+
+// handleSetTheme stores the chosen theme in a cookie (or clears it for
+// "system") and bounces back to wherever the switcher was clicked from —
+// same shape as handleSetLang.
+func (s *server) handleSetTheme(w http.ResponseWriter, r *http.Request) {
+ choice := r.PathValue("theme")
+ cookie := &http.Cookie{
+ Name: themeCookieName,
+ Path: "/",
+ HttpOnly: true,
+ Secure: true,
+ SameSite: http.SameSiteLaxMode,
+ }
+ if choice == "light" || choice == "dark" {
+ cookie.Value = choice
+ cookie.Expires = time.Now().Add(365 * 24 * time.Hour)
+ } else {
+ cookie.MaxAge = -1 // "system" — clear any previous explicit choice
+ }
+ http.SetCookie(w, cookie)
+
+ next := r.URL.Query().Get("next")
+ if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") {
+ next = "/"
+ }
+ http.Redirect(w, r, next, http.StatusSeeOther)
+}
internal/i18n/strings_en.go
@@ -13,6 +13,9 @@ var en = map[string]string{
"nav.admin": "Admin",
"nav.logout": "Log out",
"nav.notifications": "Notifications",
+ "theme.system": "Auto",
+ "theme.light": "Light",
+ "theme.dark": "Dark",
"footer.changelog": "Gitfed %s",
// ---------- explore ----------
internal/i18n/strings_fr.go
@@ -13,6 +13,9 @@ var fr = map[string]string{
"nav.admin": "Administration",
"nav.logout": "Se déconnecter",
"nav.notifications": "Notifications",
+ "theme.system": "Auto",
+ "theme.light": "Clair",
+ "theme.dark": "Sombre",
"footer.changelog": "Gitfed %s",
// ---------- explore ----------