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 {
5555 proxy_pass http://((IP)):((PORT));
5656 }
5757}
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 @@
2222## v1.4
2323
2424- [ ] Implement an API
25- [ ] Implement themes
25- [x] Implement themes
2626- [ ] Switch to arenas in the cache
2727- [ ] Implement a multilingual interface
app/api.go +1
@@ -28,6 +28,7 @@ func (a API) Info() {
2828 Nsfw: CFG.Nsfw,
2929 Proxy: CFG.Proxy,
3030 HideAI: CFG.HideAI,
31 Theme: CFG.Theme,
3132 },
3233 })
3334 try(err)
app/cli.go +5 −3
@@ -55,9 +55,10 @@ type settingsUrls struct {
5555}
5656
5757type settingsParams struct {
58 Nsfw bool `json:"nsfw"`
59 Proxy bool `json:"proxy"`
60 HideAI bool `json:"hide-ai"`
58 Nsfw bool `json:"nsfw"`
59 Proxy bool `json:"proxy"`
60 HideAI bool `json:"hide-ai"`
61 Theme string `json:"theme"`
6162}
6263
6364type settings struct {
@@ -116,6 +117,7 @@ func addInstance() {
116117 Nsfw: CFG.Nsfw,
117118 Proxy: CFG.Proxy,
118119 HideAI: CFG.HideAI,
120 Theme: CFG.Theme,
119121 },
120122 Urls: settingsUrls{
121123 Clearnet: prompt("Clearnet link", false),
app/config.go +27
@@ -35,6 +35,7 @@ type config struct {
3535 Proxy bool `json:"proxy"`
3636 Nsfw bool `json:"nsfw"`
3737 HideAI bool `json:"hide-ai"`
38 Theme string `json:"theme"`
3839 UserAgent string `json:"user-agent"`
3940 DownloadProxy string `json:"download-proxy"`
4041 StaticPath string `json:"static-path"`
@@ -45,6 +46,7 @@ type config struct {
4546var CFG = config{
4647 cfg: "config.json",
4748 Listen: "127.0.0.1:3003",
49 Theme: "auto",
4850 URI: "/",
4951 Cache: cacheConfig{
5052 Enabled: false,
@@ -137,9 +139,34 @@ func ExecuteConfig() {
137139 Proxy: CFG.Proxy,
138140 Nsfw: CFG.Nsfw,
139141 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)
140151 }
141152
142153 static.StaticPath = CFG.StaticPath
143154 devianter.UserAgent = CFG.UserAgent
144155 }
145156}
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() {
136136 case "stylesheet":
137137 w.Header().Add("Content-Type", "text/css")
138138 _, _ = 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 }
139146 case "favicon.ico":
140147 _, _ = w.Write(open("images/logo.png"))
141148
app/util.go +1
@@ -73,6 +73,7 @@ type instanceAbout struct {
7373 Proxy bool `json:"proxy"`
7474 Nsfw bool `json:"nsfw"`
7575 HideAI bool `json:"hide-ai"`
76 Theme string `json:"theme"`
7677 Instances []settings `json:"instances"`
7778}
7879
app/util_test.go +48
@@ -72,3 +72,51 @@ func TestInstanceAboutCarriesHideAI(t *testing.T) {
7272 }
7373 }
7474}
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 @@
1414 "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",
1515 "proxy": true,
1616 "nsfw": false,
17 "hide-ai": false
17 "hide-ai": false,
18 "theme": "auto"
1819}
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
152/* TAGS */
253html {
354 font-family: ubuntu, system-ui;
4 background-color:black;
5 color: rgb(234, 216, 216);
55 background-color: var(--bg);
56 color: var(--fg);
657}
758a {
859 text-decoration: none;
9 color: cadetblue;
60 color: var(--link);
1061}
1162a:hover {
12 color: #d0ff00;
63 color: var(--link-hover);
1364 transition: 400ms;
1465}
1566header h1 {
@@ -23,13 +74,13 @@ header, form {
2374 display: flex;
2475}
2576form {
26 border: solid #164e3e 1px;
77 border: solid var(--edge) 1px;
2778 max-width: fit-content;
2879}
2980form input, button, select {
30 background-color: #134134;
81 background-color: var(--surface);
3182 padding: 5px;
32 color: whitesmoke;
83 color: var(--fg-strong);
3384 border: 0;
3485}
3586input:focus {
@@ -46,9 +97,9 @@ input:focus {
4697}
4798.block {
4899 padding: 0px 0px 6px 0px;
49 border: 3px solid #000;
100 border: 3px solid var(--bg);
50101 word-break: break-all;
51 background-color: #091f19;
102 background-color: var(--surface-sunken);
52103 margin-left: 5px;
53104 margin-top: 5px;
54105 text-align: center;
@@ -57,7 +108,7 @@ input:focus {
57108 padding: 8.5vh;
58109}
59110.block:hover {
60 border: 3px solid #4d27d6;
111 border: 3px solid var(--accent);
61112 transition: 400ms;
62113}
63114
@@ -67,8 +118,8 @@ input:focus {
67118
68119/* MESSAGES */
69120.msg {
70 background-color: #091f19;
71 color: whitesmoke;
121 background-color: var(--surface-sunken);
122 color: var(--fg-strong);
72123 width: fit-content;
73124 max-width: 90%;
74125 padding: 4px;
@@ -78,11 +129,11 @@ input:focus {
78129 transition: 350ms;
79130}
80131.msg:hover {
81 background-color: #134134;
132 background-color: var(--surface);
82133}
83134.reply {
84135 border-radius: 0px 2px 2px 0px;
85 border-left: #258268 solid;
136 border-left: var(--edge-strong) solid;
86137 margin-left: 40px;
87138}
88139
@@ -92,18 +143,18 @@ input:focus {
92143 flex-wrap: wrap;
93144}
94145.folder-item {
95 background-color: #060820;
96 border: 3px solid #060820;
146 background-color: var(--surface-alt);
147 border: 3px solid var(--surface-alt);
97148 width: 10%
98149}
99150.admins .user-plate {
100151 width: 5%;
101 background-color: #011522;
152 background-color: var(--surface-deep);
102153}
103154.plates .user-plate {
104155 margin-left: 1%;
105156 margin-bottom: 1%;
106 background-color: #091f19;
157 background-color: var(--surface-sunken);
107158 padding: 3px;
108159 word-break: break-all;
109160 text-align: center;
@@ -122,16 +173,16 @@ input:focus {
122173
123174/* COLORS */
124175.nsfw, .about-false {
125 color: red;
176 color: var(--status-bad);
126177}
127178.about-true {
128 color: green;
179 color: var(--status-good);
129180}
130181.author {
131 color: seagreen;
182 color: var(--status-mild);
132183}
133184.dd {
134 color: rgb(160, 0, 147);
185 color: var(--status-note);
135186}
136187
137188/* SCREEN OPTIMISATIONS */
@@ -154,7 +205,7 @@ input:focus {
154205
155206 form {
156207 font-size: 60%;
157 border: solid #164e3e 5px;
208 border: solid var(--edge) 5px;
158209 }
159210
160211 .content {
static/html/about.htm +1
@@ -12,6 +12,7 @@
1212 <li><b>NSFW</b>: <span class="about-{{.Templates.About.Nsfw}}">{{if .Templates.About.Nsfw}}YES{{else}}NO{{end}}</span></li>
1313 <li><b>Proxyfing</b>: <span class="about-{{.Templates.About.Proxy}}">{{if .Templates.About.Proxy}}YES{{else}}NO{{end}}</span></li>
1414 <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>
1516 </ul>
1617 <details>
1718 <summary><b>Instances:</b></summary>