Commit a6beee8f21
Verified · cmc
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 | ||
| 57 | type settingsParams struct { | 57 | type 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 | ||
| 63 | type settings struct { | 64 | type 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 { | |||
| 45 | var CFG = config{ | 46 | var 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. | ||
| 162 | func 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. | ||
| 79 | func 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. | ||
| 103 | func 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 */ |
| 2 | html { | 53 | html { |
| 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 | } |
| 7 | a { | 58 | a { |
| 8 | text-decoration: none; | 59 | text-decoration: none; |
| 9 | color: cadetblue; | 60 | color: var(--link); |
| 10 | } | 61 | } |
| 11 | a:hover { | 62 | a:hover { |
| 12 | color: #d0ff00; | 63 | color: var(--link-hover); |
| 13 | transition: 400ms; | 64 | transition: 400ms; |
| 14 | } | 65 | } |
| 15 | header h1 { | 66 | header h1 { |
| @@ -23,13 +74,13 @@ header, form { | |||
| 23 | display: flex; | 74 | display: flex; |
| 24 | } | 75 | } |
| 25 | form { | 76 | form { |
| 26 | border: solid #164e3e 1px; | 77 | border: solid var(--edge) 1px; |
| 27 | max-width: fit-content; | 78 | max-width: fit-content; |
| 28 | } | 79 | } |
| 29 | form input, button, select { | 80 | form 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 | } |
| 35 | input:focus { | 86 | input: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> |