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
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(-) @@ -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. + @@ -22,6 +22,6 @@ ## v1.4 - [ ] Implement an API -- [ ] Implement themes +- [x] Implement themes - [ ] Switch to arenas in the cache - [ ] Implement a multilingual interface @@ -28,6 +28,7 @@ func (a API) Info() { Nsfw: CFG.Nsfw, Proxy: CFG.Proxy, HideAI: CFG.HideAI, + Theme: CFG.Theme, }, }) try(err) @@ -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), @@ -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 "" +} @@ -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")) @@ -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"` } @@ -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) + } + } + } +} @@ -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" } @@ -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 { @@ -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>