Commit 68df7ac580
68df7ac58098b839637accd7163e41abb5904b8c
parent: 9db3cae4a5
Verified · cmc ci/build: success ci/lint: success ci/test: success
cmc <hello@cleberg.net> · 2026-09-11 14:37 UTC
Standard viewport, alt text on every image, a post heading, one stylesheet
The viewport meta was initial-scale=0.4 with height=device-height, and
the portrait CSS compensated with 120% fonts and 200% widths; both are
gone and the portrait rules are sized for a real 1:1 viewport. Every
image the Go builders emit has alt text. The post title is an h1. The
index page's inline stylesheet moved into skunky.css under .index.
Closes #28
Closes #29
Closes #30
Layout: unified · split
app/escape_test.go
+27 −2
| @@ -109,8 +109,8 @@ func TestParseCommentsEscapesUsernames(t *testing.T) { |
| 109 | 109 | if strings.Contains(out, "<b id=injected>") { |
| 110 | 110 | t.Fatalf("username rendered as markup:\n%s", out) |
| 111 | 111 | } |
| 112 | | if n := strings.Count(out, "<b id=injected>"); n != 4 { |
| 113 | | t.Errorf("escaped username appears %d times, want 4 (avatar, link, author, reply target):\n%s", n, out) |
| 112 | if n := strings.Count(out, "<b id=injected>"); n != 5 { |
| 113 | t.Errorf("escaped username appears %d times, want 5 (avatar src and alt, link, author, reply target):\n%s", n, out) |
| 114 | 114 | } |
| 115 | 115 | } |
| 116 | 116 | |
| @@ -162,3 +162,28 @@ func TestExecuteTemplateUsesTheRequestLanguage(t *testing.T) { |
| 162 | 162 | t.Errorf("Spanish request rendered without the Spanish catalogue:\n%s", rec.Body.String()) |
| 163 | 163 | } |
| 164 | 164 | } |
| 165 | |
| 166 | // TestListingImagesCarryAltText covers the accessibility fix: every image the |
| 167 | // Go builders emit names what it shows. |
| 168 | func TestListingImagesCarryAltText(t *testing.T) { |
| 169 | nsfw := CFG.Nsfw |
| 170 | CFG.Nsfw = true |
| 171 | defer func() { CFG.Nsfw = nsfw }() |
| 172 | |
| 173 | d := *fullviewDeviation() |
| 174 | d.Title = "T" |
| 175 | d.Author.Username = "alice" |
| 176 | if out := (skunkyart{Host: "http://localhost"}).DeviationList([]devianter.Deviation{d}, false); !strings.Contains(out, `alt="alice - T"`) { |
| 177 | t.Errorf("listing image has no alt text:\n%s", out) |
| 178 | } |
| 179 | if out := BuildUserPlate("http://localhost", "bob"); !strings.Contains(out, `alt="bob"`) { |
| 180 | t.Errorf("user plate image has no alt text:\n%s", out) |
| 181 | } |
| 182 | var c devianter.Comments |
| 183 | var th devianter.Thread |
| 184 | th.User.Username = "carol" |
| 185 | c.Thread = []devianter.Thread{th} |
| 186 | if out := (skunkyart{Host: "http://localhost"}).ParseComments(c, devianter.Error{}); !strings.Contains(out, `alt="carol"`) { |
| 187 | t.Errorf("comment avatar has no alt text:\n%s", out) |
| 188 | } |
| 189 | } |
app/parsers.go
+12 −2
| @@ -36,7 +36,9 @@ func (s skunkyart) ParseComments(c devianter.Comments, daError devianter.Error) |
| 36 | 36 | cmmts.WriteString(strconv.Itoa(x.ID)) |
| 37 | 37 | cmmts.WriteString(`"><img src="`) |
| 38 | 38 | cmmts.WriteString(esc(URLBuilder(s.Host, "media", "emojitar", x.User.Username, "?type=a"))) |
| 39 | | cmmts.WriteString(`" width="30px" height="30px"><a href="`) |
| 39 | cmmts.WriteString(`" width="30px" height="30px" alt="`) |
| 40 | cmmts.WriteString(esc(x.User.Username)) |
| 41 | cmmts.WriteString(`"><a href="`) |
| 40 | 42 | cmmts.WriteString(esc(URLBuilder(s.Host, "group_user", "?q=", x.User.Username, "&type=a"))) |
| 41 | 43 | cmmts.WriteString(`"><b`) |
| 42 | 44 | cmmts.WriteString(` class="`) |
| @@ -160,7 +162,11 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, con |
| 160 | 162 | listContent.WriteString(fullview) |
| 161 | 163 | listContent.WriteString(`"><img loading="lazy" src="`) |
| 162 | 164 | listContent.WriteString(preview) |
| 163 | | listContent.WriteString(`" width="15%"></a>`) |
| 165 | listContent.WriteString(`" width="15%" alt="`) |
| 166 | listContent.WriteString(author) |
| 167 | listContent.WriteString(" - ") |
| 168 | listContent.WriteString(title) |
| 169 | listContent.WriteString(`"></a>`) |
| 164 | 170 | } else { |
| 165 | 171 | listContent.WriteString("<h1>[ " + esc(T(s.Lang, "list.text")) + " ]</h1>") |
| 166 | 172 | } |
| @@ -361,6 +367,10 @@ func ParseDescription(host string, dscr devianter.Text) string { |
| 361 | 367 | parsedDescription.WriteString(esc(ConvertDeviantArtURLToSkunkyArt(host, d.Url))) |
| 362 | 368 | parsedDescription.WriteString(`"><img width="50%" src="`) |
| 363 | 369 | parsedDescription.WriteString(esc(ParseMedia(host, d.Media))) |
| 370 | parsedDescription.WriteString(`" alt="`) |
| 371 | parsedDescription.WriteString(esc(d.Author.Username)) |
| 372 | parsedDescription.WriteString(" - ") |
| 373 | parsedDescription.WriteString(esc(d.Title)) |
| 364 | 374 | parsedDescription.WriteString(`" title="`) |
| 365 | 375 | parsedDescription.WriteString(esc(d.Author.Username)) |
| 366 | 376 | parsedDescription.WriteString(" - ") |
app/util.go
+2
| @@ -365,6 +365,8 @@ func BuildUserPlate(host, name string) string { |
| 365 | 365 | var htm strings.Builder |
| 366 | 366 | htm.WriteString(`<div class="user-plate"><img src="`) |
| 367 | 367 | htm.WriteString(esc(URLBuilder(host, "media", "emojitar", name, "?type=a"))) |
| 368 | htm.WriteString(`" alt="`) |
| 369 | htm.WriteString(esc(name)) |
| 368 | 370 | htm.WriteString(`"><a href="`) |
| 369 | 371 | htm.WriteString(esc(URLBuilder(host, "group_user", "?type=about&q=", name))) |
| 370 | 372 | htm.WriteString(`">`) |
app/wrapper.go
+2
| @@ -184,6 +184,8 @@ func (s skunkyart) GRUser() { |
| 184 | 184 | folders.WriteString(esc(ParseMedia(s.Host, x.Thumb.Media))) |
| 185 | 185 | folders.WriteString(`" title="`) |
| 186 | 186 | folders.WriteString(esc(x.Thumb.Title)) |
| 187 | folders.WriteString(`" alt="`) |
| 188 | folders.WriteString(esc(x.Thumb.Title)) |
| 187 | 189 | folders.WriteString(`"></a>`) |
| 188 | 190 | } else { |
| 189 | 191 | folders.WriteString(`<h1>[ <span class="nsfw">NSFW</span> ]</h1>`) |
static/css/skunky.css
+82 −14
| @@ -187,25 +187,15 @@ input:focus { |
| 187 | 187 | |
| 188 | 188 | /* SCREEN OPTIMISATIONS */ |
| 189 | 189 | @media (orientation: portrait) { |
| 190 | | * { |
| 191 | | font-size: 120% |
| 192 | | } |
| 193 | | |
| 194 | | ul { |
| 195 | | font-size: 80% |
| 196 | | } |
| 197 | | |
| 198 | 190 | header { |
| 199 | 191 | margin-left: 3%; |
| 200 | 192 | text-align: center; |
| 201 | 193 | display: inline-block; |
| 202 | 194 | clear: both; |
| 203 | | font-size: 200%; |
| 204 | 195 | } |
| 205 | 196 | |
| 206 | 197 | form { |
| 207 | | font-size: 60%; |
| 208 | | border: solid var(--edge) 5px; |
| 198 | border: solid var(--edge) 1px; |
| 209 | 199 | } |
| 210 | 200 | |
| 211 | 201 | .content { |
| @@ -215,7 +205,7 @@ input:focus { |
| 215 | 205 | } |
| 216 | 206 | .block { |
| 217 | 207 | margin-top: 10%; |
| 218 | | max-width: 200%; |
| 208 | max-width: 100%; |
| 219 | 209 | } |
| 220 | 210 | .folder-item { |
| 221 | 211 | width: 25% |
| @@ -231,7 +221,6 @@ input:focus { |
| 231 | 221 | width: 100% |
| 232 | 222 | } |
| 233 | 223 | .msg { |
| 234 | | font-size: 60%; |
| 235 | 224 | max-width: 80% |
| 236 | 225 | } |
| 237 | 226 | .block img, .plates .user-plate img { |
| @@ -260,4 +249,83 @@ input:focus { |
| 260 | 249 | .block { |
| 261 | 250 | max-width: 35%; |
| 262 | 251 | } |
| 263 | | } |
| \ No newline at end of file |
| 252 | } |
| 253 | /* POST */ |
| 254 | figure h1 { |
| 255 | font-size: 1.2em; |
| 256 | margin: 0.3em 0; |
| 257 | } |
| 258 | |
| 259 | /* INDEX */ |
| 260 | .index { |
| 261 | display: flex; |
| 262 | max-width: fit-content; |
| 263 | position: absolute; |
| 264 | top: 50%; |
| 265 | right: 50%; |
| 266 | transform: translate(50%, -50%); |
| 267 | } |
| 268 | .index div { |
| 269 | transform: translate(0, 50%); |
| 270 | margin-left: 4%; |
| 271 | flex-basis: 100%; |
| 272 | height: 30%; |
| 273 | display: block; |
| 274 | max-width: fit-content; |
| 275 | } |
| 276 | .index div h1, .index form { |
| 277 | margin: 0; |
| 278 | } |
| 279 | .index div form { |
| 280 | font-size: 100%; |
| 281 | max-width: 100%; |
| 282 | } |
| 283 | .index div form input { |
| 284 | width: 100%; |
| 285 | } |
| 286 | .index img { |
| 287 | width: 30%; |
| 288 | height: 30%; |
| 289 | } |
| 290 | .index .source { |
| 291 | margin-top: 5%; |
| 292 | font-size: 200%; |
| 293 | text-align: center; |
| 294 | } |
| 295 | @media (orientation: portrait) { |
| 296 | .index { |
| 297 | display: block; |
| 298 | position: static; |
| 299 | transform: none; |
| 300 | width: 100%; |
| 301 | max-width: none; |
| 302 | padding: 1em; |
| 303 | box-sizing: border-box; |
| 304 | } |
| 305 | .index img { |
| 306 | width: 60%; |
| 307 | height: auto; |
| 308 | display: block; |
| 309 | margin: 0 auto; |
| 310 | } |
| 311 | .index form { |
| 312 | width: 100%; |
| 313 | } |
| 314 | .index div { |
| 315 | transform: none; |
| 316 | margin: 0; |
| 317 | width: 100%; |
| 318 | max-width: none; |
| 319 | } |
| 320 | .index div h1 { |
| 321 | text-align: center; |
| 322 | } |
| 323 | } |
| 324 | @media (max-width: 1155px) and (orientation: landscape) { |
| 325 | .index img { |
| 326 | width: 50%; |
| 327 | } |
| 328 | .index div { |
| 329 | transform: none; |
| 330 | } |
| 331 | } |
static/html/deviantion.htm
+2 −2
| @@ -5,12 +5,12 @@ |
| 5 | 5 | {{template "header" . }} |
| 6 | 6 | <figure> |
| 7 | 7 | <img src="{{.BasePath}}media/emojitar/{{.Templates.Deviation.Post.Deviation.Author.Username}}?type=a" width="30px" alt="{{.Templates.Deviation.Post.Deviation.Author.Username}} avatar"> |
| 8 | | <span><strong><a href="{{.BasePath}}group_user?type=about&q={{.Templates.Deviation.Post.Deviation.Author.Username}}">{{.Templates.Deviation.Post.Deviation.Author.Username}}</a></strong> — {{if (.Templates.Deviation.Post.Deviation.DD)}} |
| 8 | <h1><strong><a href="{{.BasePath}}group_user?type=about&q={{.Templates.Deviation.Post.Deviation.Author.Username}}">{{.Templates.Deviation.Post.Deviation.Author.Username}}</a></strong> — {{if (.Templates.Deviation.Post.Deviation.DD)}} |
| 9 | 9 | <span class="dd" title="{{T "deviation.daily"}}"><b>{{.Templates.Deviation.Post.Deviation.Title}}</b></span> |
| 10 | 10 | {{else}}{{.Templates.Deviation.Post.Deviation.Title}}{{end}} |
| 11 | 11 | {{if (ne .Templates.Deviation.Post.Deviation.License "none")}}<mark title="{{T "deviation.license"}}">{{.Templates.Deviation.Post.Deviation.License}}</mark>{{end}} {{if (.Templates.Deviation.Post.Deviation.AI)}}[🤖]{{end}} |
| 12 | 12 | {{if (.Templates.Deviation.Post.Deviation.NSFW)}}[<span class="nsfw">{{T "deviation.nsfw"}}</span>]{{end}} |
| 13 | | </span> |
| 13 | </h1> |
| 14 | 14 | <br> |
| 15 | 15 | {{if (ne .Templates.Deviation.Post.IMG "")}} |
| 16 | 16 | <a href="{{.Templates.Deviation.Post.IMG}}" title="{{T "deviation.open"}}"><img src="{{.Templates.Deviation.Post.IMG}}" width="50%" alt="{{.Templates.Deviation.Post.Deviation.Title}}"></a> |
static/html/head.htm
+1 −1
| @@ -21,6 +21,6 @@ |
| 21 | 21 | <meta name="referrer" content="no-referrer" /> |
| 22 | 22 | <link rel="stylesheet" href="{{.BasePath}}stylesheet"> |
| 23 | 23 | <link rel="icon" type="image/x-icon" href="{{.BasePath}}favicon.ico"> |
| 24 | | <meta name="viewport" content="width=device-width, height=device-height, initial-scale=0.4"/> |
| 24 | <meta name="viewport" content="width=device-width, initial-scale=1"/> |
| 25 | 25 | </head> |
| 26 | 26 | {{end}} |
| \ No newline at end of file |
static/html/index.htm
+3 −65
| @@ -4,72 +4,10 @@ |
| 4 | 4 | <title>SkunkyArt</title> |
| 5 | 5 | <link rel="stylesheet" href="{{.BasePath}}stylesheet"/> |
| 6 | 6 | <link rel="icon" type="image/x-icon" href="{{.BasePath}}favicon.ico"> |
| 7 | | <meta name="viewport" content="width=device-width, height=device-height, initial-scale=0.4"/> |
| 7 | <meta name="viewport" content="width=device-width, initial-scale=1"/> |
| 8 | 8 | |
| 9 | | <style> |
| 10 | | main { |
| 11 | | display: flex; |
| 12 | | max-width: fit-content; |
| 13 | | position: absolute; |
| 14 | | top: 50%; |
| 15 | | right: 50%; |
| 16 | | transform: translate(50%, -50%); |
| 17 | | } |
| 18 | | div { |
| 19 | | transform: translate(0, 50%); |
| 20 | | margin-left: 4%; |
| 21 | | flex-basis: 100%; |
| 22 | | height: 30%; |
| 23 | | display: block; |
| 24 | | max-width: fit-content; |
| 25 | | } |
| 26 | | div h1, form { |
| 27 | | margin: 0; |
| 28 | | } |
| 29 | | div form { |
| 30 | | font-size: 100%; |
| 31 | | max-width: 100% |
| 32 | | } |
| 33 | | div form input { |
| 34 | | width: 100% |
| 35 | | } |
| 36 | | img { |
| 37 | | width: 30%; |
| 38 | | height: 30%; |
| 39 | | } |
| 40 | | |
| 41 | | @media (orientation: portrait) { |
| 42 | | main { |
| 43 | | display: block; |
| 44 | | width: 200%; |
| 45 | | } |
| 46 | | img { |
| 47 | | width: 100%; |
| 48 | | height: 100%; |
| 49 | | } |
| 50 | | form { |
| 51 | | width: 200%; |
| 52 | | } |
| 53 | | div { |
| 54 | | margin: -25%; |
| 55 | | margin-top: auto; |
| 56 | | width: 200%; |
| 57 | | } |
| 58 | | div h1 { |
| 59 | | text-align: center; |
| 60 | | } |
| 61 | | } |
| 62 | | @media (max-width: 1155px) and (orientation: landscape) { |
| 63 | | img { |
| 64 | | width: 50%; |
| 65 | | } |
| 66 | | div { |
| 67 | | transform: none; |
| 68 | | } |
| 69 | | } |
| 70 | | </style> |
| 71 | 9 | </head> |
| 72 | | <main> |
| 10 | <main class="index"> |
| 73 | 11 | <img src="{{.BasePath}}favicon.ico" title="{{T "index.logo"}}" alt="SkunkyArt logo" draggable="false"> |
| 74 | 12 | <div> |
| 75 | 13 | <h1><a href="{{.BasePath}}dd">{{T "nav.daily"}}</a> | <a href="{{.BasePath}}about">{{T "nav.about"}}</a></h1> |
| @@ -82,7 +20,7 @@ |
| 82 | 20 | </select> |
| 83 | 21 | <button type="submit">{{T "search.submit"}}</button> |
| 84 | 22 | </form> |
| 85 | | <h1 style="margin-top: 5%; font-size: 200%; text-align: center;"> |
| 23 | <h1 class="source"> |
| 86 | 24 | <a href="https://github.com/krazywarez/skunky-art" target="_blank" title="{{T "index.source"}}">SkunkyArt</a> |
| 87 | 25 | </h1> |
| 88 | 26 | </div> |