Commit a6beee8f21

a6beee8f21fc5409638136e6bc627aead3738e20

parent: b5d702053e

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-23 00:48 UTC

Add themes

Every colour in skunky.css was a literal, so changing the look meant editing the
stylesheet. They are now sixteen custom properties, with the original palette as
the default — the rendered output is unchanged for anyone who does nothing.

A light palette sits behind prefers-color-scheme, so a visitor whose system asks
for light gets it with no cookie, no query string and no JavaScript. For a
frontend whose point is not identifying people, that matters more than a theme
switcher: there is nothing to store and nothing to fingerprint.

An instance can pin one with "theme" in its config. auto is the default; dark
and light append a palette after the stylesheet, winning on order rather than
!important. An unrecognised value stops startup instead of silently meaning
auto. It shows on the About page and in /api/instance alongside the other
instance settings.

Tests cover that auto appends nothing and that neither forced palette omits a
variable — a half-applied palette falls through to the default one, which is how
a light theme ends up with black panels.

Layout: unified · split

SETUP.txt +8 −1
@@ -55,4 +55,11 @@ server {
55 proxy_pass http://((IP)):((PORT)); 55 proxy_pass http://((IP)):((PORT));
56 } 56 }
57} 57}
58```\ No newline at end of file 58```
59
60* `theme` — Palette for the interface. `auto` (default) serves the dark theme
61 and lets a visitor whose system asks for light get the light one, through
62 `prefers-color-scheme` — no cookie, no query string, no JavaScript. `dark` or
63 `light` pins one for everybody. An unrecognised value stops startup rather
64 than quietly falling back.
65
TODO.txt +1 −1
@@ -22,6 +22,6 @@
22## v1.4 22## v1.4
23 23
24- [ ] Implement an API 24- [ ] Implement an API
25- [ ] Implement themes 25- [x] Implement themes
26- [ ] Switch to arenas in the cache 26- [ ] Switch to arenas in the cache
27- [ ] Implement a multilingual interface 27- [ ] Implement a multilingual interface
app/api.go +1
@@ -28,6 +28,7 @@ func (a API) Info() {
28 Nsfw: CFG.Nsfw, 28 Nsfw: CFG.Nsfw,
29 Proxy: CFG.Proxy, 29 Proxy: CFG.Proxy,
30 HideAI: CFG.HideAI, 30 HideAI: CFG.HideAI,
31 Theme: CFG.Theme,
31 }, 32 },
32 }) 33 })
33 try(err) 34 try(err)
app/cli.go +5 −3
@@ -55,9 +55,10 @@ type settingsUrls struct {
55} 55}
56 56
57type settingsParams struct { 57type settingsParams struct {
58 Nsfw bool `json:"nsfw"` 58 Nsfw bool `json:"nsfw"`
59 Proxy bool `json:"proxy"` 59 Proxy bool `json:"proxy"`
60 HideAI bool `json:"hide-ai"` 60 HideAI bool `json:"hide-ai"`
61 Theme string `json:"theme"`
61} 62}
62 63
63type settings struct { 64type settings struct {
@@ -116,6 +117,7 @@ func addInstance() {
116 Nsfw: CFG.Nsfw, 117 Nsfw: CFG.Nsfw,
117 Proxy: CFG.Proxy, 118 Proxy: CFG.Proxy,
118 HideAI: CFG.HideAI, 119 HideAI: CFG.HideAI,
120 Theme: CFG.Theme,
119 }, 121 },
120 Urls: settingsUrls{ 122 Urls: settingsUrls{
121 Clearnet: prompt("Clearnet link", false), 123 Clearnet: prompt("Clearnet link", false),
app/config.go +27
@@ -35,6 +35,7 @@ type config struct {
35 Proxy bool `json:"proxy"` 35 Proxy bool `json:"proxy"`
36 Nsfw bool `json:"nsfw"` 36 Nsfw bool `json:"nsfw"`
37 HideAI bool `json:"hide-ai"` 37 HideAI bool `json:"hide-ai"`
38 Theme string `json:"theme"`
38 UserAgent string `json:"user-agent"` 39 UserAgent string `json:"user-agent"`
39 DownloadProxy string `json:"download-proxy"` 40 DownloadProxy string `json:"download-proxy"`
40 StaticPath string `json:"static-path"` 41 StaticPath string `json:"static-path"`
@@ -45,6 +46,7 @@ type config struct {
45var CFG = config{ 46var CFG = config{
46 cfg: "config.json", 47 cfg: "config.json",
47 Listen: "127.0.0.1:3003", 48 Listen: "127.0.0.1:3003",
49 Theme: "auto",
48 URI: "/", 50 URI: "/",
49 Cache: cacheConfig{ 51 Cache: cacheConfig{
50 Enabled: false, 52 Enabled: false,
@@ -137,9 +139,34 @@ func ExecuteConfig() {
137 Proxy: CFG.Proxy, 139 Proxy: CFG.Proxy,
138 Nsfw: CFG.Nsfw, 140 Nsfw: CFG.Nsfw,
139 HideAI: CFG.HideAI, 141 HideAI: CFG.HideAI,
142 Theme: CFG.Theme,
143 }
144
145 // A theme the stylesheet cannot honour would silently fall back to auto,
146 // so say so instead.
147 switch CFG.Theme {
148 case "auto", "dark", "light":
149 default:
150 exit("config: theme must be one of auto, dark, light; got "+CFG.Theme, 1)
140 } 151 }
141 152
142 static.StaticPath = CFG.StaticPath 153 static.StaticPath = CFG.StaticPath
143 devianter.UserAgent = CFG.UserAgent 154 devianter.UserAgent = CFG.UserAgent
144 } 155 }
145} 156}
157
158// forcedThemeCSS returns a block that pins the palette when the instance has
159// chosen a theme, or "" for "auto". Light repeats what the prefers-color-scheme
160// block already holds; dark repeats :root. Both are emitted after the
161// stylesheet so they win on order rather than on !important.
162func forcedThemeCSS() string {
163 switch CFG.Theme {
164 case "light":
165 return `
166:root{--bg:#f4f1ee;--fg:#1f2421;--fg-strong:#0d100e;--link:#1c6b78;--link-hover:#5a6b00;--edge:#8fbcae;--edge-strong:#258268;--accent:#4d27d6;--surface:#d9e8e1;--surface-sunken:#e8f0ec;--surface-alt:#e2e4f2;--surface-deep:#dbe7ef;--status-bad:#a11;--status-good:#157a3a;--status-mild:#2e8b57;--status-note:#8a007f}`
167 case "dark":
168 return `
169:root{--bg:black;--fg:rgb(234,216,216);--fg-strong:whitesmoke;--link:cadetblue;--link-hover:#d0ff00;--edge:#164e3e;--edge-strong:#258268;--accent:#4d27d6;--surface:#134134;--surface-sunken:#091f19;--surface-alt:#060820;--surface-deep:#011522;--status-bad:red;--status-good:green;--status-mild:seagreen;--status-note:rgb(160,0,147)}`
170 }
171 return ""
172}
app/router.go +7
@@ -136,6 +136,13 @@ func Router() {
136 case "stylesheet": 136 case "stylesheet":
137 w.Header().Add("Content-Type", "text/css") 137 w.Header().Add("Content-Type", "text/css")
138 _, _ = w.Write(open("css/skunky.css")) 138 _, _ = w.Write(open("css/skunky.css"))
139 // "auto" is the stylesheet as written: dark, with a light palette
140 // behind prefers-color-scheme. Forcing a theme means re-declaring
141 // that palette unconditionally, which outranks the media query
142 // because it comes later with equal specificity.
143 if css := forcedThemeCSS(); css != "" {
144 _, _ = w.Write([]byte(css))
145 }
139 case "favicon.ico": 146 case "favicon.ico":
140 _, _ = w.Write(open("images/logo.png")) 147 _, _ = w.Write(open("images/logo.png"))
141 148
app/util.go +1
@@ -73,6 +73,7 @@ type instanceAbout struct {
73 Proxy bool `json:"proxy"` 73 Proxy bool `json:"proxy"`
74 Nsfw bool `json:"nsfw"` 74 Nsfw bool `json:"nsfw"`
75 HideAI bool `json:"hide-ai"` 75 HideAI bool `json:"hide-ai"`
76 Theme string `json:"theme"`
76 Instances []settings `json:"instances"` 77 Instances []settings `json:"instances"`
77} 78}
78 79
app/util_test.go +48
@@ -72,3 +72,51 @@ func TestInstanceAboutCarriesHideAI(t *testing.T) {
72 } 72 }
73 } 73 }
74} 74}
75
76// TestForcedThemeCSSOnlyForAnExplicitChoice pins that "auto" adds nothing: the
77// stylesheet already carries dark plus a prefers-color-scheme light block, and
78// appending a palette would defeat the visitor's own system setting.
79func TestForcedThemeCSSOnlyForAnExplicitChoice(t *testing.T) {
80 theme := CFG.Theme
81 defer func() { CFG.Theme = theme }()
82
83 CFG.Theme = "auto"
84 if got := forcedThemeCSS(); got != "" {
85 t.Errorf("auto appended %q, want nothing", got)
86 }
87
88 for _, want := range []string{"dark", "light"} {
89 CFG.Theme = want
90 css := forcedThemeCSS()
91 if !strings.Contains(css, ":root{") {
92 t.Errorf("%s theme did not emit a :root block: %q", want, css)
93 }
94 if !strings.Contains(css, "--bg:") || !strings.Contains(css, "--status-note:") {
95 t.Errorf("%s theme is missing palette variables: %q", want, css)
96 }
97 }
98}
99
100// TestForcedThemeCSSCoversEveryVariable is the guard against a half-applied
101// palette: a forced theme that omits a variable falls through to the default
102// one, which is how a light theme ends up with black panels.
103func TestForcedThemeCSSCoversEveryVariable(t *testing.T) {
104 theme := CFG.Theme
105 defer func() { CFG.Theme = theme }()
106
107 vars := []string{
108 "--bg", "--fg", "--fg-strong", "--link", "--link-hover", "--edge",
109 "--edge-strong", "--accent", "--surface", "--surface-sunken",
110 "--surface-alt", "--surface-deep", "--status-bad", "--status-good",
111 "--status-mild", "--status-note",
112 }
113 for _, mode := range []string{"dark", "light"} {
114 CFG.Theme = mode
115 css := forcedThemeCSS()
116 for _, v := range vars {
117 if !strings.Contains(css, v+":") {
118 t.Errorf("%s theme does not set %s", mode, v)
119 }
120 }
121 }
122}
config.example.json +2 −1
@@ -14,5 +14,6 @@
14 "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36", 14 "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36",
15 "proxy": true, 15 "proxy": true,
16 "nsfw": false, 16 "nsfw": false,
17 "hide-ai": false 17 "hide-ai": false,
18 "theme": "auto"
18} 19}
static/css/skunky.css +74 −23
@@ -1,15 +1,66 @@
1/* THEME
2 *
3 * Every colour in this file comes from a variable below, so a theme is a
4 * palette rather than a fork of the stylesheet. The dark palette is the
5 * original SkunkyArt look and stays the default.
6 *
7 * Light is offered through prefers-color-scheme, so a visitor who has asked
8 * their system for light gets it with no cookie, no query string and no
9 * JavaScript — nothing that identifies them. An instance can force one or the
10 * other with "theme" in its config, which appends a block after this file.
11 */
12:root {
13 --bg: black;
14 --fg: rgb(234, 216, 216);
15 --fg-strong: whitesmoke;
16 --link: cadetblue;
17 --link-hover: #d0ff00;
18 --edge: #164e3e;
19 --edge-strong: #258268;
20 --accent: #4d27d6;
21 --surface: #134134;
22 --surface-sunken: #091f19;
23 --surface-alt: #060820;
24 --surface-deep: #011522;
25 --status-bad: red;
26 --status-good: green;
27 --status-mild: seagreen;
28 --status-note: rgb(160, 0, 147);
29}
30
31@media (prefers-color-scheme: light) {
32 :root {
33 --bg: #f4f1ee;
34 --fg: #1f2421;
35 --fg-strong: #0d100e;
36 --link: #1c6b78;
37 --link-hover: #5a6b00;
38 --edge: #8fbcae;
39 --edge-strong: #258268;
40 --accent: #4d27d6;
41 --surface: #d9e8e1;
42 --surface-sunken: #e8f0ec;
43 --surface-alt: #e2e4f2;
44 --surface-deep: #dbe7ef;
45 --status-bad: #a11;
46 --status-good: #157a3a;
47 --status-mild: #2e8b57;
48 --status-note: #8a007f;
49 }
50}
51
1/* TAGS */ 52/* TAGS */
2html { 53html {
3 font-family: ubuntu, system-ui; 54 font-family: ubuntu, system-ui;
4 background-color:black; 55 background-color: var(--bg);
5 color: rgb(234, 216, 216); 56 color: var(--fg);
6} 57}
7a { 58a {
8 text-decoration: none; 59 text-decoration: none;
9 color: cadetblue; 60 color: var(--link);
10} 61}
11a:hover { 62a:hover {
12 color: #d0ff00; 63 color: var(--link-hover);
13 transition: 400ms; 64 transition: 400ms;
14} 65}
15header h1 { 66header h1 {
@@ -23,13 +74,13 @@ header, form {
23 display: flex; 74 display: flex;
24} 75}
25form { 76form {
26 border: solid #164e3e 1px; 77 border: solid var(--edge) 1px;
27 max-width: fit-content; 78 max-width: fit-content;
28} 79}
29form input, button, select { 80form input, button, select {
30 background-color: #134134; 81 background-color: var(--surface);
31 padding: 5px; 82 padding: 5px;
32 color: whitesmoke; 83 color: var(--fg-strong);
33 border: 0; 84 border: 0;
34} 85}
35input:focus { 86input:focus {
@@ -46,9 +97,9 @@ input:focus {
46} 97}
47.block { 98.block {
48 padding: 0px 0px 6px 0px; 99 padding: 0px 0px 6px 0px;
49 border: 3px solid #000; 100 border: 3px solid var(--bg);
50 word-break: break-all; 101 word-break: break-all;
51 background-color: #091f19; 102 background-color: var(--surface-sunken);
52 margin-left: 5px; 103 margin-left: 5px;
53 margin-top: 5px; 104 margin-top: 5px;
54 text-align: center; 105 text-align: center;
@@ -57,7 +108,7 @@ input:focus {
57 padding: 8.5vh; 108 padding: 8.5vh;
58} 109}
59.block:hover { 110.block:hover {
60 border: 3px solid #4d27d6; 111 border: 3px solid var(--accent);
61 transition: 400ms; 112 transition: 400ms;
62} 113}
63 114
@@ -67,8 +118,8 @@ input:focus {
67 118
68/* MESSAGES */ 119/* MESSAGES */
69.msg { 120.msg {
70 background-color: #091f19; 121 background-color: var(--surface-sunken);
71 color: whitesmoke; 122 color: var(--fg-strong);
72 width: fit-content; 123 width: fit-content;
73 max-width: 90%; 124 max-width: 90%;
74 padding: 4px; 125 padding: 4px;
@@ -78,11 +129,11 @@ input:focus {
78 transition: 350ms; 129 transition: 350ms;
79} 130}
80.msg:hover { 131.msg:hover {
81 background-color: #134134; 132 background-color: var(--surface);
82} 133}
83.reply { 134.reply {
84 border-radius: 0px 2px 2px 0px; 135 border-radius: 0px 2px 2px 0px;
85 border-left: #258268 solid; 136 border-left: var(--edge-strong) solid;
86 margin-left: 40px; 137 margin-left: 40px;
87} 138}
88 139
@@ -92,18 +143,18 @@ input:focus {
92 flex-wrap: wrap; 143 flex-wrap: wrap;
93} 144}
94.folder-item { 145.folder-item {
95 background-color: #060820; 146 background-color: var(--surface-alt);
96 border: 3px solid #060820; 147 border: 3px solid var(--surface-alt);
97 width: 10% 148 width: 10%
98} 149}
99.admins .user-plate { 150.admins .user-plate {
100 width: 5%; 151 width: 5%;
101 background-color: #011522; 152 background-color: var(--surface-deep);
102} 153}
103.plates .user-plate { 154.plates .user-plate {
104 margin-left: 1%; 155 margin-left: 1%;
105 margin-bottom: 1%; 156 margin-bottom: 1%;
106 background-color: #091f19; 157 background-color: var(--surface-sunken);
107 padding: 3px; 158 padding: 3px;
108 word-break: break-all; 159 word-break: break-all;
109 text-align: center; 160 text-align: center;
@@ -122,16 +173,16 @@ input:focus {
122 173
123/* COLORS */ 174/* COLORS */
124.nsfw, .about-false { 175.nsfw, .about-false {
125 color: red; 176 color: var(--status-bad);
126} 177}
127.about-true { 178.about-true {
128 color: green; 179 color: var(--status-good);
129} 180}
130.author { 181.author {
131 color: seagreen; 182 color: var(--status-mild);
132} 183}
133.dd { 184.dd {
134 color: rgb(160, 0, 147); 185 color: var(--status-note);
135} 186}
136 187
137/* SCREEN OPTIMISATIONS */ 188/* SCREEN OPTIMISATIONS */
@@ -154,7 +205,7 @@ input:focus {
154 205
155 form { 206 form {
156 font-size: 60%; 207 font-size: 60%;
157 border: solid #164e3e 5px; 208 border: solid var(--edge) 5px;
158 } 209 }
159 210
160 .content { 211 .content {
static/html/about.htm +1
@@ -12,6 +12,7 @@
12 <li><b>NSFW</b>: <span class="about-{{.Templates.About.Nsfw}}">{{if .Templates.About.Nsfw}}YES{{else}}NO{{end}}</span></li> 12 <li><b>NSFW</b>: <span class="about-{{.Templates.About.Nsfw}}">{{if .Templates.About.Nsfw}}YES{{else}}NO{{end}}</span></li>
13 <li><b>Proxyfing</b>: <span class="about-{{.Templates.About.Proxy}}">{{if .Templates.About.Proxy}}YES{{else}}NO{{end}}</span></li> 13 <li><b>Proxyfing</b>: <span class="about-{{.Templates.About.Proxy}}">{{if .Templates.About.Proxy}}YES{{else}}NO{{end}}</span></li>
14 <li><b>Hide AI</b>: <span class="about-{{.Templates.About.HideAI}}">{{if .Templates.About.HideAI}}YES{{else}}NO{{end}}</span></li> 14 <li><b>Hide AI</b>: <span class="about-{{.Templates.About.HideAI}}">{{if .Templates.About.HideAI}}YES{{else}}NO{{end}}</span></li>
15 <li><b>Theme</b>: {{.Templates.About.Theme}}</li>
15 </ul> 16 </ul>
16 <details> 17 <details>
17 <summary><b>Instances:</b></summary> 18 <summary><b>Instances:</b></summary>