krz/skunky-art

Alternative privacy frontend for DeviantArt.

clone: git clone https://gitbay.org/krz/skunky-art.git

a6beee8f21fc5409638136e6bc627aead3738e20

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-08-23T00:48:41Z

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.
 SETUP.txt             |  9 ++++-
 TODO.txt              |  2 +-
 app/api.go            |  1 +
 app/cli.go            |  8 +++--
 app/config.go         | 27 ++++++++++++++
 app/router.go         |  7 ++++
 app/util.go           |  1 +
 app/util_test.go      | 48 +++++++++++++++++++++++++
 config.example.json   |  3 +-
 static/css/skunky.css | 97 +++++++++++++++++++++++++++++++++++++++------------
 static/html/about.htm |  1 +
 11 files changed, 175 insertions(+), 29 deletions(-)

diff --git a/SETUP.txt b/SETUP.txt
index cbc61fa..15c9d01 100755
--- a/SETUP.txt
+++ b/SETUP.txt
@@ -55,4 +55,11 @@ server {
         proxy_pass http://((IP)):((PORT));
     }
 }
-```
\ No newline at end of file
+```
+
+* `theme` — Palette for the interface. `auto` (default) serves the dark theme
+  and lets a visitor whose system asks for light get the light one, through
+  `prefers-color-scheme` — no cookie, no query string, no JavaScript. `dark` or
+  `light` pins one for everybody. An unrecognised value stops startup rather
+  than quietly falling back.
+
diff --git a/TODO.txt b/TODO.txt
index e5b372e..4cf4d10 100755
--- a/TODO.txt
+++ b/TODO.txt
@@ -22,6 +22,6 @@
 ## v1.4
 
 - [ ] Implement an API
-- [ ] Implement themes
+- [x] Implement themes
 - [ ] Switch to arenas in the cache
 - [ ] Implement a multilingual interface
diff --git a/app/api.go b/app/api.go
index 2c04123..31b1c11 100755
--- a/app/api.go
+++ b/app/api.go
@@ -28,6 +28,7 @@ func (a API) Info() {
 			Nsfw:   CFG.Nsfw,
 			Proxy:  CFG.Proxy,
 			HideAI: CFG.HideAI,
+			Theme:  CFG.Theme,
 		},
 	})
 	try(err)
