Commit a6beee8f21
Verified · cmc
Layout: unified · split
SETUP.txt +8 −1
| @@ -55,4 +55,11 @@ server { | ||
| 55 | 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 | 22 | ## v1.4 |
| 23 | 23 | |
| 24 | 24 | - [ ] Implement an API |
| 25 | - [ ] Implement themes | |
| 25 | - [x] Implement themes | |
| 26 | 26 | - [ ] Switch to arenas in the cache |
| 27 | 27 | - [ ] Implement a multilingual interface |
app/api.go +1
| @@ -28,6 +28,7 @@ func (a API) Info() { | ||
| 28 | 28 | Nsfw: CFG.Nsfw, |
| 29 | 29 | Proxy: CFG.Proxy, |
| 30 | 30 | HideAI: CFG.HideAI, |
| 31 | Theme: CFG.Theme, | |
| 31 | 32 | }, |
| 32 | 33 | }) |
| 33 | 34 | try(err) |
app/cli.go +5 −3
| @@ -55,9 +55,10 @@ type settingsUrls struct { | ||
| 55 | 55 | } |
| 56 | 56 | |
| 57 | 57 | type 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"` | |
| 61 | 62 | } |
| 62 | 63 | |
| 63 | 64 | type settings struct { |
| @@ -116,6 +117,7 @@ func addInstance() { | ||
| 116 | 117 | Nsfw: CFG.Nsfw, |
| 117 | 118 | Proxy: CFG.Proxy, |
| 118 | 119 | HideAI: CFG.HideAI, |
| 120 | Theme: CFG.Theme, | |
| 119 | 121 | }, |
| 120 | 122 | Urls: settingsUrls{ |
| 121 | 123 | Clearnet: prompt("Clearnet link", false), |
app/config.go +27
| @@ -35,6 +35,7 @@ type config struct { | ||
| 35 | 35 | Proxy bool `json:"proxy"` |
| 36 | 36 | Nsfw bool `json:"nsfw"` |
| 37 | 37 | HideAI bool `json:"hide-ai"` |
| 38 | Theme string `json:"theme"` | |
| 38 | 39 | UserAgent string `json:"user-agent"` |
| 39 | 40 | DownloadProxy string `json:"download-proxy"` |
| 40 | 41 | StaticPath string `json:"static-path"` |
| @@ -45,6 +46,7 @@ type config struct { | ||
| 45 | 46 | var CFG = config{ |
| 46 | 47 | cfg: "config.json", |
| 47 | 48 | Listen: "127.0.0.1:3003", |
| 49 | Theme: "auto", | |
| 48 | 50 | URI: "/", |
| 49 | 51 | Cache: cacheConfig{ |
| 50 | 52 | Enabled: false, |
| @@ -137,9 +139,34 @@ func ExecuteConfig() { | ||
| 137 | 139 | Proxy: CFG.Proxy, |
| 138 | 140 | Nsfw: CFG.Nsfw, |
| 139 | 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 | 153 | static.StaticPath = CFG.StaticPath |
| 143 | 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 | 136 | case "stylesheet": |
| 137 | 137 | w.Header().Add("Content-Type", "text/css") |
| 138 | 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 | 146 | case "favicon.ico": |
| 140 | 147 | _, _ = w.Write(open("images/logo.png")) |
| 141 | 148 | |
app/util.go +1
| @@ -73,6 +73,7 @@ type instanceAbout struct { | ||
| 73 | 73 | Proxy bool `json:"proxy"` |
| 74 | 74 | Nsfw bool `json:"nsfw"` |
| 75 | 75 | HideAI bool `json:"hide-ai"` |
| 76 | Theme string `json:"theme"` | |
| 76 | 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 | 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 | 15 | "proxy": true, |
| 16 | 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 | 52 | /* TAGS */ |
| 2 | 53 | html { |
| 3 | 54 | font-family: ubuntu, system-ui; |
| 4 | background-color:black; | |
| 5 | color: rgb(234, 216, 216); | |
| 55 | background-color: var(--bg); | |
| 56 | color: var(--fg); | |
| 6 | 57 | } |
| 7 | 58 | a { |
| 8 | 59 | text-decoration: none; |
| 9 | color: cadetblue; | |
| 60 | color: var(--link); | |
| 10 | 61 | } |
| 11 | 62 | a:hover { |
| 12 | color: #d0ff00; | |
| 63 | color: var(--link-hover); | |
| 13 | 64 | transition: 400ms; |
| 14 | 65 | } |
| 15 | 66 | header h1 { |
| @@ -23,13 +74,13 @@ header, form { | ||
| 23 | 74 | display: flex; |
| 24 | 75 | } |
| 25 | 76 | form { |
| 26 | border: solid #164e3e 1px; | |
| 77 | border: solid var(--edge) 1px; | |
| 27 | 78 | max-width: fit-content; |
| 28 | 79 | } |
| 29 | 80 | form input, button, select { |
| 30 | background-color: #134134; | |
| 81 | background-color: var(--surface); | |
| 31 | 82 | padding: 5px; |
| 32 | color: whitesmoke; | |
| 83 | color: var(--fg-strong); | |
| 33 | 84 | border: 0; |
| 34 | 85 | } |
| 35 | 86 | input:focus { |
| @@ -46,9 +97,9 @@ input:focus { | ||
| 46 | 97 | } |
| 47 | 98 | .block { |
| 48 | 99 | padding: 0px 0px 6px 0px; |
| 49 | border: 3px solid #000; | |
| 100 | border: 3px solid var(--bg); | |
| 50 | 101 | word-break: break-all; |
| 51 | background-color: #091f19; | |
| 102 | background-color: var(--surface-sunken); | |
| 52 | 103 | margin-left: 5px; |
| 53 | 104 | margin-top: 5px; |
| 54 | 105 | text-align: center; |
| @@ -57,7 +108,7 @@ input:focus { | ||
| 57 | 108 | padding: 8.5vh; |
| 58 | 109 | } |
| 59 | 110 | .block:hover { |
| 60 | border: 3px solid #4d27d6; | |
| 111 | border: 3px solid var(--accent); | |
| 61 | 112 | transition: 400ms; |
| 62 | 113 | } |
| 63 | 114 | |
| @@ -67,8 +118,8 @@ input:focus { | ||
| 67 | 118 | |
| 68 | 119 | /* MESSAGES */ |
| 69 | 120 | .msg { |
| 70 | background-color: #091f19; | |
| 71 | color: whitesmoke; | |
| 121 | background-color: var(--surface-sunken); | |
| 122 | color: var(--fg-strong); | |
| 72 | 123 | width: fit-content; |
| 73 | 124 | max-width: 90%; |
| 74 | 125 | padding: 4px; |
| @@ -78,11 +129,11 @@ input:focus { | ||
| 78 | 129 | transition: 350ms; |
| 79 | 130 | } |
| 80 | 131 | .msg:hover { |
| 81 | background-color: #134134; | |
| 132 | background-color: var(--surface); | |
| 82 | 133 | } |
| 83 | 134 | .reply { |
| 84 | 135 | border-radius: 0px 2px 2px 0px; |
| 85 | border-left: #258268 solid; | |
| 136 | border-left: var(--edge-strong) solid; | |
| 86 | 137 | margin-left: 40px; |
| 87 | 138 | } |
| 88 | 139 | |
| @@ -92,18 +143,18 @@ input:focus { | ||
| 92 | 143 | flex-wrap: wrap; |
| 93 | 144 | } |
| 94 | 145 | .folder-item { |
| 95 | background-color: #060820; | |
| 96 | border: 3px solid #060820; | |
| 146 | background-color: var(--surface-alt); | |
| 147 | border: 3px solid var(--surface-alt); | |
| 97 | 148 | width: 10% |
| 98 | 149 | } |
| 99 | 150 | .admins .user-plate { |
| 100 | 151 | width: 5%; |
| 101 | background-color: #011522; | |
| 152 | background-color: var(--surface-deep); | |
| 102 | 153 | } |
| 103 | 154 | .plates .user-plate { |
| 104 | 155 | margin-left: 1%; |
| 105 | 156 | margin-bottom: 1%; |
| 106 | background-color: #091f19; | |
| 157 | background-color: var(--surface-sunken); | |
| 107 | 158 | padding: 3px; |
| 108 | 159 | word-break: break-all; |
| 109 | 160 | text-align: center; |
| @@ -122,16 +173,16 @@ input:focus { | ||
| 122 | 173 | |
| 123 | 174 | /* COLORS */ |
| 124 | 175 | .nsfw, .about-false { |
| 125 | color: red; | |
| 176 | color: var(--status-bad); | |
| 126 | 177 | } |
| 127 | 178 | .about-true { |
| 128 | color: green; | |
| 179 | color: var(--status-good); | |
| 129 | 180 | } |
| 130 | 181 | .author { |
| 131 | color: seagreen; | |
| 182 | color: var(--status-mild); | |
| 132 | 183 | } |
| 133 | 184 | .dd { |
| 134 | color: rgb(160, 0, 147); | |
| 185 | color: var(--status-note); | |
| 135 | 186 | } |
| 136 | 187 | |
| 137 | 188 | /* SCREEN OPTIMISATIONS */ |
| @@ -154,7 +205,7 @@ input:focus { | ||
| 154 | 205 | |
| 155 | 206 | form { |
| 156 | 207 | font-size: 60%; |
| 157 | border: solid #164e3e 5px; | |
| 208 | border: solid var(--edge) 5px; | |
| 158 | 209 | } |
| 159 | 210 | |
| 160 | 211 | .content { |
static/html/about.htm +1
| @@ -12,6 +12,7 @@ | ||
| 12 | 12 | <li><b>NSFW</b>: <span class="about-{{.Templates.About.Nsfw}}">{{if .Templates.About.Nsfw}}YES{{else}}NO{{end}}</span></li> |
| 13 | 13 | <li><b>Proxyfing</b>: <span class="about-{{.Templates.About.Proxy}}">{{if .Templates.About.Proxy}}YES{{else}}NO{{end}}</span></li> |
| 14 | 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 | 16 | </ul> |
| 16 | 17 | <details> |
| 17 | 18 | <summary><b>Instances:</b></summary> |