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 if strings.Contains(out, "<b id=injected>") { 109 if strings.Contains(out, "<b id=injected>") {
110 t.Fatalf("username rendered as markup:\n%s", out) 110 t.Fatalf("username rendered as markup:\n%s", out)
111 } 111 }
112 if n := strings.Count(out, "&lt;b id=injected&gt;"); n != 4 { 112 if n := strings.Count(out, "&lt;b id=injected&gt;"); n != 5 {
113 t.Errorf("escaped username appears %d times, want 4 (avatar, link, author, reply target):\n%s", n, out) 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 t.Errorf("Spanish request rendered without the Spanish catalogue:\n%s", rec.Body.String()) 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.
168func 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 cmmts.WriteString(strconv.Itoa(x.ID)) 36 cmmts.WriteString(strconv.Itoa(x.ID))
37 cmmts.WriteString(`"><img src="`) 37 cmmts.WriteString(`"><img src="`)
38 cmmts.WriteString(esc(URLBuilder(s.Host, "media", "emojitar", x.User.Username, "?type=a"))) 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 cmmts.WriteString(esc(URLBuilder(s.Host, "group_user", "?q=", x.User.Username, "&type=a"))) 42 cmmts.WriteString(esc(URLBuilder(s.Host, "group_user", "?q=", x.User.Username, "&type=a")))
41 cmmts.WriteString(`"><b`) 43 cmmts.WriteString(`"><b`)
42 cmmts.WriteString(` class="`) 44 cmmts.WriteString(` class="`)
@@ -160,7 +162,11 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, con
160 listContent.WriteString(fullview) 162 listContent.WriteString(fullview)
161 listContent.WriteString(`"><img loading="lazy" src="`) 163 listContent.WriteString(`"><img loading="lazy" src="`)
162 listContent.WriteString(preview) 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 } else { 170 } else {
165 listContent.WriteString("<h1>[ " + esc(T(s.Lang, "list.text")) + " ]</h1>") 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 parsedDescription.WriteString(esc(ConvertDeviantArtURLToSkunkyArt(host, d.Url))) 367 parsedDescription.WriteString(esc(ConvertDeviantArtURLToSkunkyArt(host, d.Url)))
362 parsedDescription.WriteString(`"><img width="50%" src="`) 368 parsedDescription.WriteString(`"><img width="50%" src="`)
363 parsedDescription.WriteString(esc(ParseMedia(host, d.Media))) 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 parsedDescription.WriteString(`" title="`) 374 parsedDescription.WriteString(`" title="`)
365 parsedDescription.WriteString(esc(d.Author.Username)) 375 parsedDescription.WriteString(esc(d.Author.Username))
366 parsedDescription.WriteString(" - ") 376 parsedDescription.WriteString(" - ")
app/util.go +2
@@ -365,6 +365,8 @@ func BuildUserPlate(host, name string) string {
365 var htm strings.Builder 365 var htm strings.Builder
366 htm.WriteString(`<div class="user-plate"><img src="`) 366 htm.WriteString(`<div class="user-plate"><img src="`)
367 htm.WriteString(esc(URLBuilder(host, "media", "emojitar", name, "?type=a"))) 367 htm.WriteString(esc(URLBuilder(host, "media", "emojitar", name, "?type=a")))
368 htm.WriteString(`" alt="`)
369 htm.WriteString(esc(name))
368 htm.WriteString(`"><a href="`) 370 htm.WriteString(`"><a href="`)
369 htm.WriteString(esc(URLBuilder(host, "group_user", "?type=about&q=", name))) 371 htm.WriteString(esc(URLBuilder(host, "group_user", "?type=about&q=", name)))
370 htm.WriteString(`">`) 372 htm.WriteString(`">`)
app/wrapper.go +2
@@ -184,6 +184,8 @@ func (s skunkyart) GRUser() {
184 folders.WriteString(esc(ParseMedia(s.Host, x.Thumb.Media))) 184 folders.WriteString(esc(ParseMedia(s.Host, x.Thumb.Media)))
185 folders.WriteString(`" title="`) 185 folders.WriteString(`" title="`)
186 folders.WriteString(esc(x.Thumb.Title)) 186 folders.WriteString(esc(x.Thumb.Title))
187 folders.WriteString(`" alt="`)
188 folders.WriteString(esc(x.Thumb.Title))
187 folders.WriteString(`"></a>`) 189 folders.WriteString(`"></a>`)
188 } else { 190 } else {
189 folders.WriteString(`<h1>[ <span class="nsfw">NSFW</span> ]</h1>`) 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/* SCREEN OPTIMISATIONS */ 188/* SCREEN OPTIMISATIONS */
189@media (orientation: portrait) { 189@media (orientation: portrait) {
190 * {
191 font-size: 120%
192 }
193
194 ul {
195 font-size: 80%
196 }
197
198 header { 190 header {
199 margin-left: 3%; 191 margin-left: 3%;
200 text-align: center; 192 text-align: center;
201 display: inline-block; 193 display: inline-block;
202 clear: both; 194 clear: both;
203 font-size: 200%;
204 } 195 }
205 196
206 form { 197 form {
207 font-size: 60%; 198 border: solid var(--edge) 1px;
208 border: solid var(--edge) 5px;
209 } 199 }
210 200
211 .content { 201 .content {
@@ -215,7 +205,7 @@ input:focus {
215 } 205 }
216 .block { 206 .block {
217 margin-top: 10%; 207 margin-top: 10%;
218 max-width: 200%; 208 max-width: 100%;
219 } 209 }
220 .folder-item { 210 .folder-item {
221 width: 25% 211 width: 25%
@@ -231,7 +221,6 @@ input:focus {
231 width: 100% 221 width: 100%
232 } 222 }
233 .msg { 223 .msg {
234 font-size: 60%;
235 max-width: 80% 224 max-width: 80%
236 } 225 }
237 .block img, .plates .user-plate img { 226 .block img, .plates .user-plate img {
@@ -260,4 +249,83 @@ input:focus {
260 .block { 249 .block {
261 max-width: 35%; 250 max-width: 35%;
262 } 251 }
263}\ No newline at end of file 252}
253/* POST */
254figure 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 {{template "header" . }} 5 {{template "header" . }}
6 <figure> 6 <figure>
7 <img src="{{.BasePath}}media/emojitar/{{.Templates.Deviation.Post.Deviation.Author.Username}}?type=a" width="30px" alt="{{.Templates.Deviation.Post.Deviation.Author.Username}} avatar"> 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 <span class="dd" title="{{T "deviation.daily"}}"><b>{{.Templates.Deviation.Post.Deviation.Title}}</b></span> 9 <span class="dd" title="{{T "deviation.daily"}}"><b>{{.Templates.Deviation.Post.Deviation.Title}}</b></span>
10 {{else}}{{.Templates.Deviation.Post.Deviation.Title}}{{end}} 10 {{else}}{{.Templates.Deviation.Post.Deviation.Title}}{{end}}
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}} 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 {{if (.Templates.Deviation.Post.Deviation.NSFW)}}[<span class="nsfw">{{T "deviation.nsfw"}}</span>]{{end}} 12 {{if (.Templates.Deviation.Post.Deviation.NSFW)}}[<span class="nsfw">{{T "deviation.nsfw"}}</span>]{{end}}
13 </span> 13 </h1>
14 <br> 14 <br>
15 {{if (ne .Templates.Deviation.Post.IMG "")}} 15 {{if (ne .Templates.Deviation.Post.IMG "")}}
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> 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 <meta name="referrer" content="no-referrer" /> 21 <meta name="referrer" content="no-referrer" />
22 <link rel="stylesheet" href="{{.BasePath}}stylesheet"> 22 <link rel="stylesheet" href="{{.BasePath}}stylesheet">
23 <link rel="icon" type="image/x-icon" href="{{.BasePath}}favicon.ico"> 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</head> 25</head>
26{{end}}\ No newline at end of file 26{{end}}\ No newline at end of file
static/html/index.htm +3 −65
@@ -4,72 +4,10 @@
4 <title>SkunkyArt</title> 4 <title>SkunkyArt</title>
5 <link rel="stylesheet" href="{{.BasePath}}stylesheet"/> 5 <link rel="stylesheet" href="{{.BasePath}}stylesheet"/>
6 <link rel="icon" type="image/x-icon" href="{{.BasePath}}favicon.ico"> 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 </head> 9 </head>
72 <main> 10 <main class="index">
73 <img src="{{.BasePath}}favicon.ico" title="{{T "index.logo"}}" alt="SkunkyArt logo" draggable="false"> 11 <img src="{{.BasePath}}favicon.ico" title="{{T "index.logo"}}" alt="SkunkyArt logo" draggable="false">
74 <div> 12 <div>
75 <h1><a href="{{.BasePath}}dd">{{T "nav.daily"}}</a> | <a href="{{.BasePath}}about">{{T "nav.about"}}</a></h1> 13 <h1><a href="{{.BasePath}}dd">{{T "nav.daily"}}</a> | <a href="{{.BasePath}}about">{{T "nav.about"}}</a></h1>
@@ -82,7 +20,7 @@
82 </select> 20 </select>
83 <button type="submit">{{T "search.submit"}}</button> 21 <button type="submit">{{T "search.submit"}}</button>
84 </form> 22 </form>
85 <h1 style="margin-top: 5%; font-size: 200%; text-align: center;"> 23 <h1 class="source">
86 <a href="https://github.com/krazywarez/skunky-art" target="_blank" title="{{T "index.source"}}">SkunkyArt</a> 24 <a href="https://github.com/krazywarez/skunky-art" target="_blank" title="{{T "index.source"}}">SkunkyArt</a>
87 </h1> 25 </h1>
88 </div> 26 </div>