diff --git a/app/cli.go b/app/cli.go
index ec2b614..d3a4628 100755
--- a/app/cli.go
+++ b/app/cli.go
@@ -55,9 +55,10 @@ type settingsUrls struct {
 }
 
 type settingsParams struct {
-	Nsfw   bool `json:"nsfw"`
-	Proxy  bool `json:"proxy"`
-	HideAI bool `json:"hide-ai"`
+	Nsfw   bool   `json:"nsfw"`
+	Proxy  bool   `json:"proxy"`
+	HideAI bool   `json:"hide-ai"`
+	Theme  string `json:"theme"`
 }
 
 type settings struct {
@@ -116,6 +117,7 @@ func addInstance() {
 					Nsfw:   CFG.Nsfw,
 					Proxy:  CFG.Proxy,
 					HideAI: CFG.HideAI,
+					Theme:  CFG.Theme,
 				},
 				Urls: settingsUrls{
 					Clearnet: prompt("Clearnet link", false),
diff --git a/app/config.go b/app/config.go
index 7a513f6..e1abead 100755
--- a/app/config.go
+++ b/app/config.go
@@ -35,6 +35,7 @@ type config struct {
 	Proxy         bool        `json:"proxy"`
 	Nsfw          bool        `json:"nsfw"`
 	HideAI        bool        `json:"hide-ai"`
+	Theme         string      `json:"theme"`
 	UserAgent     string      `json:"user-agent"`
 	DownloadProxy string      `json:"download-proxy"`
 	StaticPath    string      `json:"static-path"`
@@ -45,6 +46,7 @@ type config struct {
 var CFG = config{
 	cfg:    "config.json",
 	Listen: "127.0.0.1:3003",
+	Theme:  "auto",
 	URI:    "/",
 	Cache: cacheConfig{
 		Enabled:        false,
@@ -137,9 +139,34 @@ func ExecuteConfig() {
 			Proxy:  CFG.Proxy,
 			Nsfw:   CFG.Nsfw,
 			HideAI: CFG.HideAI,
+			Theme:  CFG.Theme,
+		}
+
+		// A theme the stylesheet cannot honour would silently fall back to auto,
+		// so say so instead.
+		switch CFG.Theme {
+		case "auto", "dark", "light":
+		default:
+			exit("config: theme must be one of auto, dark, light; got "+CFG.Theme, 1)
 		}
 
 		static.StaticPath = CFG.StaticPath
 		devianter.UserAgent = CFG.UserAgent
 	}
 }
+
+// forcedThemeCSS returns a block that pins the palette when the instance has
+// chosen a theme, or "" for "auto". Light repeats what the prefers-color-scheme
+// block already holds; dark repeats :root. Both are emitted after the
+// stylesheet so they win on order rather than on !important.
+func forcedThemeCSS() string {
+	switch CFG.Theme {
+	case "light":
+		return `
+: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}`
+	case "dark":
+		return `
+: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)}`
+	}
+	return ""
+}
diff --git a/app/router.go b/app/router.go
index 4b68282..74dd5b1 100755
--- a/app/router.go
+++ b/app/router.go
@@ -136,6 +136,13 @@ func Router() {
 		case "stylesheet":
 			w.Header().Add("Content-Type", "text/css")
 			_, _ = w.Write(open("css/skunky.css"))
+			// "auto" is the stylesheet as written: dark, with a light palette
+			// behind prefers-color-scheme. Forcing a theme means re-declaring
+			// that palette unconditionally, which outranks the media query
+			// because it comes later with equal specificity.
+			if css := forcedThemeCSS(); css != "" {
+				_, _ = w.Write([]byte(css))
+			}
 		case "favicon.ico":
 			_, _ = w.Write(open("images/logo.png"))
 
diff --git a/app/util.go b/app/util.go
index 804d06e..52b1d48 100755
--- a/app/util.go
+++ b/app/util.go
@@ -73,6 +73,7 @@ type instanceAbout struct {
 	Proxy     bool       `json:"proxy"`
 	Nsfw      bool       `json:"nsfw"`
 	HideAI    bool       `json:"hide-ai"`
+	Theme     string     `json:"theme"`
 	Instances []settings `json:"instances"`
 }
 
diff --git a/app/util_test.go b/app/util_test.go
index 7a70b68..efaf191 100644
--- a/app/util_test.go
+++ b/app/util_test.go
@@ -72,3 +72,51 @@ func TestInstanceAboutCarriesHideAI(t *testing.T) {
 		}
 	}
 }
+
+// TestForcedThemeCSSOnlyForAnExplicitChoice pins that "auto" adds nothing: the
+// stylesheet already carries dark plus a prefers-color-scheme light block, and
+// appending a palette would defeat the visitor's own system setting.
+func TestForcedThemeCSSOnlyForAnExplicitChoice(t *testing.T) {
+	theme := CFG.Theme
+	defer func() { CFG.Theme = theme }()
+
+	CFG.Theme = "auto"
+	if got := forcedThemeCSS(); got != "" {
+		t.Errorf("auto appended %q, want nothing", got)
+	}
+
+	for _, want := range []string{"dark", "light"} {
+		CFG.Theme = want
+		css := forcedThemeCSS()
+		if !strings.Contains(css, ":root{") {
+			t.Errorf("%s theme did not emit a :root block: %q", want, css)
+		}
+		if !strings.Contains(css, "--bg:") || !strings.Contains(css, "--status-note:") {
+			t.Errorf("%s theme is missing palette variables: %q", want, css)
+		}
+	}
+}
+
+// TestForcedThemeCSSCoversEveryVariable is the guard against a half-applied
+// palette: a forced theme that omits a variable falls through to the default
+// one, which is how a light theme ends up with black panels.
+func TestForcedThemeCSSCoversEveryVariable(t *testing.T) {
+	theme := CFG.Theme
+	defer func() { CFG.Theme = theme }()
+
+	vars := []string{
+		"--bg", "--fg", "--fg-strong", "--link", "--link-hover", "--edge",
+		"--edge-strong", "--accent", "--surface", "--surface-sunken",
+		"--surface-alt", "--surface-deep", "--status-bad", "--status-good",
+		"--status-mild", "--status-note",
+	}
+	for _, mode := range []string{"dark", "light"} {
+		CFG.Theme = mode
+		css := forcedThemeCSS()
+		for _, v := range vars {
+			if !strings.Contains(css, v+":") {
+				t.Errorf("%s theme does not set %s", mode, v)
+			}
+		}
+	}
+}
diff --git a/config.example.json b/config.example.json
index c90886e..1b492f5 100755
--- a/config.example.json
+++ b/config.example.json
@@ -14,5 +14,6 @@
     "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",
     "proxy": true,
     "nsfw": false,
-    "hide-ai": false
+    "hide-ai": false,
+    "theme": "auto"
 }
diff --git a/static/css/skunky.css b/static/css/skunky.css
index 09cc71c..542303b 100755
--- a/static/css/skunky.css
+++ b/static/css/skunky.css
@@ -1,15 +1,66 @@
+/* THEME
+ *
+ * Every colour in this file comes from a variable below, so a theme is a
+ * palette rather than a fork of the stylesheet. The dark palette is the
+ * original SkunkyArt look and stays the default.
+ *
+ * Light is offered through prefers-color-scheme, so a visitor who has asked
+ * their system for light gets it with no cookie, no query string and no
+ * JavaScript — nothing that identifies them. An instance can force one or the
+ * other with "theme" in its config, which appends a block after this file.
+ */
+: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);
+}
+
+@media (prefers-color-scheme: light) {
+    :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;
+    }
+}
+
 /* TAGS */
 html {
     font-family: ubuntu, system-ui;
-    background-color:black;
-    color: rgb(234, 216, 216);
+    background-color: var(--bg);
+    color: var(--fg);
 }
 a {
     text-decoration: none;
-    color: cadetblue;
+    color: var(--link);
 }
 a:hover {
-    color: #d0ff00;
+    color: var(--link-hover);
     transition: 400ms;
 }
 header h1 {
@@ -23,13 +74,13 @@ header, form {
     display: flex;
 }
 form {
-    border: solid #164e3e 1px;
+    border: solid var(--edge) 1px;
     max-width: fit-content;
 }
 form input, button, select {
-    background-color: #134134;
+    background-color: var(--surface);
     padding: 5px;
-    color: whitesmoke;
+    color: var(--fg-strong);
     border: 0;
 }
 input:focus {
@@ -46,9 +97,9 @@ input:focus {
 }
 .block {
     padding: 0px 0px 6px 0px;
-    border: 3px solid #000;
+    border: 3px solid var(--bg);
     word-break: break-all;
-    background-color: #091f19;
+    background-color: var(--surface-sunken);
     margin-left: 5px;
     margin-top: 5px;
     text-align: center;
@@ -57,7 +108,7 @@ input:focus {
     padding: 8.5vh;
 }
 .block:hover {
-    border: 3px solid #4d27d6;
+    border: 3px solid var(--accent);
     transition: 400ms;
 }
 
@@ -67,8 +118,8 @@ input:focus {
 
 /* MESSAGES */
 .msg {
-    background-color: #091f19;
-    color: whitesmoke;
+    background-color: var(--surface-sunken);
+    color: var(--fg-strong);
     width: fit-content;
     max-width: 90%;
     padding: 4px;
@@ -78,11 +129,11 @@ input:focus {
     transition: 350ms;
 }
 .msg:hover {
-    background-color: #134134;
+    background-color: var(--surface);
 }
 .reply {
     border-radius: 0px 2px 2px 0px;
-    border-left: #258268 solid;
+    border-left: var(--edge-strong) solid;
     margin-left: 40px;
 }
 
@@ -92,18 +143,18 @@ input:focus {
     flex-wrap: wrap;
 }
 .folder-item {
-    background-color: #060820;
-    border: 3px solid #060820;
+    background-color: var(--surface-alt);
+    border: 3px solid var(--surface-alt);
     width: 10%
 }
 .admins .user-plate {
     width: 5%;
-    background-color: #011522;
+    background-color: var(--surface-deep);
 }
 .plates .user-plate {
     margin-left: 1%;
     margin-bottom: 1%;
-    background-color: #091f19;
+    background-color: var(--surface-sunken);
     padding: 3px;
     word-break: break-all;
     text-align: center;
@@ -122,16 +173,16 @@ input:focus {
 
 /* COLORS */
 .nsfw, .about-false {
-    color: red;
+    color: var(--status-bad);
 }
 .about-true {
-    color: green;
+    color: var(--status-good);
 }
 .author {
-    color: seagreen;
+    color: var(--status-mild);
 }
 .dd {
-    color: rgb(160, 0, 147);
+    color: var(--status-note);
 }
 
 /* SCREEN OPTIMISATIONS */
@@ -154,7 +205,7 @@ input:focus {
 
     form {
         font-size: 60%;
-        border: solid #164e3e 5px;
+        border: solid var(--edge) 5px;
     }
     
     .content {
diff --git a/static/html/about.htm b/static/html/about.htm
index 8773437..59072b4 100755
--- a/static/html/about.htm
+++ b/static/html/about.htm
@@ -12,6 +12,7 @@
             <li><b>NSFW</b>: <span class="about-{{.Templates.About.Nsfw}}">{{if .Templates.About.Nsfw}}YES{{else}}NO{{end}}</span></li>
             <li><b>Proxyfing</b>: <span class="about-{{.Templates.About.Proxy}}">{{if .Templates.About.Proxy}}YES{{else}}NO{{end}}</span></li>
             <li><b>Hide AI</b>: <span class="about-{{.Templates.About.HideAI}}">{{if .Templates.About.HideAI}}YES{{else}}NO{{end}}</span></li>
+            <li><b>Theme</b>: {{.Templates.About.Theme}}</li>
         </ul>
         <details>
             <summary><b>Instances:</b></summary>