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) {
109109 if strings.Contains(out, "<b id=injected>") {
110110 t.Fatalf("username rendered as markup:\n%s", out)
111111 }
112 if n := strings.Count(out, "&lt;b id=injected&gt;"); 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, "&lt;b id=injected&gt;"); n != 5 {
113 t.Errorf("escaped username appears %d times, want 5 (avatar src and alt, link, author, reply target):\n%s", n, out)
114114 }
115115}
116116
@@ -162,3 +162,28 @@ func TestExecuteTemplateUsesTheRequestLanguage(t *testing.T) {
162162 t.Errorf("Spanish request rendered without the Spanish catalogue:\n%s", rec.Body.String())
163163 }
164164}
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)
3636 cmmts.WriteString(strconv.Itoa(x.ID))
3737 cmmts.WriteString(`"><img src="`)
3838 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="`)
4042 cmmts.WriteString(esc(URLBuilder(s.Host, "group_user", "?q=", x.User.Username, "&type=a")))
4143 cmmts.WriteString(`"><b`)
4244 cmmts.WriteString(` class="`)
@@ -160,7 +162,11 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, con
160162 listContent.WriteString(fullview)
161163 listContent.WriteString(`"><img loading="lazy" src="`)
162164 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>`)
164170 } else {
165171 listContent.WriteString("<h1>[ " + esc(T(s.Lang, "list.text")) + " ]</h1>")
166172 }
@@ -361,6 +367,10 @@ func ParseDescription(host string, dscr devianter.Text) string {
361367 parsedDescription.WriteString(esc(ConvertDeviantArtURLToSkunkyArt(host, d.Url)))
362368 parsedDescription.WriteString(`"><img width="50%" src="`)
363369 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))
364374 parsedDescription.WriteString(`" title="`)
365375 parsedDescription.WriteString(esc(d.Author.Username))
366376 parsedDescription.WriteString(" - ")
app/util.go +2
@@ -365,6 +365,8 @@ func BuildUserPlate(host, name string) string {
365365 var htm strings.Builder
366366 htm.WriteString(`<div class="user-plate"><img src="`)
367367 htm.WriteString(esc(URLBuilder(host, "media", "emojitar", name, "?type=a")))
368 htm.WriteString(`" alt="`)
369 htm.WriteString(esc(name))
368370 htm.WriteString(`"><a href="`)
369371 htm.WriteString(esc(URLBuilder(host, "group_user", "?type=about&q=", name)))
370372 htm.WriteString(`">`)
app/wrapper.go +2
@@ -184,6 +184,8 @@ func (s skunkyart) GRUser() {
184184 folders.WriteString(esc(ParseMedia(s.Host, x.Thumb.Media)))
185185 folders.WriteString(`" title="`)
186186 folders.WriteString(esc(x.Thumb.Title))
187 folders.WriteString(`" alt="`)
188 folders.WriteString(esc(x.Thumb.Title))
187189 folders.WriteString(`"></a>`)
188190 } else {
189191 folders.WriteString(`<h1>[ <span class="nsfw">NSFW</span> ]</h1>`)
static/css/skunky.css +82 −14
@@ -187,25 +187,15 @@ input:focus {
187187
188188/* SCREEN OPTIMISATIONS */
189189@media (orientation: portrait) {
190 * {
191 font-size: 120%
192 }
193
194 ul {
195 font-size: 80%
196 }
197
198190 header {
199191 margin-left: 3%;
200192 text-align: center;
201193 display: inline-block;
202194 clear: both;
203 font-size: 200%;
204195 }
205196
206197 form {
207 font-size: 60%;
208 border: solid var(--edge) 5px;
198 border: solid var(--edge) 1px;
209199 }
210200
211201 .content {
@@ -215,7 +205,7 @@ input:focus {
215205 }
216206 .block {
217207 margin-top: 10%;
218 max-width: 200%;
208 max-width: 100%;
219209 }
220210 .folder-item {
221211 width: 25%
@@ -231,7 +221,6 @@ input:focus {
231221 width: 100%
232222 }
233223 .msg {
234 font-size: 60%;
235224 max-width: 80%
236225 }
237226 .block img, .plates .user-plate img {
@@ -260,4 +249,83 @@ input:focus {
260249 .block {
261250 max-width: 35%;
262251 }
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 @@
55 {{template "header" . }}
66 <figure>
77 <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)}}
99 <span class="dd" title="{{T "deviation.daily"}}"><b>{{.Templates.Deviation.Post.Deviation.Title}}</b></span>
1010 {{else}}{{.Templates.Deviation.Post.Deviation.Title}}{{end}}
1111 {{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}}
1212 {{if (.Templates.Deviation.Post.Deviation.NSFW)}}[<span class="nsfw">{{T "deviation.nsfw"}}</span>]{{end}}
13 </span>
13 </h1>
1414 <br>
1515 {{if (ne .Templates.Deviation.Post.IMG "")}}
1616 <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 @@
2121 <meta name="referrer" content="no-referrer" />
2222 <link rel="stylesheet" href="{{.BasePath}}stylesheet">
2323 <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"/>
2525</head>
2626{{end}}
\ No newline at end of file
static/html/index.htm +3 −65
@@ -4,72 +4,10 @@
44 <title>SkunkyArt</title>
55 <link rel="stylesheet" href="{{.BasePath}}stylesheet"/>
66 <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"/>
88
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>
719 </head>
72 <main>
10 <main class="index">
7311 <img src="{{.BasePath}}favicon.ico" title="{{T "index.logo"}}" alt="SkunkyArt logo" draggable="false">
7412 <div>
7513 <h1><a href="{{.BasePath}}dd">{{T "nav.daily"}}</a> | <a href="{{.BasePath}}about">{{T "nav.about"}}</a></h1>
@@ -82,7 +20,7 @@
8220 </select>
8321 <button type="submit">{{T "search.submit"}}</button>
8422 </form>
85 <h1 style="margin-top: 5%; font-size: 200%; text-align: center;">
23 <h1 class="source">
8624 <a href="https://github.com/krazywarez/skunky-art" target="_blank" title="{{T "index.source"}}">SkunkyArt</a>
8725 </h1>
8826 </div>