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