1// ===== art_js.go =====
2//go:build js && wasm
3
4package main
5
6// Swapping in the two things the server draws: product photographs as ANSI art
7// turned into HTML, and headings as FIGlet banners.
8//
9// Both are fetched rather than drawn here. The art needs a JPEG decode and a
10// dither per product and the banner needs eight hundred kilobytes of FIGlet
11// fonts; neither belongs in a decorator, and both are the same answer every
12// time, so the server renders and caches them.
13//
14// Everything degrades to nothing. A failed fetch leaves the DOM exactly as it
15// was, which is the same promise the frames make: a browser that cannot run
16// this sees the page it always saw.
17
18import (
19 "fmt"
20 "strconv"
21 "strings"
22 "syscall/js"
23)
24
25// artCols is how wide a product photograph is drawn, in characters.
26//
27// Wider than the thumbnail it sits behind, so it shows as a halo around it
28// rather than hiding under it. Wider art is also better art — the dither has
29// more cells to work with — and since it is drawn out of flow, the column does
30// not have to grow to hold it.
31const artCols = 28
32
33// swapArt replaces the product photographs in one table with ANSI art.
34//
35// The image URL carries what the endpoint needs: images are served from
36// /i/<category>/<file>, so the last two segments are the arguments. Anything
37// else is left alone rather than guessed at.
38func swapArt(doc, tbl js.Value) {
39 imgs := tbl.Call("querySelectorAll", "img")
40 for i := 0; i < imgs.Get("length").Int(); i++ {
41 img := imgs.Index(i)
42 if img.Get("dataset").Get("decoArt").Truthy() {
43 continue // already done, or being done
44 }
45 raw := img.Call("getAttribute", "src")
46 if !raw.Truthy() {
47 continue
48 }
49 parts := strings.Split(strings.TrimSuffix(raw.String(), "/"), "/")
50 if len(parts) < 2 {
51 continue
52 }
53 cat, name := parts[len(parts)-2], parts[len(parts)-1]
54 img.Get("dataset").Set("decoArt", "1")
55 url := "/art/" + cat + "/" + name + "?cols=" + strconv.Itoa(artCols)
56 el := img
57 fetchText(url, func(body string, ok bool) {
58 if !ok || body == "" || !el.Truthy() {
59 return
60 }
61 cell := el.Get("parentElement")
62 if !cell.Truthy() {
63 return
64 }
65 // Behind the photograph, not instead of it.
66 //
67 // Replacing the image loses the thing a shopper came to look at, and
68 // sixteen characters of dither is not a picture of a diode. Drawn
69 // larger and put behind, it is a halo around the photograph in the
70 // colors of the photograph, which is what the terminal is showing of
71 // the same file through the same renderer.
72 //
73 // Absolute, so the table does not move: the art is out of flow and the
74 // cell keeps the size the image gave it.
75 cell.Get("classList").Call("add", "deco-host")
76 r := el.Call("getBoundingClientRect")
77 cr := cell.Call("getBoundingClientRect")
78 span := doc.Call("createElement", "span")
79 span.Get("classList").Call("add", "deco-art")
80 span.Call("setAttribute", "aria-hidden", "true")
81 span.Get("style").Set("cssText", fmt.Sprintf(
82 "position:absolute;left:%.1fpx;top:%.1fpx;transform:translate(-50%%,-50%%)",
83 r.Get("left").Float()-cr.Get("left").Float()+r.Get("width").Float()/2,
84 r.Get("top").Float()-cr.Get("top").Float()+r.Get("height").Float()/2))
85 span.Set("innerHTML", body)
86 cell.Call("insertBefore", span, el)
87 el.Get("style").Set("position", "relative")
88 el.Get("style").Set("zIndex", "1")
89 // The halo is wider than the image, so it reaches into the next
90 // column. z-index:-1 puts it behind everything in the row rather
91 // than merely behind its own cell: an absolutely positioned element
92 // at z-index 0 paints ABOVE the in-flow text of a sibling cell, and
93 // on the live page that clipped the left of every product name.
94 })
95 }
96}
97
98// swapHeadings turns the headings of one section into FIGlet banners.
99//
100// h2 only. The h3 above a products table says "Products:" on every section of
101// the page, and eight identical banners is noise where one heading was
102// information.
103func swapHeadings(doc, host js.Value) {
104 hs := host.Call("querySelectorAll", ":scope > h2")
105 for i := 0; i < hs.Get("length").Int(); i++ {
106 h := hs.Index(i)
107 if h.Get("dataset").Get("decoBanner").Truthy() {
108 continue
109 }
110 text := strings.TrimSpace(h.Get("textContent").String())
111 if text == "" || len([]rune(text)) > 48 {
112 continue
113 }
114 h.Get("dataset").Set("decoBanner", "1")
115 el := h
116 fetchText("/banner?text="+js.Global().Call("encodeURIComponent", text).String(), func(body string, ok bool) {
117 if !ok || body == "" || !el.Truthy() {
118 return
119 }
120 pre := doc.Call("createElement", "pre")
121 pre.Get("classList").Call("add", "deco-banner")
122 pre.Set("textContent", body)
123 pre.Call("setAttribute", "aria-hidden", "true")
124 // The heading stays visible underneath, in the page's own font.
125 //
126 // A FIGlet banner is decoration before it is text — the future font
127 // draws letters out of box-drawing glyphs, and at a glance "1N40XX"
128 // is a pattern rather than a part number. Keeping the words below it
129 // means nobody has to decode the picture to read the heading, and the
130 // banner is marked aria-hidden so a screen reader gets the words once
131 // rather than twice.
132 if p := el.Get("parentElement"); p.Truthy() {
133 p.Call("insertBefore", pre, el)
134 }
135 })
136 }
137}
138
139// fetchText gets a URL and hands the body to cb, or reports that it could not.
140//
141// Errors are swallowed on purpose. The endpoints are new and the server may be
142// an older build that has never heard of them; a 404 then means the page keeps
143// its photographs and its plain headings, which is a perfectly good page.
144func fetchText(url string, cb func(string, bool)) {
145 promise := js.Global().Call("fetch", url)
146 var onText, onResp, onErr js.Func
147 onText = js.FuncOf(func(_ js.Value, args []js.Value) any {
148 defer onText.Release()
149 if len(args) > 0 {
150 cb(args[0].String(), true)
151 }
152 return nil
153 })
154 onErr = js.FuncOf(func(_ js.Value, _ []js.Value) any {
155 defer onErr.Release()
156 cb("", false)
157 return nil
158 })
159 onResp = js.FuncOf(func(_ js.Value, args []js.Value) any {
160 defer onResp.Release()
161 if len(args) == 0 || !args[0].Get("ok").Bool() {
162 cb("", false)
163 return nil
164 }
165 args[0].Call("text").Call("then", onText).Call("catch", onErr)
166 return nil
167 })
168 promise.Call("then", onResp).Call("catch", onErr)
169}
170
171
172// ===== main.go =====
173//go:build js && wasm
174
175// Package main wasm/deco/main.go — draws borders on the store's pages.
176//
177// The frames are pisano designs: an integer sequence reduced modulo m, walked
178// as a turtle, and rendered with box-drawing characters. The same figures the
179// pisano command draws.
180//
181// It decorates rather than renders. Everything on the page is already there
182// when this runs; this finds boxes and puts a frame around them, so removing
183// the binary removes the decoration and leaves the page working. That is also
184// why it is a wasm drop-in and not a change to the templates: the server keeps
185// emitting the same HTML.
186//
187// The composition is pisano's border package rather than anything here. This
188// file used to draw one motif, mirror it into four corners and join them with
189// plain rules, which is a fair description of a frame and not of a good one:
190// the runs were straight lines rather than figures, the corners met them
191// wherever they happened to fall, and the mirroring was a table of six glyph
192// swaps that had to be got right by hand. The package composes both runs and
193// the corners onto one lattice, cuts each run where the crossing of its own
194// wave meets the corner, and reports whether the result is a single closed
195// line. What is left here is the browser: finding the boxes, measuring a
196// character, and putting the text where it goes.
197package main
198
199import (
200 "fmt"
201 "strconv"
202 "strings"
203 "syscall/js"
204
205 "github.com/0magnet/pisano/pkg/border"
206)
207
208func main() {
209 ready := make(chan struct{})
210 doc := js.Global().Get("document")
211 if s := doc.Get("readyState").String(); s == "interactive" || s == "complete" {
212 close(ready)
213 } else {
214 cb := js.FuncOf(func(js.Value, []js.Value) any { close(ready); return nil })
215 doc.Call("addEventListener", "DOMContentLoaded", cb)
216 }
217 <-ready
218 decorate(doc)
219 select {} // keep the runtime alive for the resize handler
220}
221
222// A frame is three figures: one running along the top and bottom, one down the
223// sides, and a closed one at the corners.
224type recipe struct {
225 across, down, corner border.Figure
226 ok bool
227}
228
229// The frames, largest corner first.
230//
231// Modulus 13 is BOTH runs. It travels +0,-4, which makes it a side; turned a
232// quarter it travels +4,+0 and makes a top, and the two are then square to each
233// other by construction rather than by search. One drawing seen twice is what a
234// frame usually wants, and it is why this is not a hunt for a matching pair.
235//
236// Modulus 31 at four passes is the corner: closed, eight cells square, and with
237// the full symmetry of the square — four-fold rotation and four mirror axes,
238// which no larger closed figure below modulus 9000 has. Closed matters. An open
239// figure has loose ends of its own at the corner, where a run is trying to
240// stop.
241//
242// The pass count is doing real work: 31 is closed only at four passes and
243// eight. At one, two or three it travels, and would trail off exactly where the
244// frame needs it to hold still.
245//
246// The second rung is for boxes too small for the first. The smallest frame the
247// 31 corners make is 17x17 characters, and a subcategory that is not the
248// :target can be far less than that; modulus 8 gives a four-cell corner and a
249// 13x14 minimum. Drawing a smaller figure beats drawing none, which is what the
250// old ladder was for too.
251func recipes() []recipe {
252 var out []recipe
253 side, ok := border.OfPasses(13, 1)
254 if !ok {
255 return nil
256 }
257 top := side.RotateCW()
258 for _, c := range [][2]int{{31, 4}, {8, 2}} {
259 corner, ok := border.OfPasses(c[0], c[1])
260 if !ok || !corner.Closed {
261 continue
262 }
263 out = append(out, recipe{across: top, down: side, corner: corner, ok: true})
264 }
265 return out
266}
267
268// fits draws the first recipe that will go in a box this size, or reports that
269// none will.
270//
271// The reason this is a list is unchanged from the version that had one motif
272// per rung: a frame that does not fit draws NOTHING, and a frame element on the
273// page containing nothing is the least debuggable outcome there is. Choosing
274// per box means a short category gets a smaller figure rather than an invisible
275// one.
276func fits(rs []recipe, cols, rows int) (f frame, ok bool) {
277 if cols > maxCells/minSide || rows > maxRows || cols*rows > maxCells {
278 return frame{}, false
279 }
280 key := strconv.Itoa(cols) + "x" + strconv.Itoa(rows)
281 if hit, seen := frameCache[key]; seen {
282 return hit, hit.markup != ""
283 }
284 for _, r := range rs {
285 if !r.ok {
286 continue
287 }
288 tmpl := border.Spec{Across: r.across, Down: r.down, Corner: r.corner, Detached: true}
289 if l, g, in, ok := border.FitAround(tmpl, cols, rows); ok {
290 f := frame{
291 markup: g.TintHTML(l.TintByCopy(g, 6), nil),
292 w: g.W(), h: g.H(),
293 inX: in.X0, inY: in.Y0,
294 }
295 frameCache[key] = f
296 return f, true
297 }
298 }
299 frameCache[key] = frame{}
300 return frame{}, false
301}
302
303// frame is a drawn border, its size, and where its clear middle begins.
304//
305// The middle is what the caller lines up with the table. A frame is quantised
306// by its figures, so it is never exactly the size asked for; hanging it from
307// its own top left would put the border a few cells off the thing it is
308// supposed to be around.
309type frame struct {
310 markup string
311 w, h int // the whole frame, in cells
312 inX, inY int // where its clear middle starts, in cells
313 inH int // and how tall it is
314}
315
316// The largest frame worth drawing, in character cells. A box over it gets no
317// frame, which is the answer a box under the minimum already gets.
318//
319// Set high enough to cover this page, deliberately. The quarter-watt resistors
320// are a table nine hundred rows tall, and a tighter cap left exactly that one
321// table bare while its five neighbors were framed — a border that skips the
322// longest list on the page is worse than no border.
323//
324// Measured for that table: 46ms to compose, 14ms to tint, 206KB of markup, once
325// and then cached. Heavy, and worth it.
326//
327// This is about weight, not about a crash. The frames that came back as
328// high-plane garbage were a stack overflow in the flood fill, since fixed by
329// walking components with an explicit stack; a cap would have hidden that
330// rather than cured it, and for a while it did.
331const (
332 maxCells = 250000
333 maxRows = 1500
334 minSide = 8
335)
336
337// frameCache keeps one frame per box SIZE, which is what makes this affordable
338// in a browser.
339//
340// Composing a frame is not free: it lays every copy of both runs and all four
341// corners onto a grid, cuts the runs, trims, smooths and walks the whole thing
342// to check it is one line. For a category nine hundred rows tall that is a
343// hundred and fifty thousand cells. The page has sixty-nine boxes and they
344// share a handful of widths — everything is the same column — so nearly every
345// one is a cache hit, and a redraw costs the measuring rather than the drawing.
346//
347// A miss is stored as the empty string, so a size that fits nothing is not
348// retried on every resize either.
349var frameCache = map[string]frame{}
350
351// catSel matches the products tables, one per category and one per
352// subcategory. Those are what a frame belongs around: a table of parts is a
353// thing with edges, and a border drawn on it says so.
354//
355// The category DIV was the target before, and it is the wrong one. It holds the
356// heading and, where a category has subcategories, only the "narrow to" list of
357// links — so the frame went round a line of links with the products nowhere
358// near it. The categories table at the top of the page is not matched, since it
359// is not inside a cat- div.
360//
361// It was `aside` and `footer` before that. `aside` never matched anything —
362// categories.html, the only template with one, is included by nothing — which
363// is a good argument for choosing targets by looking at the served page rather
364// than at the template directory.
365const catSel = "div[id^=\"cat-\"] table"
366
367// decorate puts a frame behind each target box.
368//
369// The frame is a sibling positioned under the box rather than a wrapper around
370// it: wrapping would reflow the page, and the whole claim of this binary is
371// that it changes nothing that was already laid out. It is also aria-hidden —
372// it is a drawing, and a screen reader given box-drawing characters reads them
373// out one by one.
374func decorate(doc js.Value) {
375 style := doc.Call("createElement", "style")
376 style.Set("textContent", `
377.deco-frame{position:absolute;inset:0;z-index:0;pointer-events:none;white-space:pre;
378 font:`+frameFont+`;color:#2f6f8f;opacity:.55;overflow:hidden;
379 margin:0;padding:0;border:0;background:none}
380.deco-host{position:relative}
381.deco-art{font:8px/1 ui-monospace,monospace;white-space:pre;pointer-events:none;z-index:-1;opacity:.75}
382.deco-banner{font:12px/1 ui-monospace,monospace;white-space:pre;margin:0 0 6px;color:#7fd1a8}
383`)
384 doc.Get("head").Call("appendChild", style)
385
386 cell := measureCell(doc)
387 rs := recipes()
388 draw := func() {
389 nodes := doc.Call("querySelectorAll", catSel)
390 for i := 0; i < nodes.Get("length").Int(); i++ {
391 drawInto(doc, nodes.Index(i), rs, cell)
392 }
393 }
394 draw()
395
396 // Watch every box for gaining a size, which is the only reliable moment to
397 // draw one.
398 //
399 // Measuring once at DOMContentLoaded draws nothing at all, and that was the
400 // bug: this page shows a category only while it is the :target, so at load
401 // EVERY box is display:none and measures 0x0, the guard below rejects all
402 // of them, and the run ends having added a class to sixty-nine elements and
403 // a frame to none. Loading straight into a fragment does not help either,
404 // because the box still has no layout when the module runs. Measured on the
405 // live page: 69 hosts, 0 frames, no box wider than nothing.
406 //
407 // resize and hashchange were meant to cover this and cannot. hashchange
408 // does not fire for the fragment a page is opened with, and neither fires
409 // when a box is revealed by anything other than the address bar. A
410 // ResizeObserver fires on the transition from no layout to some, whatever
411 // caused it, which is exactly the event wanted.
412 //
413 // Appending the frame cannot retrigger it: the frame is absolutely
414 // positioned and inset to its host, so it takes no space of its own. The
415 // size check in drawInto guards the rest.
416 if ro := js.Global().Get("ResizeObserver"); ro.Truthy() {
417 cb := js.FuncOf(func(_ js.Value, args []js.Value) any {
418 if len(args) == 0 {
419 return nil
420 }
421 es := args[0]
422 for i := 0; i < es.Get("length").Int(); i++ {
423 drawInto(doc, es.Index(i).Get("target"), rs, cell)
424 }
425 return nil
426 })
427 obs := ro.New(cb)
428 nodes := doc.Call("querySelectorAll", catSel)
429 for i := 0; i < nodes.Get("length").Int(); i++ {
430 obs.Call("observe", nodes.Index(i))
431 }
432 }
433
434 // Still worth keeping both of these. resize because the frame is measured
435 // in character cells and is wrong the moment the box changes width, and
436 // load because webfonts and images settle after DOMContentLoaded and move
437 // everything.
438 redraw := js.FuncOf(func(js.Value, []js.Value) any { draw(); return nil })
439 for _, ev := range []string{"resize", "load", "hashchange"} {
440 js.Global().Get("window").Call("addEventListener", ev, redraw)
441 }
442}
443
444// frameFont is the frame's font, named once because two places must agree: the
445// stylesheet that draws the frames and the probe that measures a character.
446// When they disagreed the frames came out empty, and the reason was invisible.
447const frameFont = "12px/1 ui-monospace,monospace"
448
449// measureCell finds the advance and line height of the frame font by measuring
450// a run of glyphs, rather than assuming what 12px monospace comes out at.
451// Fonts differ, the user may have changed the default, and a frame drawn on a
452// guessed cell lands beside the box instead of on it.
453func measureCell(doc js.Value) (c struct{ W, H float64 }) {
454 p := doc.Call("createElement", "div")
455 p.Get("classList").Call("add", "deco-frame")
456 // The probe carries its OWN font and an empty box, rather than leaning on the
457 // .deco-frame class.
458 //
459 // It is appended to <body>, and on this host <body> is the CSS grid container
460 // with a rule `.grid-container > div {padding:20px 0;font-size:30px}`. A probe
461 // that inherited that measured a 30px font in a 70px-tall box, so a category
462 // 263px tall looked like 3 rows instead of 21, every box came out under the
463 // motif's minimum, and render() returned an empty string for all of them: 12
464 // frames on the page, every one blank. The frames themselves were always fine
465 // — they live inside the category boxes, where nothing overrode the font.
466 p.Get("style").Set("cssText", "position:absolute;visibility:hidden;inset:auto;"+
467 "margin:0;padding:0;border:0;white-space:pre;font:"+frameFont)
468 const n = 50
469 p.Set("textContent", strings.Repeat("─", n))
470 doc.Get("body").Call("appendChild", p)
471 r := p.Call("getBoundingClientRect")
472 c.W = r.Get("width").Float() / n
473 c.H = r.Get("height").Float()
474 p.Call("remove")
475 if c.W <= 0 {
476 c.W = 7.2
477 }
478 if c.H <= 0 {
479 c.H = 12
480 }
481 return c
482}
483
484// drawInto puts a frame behind one products table.
485//
486// The frame is a sibling of the table, positioned over it, rather than a child
487// of it. A div is not allowed inside a table — the content model runs caption,
488// colgroup, thead, tbody, tfoot and nothing else — and a wrapper round the
489// table would reflow the page, which is the one thing this binary promises not
490// to do. So it goes in the table's parent, which the category div already is,
491// and is placed at the table's offset within it.
492func drawInto(doc, tbl js.Value, rs []recipe, cell struct{ W, H float64 }) {
493 if !tbl.Truthy() {
494 return
495 }
496 host := tbl.Get("parentElement")
497 if !host.Truthy() {
498 return
499 }
500 host.Get("classList").Call("add", "deco-host")
501
502 // One frame per table, keyed to it, so a redraw replaces its own and not a
503 // sibling table's — a category div holds one table, but a subcategory list
504 // and an "other products" table can share a parent.
505 id := tbl.Get("dataset").Get("decoId")
506 key := ""
507 if id.Truthy() {
508 key = id.String()
509 } else {
510 decoSeq++
511 key = strconv.Itoa(decoSeq)
512 tbl.Get("dataset").Set("decoId", key)
513 }
514 if old := host.Call("querySelector", "[data-deco-for=\""+key+"\"]"); old.Truthy() {
515 old.Call("remove")
516 }
517
518 r := tbl.Call("getBoundingClientRect")
519 cols := int(r.Get("width").Float() / cell.W)
520 rows := int(r.Get("height").Float() / cell.H)
521 // A table with no layout is one inside a category that is not the :target —
522 // that is how this page navigates, so most are display:none most of the
523 // time and measure 0x0. Drawing a 0x0 frame is not merely wasted: it
524 // REPLACES the frame drawn when the table was last visible, so a category
525 // decorated once would come back bare the next time it was opened.
526 if cols < 2 || rows < 2 {
527 return
528 }
529 // The photographs and the headings first: both change the table's size, and
530 // the frame is measured from it. They are asynchronous, so the frame drawn
531 // now is for the table as it stands; when a swap lands the table resizes,
532 // the observer fires, and the frame is drawn again for the new size.
533 swapArt(doc, tbl)
534 swapHeadings(doc, host)
535
536 f, ok := fits(rs, cols, rows)
537 if !ok {
538 return // nothing fits; draw no frame rather than an empty one
539 }
540 // Make room for the frame before measuring where to put it.
541 //
542 // The border reaches out past the table on all four sides, and above the
543 // table is where the "Products:" heading is — so without this the frame is
544 // drawn straight over it. Margins on the table push the heading clear and
545 // leave the border a gap of its own to sit in.
546 //
547 // Margins and not padding, and on the table and not the frame: a margin
548 // moves the table without changing its content box, so the ResizeObserver
549 // watching it does not fire and this cannot chase its own tail. The rect is
550 // read again afterwards because setting them has moved the thing it
551 // describes.
552 // One cell more than the frame needs, at each end. Exactly the frame's own
553 // reach leaves the heading's bottom edge and the border's top edge on the
554 // same pixel, which is touching rather than clear.
555 top := float64(f.inY+1) * cell.H
556 bot := float64(f.h-f.inY-f.inH+1) * cell.H
557 tbl.Get("style").Set("marginTop", fmt.Sprintf("%.1fpx", top))
558 tbl.Get("style").Set("marginBottom", fmt.Sprintf("%.1fpx", bot))
559 r = tbl.Call("getBoundingClientRect")
560 hr := host.Call("getBoundingClientRect")
561 // Placed so the frame's CLEAR MIDDLE lands on the table, which is what puts
562 // the border around it rather than over it.
563 dx := -float64(f.inX) * cell.W
564 dy := -float64(f.inY) * cell.H
565 pre := doc.Call("createElement", "div")
566 pre.Get("classList").Call("add", "deco-frame")
567 pre.Call("setAttribute", "aria-hidden", "true")
568 pre.Call("setAttribute", "data-deco-for", key)
569 pre.Get("style").Set("cssText", fmt.Sprintf(
570 "position:absolute;left:%.1fpx;top:%.1fpx;width:%.1fpx;height:%.1fpx",
571 r.Get("left").Float()-hr.Get("left").Float()+dx,
572 r.Get("top").Float()-hr.Get("top").Float()+dy,
573 float64(f.w)*cell.W, float64(f.h)*cell.H))
574 // innerHTML because the frame is colored: TintHTML wraps each run of one
575 // palette color in a span, and it escapes the box characters itself.
576 pre.Set("innerHTML", f.markup)
577 host.Call("insertBefore", pre, tbl)
578}
579
580// decoSeq names frames so each table's own can be found again.
581var decoSeq int
582
583