1// ===== pkg/config/config.go =====
    2// Package config pkg/config/config.go β€” the store's configuration.
    3//
    4// Values are sourced from a bash config file named by the MENV environment
    5// variable (see `m2 gen` for the template), overridable by CLI flags. The
    6// flag-registration helpers here bind a Values field to a flag whose default
    7// is read from the MENV file, so every command that registers flags gets the
    8// file-sourced defaults for free.
    9package config
   10
   11import (
   12	"embed"
   13	"fmt"
   14	"os"
   15	"reflect"
   16	"runtime"
   17	"strconv"
   18	"strings"
   19	"time"
   20
   21	"github.com/bitfield/script"
   22	"github.com/spf13/cobra"
   23	"github.com/stripe/stripe-go/v81"
   24)
   25
   26//go:embed *.go
   27var Source embed.FS
   28
   29// MENV names the bash-sourced config file, from the environment.
   30var MENV = os.Getenv("MENV")
   31
   32// Values holds every configurable value; field names map to the MENV file's
   33// uppercase variable names (SITENAME, PRODUCTSCSV, ...).
   34type Values struct {
   35	Teststripekey      bool
   36	ProductsCSV        string
   37	WebPort            int
   38	StripelivePK       string
   39	StripeliveSK       string
   40	StripetestPK       string
   41	StripetestSK       string
   42	StripeSK           string
   43	StripePK           string
   44	Siteimagesrc       string
   45	Siteordersurl      string
   46	Sitename           string
   47	Siteext            string
   48	Sitedomain         string
   49	Sitelongname       string
   50	Sitetagline        string
   51	Sitemeta           string
   52	Siteprettyname     string
   53	Siteprettynamecap  string
   54	Siteprettynamecaps string
   55	SiteASCIILogo      string
   56	Tgcontact          string
   57	Tgchannel          string
   58	UseTinygo          bool
   59	WasmSRC            []string
   60	WasmExecPath       string
   61	WasmExecPathGo     string
   62	WasmExecPathTinyGo string
   63	Gobuild            string
   64	Tinygobuild        string
   65	Buildwasmwith      string
   66	LDFlagsX           string
   67	PrinterName        string        // CUPS queue name (blank = default)
   68	CupsOptions        string        // comma-separated -o options
   69	LpTimeout          time.Duration // timeout for `lp`
   70	Storeurl           string        // tui client mode: browse this store over http instead of local files
   71
   72	// The real-origin browse substrate: where the desk's nested browser
   73	// renders the in-tab store. See pkg/web/browse.go. Empty disables it and
   74	// the browser falls back to netscrape's transcoder.
   75	BrowseSuffix string // browse-origin domain, a DIFFERENT registrable domain from the site's
   76	BrowseAddr   string // listen address for the browse-origin bootstrap
   77
   78	// Crypto checkout. Empty SkyXpub leaves the whole thing off and the
   79	// store card-only, which is what a deployment that has not configured
   80	// a wallet should get rather than a button that errors.
   81	SkyXpub     string // BIP44 ACCOUNT-level xpub, watch-only; must not be the reward account
   82	SkyNodeURL  string // skycoin node API root; empty means the local node
   83	SkyRatePair string // exchange pair for pricing, e.g. "skycoin_usdt"; never a bare ticker
   84	SkyRateFix  string // a rate the operator sets instead of asking an exchange
   85	RateMin     string // refuse a quote below this, as a decimal string
   86	RateMax     string // refuse a quote above this
   87	InvoiceMins int    // how long a quote is held before it expires
   88}
   89
   90// F is the live configuration, shared by every package.
   91var F = Values{
   92	//	WasmSRC: []string{"wasm/stl2.go","wasm/checkout_wasm.go"},
   93	WasmExecPath:       runtime.GOROOT() + "/lib/wasm/wasm_exec.js",                                     //nolint
   94	WasmExecPathGo:     runtime.GOROOT() + "/lib/wasm/wasm_exec.js",                                     //nolint
   95	WasmExecPathTinyGo: strings.TrimSuffix(runtime.GOROOT(), "go") + "tinygo" + "/targets/wasm_exec.js", //nolint
   96	Gobuild:            "go build",
   97	Tinygobuild:        "tinygo build -target=wasm --no-debug",
   98	Buildwasmwith:      "go build",
   99	LDFlagsX:           "stripePK",
  100}
  101
  102// InitStripe selects the live or test key pair, hands the secret key to the
  103// stripe library, and bakes the publishable key into the wasm ldflags.
  104func InitStripe() {
  105	F.StripeSK = F.StripeliveSK
  106	F.StripePK = F.StripelivePK
  107	if F.Teststripekey {
  108		F.StripeSK = F.StripetestSK
  109		F.StripePK = F.StripetestPK
  110	}
  111	stripe.Key = F.StripeSK
  112	// awkward way to do this
  113	F.LDFlagsX += "=" + F.StripePK
  114}
  115
  116var (
  117	// Hardcoded array of valid shorthand characters, excluding "h"
  118	shorthandChars = []rune("abcdefgijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
  119	nextShortIndex = 0 // Index for the next shorthand flag
  120)
  121
  122func getNextShortFlag() string {
  123	if nextShortIndex >= len(shorthandChars) {
  124		return ""
  125	}
  126	short := shorthandChars[nextShortIndex]
  127	nextShortIndex++
  128	return string(short)
  129}
  130
  131var a = true
  132var b = false
  133
  134// AddStringFlag registers a string flag bound to a field of F on each command,
  135// defaulting to the MENV file's value for the field's uppercase name.
  136func AddStringFlag(cmds []*cobra.Command, fieldPtr *string, description string) {
  137	for i := range cmds {
  138		cmds[i].Flags().StringVarP(fieldPtr, ccc(fieldPtr, &F, b), getNextShortFlag(), ScriptExecString(fmt.Sprintf("${%s%s}", ccc(fieldPtr, &F, a), func(s string) string {
  139			if s != "" {
  140				s = "-" + s
  141			}
  142			return s
  143		}(*fieldPtr))), fmt.Sprintf("%s env: %s\033[0m\n\r", description, ccc(fieldPtr, &F, a)))
  144	}
  145}
  146
  147// AddStringSliceFlag is AddStringFlag for []string fields (bash arrays).
  148func AddStringSliceFlag(cmds []*cobra.Command, fieldPtr *[]string, description string) {
  149	for i := range cmds {
  150		cmds[i].Flags().StringSliceVarP(
  151			fieldPtr,
  152			ccc(fieldPtr, &F, b),
  153			getNextShortFlag(),
  154			ScriptExecStringSlice(fmt.Sprintf("${%s[@]}", ccc(fieldPtr, &F, a))),
  155			fmt.Sprintf("%s env: %s\033[0m\n\r", description, ccc(fieldPtr, &F, a)),
  156		)
  157	}
  158}
  159
  160// AddBoolFlag is AddStringFlag for bool fields.
  161func AddBoolFlag(cmds []*cobra.Command, fieldPtr *bool, description string) {
  162	for i := range cmds {
  163		cmds[i].Flags().BoolVarP(fieldPtr, ccc(fieldPtr, &F, b), getNextShortFlag(), ScriptExecBool(fmt.Sprintf("${%s%s}", ccc(fieldPtr, &F, a), func(b bool) string {
  164			return "-" + strconv.FormatBool(b)
  165		}(*fieldPtr))), fmt.Sprintf("%s env: %s\033[0m\n\r", description, ccc(fieldPtr, &F, a)))
  166	}
  167}
  168
  169// AddIntFlag is AddStringFlag for int fields.
  170func AddIntFlag(cmds []*cobra.Command, fieldPtr *int, description string) {
  171	for i := range cmds {
  172		cmds[i].Flags().IntVarP(fieldPtr, ccc(fieldPtr, &F, b), getNextShortFlag(), ScriptExecInt(fmt.Sprintf("${%s%s}", ccc(fieldPtr, &F, a), func(i int) string {
  173			return fmt.Sprintf("-%d", i)
  174		}(*fieldPtr))), fmt.Sprintf("%s env: %s\033[0m\n\r", description, ccc(fieldPtr, &F, a)))
  175	}
  176}
  177
  178// AddDurationFlag is AddStringFlag for time.Duration fields.
  179func AddDurationFlag(cmds []*cobra.Command, fieldPtr *time.Duration, description string) {
  180	for i := range cmds {
  181		// Keep parity with the pattern of embedding a "-" when a non-zero default is present.
  182		def := ScriptExecDuration(fmt.Sprintf("${%s%s}",
  183			ccc(fieldPtr, &F, a),
  184			func(d time.Duration) string {
  185				if d != 0 {
  186					return "-" + d.String() // e.g. "-5s"
  187				}
  188				return ""
  189			}(*fieldPtr),
  190		))
  191		cmds[i].Flags().DurationVarP(
  192			fieldPtr,
  193			ccc(fieldPtr, &F, b),
  194			getNextShortFlag(),
  195			def,
  196			fmt.Sprintf("%s env: %s\033[0m\n\r", description, ccc(fieldPtr, &F, a)),
  197		)
  198	}
  199}
  200
  201// ccc finds the name of the struct field val points into, upper or lower case.
  202func ccc(val interface{}, strct interface{}, upper bool) string {
  203	v := reflect.ValueOf(strct)
  204	if v.Kind() == reflect.Ptr {
  205		v = v.Elem()
  206	}
  207	if v.Kind() != reflect.Struct {
  208		panic("uc: second argument must be a pointer to a struct")
  209	}
  210	for i := 0; i < v.NumField(); i++ {
  211		field := v.Field(i)
  212		if field.CanAddr() && field.Addr().Interface() == val {
  213			if upper {
  214				return strings.ToUpper(v.Type().Field(i).Name)
  215			}
  216			return strings.ToLower(v.Type().Field(i).Name)
  217		}
  218	}
  219	return ""
  220}
  221
  222// ScriptExecString evaluates a bash expression with the MENV file sourced.
  223func ScriptExecString(s string) string {
  224	z, err := script.Exec(fmt.Sprintf(`bash -c 'MENV=%s ; if [[ $MENV != "" ]] && [[ -f $MENV ]] ; then source $MENV ; fi ; printf "%s"'`, MENV, s)).String()
  225	if err == nil {
  226		return strings.TrimSpace(z)
  227	}
  228	return ""
  229}
  230
  231// ScriptExecStringSlice evaluates a bash array expression with the MENV file
  232// sourced. Empty entries are dropped: an empty or unset array still prints
  233// one empty line, which would otherwise come back as [""] and defeat every
  234// `len == 0` check downstream (e.g. WASMSRC=() disabling wasm).
  235func ScriptExecStringSlice(s string) []string {
  236	z, err := script.Exec(fmt.Sprintf(`bash -c 'MENV=%s ; if [[ $MENV != "" ]] && [[ -f $MENV ]] ; then source $MENV ; fi ; printf "%s" "%s"'`, MENV, "%s\n", s)).Slice()
  237	if err != nil {
  238		return nil
  239	}
  240	out := z[:0]
  241	for _, e := range z {
  242		if strings.TrimSpace(e) != "" {
  243			out = append(out, e)
  244		}
  245	}
  246	return out
  247}
  248
  249// ScriptExecBool evaluates a bash expression as a bool with the MENV file sourced.
  250func ScriptExecBool(s string) bool {
  251	z, err := script.Exec(fmt.Sprintf(`bash -c 'MENV=%s ; if [[ $MENV != "" ]] && [[ -f $MENV ]] ; then source $MENV ; fi ; printf "%s"'`, MENV, s)).String()
  252	if err == nil {
  253		b, err := strconv.ParseBool(z)
  254		if err == nil {
  255			return b
  256		}
  257	}
  258	return false
  259}
  260
  261// ScriptExecInt evaluates a bash expression as an int with the MENV file sourced.
  262func ScriptExecInt(s string) int {
  263	z, err := script.Exec(fmt.Sprintf(`bash -c 'MENV=%s ; if [[ $MENV != "" ]] && [[ -f $MENV ]] ; then source $MENV ; fi ; printf "%s"'`, MENV, s)).String()
  264	if err == nil {
  265		if z == "" {
  266			return 0
  267		}
  268		i, err := strconv.Atoi(z)
  269		if err == nil {
  270			return i
  271		}
  272	}
  273	return 0
  274}
  275
  276// ScriptExecDuration evaluates a bash expression as a duration with the MENV
  277// file sourced. Accepts Go duration strings ("750ms", "2s", "5m", "1h") and
  278// bare integers (treated as seconds).
  279func ScriptExecDuration(s string) time.Duration {
  280	z, err := script.Exec(fmt.Sprintf(`bash -c 'MENV=%s ; if [[ $MENV != "" ]] && [[ -f $MENV ]] ; then source $MENV ; fi ; printf "%s"'`, MENV, s)).String()
  281	if err != nil {
  282		return 0
  283	}
  284	z = strings.TrimSpace(z)
  285	if z == "" {
  286		return 0
  287	}
  288	z = strings.TrimPrefix(z, "-") // keep parity with how defaults are built
  289
  290	// Try full Go duration syntax first.
  291	if d, err := time.ParseDuration(z); err == nil {
  292		return d
  293	}
  294	// Fallback: plain integer means seconds.
  295	if n, err := strconv.ParseInt(z, 10, 64); err == nil {
  296		return time.Duration(n) * time.Second
  297	}
  298	return 0
  299}
  300
  301
  302// ===== pkg/product/csv.go =====
  303// Package product pkg/product/csv.go β€” catalog CSV loading.
  304package product
  305
  306import (
  307	"bufio"
  308	"bytes"
  309	"embed"
  310	"fmt"
  311	"log"
  312	"strings"
  313
  314	"github.com/bitfield/script"
  315)
  316
  317//go:embed *.go
  318var Source embed.FS
  319
  320func readproductscsv(csvFile string) (data []byte) {
  321	data, err := script.File(csvFile).Bytes() //nolint
  322	if err != nil {
  323		log.Printf(`Error reading %s file %v`, csvFile, err)
  324	}
  325	return data
  326}
  327
  328const csvMinFields = 51 // f[0] through f[50]
  329
  330// ReadCSV reads the catalog from a file. The parsing is in ParseCSV so that
  331// it can be tested without one.
  332func ReadCSV(csvFile string) Products {
  333	return ParseCSV(readproductscsv(csvFile))
  334}
  335
  336// ParseCSV turns the catalog bytes into products, skipping rows that are not
  337// enabled and rows too short to fill one.
  338func ParseCSV(data []byte) (prods Products) {
  339	scanner := bufio.NewScanner(bytes.NewReader(data))
  340	lineNum := 0
  341	for scanner.Scan() {
  342		lineNum++
  343		line := scanner.Text()
  344		f := strings.Split(line, ",")
  345		if len(f) < 4 {
  346			continue
  347		}
  348		if f[3] != "TRUE" {
  349			continue
  350		}
  351		if len(f) < csvMinFields {
  352			log.Printf("csv line %d: expected %d fields, got %d β€” skipping", lineNum, csvMinFields, len(f))
  353			continue
  354		}
  355		q := Product{
  356			Image1:            f[0],
  357			Partno:            f[1],
  358			Name:              f[2],
  359			Enable:            f[3],
  360			Price:             f[4],
  361			Quantity:          f[5],
  362			Shippable:         f[6],
  363			Minorder:          f[7],
  364			Maxorder:          f[8],
  365			Defaultquantity:   f[9],
  366			Stepquantity:      f[10],
  367			Mfgpartno:         f[11],
  368			Mfgname:           f[12],
  369			Category:          f[13],
  370			Subcategory:       f[14],
  371			Location:          f[15],
  372			Msrp:              f[16],
  373			Cost:              f[17],
  374			Typ:               f[18],
  375			Packagetype:       f[19],
  376			Technology:        f[20],
  377			Materials:         f[21],
  378			Value:             f[22],
  379			ValUnit:           f[23],
  380			Resistance:        f[24],
  381			ResUnit:           f[25],
  382			Tolerance:         f[26],
  383			VoltsRating:       f[27],
  384			AmpsRating:        f[28],
  385			WattsRating:       f[29],
  386			TempRating:        f[30],
  387			TempUnit:          f[31],
  388			Description1:      f[32],
  389			Description2:      f[33],
  390			Color1:            f[34],
  391			Color2:            f[35],
  392			Sourceinfo:        f[36],
  393			Datasheet:         f[37],
  394			Docs:              f[38],
  395			Reference:         f[39],
  396			Attributes:        f[40],
  397			Year:              f[41],
  398			Condition:         f[42],
  399			Note:              f[43],
  400			Warning:           f[44],
  401			CableLengthInches: f[45],
  402			LengthInches:      f[46],
  403			WidthInches:       f[47],
  404			HeightInches:      f[48],
  405			WeightLb:          f[49],
  406			WeightOz:          f[50],
  407		}
  408		prods = append(prods, q)
  409	}
  410	return prods
  411}
  412
  413// ValidateCSV scans products for patterns that could cause issues in HTML/JS rendering.
  414// Returns a list of warnings. Call after ReadCSV to check data integrity.
  415func ValidateCSV(prods Products) []string {
  416	var warnings []string
  417	for i, pr := range prods {
  418		check := func(field, value string) {
  419			if strings.ContainsAny(value, "<>\"'&") {
  420				warnings = append(warnings, fmt.Sprintf("product %d (%s): %s contains HTML-unsafe characters: %q", i, pr.Partno, field, value))
  421			}
  422			if strings.Contains(value, "|") {
  423				warnings = append(warnings, fmt.Sprintf("product %d (%s): %s contains pipe character: %q", i, pr.Partno, field, value))
  424			}
  425		}
  426		check("Partno", pr.Partno)
  427		check("Name", pr.Name)
  428		check("Description1", pr.Description1)
  429		check("Description2", pr.Description2)
  430		check("Note", pr.Note)
  431		check("Warning", pr.Warning)
  432		check("Category", pr.Category)
  433		check("Subcategory", pr.Subcategory)
  434		check("Mfgname", pr.Mfgname)
  435		check("Mfgpartno", pr.Mfgpartno)
  436	}
  437	return warnings
  438}
  439
  440
  441// ===== pkg/product/csv_test.go =====
  442package product
  443
  444import (
  445	"strings"
  446	"testing"
  447)
  448
  449// row builds a catalog line with the fields the parser needs, so a test can
  450// set the few it cares about without writing fifty-one commas.
  451func row(set map[int]string) string {
  452	f := make([]string, csvMinFields)
  453	f[0] = "img.jpg"
  454	f[1] = "PN-1"
  455	f[2] = "A Part"
  456	f[3] = "TRUE"
  457	f[4] = "1.50"
  458	f[5] = "10"
  459	for i, v := range set {
  460		f[i] = v
  461	}
  462	return strings.Join(f, ",")
  463}
  464
  465func TestParseCSVReadsAProduct(t *testing.T) {
  466	prods := ParseCSV([]byte(row(nil)))
  467	if len(prods) != 1 {
  468		t.Fatalf("got %d products, want 1", len(prods))
  469	}
  470	got := prods[0]
  471	if got.Partno != "PN-1" || got.Name != "A Part" || got.Price != "1.50" || got.Quantity != "10" {
  472		t.Errorf("fields did not land where they should: %+v", got)
  473	}
  474}
  475
  476// The last field is the fiftieth, and off-by-one at the end of a fifty-one
  477// column row is invisible in a spreadsheet.
  478func TestParseCSVReadsTheLastField(t *testing.T) {
  479	prods := ParseCSV([]byte(row(map[int]string{50: "3.25", 49: "1"})))
  480	if len(prods) != 1 {
  481		t.Fatalf("got %d products, want 1", len(prods))
  482	}
  483	if prods[0].WeightOz != "3.25" {
  484		t.Errorf("WeightOz = %q, want the last column", prods[0].WeightOz)
  485	}
  486	if prods[0].WeightLb != "1" {
  487		t.Errorf("WeightLb = %q, want the second to last column", prods[0].WeightLb)
  488	}
  489}
  490
  491// Only enabled rows are sold. A row that says anything but TRUE is a product
  492// deliberately withdrawn, and showing it anyway sells something not in stock.
  493func TestParseCSVSkipsRowsThatAreNotEnabled(t *testing.T) {
  494	for _, enable := range []string{"FALSE", "false", "true", "", "1", "yes"} {
  495		prods := ParseCSV([]byte(row(map[int]string{3: enable})))
  496		if len(prods) != 0 {
  497			t.Errorf("Enable=%q produced a product; only TRUE should", enable)
  498		}
  499	}
  500}
  501
  502// A short row cannot fill a product, and reading one anyway would panic on a
  503// live catalog rather than skip a line.
  504func TestParseCSVSkipsShortRows(t *testing.T) {
  505	short := strings.Join([]string{"img.jpg", "PN-1", "A Part", "TRUE", "1.50"}, ",")
  506	if prods := ParseCSV([]byte(short)); len(prods) != 0 {
  507		t.Errorf("a row with 5 fields produced %d products", len(prods))
  508	}
  509	// Exactly one short of the minimum is the boundary worth pinning.
  510	f := make([]string, csvMinFields-1)
  511	for i := range f {
  512		f[i] = "x"
  513	}
  514	f[3] = "TRUE"
  515	if prods := ParseCSV([]byte(strings.Join(f, ","))); len(prods) != 0 {
  516		t.Errorf("a row one field short produced %d products", len(prods))
  517	}
  518}
  519
  520func TestParseCSVSkipsVeryShortAndBlankRows(t *testing.T) {
  521	for _, line := range []string{"", ",", "a,b,c", "\n\n"} {
  522		if prods := ParseCSV([]byte(line)); len(prods) != 0 {
  523			t.Errorf("%q produced %d products", line, len(prods))
  524		}
  525	}
  526}
  527
  528func TestParseCSVReadsSeveralRows(t *testing.T) {
  529	data := strings.Join([]string{
  530		row(map[int]string{1: "AAA"}),
  531		row(map[int]string{3: "FALSE", 1: "SKIPPED"}),
  532		row(map[int]string{1: "BBB"}),
  533	}, "\n")
  534	prods := ParseCSV([]byte(data))
  535	if len(prods) != 2 {
  536		t.Fatalf("got %d products, want 2", len(prods))
  537	}
  538	if prods[0].Partno != "AAA" || prods[1].Partno != "BBB" {
  539		t.Errorf("got %q and %q, want AAA and BBB", prods[0].Partno, prods[1].Partno)
  540	}
  541}
  542
  543// The catalog is split on commas rather than parsed as CSV, so a quoted
  544// field containing one shifts every column after it β€” including Enable, which
  545// is column 3. The row then does not say TRUE where the parser looks, and the
  546// product is dropped from the store without a word.
  547//
  548// A spreadsheet writes that quoting itself the moment a name contains a comma,
  549// so this is reachable from ordinary editing rather than from bad data.
  550//
  551// This test records what the parser does today. If it is ever changed to use
  552// encoding/csv, this test is the one that should fail.
  553func TestParseCSVSilentlyDropsRowsWithAQuotedComma(t *testing.T) {
  554	f := make([]string, csvMinFields)
  555	for i := range f {
  556		f[i] = "x"
  557	}
  558	f[1] = "PN-1"
  559	f[2] = `"Resistor, 10k"` // one field in a spreadsheet, two after a split
  560	f[3] = "TRUE"
  561	f[4] = "1.50"
  562	line := strings.Join(f, ",")
  563
  564	// The row is long enough β€” it is the shift that loses it, not the length.
  565	if n := len(strings.Split(line, ",")); n <= csvMinFields {
  566		t.Fatalf("the fixture has %d fields; it needs more than %d to isolate the shift", n, csvMinFields)
  567	}
  568
  569	prods := ParseCSV([]byte(line))
  570	if len(prods) != 0 {
  571		t.Fatalf("the parser now keeps rows with a quoted comma (%d products); "+
  572			"replace this test with one asserting the fields are correct", len(prods))
  573	}
  574
  575	// Without the comma the same row is read, which is what shows the comma is
  576	// the cause rather than anything else in the fixture.
  577	f[2] = "Resistor 10k"
  578	if prods := ParseCSV([]byte(strings.Join(f, ","))); len(prods) != 1 {
  579		t.Errorf("the same row without the comma gave %d products, want 1", len(prods))
  580	}
  581}
  582
  583func TestValidateCSVAcceptsCleanData(t *testing.T) {
  584	prods := ParseCSV([]byte(row(nil)))
  585	if w := ValidateCSV(prods); len(w) != 0 {
  586		t.Errorf("clean data produced warnings: %v", w)
  587	}
  588}
  589
  590// These characters are what turn a product name into markup on the page.
  591func TestValidateCSVFlagsCharactersThatBreakThePage(t *testing.T) {
  592	for _, bad := range []string{`<script>`, `a"b`, "a'b", "a&b", "a>b"} {
  593		prods := ParseCSV([]byte(row(map[int]string{2: bad})))
  594		if len(prods) != 1 {
  595			t.Fatalf("%q: got %d products", bad, len(prods))
  596		}
  597		w := ValidateCSV(prods)
  598		if len(w) == 0 {
  599			t.Errorf("%q in a product name was not flagged", bad)
  600			continue
  601		}
  602		if !strings.Contains(w[0], "Name") {
  603			t.Errorf("%q: the warning does not name the field: %s", bad, w[0])
  604		}
  605	}
  606}
  607
  608// The pipe is the field separator in the cart's stored format, so a pipe in a
  609// product name corrupts the cart rather than the page.
  610func TestValidateCSVFlagsPipes(t *testing.T) {
  611	prods := ParseCSV([]byte(row(map[int]string{2: "A|B"})))
  612	w := ValidateCSV(prods)
  613	if len(w) == 0 {
  614		t.Fatal("a pipe in a product name was not flagged")
  615	}
  616	if !strings.Contains(w[0], "pipe") {
  617		t.Errorf("the warning does not mention the pipe: %s", w[0])
  618	}
  619}
  620
  621// Every field that reaches the page is checked, not just the name.
  622func TestValidateCSVChecksEveryRenderedField(t *testing.T) {
  623	fields := map[int]string{
  624		1:  "PN<",  // Partno
  625		2:  "N<",   // Name
  626		12: "MFG<", // Mfgname
  627		11: "MP<",  // Mfgpartno
  628		13: "CAT<", // Category
  629		14: "SUB<", // Subcategory
  630		32: "D1<",  // Description1
  631		33: "D2<",  // Description2
  632		43: "NT<",  // Note
  633		44: "WN<",  // Warning
  634	}
  635	for col, val := range fields {
  636		prods := ParseCSV([]byte(row(map[int]string{3: "TRUE", col: val})))
  637		if len(prods) != 1 {
  638			t.Fatalf("column %d: got %d products", col, len(prods))
  639		}
  640		if w := ValidateCSV(prods); len(w) == 0 {
  641			t.Errorf("an unsafe character in column %d was not flagged", col)
  642		}
  643	}
  644}
  645
  646func TestValidateCSVOnNoProducts(t *testing.T) {
  647	if w := ValidateCSV(nil); len(w) != 0 {
  648		t.Errorf("an empty catalog produced warnings: %v", w)
  649	}
  650}
  651
  652// The warning names the row so it can be found in a spreadsheet of thousands.
  653func TestValidateCSVNamesTheProduct(t *testing.T) {
  654	prods := ParseCSV([]byte(row(map[int]string{1: "PN-SPECIAL", 2: "bad<"})))
  655	w := ValidateCSV(prods)
  656	if len(w) == 0 {
  657		t.Fatal("no warning")
  658	}
  659	if !strings.Contains(w[0], "PN-SPECIAL") {
  660		t.Errorf("the warning does not name the part: %s", w[0])
  661	}
  662}
  663
  664
  665// ===== pkg/product/product.go =====
  666// Package product pkg/product/product.go
  667package product
  668
  669type Product struct {
  670	Enable            string
  671	Partno            string
  672	Name              string
  673	Image1            string
  674	Price             string
  675	Quantity          string
  676	Shippable         string
  677	Minorder          string
  678	Maxorder          string
  679	Defaultquantity   string
  680	Stepquantity      string
  681	Mfgpartno         string
  682	Mfgname           string
  683	Category          string
  684	Subcategory       string
  685	Location          string
  686	Msrp              string
  687	Cost              string
  688	Typ               string
  689	Packagetype       string
  690	Technology        string
  691	Materials         string
  692	Value             string
  693	ValUnit           string
  694	Resistance        string
  695	ResUnit           string
  696	Tolerance         string
  697	VoltsRating       string
  698	AmpsRating        string
  699	WattsRating       string
  700	TempRating        string
  701	TempUnit          string
  702	Description1      string
  703	Description2      string
  704	Color1            string
  705	Color2            string
  706	Sourceinfo        string
  707	Datasheet         string
  708	Docs              string
  709	Reference         string
  710	Attributes        string
  711	Year              string
  712	Condition         string
  713	Note              string
  714	Warning           string
  715	CableLengthInches string
  716	LengthInches      string
  717	WidthInches       string
  718	HeightInches      string
  719	WeightLb          string
  720	WeightOz          string
  721}
  722
  723// Products is an array of Product
  724type Products []Product
  725
  726
  727// ===== pkg/tui/address.go =====
  728package tui
  729
  730import (
  731	"strings"
  732
  733	"github.com/0magnet/websh/progressive"
  734)
  735
  736// The page's address (websh's PROTOCOL.md, The page): where the host lets
  737// it, the store tells it which page is showing, as the site's own path β€”
  738// /p/<part number>, /cat/<category> β€” so the place can be linked to, and a
  739// link opens the store there again. Elsewhere nothing is sent.
  740
  741// StartPath is the site path to open at, instead of the home page: the
  742// path a link handed back (progressive.Caps.LinkPath).
  743var StartPath string
  744
  745// announcePage tells the host the page now showing, when it changed: its
  746// path for the page's address, and its mirror (mirror.go).
  747func (u *ui) announcePage() {
  748	c := progressive.Current()
  749	if !c.Has("page") && !c.Has("mirror") {
  750		return
  751	}
  752	p := u.pagePath()
  753	if p == u.announced {
  754		return
  755	}
  756	u.announced = p
  757	if c.Has("mirror") {
  758		for _, seq := range progressive.Mirror([]byte(u.mirrorHTML())) {
  759			u.writeTerm(seq)
  760		}
  761	}
  762	if !c.Has("page") {
  763		return
  764	}
  765	title := u.sitedomain
  766	if part, ok := strings.CutPrefix(p, "/p/"); ok {
  767		if prod := u.cat.find(part); prod != nil && prod.Name != "" {
  768			title = prod.Name + " β€” " + u.sitedomain
  769		}
  770	}
  771	u.writeTerm(progressive.Page(p, title))
  772}
  773
  774// pageFromPath is pagePath backwards: the page a site path names, or "" for
  775// none this store has.
  776func (u *ui) pageFromPath(p string) string {
  777	switch {
  778	case p == "":
  779		return ""
  780	case p == "/":
  781		return "home"
  782	case strings.HasPrefix(p, "/#"):
  783		return "content:" + strings.TrimPrefix(p, "/#")
  784	case strings.HasPrefix(p, "/p/"):
  785		part := strings.TrimPrefix(p, "/p/")
  786		if u.cat.find(part) == nil {
  787			return ""
  788		}
  789		return "prod:" + part
  790	case p == "/cat" || strings.HasPrefix(p, "/cat/"):
  791		// As the site reads it: case aside, and a subcategory by either
  792		// spelling.
  793		if t, ok := u.cat.resolve(strings.TrimPrefix(p, "/cat")); ok {
  794			return listPageID(t)
  795		}
  796	}
  797	return ""
  798}
  799
  800
  801// ===== pkg/tui/address_test.go =====
  802package tui
  803
  804import (
  805	"strings"
  806	"testing"
  807
  808	"github.com/0magnet/m2/pkg/product"
  809)
  810
  811// TestAddressAndMirror: a site path names the page the store would show at
  812// it, as the site reads it, and the mirror of each page is its structure,
  813// escaped, with links to the site.
  814func TestAddressAndMirror(t *testing.T) {
  815	old := f.Storeurl
  816	f.Storeurl = "https://shop.example"
  817	defer func() { f.Storeurl = old }()
  818	u := &ui{cat: newCatalogInfo(product.Products{
  819		{Partno: "qjack", Name: "mono <jack>", Price: "1.25", Quantity: "50", Category: "audio", Subcategory: "jack"},
  820		{Partno: "r10k", Name: "10k resistor", Price: "0.05", Category: "resistor"},
  821	}), sitedomain: "shop.example"}
  822
  823	for path, want := range map[string]string{
  824		"/":               "home",
  825		"/p/qjack":        "prod:qjack",
  826		"/p/nosuch":       "",
  827		"/cat":            "list:",
  828		"/cat/Audio":      "list:audio",
  829		"/cat/audio/jack": "list:audio|jack",
  830		"/cat/nosuch":     "",
  831		"/elsewhere":      "",
  832	} {
  833		if got := u.pageFromPath(path); got != want {
  834			t.Errorf("%s: %q, want %q", path, got, want)
  835		}
  836	}
  837
  838	u.current = "prod:qjack"
  839	if m := u.mirrorHTML(); !strings.Contains(m, "<h1>mono &lt;jack&gt;</h1>") || !strings.Contains(m, `href="https://shop.example/p/qjack"`) || !strings.Contains(m, `href="https://shop.example/cat/audio/jack"`) {
  840		t.Errorf("product mirror: %s", m)
  841	}
  842	u.current = "list:audio"
  843	if m := u.mirrorHTML(); !strings.Contains(m, "<h1>audio</h1><p>1 products.</p>") || !strings.Contains(m, "mono &lt;jack&gt;</a> β€” $1.25") {
  844		t.Errorf("list mirror: %s", m)
  845	}
  846	u.current = "home"
  847	if m := u.mirrorHTML(); !strings.Contains(m, `<a href="https://shop.example/cat/resistor">resistor</a> (1)`) {
  848		t.Errorf("home mirror: %s", m)
  849	}
  850}
  851
  852
  853// ===== pkg/tui/border.go =====
  854// Package tui pkg/tui/border.go β€” the pisano frame around the product
  855// table.
  856//
  857// The same drawing the website puts around its category tables, from the
  858// same library: an integer sequence reduced modulo m, walked as a turtle
  859// and rendered in box-drawing characters. On the page that is the wasm/deco
  860// drop-in decorating the DOM after the fact; here there is no DOM to
  861// decorate, so the frame is composed into the cells the table is drawn in.
  862//
  863// The figures are wasm/deco's, deliberately β€” one frame seen in two places
  864// is the point, and a terminal that invented its own would just be a second
  865// drawing to keep in step. What differs is the fit. The page knows the table
  866// and grows a frame around it (FitAround); a terminal knows the area and has
  867// to put both inside it (FitBox), because there is nowhere for a border to
  868// overflow to.
  869package tui
  870
  871import (
  872	"strconv"
  873	"sync"
  874
  875	"github.com/gdamore/tcell/v3"
  876
  877	"github.com/0magnet/pisano/pkg/border"
  878	"github.com/0magnet/pisano/pkg/pisano"
  879)
  880
  881// borderArt is a composed frame: the glyphs, the color of each, and the
  882// clear middle the table goes in.
  883type borderArt struct {
  884	g    border.Grid
  885	tint border.Tint
  886	in   border.Rect
  887}
  888
  889// recipe is one frame's three figures: the run along the top and bottom,
  890// the run down the sides, and the closed figure at the corners.
  891type recipe struct{ across, down, corner border.Figure }
  892
  893var (
  894	recipesOnce sync.Once
  895	borderSet   []recipe
  896)
  897
  898// recipes are wasm/deco's, and the reasoning is its: modulus 13 is both
  899// runs β€” it travels +0,-4, which makes a side, and a quarter turn makes a
  900// top square to it by construction rather than by search. Modulus 31 at
  901// four passes is the corner, closed and with the full symmetry of the
  902// square; an open figure would have loose ends of its own just where a run
  903// is trying to stop. Modulus 8 is the second rung, for an area that cannot
  904// spare the first's reach β€” a smaller figure beats no figure.
  905//
  906// The order is the preference, fullest corner first, and fitBorder keeps
  907// it as long as the content can afford it.
  908func recipes() []recipe {
  909	recipesOnce.Do(func() {
  910		side, ok := border.OfPasses(13, 1)
  911		if !ok {
  912			return
  913		}
  914		top := side.RotateCW()
  915		for _, c := range [][2]int{{31, 4}, {8, 2}} {
  916			corner, ok := border.OfPasses(c[0], c[1])
  917			if !ok || !corner.Closed {
  918				continue
  919			}
  920			borderSet = append(borderSet, recipe{across: top, down: side, corner: corner})
  921		}
  922	})
  923	return borderSet
  924}
  925
  926// borderPalette is pisano's six pass colors, which is what its own turtle
  927// tints with β€” so the frame here and the frame on the page are the same
  928// drawing in the same colors.
  929var borderPalette = func() []tcell.Style {
  930	hex := pisano.PassHex()
  931	out := make([]tcell.Style, len(hex))
  932	for i, h := range hex {
  933		out[i] = styleText.Foreground(tcell.GetColor(h))
  934	}
  935	return out
  936}()
  937
  938// frameCache keeps one frame per area size.
  939//
  940// Composing one is not free β€” it lays every copy of both runs and all four
  941// corners onto a grid, cuts the runs where their own wave crosses, then
  942// walks the result to check it is a single closed line. A draw happens on
  943// every event the UI handles, pointer motion included, so composing per
  944// draw would be the most expensive thing the program does. A size that
  945// fits nothing is cached as a miss, so it is not retried either.
  946var (
  947	frameMu    sync.Mutex
  948	frameCache = map[string]*borderArt{}
  949)
  950
  951// minFramed is the smallest clear middle worth taking an area down to. A
  952// frame that fits but leaves room for a header and one row has spent the
  953// table to draw a picture of one.
  954const minFramed = 6
  955
  956// roomyRows and roomyCols are what a middle has to keep for the fuller
  957// corner to be worth its longer reach: a table still worth reading β€” its
  958// header and a dozen-odd products, at a width that does not start cutting
  959// names β€” rather than merely a table.
  960//
  961// Below them the corner is competing with the content for the same cells
  962// and the content wins, which is the only reason to draw a smaller figure
  963// at all.
  964const (
  965	roomyRows = 16
  966	roomyCols = 40
  967)
  968
  969// fitBorder composes the largest frame that fits in a cols by rows area,
  970// or reports none. The area is what the caller has; the frame is drawn
  971// inside it and the table goes in what is left.
  972func fitBorder(cols, rows int) *borderArt {
  973	if cols < 8 || rows < 8 {
  974		return nil
  975	}
  976	key := strconv.Itoa(cols) + "x" + strconv.Itoa(rows)
  977	frameMu.Lock()
  978	defer frameMu.Unlock()
  979	if hit, seen := frameCache[key]; seen {
  980		return hit
  981	}
  982	// The fullest corner that still leaves a table worth reading, and the
  983	// roomiest otherwise.
  984	//
  985	// wasm/deco takes the first that fits, largest corner first, and can:
  986	// it grows a frame AROUND its table, so a bigger corner only makes a
  987	// bigger frame and costs the table nothing. Here the frame is cut OUT
  988	// of a fixed area and every cell of corner is a row of products not
  989	// shown, so the order alone is not an answer β€” but neither is simply
  990	// taking the roomiest, which spends the good corner to buy four rows
  991	// on a screen that had forty to spare.
  992	//
  993	// So: the same order deco uses, and the same preference, held only as
  994	// long as the content is not the thing running out. Under that, the
  995	// figure gives way rather than the table.
  996	var got, roomiest *borderArt
  997	var bestRoom int
  998	for _, r := range recipes() {
  999		l, g, ok := border.FitBox(r.across, r.down, r.corner, cols, rows)
 1000		if !ok {
 1001			continue
 1002		}
 1003		in := g.Inside()
 1004		w, h := in.X1-in.X0, in.Y1-in.Y0
 1005		if w < minFramed || h < minFramed {
 1006			continue
 1007		}
 1008		art := &borderArt{g: g, tint: l.TintByCopy(g, len(borderPalette)), in: in}
 1009		if h >= roomyRows && w >= roomyCols {
 1010			// Room to spare, and this is the fullest corner left to try.
 1011			// Nothing after it is better, and composing it would be work
 1012			// for a frame this one has already beaten.
 1013			got = art
 1014			break
 1015		}
 1016		// Rows before columns: a terminal runs out of height first, and a
 1017		// row lost is a product off the screen where a column lost is a
 1018		// name a little shorter.
 1019		if score := h<<16 | w; score > bestRoom {
 1020			bestRoom, roomiest = score, art
 1021		}
 1022	}
 1023	if got == nil {
 1024		got = roomiest
 1025	}
 1026	frameCache[key] = got
 1027	return got
 1028}
 1029
 1030// place centers the frame in the area it was fitted to and reports the
 1031// rectangle its clear middle occupies on screen β€” where the table goes.
 1032//
 1033// Centered because a frame is quantised by its figures and is never
 1034// exactly the size asked for; hanging it from the top left would leave the
 1035// slack in one corner.
 1036func (b *borderArt) place(area rect) (origin, inner rect) {
 1037	ox := area.x + (area.w-b.g.W())/2
 1038	oy := area.y + (area.h-b.g.H())/2
 1039	origin = rect{ox, oy, b.g.W(), b.g.H()}
 1040	inner = rect{ox + b.in.X0, oy + b.in.Y0, b.in.X1 - b.in.X0, b.in.Y1 - b.in.Y0}
 1041	return origin, inner
 1042}
 1043
 1044// draw paints the frame. Blanks are skipped rather than drawn as spaces,
 1045// so the frame never erases anything it overlaps β€” it is a decoration, and
 1046// the page's is drawn under its table for the same reason.
 1047func (b *borderArt) draw(sc tcell.Screen, origin rect) {
 1048	for y, row := range b.g {
 1049		for x, ch := range row {
 1050			if ch == border.Blank {
 1051				continue
 1052			}
 1053			st := styleText
 1054			if y < len(b.tint) && x < len(b.tint[y]) {
 1055				if i := b.tint[y][x]; i >= 0 {
 1056					st = borderPalette[i%len(borderPalette)]
 1057				}
 1058			}
 1059			sc.SetContent(origin.x+x, origin.y+y, ch, nil, st)
 1060		}
 1061	}
 1062}
 1063
 1064// framed splits an area into a frame and the cells inside it. An area that
 1065// fits no frame is handed back whole, which is what the page does too: a
 1066// box with no border is the answer, not a box with a broken one.
 1067func framed(area rect) (inner rect, b *borderArt, origin rect) {
 1068	b = fitBorder(area.w, area.h)
 1069	if b == nil {
 1070		return area, nil, rect{}
 1071	}
 1072	origin, inner = b.place(area)
 1073	return inner, b, origin
 1074}
 1075
 1076
 1077// ===== pkg/tui/border_test.go =====
 1078package tui
 1079
 1080import (
 1081	"testing"
 1082
 1083	"github.com/0magnet/pisano/pkg/border"
 1084)
 1085
 1086// A frame has to fit in the area it was asked for. It is cut out of the
 1087// table's own space, so one cell over means a border drawn across the
 1088// heading or the footer.
 1089func TestBorderFitsTheAreaItIsGiven(t *testing.T) {
 1090	for _, sz := range [][2]int{{160, 60}, {120, 40}, {80, 30}, {60, 24}} {
 1091		b := fitBorder(sz[0], sz[1])
 1092		if b == nil {
 1093			continue // nothing fits here, which is an answer
 1094		}
 1095		if b.g.W() > sz[0] || b.g.H() > sz[1] {
 1096			t.Errorf("%dx%d: frame is %dx%d, larger than its area", sz[0], sz[1], b.g.W(), b.g.H())
 1097		}
 1098		w, h := b.in.X1-b.in.X0, b.in.Y1-b.in.Y0
 1099		if w < minFramed || h < minFramed {
 1100			t.Errorf("%dx%d: middle is %dx%d, under the minimum", sz[0], sz[1], w, h)
 1101		}
 1102	}
 1103}
 1104
 1105// The clear middle is where the table is drawn, so nothing may be inked
 1106// inside it β€” a glyph there would be a box character among the products.
 1107func TestBorderMiddleIsClear(t *testing.T) {
 1108	b := fitBorder(160, 60)
 1109	if b == nil {
 1110		t.Fatal("no frame at 160x60, which is the size this page runs at")
 1111	}
 1112	for y := b.in.Y0; y < b.in.Y1; y++ {
 1113		for x := b.in.X0; x < b.in.X1; x++ {
 1114			if ch := b.g[y][x]; ch != border.Blank {
 1115				t.Fatalf("%q at %d,%d is inside the clear middle", ch, x, y)
 1116			}
 1117		}
 1118	}
 1119}
 1120
 1121// place puts the middle on screen where the table will be drawn, and the
 1122// frame around it β€” both inside the area.
 1123func TestBorderPlacesItsMiddleInsideTheArea(t *testing.T) {
 1124	area := rect{x: 3, y: 9, w: 160, h: 60}
 1125	b := fitBorder(area.w, area.h)
 1126	if b == nil {
 1127		t.Fatal("no frame")
 1128	}
 1129	origin, inner := b.place(area)
 1130	if origin.x < area.x || origin.y < area.y ||
 1131		origin.x+origin.w > area.x+area.w || origin.y+origin.h > area.y+area.h {
 1132		t.Errorf("frame at %+v escapes the area %+v", origin, area)
 1133	}
 1134	if inner.x < origin.x || inner.y < origin.y ||
 1135		inner.x+inner.w > origin.x+origin.w || inner.y+inner.h > origin.y+origin.h {
 1136		t.Errorf("middle at %+v escapes the frame %+v", inner, origin)
 1137	}
 1138	if inner.w >= area.w || inner.h >= area.h {
 1139		t.Errorf("middle %dx%d is not smaller than the area %dx%d", inner.w, inner.h, area.w, area.h)
 1140	}
 1141}
 1142
 1143// cornerReach is how far a frame's corner comes in from its own edge,
 1144// which is what tells the two figures apart: the fuller corner reaches
 1145// further, and that reach is its whole cost.
 1146func cornerReach(b *borderArt) int { return b.in.Y0 }
 1147
 1148// With room to spare the frame uses the fuller corner, as the page does.
 1149func TestBorderKeepsTheFullCornerWhenThereIsRoom(t *testing.T) {
 1150	roomy := fitBorder(160, 60)
 1151	tight := fitBorder(160, 30)
 1152	if roomy == nil || tight == nil {
 1153		t.Fatal("expected a frame at both sizes")
 1154	}
 1155	if cornerReach(roomy) <= cornerReach(tight) {
 1156		t.Errorf("the roomy area's corner reaches %d, no further than the tight one's %d",
 1157			cornerReach(roomy), cornerReach(tight))
 1158	}
 1159	if h := roomy.in.Y1 - roomy.in.Y0; h < roomyRows {
 1160		t.Errorf("the full corner was kept on a middle of only %d rows", h)
 1161	}
 1162}
 1163
 1164// When it is the content running out, the figure gives way rather than
 1165// the table: the frame chosen leaves more rows than the fuller corner
 1166// would have.
 1167func TestBorderGivesUpTheCornerRatherThanTheTable(t *testing.T) {
 1168	const cols, rows = 160, 30
 1169	got := fitBorder(cols, rows)
 1170	if got == nil {
 1171		t.Fatal("no frame at 160x30")
 1172	}
 1173	full := recipes()[0]
 1174	_, g, ok := border.FitBox(full.across, full.down, full.corner, cols, rows)
 1175	if !ok {
 1176		t.Skip("the fuller corner does not fit here at all, which is a different path")
 1177	}
 1178	in := g.Inside()
 1179	if want := in.Y1 - in.Y0; got.in.Y1-got.in.Y0 <= want {
 1180		t.Errorf("gave up the corner for %d rows, no better than the %d it would have left",
 1181			got.in.Y1-got.in.Y0, want)
 1182	}
 1183}
 1184
 1185// An area too small for any figure gets no frame, rather than a broken
 1186// one or a table squeezed to nothing.
 1187func TestBorderDeclinesASmallArea(t *testing.T) {
 1188	for _, sz := range [][2]int{{4, 4}, {20, 6}, {7, 30}} {
 1189		if b := fitBorder(sz[0], sz[1]); b != nil {
 1190			t.Errorf("%dx%d got a frame with a %dx%d middle, want none",
 1191				sz[0], sz[1], b.in.X1-b.in.X0, b.in.Y1-b.in.Y0)
 1192		}
 1193	}
 1194}
 1195
 1196// Composing is the expensive thing here and a draw happens on every
 1197// event, so the same size must come back from the cache rather than be
 1198// composed again.
 1199func TestBorderCachesBySize(t *testing.T) {
 1200	a := fitBorder(132, 44)
 1201	b := fitBorder(132, 44)
 1202	if a != b {
 1203		t.Error("the same size composed twice instead of coming from the cache")
 1204	}
 1205}
 1206
 1207
 1208// ===== pkg/tui/cartsave.go =====
 1209package tui
 1210
 1211import (
 1212	"encoding/json"
 1213	"os"
 1214	"path/filepath"
 1215	"runtime"
 1216	"strings"
 1217
 1218	"github.com/0magnet/m2/pkg/storepage"
 1219)
 1220
 1221// The cart is a file, as a desktop program keeps its state:
 1222// ~/.config/m2/cart-<store>.json, read at start and written on every change.
 1223// The same code runs in the browser, where the filesystem is bottle's jsfs β€”
 1224// the page persists its /home to IndexedDB β€” so a reload finds the cart where
 1225// a restart does on a desktop. One file per store, so carts at two stores do
 1226// not mix.
 1227//
 1228// It holds what the buyer chose and nothing the store decides: part numbers
 1229// and quantities, and where to ship. Prices come from the catalog when the
 1230// cart is shown, and the server checks them again at checkout.
 1231
 1232// savedCart is the file.
 1233type savedCart struct {
 1234	Items    []savedItem         `json:"items"`
 1235	Shipping *storepage.Shipping `json:"shipping,omitempty"`
 1236}
 1237
 1238type savedItem struct {
 1239	Partno string `json:"partno"`
 1240	Qty    int    `json:"qty"`
 1241}
 1242
 1243// configDir is where a user's settings live: XDG's, or the shell's home in
 1244// a page, whose process has no HOME of its own.
 1245func configDir() string {
 1246	if d, err := os.UserConfigDir(); err == nil {
 1247		return d
 1248	}
 1249	if runtime.GOOS == "js" {
 1250		return "/home/user/.config"
 1251	}
 1252	return ""
 1253}
 1254
 1255// cartPath is this store's cart file, or "" where there is nowhere to keep one.
 1256func (u *ui) cartPath() string {
 1257	dir := configDir()
 1258	if dir == "" {
 1259		return ""
 1260	}
 1261	name := strings.Map(func(r rune) rune {
 1262		switch {
 1263		case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '-':
 1264			return r
 1265		}
 1266		return '_'
 1267	}, u.sitedomain)
 1268	return filepath.Join(dir, "m2", "cart-"+name+".json")
 1269}
 1270
 1271// loadCart reads the cart file, if there is one.
 1272func (u *ui) loadCart() {
 1273	p := u.cartPath()
 1274	if p == "" {
 1275		return
 1276	}
 1277	b, err := os.ReadFile(p) //nolint:gosec // the user's own config file
 1278	if err != nil {
 1279		return
 1280	}
 1281	var c savedCart
 1282	if json.Unmarshal(b, &c) != nil {
 1283		return
 1284	}
 1285	u.cartQty = map[string]int{}
 1286	u.cartOrder = nil
 1287	for _, it := range c.Items {
 1288		if it.Partno == "" || it.Qty <= 0 {
 1289			continue
 1290		}
 1291		if _, ok := u.cartQty[it.Partno]; !ok {
 1292			u.cartOrder = append(u.cartOrder, it.Partno)
 1293		}
 1294		u.cartQty[it.Partno] += it.Qty
 1295	}
 1296	u.shipping = c.Shipping
 1297}
 1298
 1299// cartChanged writes the cart after any change to it: to a file beside it
 1300// first, then over it, so a cart is never half written.
 1301func (u *ui) cartChanged() {
 1302	p := u.cartPath()
 1303	if p == "" {
 1304		return
 1305	}
 1306	c := savedCart{Items: []savedItem{}, Shipping: u.shipping}
 1307	for _, partno := range u.cartOrder {
 1308		if q := u.cartQty[partno]; q > 0 {
 1309			c.Items = append(c.Items, savedItem{partno, q})
 1310		}
 1311	}
 1312	b, err := json.MarshalIndent(c, "", "  ")
 1313	if err != nil {
 1314		return
 1315	}
 1316	if os.MkdirAll(filepath.Dir(p), 0o700) != nil {
 1317		return
 1318	}
 1319	tmp := p + ".tmp"
 1320	if os.WriteFile(tmp, append(b, '\n'), 0o600) != nil {
 1321		return
 1322	}
 1323	_ = os.Rename(tmp, p) //nolint:errcheck // the cart stays as it was; the next change tries again
 1324}
 1325
 1326// cartLine is one line of the cart as the website's cart wasm writes it and
 1327// /submit-order reads it: a product's id is its part number and its amount the line's cents
 1328// (price Γ— quantity); the shipping line's id is storepage's shipping-to line.
 1329type cartLine struct {
 1330	ID     string `json:"id"`
 1331	Amount int    `json:"amount"`
 1332	Qty    int    `json:"quantity"`
 1333}
 1334
 1335// cartLines is the cart in that form, for the order sent at checkout.
 1336func (u *ui) cartLines() []cartLine {
 1337	var ls []cartLine
 1338	for _, partno := range u.cartOrder {
 1339		qty := u.cartQty[partno]
 1340		if qty <= 0 {
 1341			continue
 1342		}
 1343		cents := 0
 1344		if p := u.cat.find(partno); p != nil {
 1345			cents = priceCents(p.Price)
 1346		}
 1347		ls = append(ls, cartLine{ID: partno, Amount: cents * qty, Qty: qty})
 1348	}
 1349	if u.shipping != nil {
 1350		ls = append(ls, cartLine{ID: u.shipping.ID(), Amount: u.shipping.Cents, Qty: 1})
 1351	}
 1352	return ls
 1353}
 1354
 1355// setQty sets one product's quantity in the cart; zero takes it out.
 1356func (u *ui) setQty(partno string, qty int) {
 1357	if qty <= 0 {
 1358		delete(u.cartQty, partno)
 1359		for i, p := range u.cartOrder {
 1360			if p == partno {
 1361				u.cartOrder = append(u.cartOrder[:i], u.cartOrder[i+1:]...)
 1362				break
 1363			}
 1364		}
 1365	} else {
 1366		if _, ok := u.cartQty[partno]; !ok {
 1367			u.cartOrder = append(u.cartOrder, partno)
 1368		}
 1369		u.cartQty[partno] = qty
 1370	}
 1371	u.cartChanged()
 1372}
 1373
 1374// emptyCart takes everything out, the shipping line too, as the page's
 1375// Empty Cart does.
 1376func (u *ui) emptyCart() {
 1377	u.cartQty = map[string]int{}
 1378	u.cartOrder = nil
 1379	u.shipping = nil
 1380	u.cartChanged()
 1381}
 1382
 1383
 1384// ===== pkg/tui/cartsave_test.go =====
 1385package tui
 1386
 1387import (
 1388	"os"
 1389	"path/filepath"
 1390	"reflect"
 1391	"strings"
 1392	"testing"
 1393
 1394	"github.com/gdamore/tcell/v3"
 1395
 1396	"github.com/0magnet/m2/pkg/product"
 1397	"github.com/0magnet/m2/pkg/storepage"
 1398)
 1399
 1400// TestTheCartIsAFile: the cart is written to the store's file in the user's
 1401// config directory on every change and read back from it at start β€” what
 1402// the buyer chose, without prices.
 1403func TestTheCartIsAFile(t *testing.T) {
 1404	dir := t.TempDir()
 1405	t.Setenv("XDG_CONFIG_HOME", dir)
 1406	cat := newCatalogInfo(product.Products{
 1407		{Partno: "A1", Price: "1.50", Enable: "TRUE", Category: "x"},
 1408		{Partno: "B2", Price: "10", Enable: "TRUE", Category: "x"},
 1409	})
 1410	u := &ui{cartQty: map[string]int{}, cat: cat, sitedomain: "shop.example"}
 1411	u.setQty("A1", 3)
 1412	u.setQty("B2", 1)
 1413	u.shipping = &storepage.Shipping{Cents: 795, Name: "N", Address: "1 St", City: "C", State: "TX", Zip: "77001", Country: "USA", Phone: "5"}
 1414	u.cartChanged()
 1415
 1416	b, err := os.ReadFile(filepath.Join(dir, "m2", "cart-shop.example.json")) //nolint:gosec // the test's own temp dir
 1417	if err != nil {
 1418		t.Fatal(err)
 1419	}
 1420	if strings.Contains(string(b), "1.50") || !strings.Contains(string(b), `"partno": "A1"`) {
 1421		t.Errorf("the file:\n%s", b)
 1422	}
 1423
 1424	v := &ui{cat: cat, sitedomain: "shop.example"}
 1425	v.loadCart()
 1426	if !reflect.DeepEqual(v.cartLines(), u.cartLines()) {
 1427		t.Fatalf("read back as\n%+v\nwant\n%+v", v.cartLines(), u.cartLines())
 1428	}
 1429	if got := v.cartLines()[0]; got != (cartLine{ID: "A1", Amount: 450, Qty: 3}) {
 1430		t.Errorf("first line %+v", got)
 1431	}
 1432
 1433	v.setQty("A1", 0)
 1434	if _, ok := v.cartQty["A1"]; ok || len(v.cartOrder) != 1 {
 1435		t.Fatalf("A1 not removed: %v %v", v.cartQty, v.cartOrder)
 1436	}
 1437	w := &ui{cat: cat, sitedomain: "other.example"}
 1438	w.loadCart()
 1439	if len(w.cartOrder) != 0 {
 1440		t.Error("another store's cart was read")
 1441	}
 1442}
 1443
 1444// TestPlacementOnItsOverlay: what an overlay places lies on that overlay,
 1445// and only what is drawn after it takes it away.
 1446func TestPlacementOnItsOverlay(t *testing.T) {
 1447	u := &ui{screen: &rawScreen{}}
 1448	dialog := rect{10, 5, 40, 20}
 1449	u.overlaysDrawn = 1
 1450	u.want("card", placed{r: rect{11, 6, 38, 17}, widget: "stripe"})
 1451	u.overlaysDrawn = 0
 1452	u.want("photo", placed{r: rect{0, 0, 30, 10}, url: "https://x/y.jpg"})
 1453	u.placeSync([]rect{dialog})
 1454	if _, ok := u.placeSent["card"]; !ok {
 1455		t.Error("the card form was taken off its own dialog")
 1456	}
 1457	if _, ok := u.placeSent["photo"]; ok {
 1458		t.Error("the photo under the dialog was left over it")
 1459	}
 1460
 1461	u.overlaysDrawn = 1
 1462	u.want("card", placed{r: rect{11, 6, 38, 17}, widget: "stripe"})
 1463	u.placeSync([]rect{dialog, {20, 10, 10, 5}}) // a notice over the form
 1464	if _, ok := u.placeSent["card"]; ok {
 1465		t.Error("the card form was left over the notice drawn after it")
 1466	}
 1467}
 1468
 1469// rawScreen takes what is written to the terminal itself, as the page's
 1470// terminal does; nothing else of it is used.
 1471type rawScreen struct {
 1472	tcell.Screen
 1473	out []byte
 1474}
 1475
 1476func (r *rawScreen) WriteRaw(b []byte) { r.out = append(r.out, b...) }
 1477
 1478
 1479// ===== pkg/tui/catalog.go =====
 1480// Package tui pkg/tui/catalog.go β€” the site's structures, derived the same
 1481// way the templates derive them: category ordering from getcategories, the
 1482// navigation tree from htmpl/catsubcats.html, the product page from
 1483// htmpl/product.html. Where the website renders HTML, this renders the same
 1484// data to terminal text.
 1485package tui
 1486
 1487import (
 1488	"fmt"
 1489	"sort"
 1490	"strconv"
 1491	"strings"
 1492
 1493	"github.com/0magnet/tallytree"
 1494
 1495	"github.com/0magnet/m2/pkg/product"
 1496	"github.com/0magnet/m2/pkg/storepage"
 1497)
 1498
 1499// catalogInfo mirrors what pageMeta hands the templates: categories sorted
 1500// by product count, subcategories likewise, with counts.
 1501type catalogInfo struct {
 1502	prods        product.Products
 1503	cats         []string
 1504	catCounts    map[string]int
 1505	subcatCounts map[string]map[string]int
 1506	subcatsByCat map[string][]string
 1507}
 1508
 1509func newCatalogInfo(prods product.Products) *catalogInfo {
 1510	c := &catalogInfo{
 1511		prods:        prods,
 1512		catCounts:    make(map[string]int),
 1513		subcatCounts: make(map[string]map[string]int),
 1514		subcatsByCat: make(map[string][]string),
 1515	}
 1516	for _, prod := range prods {
 1517		if prod.Category == "" {
 1518			continue
 1519		}
 1520		c.catCounts[prod.Category]++
 1521		if prod.Subcategory != "" {
 1522			if c.subcatCounts[prod.Category] == nil {
 1523				c.subcatCounts[prod.Category] = make(map[string]int)
 1524			}
 1525			c.subcatCounts[prod.Category][prod.Subcategory]++
 1526		}
 1527	}
 1528	for cat := range c.catCounts {
 1529		c.cats = append(c.cats, cat)
 1530	}
 1531	sort.Slice(c.cats, func(i, j int) bool {
 1532		if c.catCounts[c.cats[i]] != c.catCounts[c.cats[j]] {
 1533			return c.catCounts[c.cats[i]] > c.catCounts[c.cats[j]]
 1534		}
 1535		return c.cats[i] < c.cats[j]
 1536	})
 1537	for cat, subs := range c.subcatCounts {
 1538		var names []string
 1539		for sub := range subs {
 1540			names = append(names, sub)
 1541		}
 1542		sort.Slice(names, func(i, j int) bool {
 1543			if subs[names[i]] != subs[names[j]] {
 1544				return subs[names[i]] > subs[names[j]]
 1545			}
 1546			return names[i] < names[j]
 1547		})
 1548		c.subcatsByCat[cat] = names
 1549	}
 1550	return c
 1551}
 1552
 1553// products returns the rows the matching category/subcategory table
 1554// lists, as /cat/<cat>[/<subcat>] does: every product in the category,
 1555// subcategorized or not, and a subcategory page narrowing that to one.
 1556//
 1557// It used to hold back the products that have a subcategory, which is
 1558// front.html's rule for its "Other Products in X" table and not this
 1559// page's. Where every product in a category is subcategorized β€” which
 1560// is most of them β€” that left the category page with nothing on it. The
 1561// site had the same bug in its homepage sections and fixed it there
 1562// (catTargetCSS in pkg/web); this is that fix, for that reason.
 1563func (c *catalogInfo) products(cat, subcat string) product.Products {
 1564	var out product.Products
 1565	for _, p := range c.prods {
 1566		switch {
 1567		case cat == "":
 1568			out = append(out, p)
 1569		case subcat == "":
 1570			if p.Category == cat {
 1571				out = append(out, p)
 1572			}
 1573		default:
 1574			if p.Category == cat && p.Subcategory == subcat {
 1575				out = append(out, p)
 1576			}
 1577		}
 1578	}
 1579	return out
 1580}
 1581
 1582// resolve reads a "<cat>" or "<cat>/<subcat>" path as a place in this
 1583// catalog, matching the way a URL does: case-insensitively, and only
 1584// where the catalog holds that name. It reports whether it found one.
 1585func (c *catalogInfo) resolve(path string) (navTarget, bool) {
 1586	parts := strings.SplitN(strings.Trim(path, "/"), "/", 2)
 1587	if parts[0] == "" {
 1588		return navTarget{}, true // /cat/ is all products
 1589	}
 1590	cat := ""
 1591	for name := range c.catCounts {
 1592		if strings.EqualFold(name, parts[0]) {
 1593			cat = name
 1594			break
 1595		}
 1596	}
 1597	if cat == "" {
 1598		return navTarget{}, false
 1599	}
 1600	if len(parts) == 1 {
 1601		return navTarget{cat: cat}, true
 1602	}
 1603	// Either spelling: the name as the catalog holds it, or the name as
 1604	// a URL spells it. The site writes "ΒΌ watt 5%" into a path as
 1605	// quarter-watt-5-pct, and while the escaping lived only in pkg/web
 1606	// this could not match those at all β€” every subcategory link with a
 1607	// fraction or a percent in it left the store rather than opening the
 1608	// listing right here.
 1609	for _, sub := range c.subcatsByCat[cat] {
 1610		if strings.EqualFold(sub, parts[1]) || strings.EqualFold(storepage.EscapeSubcat(sub), parts[1]) {
 1611			return navTarget{cat: cat, subcat: sub}, true
 1612		}
 1613	}
 1614	return navTarget{}, false
 1615}
 1616
 1617func (c *catalogInfo) find(partno string) *product.Product {
 1618	for i := range c.prods {
 1619		if strings.EqualFold(c.prods[i].Partno, partno) {
 1620			return &c.prods[i]
 1621		}
 1622	}
 1623	return nil
 1624}
 1625
 1626// navTarget is a place the categories tree can go: all products, a
 1627// category, or a subcategory.
 1628type navTarget struct{ cat, subcat string }
 1629
 1630type navItem struct {
 1631	label   string
 1632	target  navTarget
 1633	hasSubs bool
 1634}
 1635
 1636// buildTree lays the category menu out: counts right-aligned by ─ padding,
 1637// β”œ/β”” branches, ┬ where a category opens into subcategories.
 1638//
 1639// The drawing is tallytree's, not this file's. It used to be written out here
 1640// and again in htmpl/catsubcats.html, two implementations of one picture kept
 1641// in agreement by hand; both now call the library, so the alignment arithmetic
 1642// exists once and has tests.
 1643//
 1644// Depth is pinned to two because the menu always COULD have subcategories, so
 1645// it reserves room for them even in a catalog where nothing does β€” otherwise
 1646// a flat catalog would draw a narrower tree than the same shop with one
 1647// subcategory added.
 1648func (c *catalogInfo) buildTree(expanded map[string]bool) []navItem {
 1649	forest := []tallytree.Node{{Label: "All Products", Count: len(c.prods)}}
 1650	for _, cat := range c.cats {
 1651		n := tallytree.Node{Label: cat, Count: c.catCounts[cat]}
 1652		for _, sub := range c.subcatsByCat[cat] {
 1653			n.Children = append(n.Children, tallytree.Node{Label: sub, Count: c.subcatCounts[cat][sub]})
 1654		}
 1655		forest = append(forest, n)
 1656	}
 1657	rows := tallytree.Rows(forest, tallytree.Options{
 1658		Depth: 2,
 1659		Open:  func(n tallytree.Node, d int) bool { return d != 0 || expanded == nil || expanded[n.Label] },
 1660	})
 1661	items := make([]navItem, 0, len(rows))
 1662	cat := ""
 1663	for _, r := range rows {
 1664		t := navTarget{}
 1665		switch {
 1666		case r.Depth == 0 && r.Label != "All Products":
 1667			cat = r.Label
 1668			t = navTarget{cat: r.Label}
 1669		case r.Depth > 0:
 1670			t = navTarget{cat: cat, subcat: r.Label}
 1671		}
 1672		items = append(items, navItem{r.Line(tallytree.Options{}), t, r.HasChildren})
 1673	}
 1674	return items
 1675}
 1676
 1677// checkerboard styles alternate letters inverse-video, as the site's
 1678// checkerBoard wraps every other letter of the domain in an .nv span.
 1679func checkerboard(s string) string {
 1680	var out strings.Builder
 1681	for i, ch := range s {
 1682		if i%2 == 0 {
 1683			fmt.Fprintf(&out, "[black:white]%c[white:black]", ch)
 1684		} else {
 1685			out.WriteRune(ch)
 1686		}
 1687	}
 1688	out.WriteString("[-:-]")
 1689	return out.String()
 1690}
 1691
 1692// set reports a field the way product.html's conditions do: present unless
 1693// empty or a bare zero.
 1694func set(v string) bool {
 1695	return v != "" && v != "0" && v != "0.0"
 1696}
 1697
 1698// productLines renders htmpl/product.html: the same fields, labels, and
 1699// order, as terminal text.
 1700//
 1701// anchor wraps the template's clickable things β€” its Add to cart button
 1702// and its category, subcategory and datasheet links β€” in whatever makes
 1703// them clickable on the page doing the drawing. A nil anchor draws them
 1704// as plain text, which is what a page with nowhere to send a click gets.
 1705func productLines(u *ui, p *product.Product, anchor func(text string, act func()) string) string {
 1706	if anchor == nil {
 1707		anchor = func(text string, _ func()) string { return text }
 1708	}
 1709	var b strings.Builder
 1710	line := func(format string, args ...interface{}) {
 1711		fmt.Fprintf(&b, format+"\n", args...)
 1712	}
 1713	line("[::b]%s[-:-:-]", esc(p.Name))
 1714	line("Price: [white]$%s[-]", esc(p.Price))
 1715	line("In stock: %s", esc(p.Quantity))
 1716	if p.Quantity != "0" {
 1717		// The template's <button onclick='addToCart(...)'>, in the place
 1718		// it sits on the page: under the stock line, above the part no.
 1719		line("%s", anchor("[black:#005f87] "+storepage.AddToCart+" [-:-]", func() { u.addToCart(p) }))
 1720		line("partno: %s", esc(p.Partno))
 1721	}
 1722	if !strings.EqualFold(strings.Join(strings.Fields(p.Description1), ""), strings.Join(strings.Fields(p.Name), "")) && p.Description1 != "" {
 1723		line("\n%s\n", esc(p.Description1))
 1724	}
 1725	if p.Mfgname != "" {
 1726		line("Brand: %s", esc(p.Mfgname))
 1727	}
 1728	if p.Mfgpartno != "" {
 1729		line("MPN: %s", esc(p.Mfgpartno))
 1730	}
 1731	cat, sub := p.Category, p.Subcategory
 1732	line("Category: %s", anchor("[aqua]"+esc(cat)+"[-]",
 1733		func() { u.show(listPageID(navTarget{cat: cat})) }))
 1734	if sub != "" {
 1735		line("Subcategory: %s", anchor("[aqua]"+esc(sub)+"[-]",
 1736			func() { u.show(listPageID(navTarget{cat: cat, subcat: sub})) }))
 1737	}
 1738	if set(p.VoltsRating) {
 1739		line("Voltage: %s", esc(p.VoltsRating))
 1740	}
 1741	if set(p.Value) {
 1742		line("Value: %s%s", esc(p.Value), esc(p.ValUnit))
 1743	}
 1744	if set(p.AmpsRating) {
 1745		line("Amperage: %s", esc(p.AmpsRating))
 1746	}
 1747	if p.Tolerance != "0" && p.Tolerance != "" {
 1748		if f, err := strconv.ParseFloat(p.Tolerance, 64); err == nil {
 1749			line("Tolerance: %.2f%%", 100*f)
 1750		}
 1751	}
 1752	if p.Typ != "" {
 1753		line("Typ: %s", esc(p.Typ))
 1754	}
 1755	if p.Packagetype != "" {
 1756		line("Package Type: %s", esc(p.Packagetype))
 1757	}
 1758	if p.Technology != "" {
 1759		line("Technology: %s", esc(p.Technology))
 1760	}
 1761	if p.Materials != "" {
 1762		line("Materials: %s", esc(p.Materials))
 1763	}
 1764	if set(p.WattsRating) {
 1765		line("Watts Rating: %s", esc(p.WattsRating))
 1766	}
 1767	if p.Year != "0" && p.Year != "" {
 1768		line("Year: %s", esc(p.Year))
 1769	}
 1770	if set(p.CableLengthInches) {
 1771		line("Cable Length: %s inches", esc(p.CableLengthInches))
 1772	}
 1773	if p.WeightOz != "0" && p.WeightOz != "0.0" && p.WeightOz != "" {
 1774		line("Weight: %s oz", esc(p.WeightOz))
 1775	}
 1776	if p.TempRating != "0" && p.TempRating != "0.0" && p.TempRating != "" {
 1777		line("Temp rating: %s%s", esc(p.TempRating), esc(p.TempUnit))
 1778	}
 1779	if p.Condition != "" {
 1780		line("Condition: %s", esc(p.Condition))
 1781	}
 1782	if p.Datasheet != "" {
 1783		// The template links this at <imgsrc>/pdf/<name>; the terminal
 1784		// cannot show a PDF, so following it hands over the URL.
 1785		ds := p.Datasheet
 1786		line("Datasheet: %s", anchor("[aqua]"+esc(ds)+"[-]",
 1787			func() { u.openURL(datasheetURL(ds)) }))
 1788	}
 1789	if p.Docs != "" {
 1790		line("Documentation: %s", esc(p.Docs))
 1791	}
 1792	if p.Note != "" {
 1793		line("Note: [yellow]%s[-]", esc(p.Note))
 1794	}
 1795	if p.Warning != "" {
 1796		line("Warning: [red]%s[-]", esc(p.Warning))
 1797	}
 1798	if p.Description2 != "" {
 1799		line("Additional Description: %s", esc(p.Description2))
 1800	}
 1801	return b.String()
 1802}
 1803
 1804
 1805// ===== pkg/tui/catalog_test.go =====
 1806package tui
 1807
 1808import (
 1809	"strings"
 1810	"testing"
 1811
 1812	"github.com/0magnet/m2/pkg/product"
 1813)
 1814
 1815// sampleCatalog is a catalog small enough to write the expected drawing out
 1816// by hand, and shaped to exercise the alignment: counts of one, two, three and
 1817// four digits, a category with subcategories and one without, and a last
 1818// sibling on both levels.
 1819func sampleCatalog() *catalogInfo {
 1820	return &catalogInfo{
 1821		prods:        make(product.Products, 1247),
 1822		cats:         []string{"resistor", "inductor"},
 1823		catCounts:    map[string]int{"resistor": 111, "inductor": 34},
 1824		subcatsByCat: map[string][]string{"resistor": {"quarter watt 5%", "ceramic"}},
 1825		subcatCounts: map[string]map[string]int{"resistor": {"quarter watt 5%": 67, "ceramic": 1}},
 1826	}
 1827}
 1828
 1829// The menu's counts are right-aligned by padding the branch rule itself, so
 1830// every count ends in the same column and the tree still reads as one line.
 1831// These are the exact rows the site drew before the layout moved to tallytree.
 1832func TestBuildTreeDrawing(t *testing.T) {
 1833	want := []string{
 1834		"β”œβ”€β”€1247 All Products",
 1835		"β”œβ”€β”¬β”€111 resistor",
 1836		"β”‚ β”œβ”€β”€67 quarter watt 5%",
 1837		"β”‚ └───1 ceramic",
 1838		"└────34 inductor",
 1839	}
 1840	got := sampleCatalog().buildTree(map[string]bool{"resistor": true})
 1841	if len(got) != len(want) {
 1842		t.Fatalf("%d rows, want %d", len(got), len(want))
 1843	}
 1844	for i := range want {
 1845		if got[i].label != want[i] {
 1846			t.Errorf("row %d:\n got %q\nwant %q", i, got[i].label, want[i])
 1847		}
 1848	}
 1849}
 1850
 1851// A collapsed category keeps the ┬ that says it has subcategories, and the
 1852// columns do not move when it closes.
 1853func TestBuildTreeCollapsed(t *testing.T) {
 1854	got := sampleCatalog().buildTree(map[string]bool{})
 1855	if len(got) != 3 {
 1856		t.Fatalf("%d rows with everything collapsed, want 3", len(got))
 1857	}
 1858	if !strings.Contains(got[1].label, "┬") {
 1859		t.Errorf("collapsed category lost its ┬: %q", got[1].label)
 1860	}
 1861	open := sampleCatalog().buildTree(map[string]bool{"resistor": true})
 1862	if a, b := strings.Index(got[1].label, " "), strings.Index(open[1].label, " "); a != b {
 1863		t.Errorf("collapsing moved the count column: %d vs %d", a, b)
 1864	}
 1865}
 1866
 1867// A category page lists everything in the category, as /cat/<cat> does
 1868// β€” not only the products with no subcategory, which is front.html's
 1869// rule for its "Other Products in X" table. Getting that wrong left
 1870// every category page in this catalog empty.
 1871func TestCategoryPageListsTheWholeCategory(t *testing.T) {
 1872	c := newCatalogInfo(product.Products{
 1873		{Partno: "a", Category: "diode", Subcategory: "1N47XX"},
 1874		{Partno: "b", Category: "diode", Subcategory: "1N47XX"},
 1875		{Partno: "c", Category: "diode", Subcategory: "bridge"},
 1876		{Partno: "d", Category: "diode"},
 1877		{Partno: "e", Category: "resistor"},
 1878	})
 1879	names := func(ps product.Products) string {
 1880		var out []string
 1881		for _, p := range ps {
 1882			out = append(out, p.Partno)
 1883		}
 1884		return strings.Join(out, ",")
 1885	}
 1886	if got := names(c.products("diode", "")); got != "a,b,c,d" {
 1887		t.Errorf("/cat/diode lists %q, want %q", got, "a,b,c,d")
 1888	}
 1889	if got := names(c.products("diode", "1N47XX")); got != "a,b" {
 1890		t.Errorf("/cat/diode/1N47XX lists %q, want %q", got, "a,b")
 1891	}
 1892	if got := names(c.products("", "")); got != "a,b,c,d,e" {
 1893		t.Errorf("all products lists %q, want %q", got, "a,b,c,d,e")
 1894	}
 1895}
 1896
 1897// Targets have to survive the layout: the rows carry where each entry goes.
 1898func TestBuildTreeTargets(t *testing.T) {
 1899	got := sampleCatalog().buildTree(map[string]bool{"resistor": true})
 1900	want := []navTarget{{}, {cat: "resistor"}, {cat: "resistor", subcat: "quarter watt 5%"},
 1901		{cat: "resistor", subcat: "ceramic"}, {cat: "inductor"}}
 1902	for i := range want {
 1903		if got[i].target != want[i] {
 1904			t.Errorf("row %d target %+v, want %+v", i, got[i].target, want[i])
 1905		}
 1906	}
 1907	if !got[1].hasSubs || got[4].hasSubs {
 1908		t.Error("hasSubs is wrong: resistor has subcategories, inductor does not")
 1909	}
 1910}
 1911
 1912
 1913// ===== pkg/tui/checkout.go =====
 1914// Package tui pkg/tui/checkout.go β€” the cart, the shipping form, and
 1915// checkout, mirroring the website's footer commerce (footer.html + the
 1916// cart wasm). On the website the cart and the shipping form are
 1917// <details> dropdowns rising from the fixed footer and checkout is a
 1918// <dialog>; here they are overlays above the footer bar, outside the
 1919// navigation history β€” v toggles the cart, Esc closes.
 1920//
 1921// The TUI is a client of the same store server the browser cart talks
 1922// to: it builds the identical items payload ("partno X qty" lines plus
 1923// a "shipping-to|..." line) and POSTs the same /create-payment-intent.
 1924// The card-entry step lives behind payWithElements: in the browser
 1925// (pay_js.go) it lays the Stripe Payment Element over the checkout
 1926// dialog, as the cart wasm opens it in the page's; a native terminal
 1927// (pay_native.go) cannot take card details, and says so.
 1928package tui
 1929
 1930import (
 1931	"bytes"
 1932	"encoding/json"
 1933	"fmt"
 1934	"io"
 1935	"net/http"
 1936	"strings"
 1937	"time"
 1938
 1939	"github.com/gdamore/tcell/v3"
 1940
 1941	"github.com/0magnet/m2/pkg/storepage"
 1942)
 1943
 1944// The shipping line, the form that fills it and the state list all live in
 1945// pkg/storepage now, so the page, this and the cart wasm cannot drift apart.
 1946// See pkg/storepage/shipping.go.
 1947
 1948// commerceRect anchors an overlay just above the footer summary it
 1949// expands from β€” the cart (and checkout) above View Cart, the form
 1950// above Add Shipping Info.
 1951func (u *ui) commerceRect() rect {
 1952	w, h := u.commerce.size()
 1953	sw, sh := u.screen.Size()
 1954	if w > sw-2 {
 1955		w = sw - 2
 1956	}
 1957	x := 1
 1958	region := 0
 1959	if _, ok := u.commerce.(*shippingOverlay); ok {
 1960		region = 1
 1961	}
 1962	if region < len(u.footRegions) {
 1963		x = u.footRegions[region].x1 - 1
 1964	}
 1965	if x+w > sw-1 {
 1966		x = sw - 1 - w
 1967	}
 1968	if x < 0 {
 1969		x = 0
 1970	}
 1971	y := sh - 2 - h
 1972	if y < 1 {
 1973		y = 1
 1974		h = sh - 3
 1975	}
 1976	return rect{x, y, w, h}
 1977}
 1978
 1979func (u *ui) closeCommerce() {
 1980	u.commerce = nil
 1981}
 1982
 1983func (u *ui) toggleCart() {
 1984	if u.commerce != nil {
 1985		u.closeCommerce()
 1986		return
 1987	}
 1988	u.openCart()
 1989}
 1990
 1991// ---- the cart overlay ----
 1992
 1993// The page's cart is a table β€” item, price, a quantity box and Remove on
 1994// each line β€” so this one has a line the arrows are on, whose quantity +
 1995// and - change and r takes out.
 1996type cartOverlay struct {
 1997	ta    *textArea
 1998	lines int
 1999	sel   int      // the line the arrows are on
 2000	items []string // the part numbers on its lines, in order
 2001}
 2002
 2003func (u *ui) openCart() {
 2004	sel := 0
 2005	if c, ok := u.commerce.(*cartOverlay); ok {
 2006		sel = c.sel
 2007	}
 2008	var items []string
 2009	for _, partno := range u.cartOrder {
 2010		if u.cartQty[partno] > 0 && u.cat.find(partno) != nil {
 2011			items = append(items, partno)
 2012		}
 2013	}
 2014	if sel >= len(items) {
 2015		sel = len(items) - 1
 2016	}
 2017	if sel < 0 {
 2018		sel = 0
 2019	}
 2020	var b strings.Builder
 2021	total := 0
 2022	for i, partno := range items {
 2023		qty := u.cartQty[partno]
 2024		p := u.cat.find(partno)
 2025		cents := priceCents(p.Price) * qty
 2026		total += cents
 2027		mark, name := "  ", "[aqua]"+esc(p.Name)+"[-]"
 2028		if i == sel {
 2029			mark, name = "[#5fd7ff]β–Έ[-] ", "[black:aqua]"+esc(p.Name)+"[-:-]"
 2030		}
 2031		fmt.Fprintf(&b, "%s%3d Γ— %s  $%d.%02d\n", mark, qty, name, cents/100, cents%100)
 2032	}
 2033	if u.shipping != nil {
 2034		total += u.shipping.Cents
 2035		to := strings.TrimSpace(u.shipping.Name + " " + u.shipping.City + " " + u.shipping.State)
 2036		fmt.Fprintf(&b, "      shipping[gray] to %s[-]  $%d.%02d\n",
 2037			esc(to), u.shipping.Cents/100, u.shipping.Cents%100)
 2038	}
 2039	if total == 0 {
 2040		b.WriteString("[gray]your cart is empty[-]\n")
 2041	}
 2042	fmt.Fprintf(&b, "\nTotal: $%d.%02d\n", total/100, total%100)
 2043	if len(items) > 0 {
 2044		b.WriteString("\n[#5fd7ff]↑↓[-] line Β· [#5fd7ff]+ -[-] quantity Β· [#5fd7ff]r[-] remove")
 2045	}
 2046	b.WriteString("\n[#5fd7ff]s[-] shipping Β· [#5fd7ff]p[-] card")
 2047	if coinOffered {
 2048		b.WriteString(" Β· [#5fd7ff]k[-] coin")
 2049	}
 2050	b.WriteString(" Β· [#5fd7ff]x[-] empty Β· [#5fd7ff]v[-] close\n")
 2051	fmt.Fprintf(&b, "[gray]checkout talks to the store server at %s[-]", esc(ordersURL()))
 2052	content := b.String()
 2053	u.commerce = &cartOverlay{ta: newTextArea(content, false), lines: strings.Count(content, "\n") + 1, sel: sel, items: items}
 2054}
 2055
 2056func (c *cartOverlay) title() string { return "View Cart" }
 2057func (c *cartOverlay) size() (int, int) {
 2058	return 70, c.lines + 2
 2059}
 2060
 2061func (c *cartOverlay) draw(u *ui, sc tcell.Screen, r rect) {
 2062	c.ta.draw(sc, r)
 2063}
 2064
 2065func (c *cartOverlay) key(u *ui, ev *tcell.EventKey) bool {
 2066	line := ""
 2067	if c.sel < len(c.items) {
 2068		line = c.items[c.sel]
 2069	}
 2070	switch ev.Key() {
 2071	case tcell.KeyUp:
 2072		if c.sel > 0 {
 2073			c.sel--
 2074			u.openCart()
 2075		}
 2076		return true
 2077	case tcell.KeyDown:
 2078		if c.sel < len(c.items)-1 {
 2079			c.sel++
 2080			u.openCart()
 2081		}
 2082		return true
 2083	case tcell.KeyDelete:
 2084		if line != "" {
 2085			u.setQty(line, 0)
 2086			u.openCart()
 2087		}
 2088		return true
 2089	}
 2090	switch keyRune(ev) {
 2091	case '+', '=':
 2092		if line != "" {
 2093			u.setQty(line, u.cartQty[line]+1)
 2094			u.openCart()
 2095		}
 2096		return true
 2097	case '-':
 2098		if line != "" {
 2099			u.setQty(line, u.cartQty[line]-1)
 2100			u.openCart()
 2101		}
 2102		return true
 2103	case 'r':
 2104		if line != "" {
 2105			u.setQty(line, 0)
 2106			u.openCart()
 2107		}
 2108		return true
 2109	case 'x':
 2110		u.emptyCart()
 2111		u.openCart()
 2112		return true
 2113	case 's':
 2114		u.openShipping()
 2115		return true
 2116	case 'p':
 2117		u.startCheckout()
 2118		return true
 2119	case 'k':
 2120		if !coinOffered {
 2121			return false
 2122		}
 2123		// The checkout a terminal can finish by itself; see crypto.go.
 2124		u.startCoinCheckout()
 2125		return true
 2126	}
 2127	return false
 2128}
 2129
 2130// ---- the shipping form, from footer.html's Add Shipping Info ----
 2131
 2132type shippingOverlay struct {
 2133	frm *form
 2134	h   int
 2135}
 2136
 2137func (s *shippingOverlay) title() string                       { return "Add Shipping Info" }
 2138func (s *shippingOverlay) size() (int, int)                    { return 62, s.h }
 2139func (s *shippingOverlay) draw(u *ui, sc tcell.Screen, r rect) { s.frm.draw(sc, r) }
 2140func (s *shippingOverlay) key(u *ui, ev *tcell.EventKey) bool  { return s.frm.key(ev) }
 2141
 2142func (u *ui) openShipping() {
 2143	prev := u.shipping
 2144	if prev == nil {
 2145		prev = &storepage.Shipping{Cents: storepage.MinShippingCents, Country: storepage.DefaultCountry}
 2146	}
 2147	// Built by walking the shared field list rather than naming each input,
 2148	// so a field added to the form appears here and on the page together.
 2149	// read[name] returns what that control holds, in the spelling the cart
 2150	// line wants β€” a state picker shows "Texas" and yields "TX".
 2151	read := map[string]func() string{}
 2152	items := make([]formItem, 0, len(storepage.ShippingFields))
 2153	for _, f := range storepage.ShippingFields {
 2154		switch f.Kind {
 2155		case storepage.FieldState:
 2156			opts := storepage.StateNames()
 2157			dd := &fdropdown{lbl: f.TUILabel, opts: opts}
 2158			for i, n := range opts {
 2159				if n == storepage.StateName(prev.State) {
 2160					dd.sel = i
 2161				}
 2162			}
 2163			items = append(items, dd)
 2164			read[f.Name] = func() string { return storepage.StateCode(dd.value()) }
 2165		case storepage.FieldCountry:
 2166			dd := &fdropdown{lbl: f.TUILabel, opts: storepage.Countries}
 2167			items = append(items, dd)
 2168			read[f.Name] = dd.value
 2169		default:
 2170			val := prev.Get(f.Name)
 2171			if f.Kind == storepage.FieldMoney {
 2172				val = fmt.Sprintf("%d.%02d", prev.Cents/100, prev.Cents%100)
 2173			}
 2174			in := &finput{lbl: f.TUILabel, text: []rune(val), width: f.Width}
 2175			in.cur = len(in.text)
 2176			items = append(items, in)
 2177			read[f.Name] = in.value
 2178		}
 2179	}
 2180
 2181	// Declared separately on purpose: the buttons below close over frm, so it
 2182	// has to exist before newForm is called.
 2183	var frm *form //nolint:staticcheck
 2184	frm = newForm(items, []fbutton{
 2185		{"Add Shipping to Cart", func() {
 2186			sh := storepage.Shipping{Cents: priceCents(read["shipping-price"]())}
 2187			for _, f := range storepage.ShippingFields {
 2188				if f.Kind != storepage.FieldMoney {
 2189					sh.Set(f.Name, read[f.Name]())
 2190				}
 2191			}
 2192			if msg := sh.Validate(); msg != "" {
 2193				u.notice = msg
 2194				return
 2195			}
 2196			u.shipping = &sh
 2197			u.cartChanged()
 2198			u.openCart()
 2199		}},
 2200		{"Cancel", func() { u.openCart() }},
 2201	}, func() { u.openCart() })
 2202	u.commerce = &shippingOverlay{frm: frm, h: len(items)*2 + 3}
 2203}
 2204
 2205// ---- checkout: the cart wasm's flow, against the same server ----
 2206
 2207type checkoutOverlay struct {
 2208	ta *textArea
 2209	// form is set while the card form is laid over the dialog (pay_js.go):
 2210	// the dialog grows to hold it, keeps a line above it for what is being
 2211	// paid and one below for the keys, and Enter pays.
 2212	form *cardForm
 2213}
 2214
 2215// cardForm is the card form's part of the dialog.
 2216type cardForm struct {
 2217	head string // what is being paid, above the form
 2218	pay  func() // confirms the payment, as the form's own Pay button does
 2219}
 2220
 2221func (c *checkoutOverlay) title() string { return "Checkout" }
 2222func (c *checkoutOverlay) size() (int, int) {
 2223	if c.form != nil {
 2224		return 72, 32
 2225	}
 2226	return 78, 14
 2227}
 2228
 2229func (c *checkoutOverlay) draw(u *ui, sc tcell.Screen, r rect) {
 2230	if c.form == nil || r.h < 6 {
 2231		c.ta.draw(sc, r)
 2232		return
 2233	}
 2234	printMarkup(sc, r.x+1, r.y, r.w-2, c.form.head, styleText)
 2235	formR := rect{r.x, r.y + 1, r.w, r.h - 3}
 2236	// The cells under the form, for a terminal that cannot place it: the
 2237	// dialog's own message says what is missing there.
 2238	c.ta.draw(sc, formR)
 2239	u.want("card", placed{r: formR, widget: "stripe", input: true})
 2240	printMarkup(sc, r.x+1, r.y+r.h-1, r.w-2, "[#5fd7ff]⏎[-] pay Β· [#5fd7ff]esc[-] cancel β€” the cart is kept Β· [gray]click the form to type in it[-]", styleText)
 2241}
 2242
 2243func (c *checkoutOverlay) key(u *ui, ev *tcell.EventKey) bool {
 2244	if c.form != nil && ev.Key() == tcell.KeyEnter {
 2245		c.form.pay()
 2246		return true
 2247	}
 2248	return c.ta.key(ev)
 2249}
 2250
 2251// ordersURL is where checkout is served from: the store being browsed
 2252// in client mode, else SITEORDERSURL when the config names one (as the
 2253// website's cart uses it), else the local store server.
 2254func ordersURL() string {
 2255	if s := storeURL(); s != "" {
 2256		return s
 2257	}
 2258	if f.Siteordersurl != "" {
 2259		return f.Siteordersurl
 2260	}
 2261	port := f.WebPort
 2262	if port == 0 {
 2263		port = 9883
 2264	}
 2265	return fmt.Sprintf("http://127.0.0.1:%d", port)
 2266}
 2267
 2268// cartItem matches the JSON the cart wasm POSTs to /create-payment-intent.
 2269type cartItem struct {
 2270	ID     string `json:"ID"`
 2271	Amount int64  `json:"Amount"`
 2272}
 2273
 2274func (u *ui) startCheckout() {
 2275	if len(u.cartOrder) == 0 {
 2276		u.notice = "the cart is empty"
 2277		return
 2278	}
 2279	if u.shipping == nil {
 2280		u.notice = "add shipping info first (s in the cart)"
 2281		return
 2282	}
 2283
 2284	// The same payload the cart wasm sends, and the same one the coin
 2285	// checkout sends: see cartItems in crypto.go.
 2286	items := u.cartItems()
 2287
 2288	co := &checkoutOverlay{ta: newTextArea("\n contacting "+esc(ordersURL())+" …", true)}
 2289	u.commerce = co
 2290
 2291	go func() {
 2292		clientSecret, err := createPaymentIntent(items)
 2293		u.post(func() {
 2294			if u.commerce != co {
 2295				return // the overlay was closed meanwhile
 2296			}
 2297			if err != nil {
 2298				co.ta.setContent(fmt.Sprintf(
 2299					"\n [red]could not create the payment:[-] %s\n\n [gray]is the store server running? (%s)\n Esc closes β€” the cart is kept[-]\n",
 2300					esc(err.Error()), esc(ordersURL())))
 2301				return
 2302			}
 2303			u.payWithElements(co, clientSecret)
 2304		})
 2305	}()
 2306}
 2307
 2308// createPaymentIntent POSTs the cart to the store server, which
 2309// validates every line against the catalog and answers with the payment
 2310// intent's client secret β€” identical to the cart wasm's fetch.
 2311func createPaymentIntent(items []cartItem) (string, error) {
 2312	body, err := json.Marshal(map[string]interface{}{"items": items})
 2313	if err != nil {
 2314		return "", err
 2315	}
 2316	client := &http.Client{Timeout: 15 * time.Second}
 2317	resp, err := client.Post(ordersURL()+"/create-payment-intent", "application/json", bytes.NewReader(body))
 2318	if err != nil {
 2319		return "", err
 2320	}
 2321	defer resp.Body.Close() //nolint:errcheck // read-side close
 2322	data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
 2323	if err != nil {
 2324		return "", err
 2325	}
 2326	var out struct {
 2327		ClientSecret string `json:"clientSecret"`
 2328		Error        string `json:"error"`
 2329	}
 2330	if err := json.Unmarshal(data, &out); err != nil {
 2331		return "", fmt.Errorf("bad response (%d): %s", resp.StatusCode, strings.TrimSpace(string(data)))
 2332	}
 2333	if out.Error != "" {
 2334		return "", fmt.Errorf("%s", out.Error)
 2335	}
 2336	if out.ClientSecret == "" {
 2337		return "", fmt.Errorf("no client secret in response (%d)", resp.StatusCode)
 2338	}
 2339	return out.ClientSecret, nil
 2340}
 2341
 2342
 2343// ===== pkg/tui/crypto.go =====
 2344// Package tui pkg/tui/crypto.go β€” paying in coin from the terminal.
 2345//
 2346// This is the checkout a terminal can actually complete. Stripe's card
 2347// entry is a browser widget, which is why payWithElements tells a native
 2348// terminal it cannot take card details; a payment address is text, and text
 2349// is what a terminal has. The same /create-invoice endpoint the page uses
 2350// answers here, so the terminal is not a second implementation of anything
 2351// β€” it renders what the server already decided.
 2352package tui
 2353
 2354import (
 2355	"bytes"
 2356	"encoding/json"
 2357	"fmt"
 2358	"io"
 2359	"net/http"
 2360	"net/url"
 2361	"strings"
 2362	"time"
 2363
 2364	"github.com/gdamore/tcell/v3"
 2365)
 2366
 2367// invoiceView is the subset of an invoice the server hands a client.
 2368type invoiceView struct {
 2369	ID          string `json:"id"`
 2370	Coin        string `json:"coin"`
 2371	Address     string `json:"address"`
 2372	Amount      string `json:"amount"`
 2373	URI         string `json:"uri"`
 2374	FiatCents   int64  `json:"fiat_cents"`
 2375	Status      string `json:"status"`
 2376	Paid        string `json:"paid"`
 2377	Outstanding string `json:"outstanding"`
 2378	Expires     string `json:"expires"`
 2379	TxID        string `json:"txid"`
 2380	Error       string `json:"error"`
 2381}
 2382
 2383type invoiceOverlay struct {
 2384	ta  *textArea
 2385	inv *invoiceView
 2386}
 2387
 2388func (o *invoiceOverlay) title() string                       { return "Pay in coin" }
 2389func (o *invoiceOverlay) size() (int, int)                    { return 74, 16 }
 2390func (o *invoiceOverlay) draw(u *ui, sc tcell.Screen, r rect) { o.ta.draw(sc, r) }
 2391
 2392func (o *invoiceOverlay) key(u *ui, ev *tcell.EventKey) bool {
 2393	if keyRune(ev) == 'r' && o.inv != nil {
 2394		u.refreshInvoice(o)
 2395		return true
 2396	}
 2397	return o.ta.key(ev)
 2398}
 2399
 2400// startCoinCheckout asks the store for an invoice and shows it.
 2401func (u *ui) startCoinCheckout() {
 2402	if len(u.cartOrder) == 0 {
 2403		u.notice = "the cart is empty"
 2404		return
 2405	}
 2406	if u.shipping == nil {
 2407		u.notice = "add shipping info first (s in the cart)"
 2408		return
 2409	}
 2410	items := u.cartItems()
 2411
 2412	ov := &invoiceOverlay{ta: newTextArea("\n asking "+esc(ordersURL())+" for an address …", true)}
 2413	u.commerce = ov
 2414
 2415	go func() {
 2416		inv, err := createInvoice(items)
 2417		u.post(func() {
 2418			if u.commerce != ov {
 2419				return // closed meanwhile
 2420			}
 2421			if err != nil {
 2422				ov.ta.setContent(fmt.Sprintf(
 2423					"\n [red]could not create the invoice:[-] %s\n\n [gray]the store may not have crypto checkout configured\n Esc closes β€” the cart is kept[-]\n",
 2424					esc(err.Error())))
 2425				return
 2426			}
 2427			ov.inv = inv
 2428			ov.ta.setContent(renderInvoice(inv))
 2429		})
 2430	}()
 2431}
 2432
 2433// refreshInvoice re-reads one invoice; the server polls the chain when
 2434// asked, so this is what turns "pending" into "paid" on screen.
 2435func (u *ui) refreshInvoice(ov *invoiceOverlay) {
 2436	id := ov.inv.ID
 2437	go func() {
 2438		inv, err := getInvoice(id)
 2439		u.post(func() {
 2440			if u.commerce != ov {
 2441				return
 2442			}
 2443			if err != nil {
 2444				u.notice = "could not refresh: " + esc(err.Error())
 2445				return
 2446			}
 2447			ov.inv = inv
 2448			ov.ta.setContent(renderInvoice(inv))
 2449		})
 2450	}()
 2451}
 2452
 2453// cartItems builds the payload both checkouts send.
 2454func (u *ui) cartItems() []cartItem {
 2455	var items []cartItem
 2456	for _, partno := range u.cartOrder {
 2457		qty := u.cartQty[partno]
 2458		p := u.cat.find(partno)
 2459		if qty == 0 || p == nil {
 2460			continue
 2461		}
 2462		items = append(items, cartItem{
 2463			ID:     fmt.Sprintf("%s X %d", partno, qty),
 2464			Amount: int64(priceCents(p.Price) * qty),
 2465		})
 2466	}
 2467	if u.shipping != nil {
 2468		items = append(items, cartItem{ID: u.shipping.ID(), Amount: int64(u.shipping.Cents)})
 2469	}
 2470	return items
 2471}
 2472
 2473func renderInvoice(inv *invoiceView) string {
 2474	var b strings.Builder
 2475	fmt.Fprintf(&b, "\n send [::b]%s %s[-:-:-]\n", esc(inv.Amount), esc(inv.Coin))
 2476	fmt.Fprintf(&b, " to   [aqua]%s[-]\n\n", esc(inv.Address))
 2477	fmt.Fprintf(&b, " [gray]uri[-]    %s\n", esc(inv.URI))
 2478	fmt.Fprintf(&b, " [gray]order[-]  $%d.%02d\n", inv.FiatCents/100, inv.FiatCents%100)
 2479	if t, err := time.Parse(time.RFC3339, inv.Expires); err == nil {
 2480		fmt.Fprintf(&b, " [gray]quote holds until[-] %s\n", esc(t.Local().Format("15:04:05")))
 2481	}
 2482	b.WriteString("\n")
 2483	switch inv.Status {
 2484	case "paid":
 2485		b.WriteString(" [green]paid β€” thank you. the order is printing.[-]\n")
 2486		if inv.TxID != "" {
 2487			fmt.Fprintf(&b, " [gray]txid %s[-]\n", esc(inv.TxID))
 2488		}
 2489	case "underpaid":
 2490		fmt.Fprintf(&b, " [yellow]part paid:[-] %s received, [::b]%s[-:-:-] still owed\n",
 2491			esc(inv.Paid), esc(inv.Outstanding))
 2492	case "expired":
 2493		b.WriteString(" [red]the quote expired[-] β€” close and start again for a fresh price\n")
 2494	default:
 2495		b.WriteString(" [gray]waiting for the payment to confirm…[-]\n")
 2496	}
 2497	b.WriteString("\n [#5fd7ff]r[-] check again Β· [#5fd7ff]esc[-] close\n")
 2498	return b.String()
 2499}
 2500
 2501func createInvoice(items []cartItem) (*invoiceView, error) {
 2502	body, err := json.Marshal(map[string]any{"coin": "SKY", "items": items})
 2503	if err != nil {
 2504		return nil, err
 2505	}
 2506	client := &http.Client{Timeout: 30 * time.Second}
 2507	resp, err := client.Post(ordersURL()+"/create-invoice", "application/json", bytes.NewReader(body))
 2508	if err != nil {
 2509		return nil, err
 2510	}
 2511	return readInvoice(resp)
 2512}
 2513
 2514func getInvoice(id string) (*invoiceView, error) {
 2515	client := &http.Client{Timeout: 30 * time.Second}
 2516	resp, err := client.Get(ordersURL() + "/invoice/" + url.PathEscape(id))
 2517	if err != nil {
 2518		return nil, err
 2519	}
 2520	return readInvoice(resp)
 2521}
 2522
 2523func readInvoice(resp *http.Response) (*invoiceView, error) {
 2524	defer resp.Body.Close() //nolint:errcheck // read-side close
 2525	data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
 2526	if err != nil {
 2527		return nil, err
 2528	}
 2529	var inv invoiceView
 2530	if err := json.Unmarshal(data, &inv); err != nil {
 2531		return nil, fmt.Errorf("%s: %s", resp.Status, strings.TrimSpace(string(data)))
 2532	}
 2533	if inv.Error != "" {
 2534		return nil, fmt.Errorf("%s", inv.Error)
 2535	}
 2536	if resp.StatusCode != http.StatusOK {
 2537		return nil, fmt.Errorf("%s", resp.Status)
 2538	}
 2539	return &inv, nil
 2540}
 2541
 2542
 2543// ===== pkg/tui/frame_dump_test.go =====
 2544package tui
 2545
 2546import (
 2547	"image/png"
 2548	"os"
 2549	"testing"
 2550)
 2551
 2552func TestDumpFrame(t *testing.T) {
 2553	out := os.Getenv("FRAMEOUT")
 2554	if out == "" {
 2555		t.Skip("set FRAMEOUT to dump a frame")
 2556	}
 2557	logo, err := loadImage(os.Getenv("FRAMELOGO"))
 2558	if err != nil {
 2559		t.Fatal(err)
 2560	}
 2561	bd := makeBackdrop(logo, 200, 100)
 2562	fr := globeFrame(bd, 0.9, 2.3, -0.6)
 2563	f, err := os.Create(out) //nolint:gosec
 2564	if err != nil {
 2565		t.Fatalf("create %s: %v", out, err)
 2566	}
 2567	defer f.Close() //nolint:errcheck,gosec
 2568	if err := png.Encode(f, fr); err != nil {
 2569		t.Fatal(err)
 2570	}
 2571}
 2572
 2573
 2574// ===== pkg/tui/htmltable.go =====
 2575// Package tui pkg/tui/htmltable.go β€” tables in a content page.
 2576//
 2577// htmlToText used to flatten a table: </td> became two spaces and </tr> a
 2578// newline, so the Links page β€” which is a three-column table of supplier,
 2579// location and notes β€” arrived as ragged prose with the columns running into
 2580// each other. The site draws it as a table and so should this.
 2581//
 2582// The work is laid out here rather than in render.go because it needs its own
 2583// wrapper: a cell's text carries the [aqua] and [link=N] tags the anchor pass
 2584// already wrote into it, and those are zero width. Wrapping on len() would
 2585// count them and wrapping without them would drop a link's styling β€” and its
 2586// clickable region β€” at the break.
 2587package tui
 2588
 2589import (
 2590	"regexp"
 2591	"strings"
 2592
 2593	xhtml "html"
 2594)
 2595
 2596var (
 2597	tagTable    = regexp.MustCompile(`(?is)<table[^>]*>(.*?)</table>`)
 2598	tagRow      = regexp.MustCompile(`(?is)<tr[^>]*>(.*?)</tr>`)
 2599	tagCellFull = regexp.MustCompile(`(?is)<(t[dh])[^>]*>(.*?)</t[dh]>`)
 2600	runOfSpaces = regexp.MustCompile(`[ \t\n]+`)
 2601)
 2602
 2603// colGap is the space between columns, in cells.
 2604const colGap = 2
 2605
 2606// minCol is how narrow shrinking may make a column before it gives up and
 2607// lets the table overflow instead. A column squeezed below this is unreadable,
 2608// and an honestly too-wide table is easier to deal with than a shredded one.
 2609const minCol = 6
 2610
 2611// cellText reduces one cell's HTML to its printed text, keeping the markup
 2612// tags the anchor pass wrote and dropping everything else.
 2613func cellText(s string) string {
 2614	s = tagBreak.ReplaceAllString(s, " ")
 2615	s = tagAny.ReplaceAllString(s, "")
 2616	s = xhtml.UnescapeString(s)
 2617	return strings.TrimSpace(runOfSpaces.ReplaceAllString(s, " "))
 2618}
 2619
 2620// isResetTag reports whether a color tag turns styling off rather than on:
 2621// "[-]", "[-:-:-]" and the like are built only from dashes and colons.
 2622func isResetTag(body string) bool {
 2623	if body == "" {
 2624		return false
 2625	}
 2626	return strings.Trim(body, "-:") == ""
 2627}
 2628
 2629// mtok is one piece of a tagged string: a zero-width tag, or one visible rune.
 2630type mtok struct {
 2631	tag string
 2632	txt string
 2633}
 2634
 2635// markupTokens splits a tagged string the way parseMarkup reads it, so the
 2636// two agree on what is a tag and what is text. "[[" is a literal bracket.
 2637func markupTokens(s string) []mtok {
 2638	var out []mtok
 2639	r := []rune(s)
 2640	for i := 0; i < len(r); i++ {
 2641		if r[i] != '[' {
 2642			out = append(out, mtok{txt: string(r[i])})
 2643			continue
 2644		}
 2645		if i+1 < len(r) && r[i+1] == '[' {
 2646			out = append(out, mtok{txt: "["})
 2647			i++
 2648			continue
 2649		}
 2650		end := -1
 2651		for j := i + 1; j < len(r) && j < i+24; j++ {
 2652			if r[j] == ']' {
 2653				end = j
 2654				break
 2655			}
 2656		}
 2657		if end < 0 {
 2658			out = append(out, mtok{txt: "["})
 2659			continue
 2660		}
 2661		body := string(r[i+1 : end])
 2662		if _, ok := linkBody(body); ok || tagBody(body) {
 2663			out = append(out, mtok{tag: "[" + body + "]"})
 2664			i = end
 2665			continue
 2666		}
 2667		out = append(out, mtok{txt: "["})
 2668	}
 2669	return out
 2670}
 2671
 2672// wrapMarkup breaks a tagged string into lines of at most w printed columns.
 2673//
 2674// The color and link tags still open at a break are repeated at the start of
 2675// the next line: each line is drawn on its own, so a wrapped link that did not
 2676// carry its [link=N] across would stop being clickable half way through.
 2677func wrapMarkup(s string, w int) []string {
 2678	if w < 1 {
 2679		w = 1
 2680	}
 2681	var (
 2682		lines       []string
 2683		line        strings.Builder
 2684		lineW       int
 2685		color, link string
 2686		word        []mtok
 2687		wordW       int
 2688	)
 2689	startLine := func() {
 2690		line.Reset()
 2691		lineW = 0
 2692		line.WriteString(color)
 2693		line.WriteString(link)
 2694	}
 2695	pushLine := func() {
 2696		lines = append(lines, line.String())
 2697		startLine()
 2698	}
 2699	emitWord := func() {
 2700		if len(word) == 0 {
 2701			return
 2702		}
 2703		switch {
 2704		case lineW > 0 && lineW+1+wordW > w:
 2705			pushLine()
 2706		case lineW > 0:
 2707			line.WriteString(" ")
 2708			lineW++
 2709		}
 2710		for _, t := range word {
 2711			if t.tag != "" {
 2712				body := t.tag[1 : len(t.tag)-1]
 2713				if _, ok := linkBody(body); ok {
 2714					if body == "/link" {
 2715						link = ""
 2716					} else {
 2717						link = t.tag
 2718					}
 2719				} else if isResetTag(body) {
 2720					color = ""
 2721				} else {
 2722					color = t.tag
 2723				}
 2724				line.WriteString(t.tag)
 2725				continue
 2726			}
 2727			if lineW >= w { // a single word longer than the column
 2728				pushLine()
 2729			}
 2730			if t.txt == "[" {
 2731				line.WriteString("[[")
 2732			} else {
 2733				line.WriteString(t.txt)
 2734			}
 2735			lineW++
 2736		}
 2737		word = word[:0]
 2738		wordW = 0
 2739	}
 2740
 2741	for _, t := range markupTokens(s) {
 2742		if t.tag == "" && t.txt == " " {
 2743			emitWord()
 2744			continue
 2745		}
 2746		word = append(word, t)
 2747		if t.tag == "" {
 2748			wordW++
 2749		}
 2750	}
 2751	emitWord()
 2752	if lineW > 0 || len(lines) == 0 {
 2753		lines = append(lines, line.String())
 2754	}
 2755	return lines
 2756}
 2757
 2758// fitCols shrinks the natural column widths until the row fits, always taking
 2759// from the widest column so the notes column gives way before the two narrow
 2760// ones do.
 2761func fitCols(nat []int, width int) []int {
 2762	cols := append([]int(nil), nat...)
 2763	n := len(cols)
 2764	if n == 0 {
 2765		return cols
 2766	}
 2767	avail := width - colGap*(n-1)
 2768	if avail < n {
 2769		avail = n
 2770	}
 2771	total := 0
 2772	for _, c := range cols {
 2773		total += c
 2774	}
 2775	for total > avail {
 2776		wi, wv := 0, cols[0]
 2777		for i, c := range cols {
 2778			if c > wv {
 2779				wi, wv = i, c
 2780			}
 2781		}
 2782		if wv <= minCol {
 2783			break
 2784		}
 2785		cols[wi]--
 2786		total--
 2787	}
 2788	return cols
 2789}
 2790
 2791// renderTable draws one table's inner HTML as aligned columns.
 2792func renderTable(inner string, width int) string {
 2793	var (
 2794		rows  [][]string
 2795		heads []bool
 2796	)
 2797	for _, r := range tagRow.FindAllStringSubmatch(inner, -1) {
 2798		cells := tagCellFull.FindAllStringSubmatch(r[1], -1)
 2799		if len(cells) == 0 {
 2800			continue
 2801		}
 2802		row := make([]string, 0, len(cells))
 2803		head := true
 2804		for _, c := range cells {
 2805			if !strings.EqualFold(c[1], "th") {
 2806				head = false
 2807			}
 2808			row = append(row, cellText(c[2]))
 2809		}
 2810		rows = append(rows, row)
 2811		heads = append(heads, head)
 2812	}
 2813	if len(rows) == 0 {
 2814		return ""
 2815	}
 2816	n := 0
 2817	for _, r := range rows {
 2818		if len(r) > n {
 2819			n = len(r)
 2820		}
 2821	}
 2822	nat := make([]int, n)
 2823	for _, r := range rows {
 2824		for i, c := range r {
 2825			if w := markupWidth(c); w > nat[i] {
 2826				nat[i] = w
 2827			}
 2828		}
 2829	}
 2830	cols := fitCols(nat, width)
 2831
 2832	var b strings.Builder
 2833	for ri, row := range rows {
 2834		cellLines := make([][]string, n)
 2835		h := 1
 2836		for i := 0; i < n; i++ {
 2837			txt := ""
 2838			if i < len(row) {
 2839				txt = row[i]
 2840			}
 2841			if heads[ri] && txt != "" {
 2842				txt = "[::b]" + txt + "[-:-:-]"
 2843			}
 2844			cellLines[i] = wrapMarkup(txt, cols[i])
 2845			if len(cellLines[i]) > h {
 2846				h = len(cellLines[i])
 2847			}
 2848		}
 2849		for li := 0; li < h; li++ {
 2850			var line strings.Builder
 2851			for i := 0; i < n; i++ {
 2852				cell := ""
 2853				if li < len(cellLines[i]) {
 2854					cell = cellLines[i][li]
 2855				}
 2856				line.WriteString(cell)
 2857				if i < n-1 {
 2858					pad := cols[i] - markupWidth(cell) + colGap
 2859					if pad < 1 {
 2860						pad = 1
 2861					}
 2862					line.WriteString(strings.Repeat(" ", pad))
 2863				}
 2864			}
 2865			b.WriteString(strings.TrimRight(line.String(), " "))
 2866			b.WriteByte('\n')
 2867		}
 2868		if heads[ri] {
 2869			var rule strings.Builder
 2870			for i := 0; i < n; i++ {
 2871				rule.WriteString(strings.Repeat("─", cols[i]))
 2872				if i < n-1 {
 2873					rule.WriteString(strings.Repeat(" ", colGap))
 2874				}
 2875			}
 2876			b.WriteString("[gray]" + rule.String() + "[-]\n")
 2877		}
 2878	}
 2879	return b.String()
 2880}
 2881
 2882
 2883// ===== pkg/tui/htmltable_test.go =====
 2884package tui
 2885
 2886import (
 2887	"strings"
 2888	"testing"
 2889)
 2890
 2891// A table is the Links page's whole content. Flattened to runs of spaces it
 2892// stopped being a table at all, so what matters is that the columns line up
 2893// and that a cell too wide for its column wraps inside it rather than
 2894// pushing everything after it out of true.
 2895func TestTableAlignsColumnsAndWraps(t *testing.T) {
 2896	const in = `<table>
 2897<thead><tr><th>Supplier</th><th>Where</th><th>Notes</th></tr></thead>
 2898<tbody>
 2899<tr><td>Alpha</td><td>Dallas, TX</td><td>one two three four five six seven</td></tr>
 2900<tr><td>Beta</td><td>Lima, OH</td><td>short</td></tr>
 2901</tbody></table>`
 2902	text, _ := htmlToText(in, "2026", 46)
 2903	lines := strings.Split(strings.TrimRight(text, "\n"), "\n")
 2904	for _, l := range lines {
 2905		if markupWidth(l) > 46 {
 2906			t.Errorf("line wider than the pane (%d): %q", markupWidth(l), l)
 2907		}
 2908	}
 2909	// Every row starts its second column at the same place.
 2910	if a, b := columnOf(lines, "Dallas, TX"), columnOf(lines, "Lima, OH"); a != b || a <= 0 {
 2911		t.Errorf("second column not aligned: %d vs %d\n%s", a, b, text)
 2912	}
 2913	// The long notes cell wrapped instead of running off.
 2914	if !strings.Contains(text, "seven") {
 2915		t.Errorf("wrapped text lost:\n%s", text)
 2916	}
 2917	if columnOf(lines, "one") != columnOf(lines, "six") {
 2918		t.Errorf("continuation not in the notes column:\n%s", text)
 2919	}
 2920}
 2921
 2922// A link that wraps has to stay a link on the second line: each line is
 2923// drawn on its own, so the [link=N] tag must be repeated at the break.
 2924func TestWrappedLinkKeepsItsTag(t *testing.T) {
 2925	const in = `<table><tr><td><a href='https://example.com/'>a very long supplier name here</a></td></tr></table>`
 2926	text, hrefs := htmlToText(in, "2026", 12)
 2927	if len(hrefs) != 1 || hrefs[0] != "https://example.com/" {
 2928		t.Fatalf("hrefs = %v", hrefs)
 2929	}
 2930	for i, l := range strings.Split(strings.TrimSpace(text), "\n") {
 2931		if strings.TrimSpace(stripTagsLine(l)) == "" {
 2932			continue
 2933		}
 2934		if !strings.Contains(l, "[link=1]") {
 2935			t.Errorf("line %d dropped the link tag: %q", i, l)
 2936		}
 2937	}
 2938}
 2939
 2940// Anchors inside a table are numbered with the rest of the page, in
 2941// document order: the table is set aside and drawn last, which must not
 2942// renumber anything.
 2943func TestTableAnchorsKeepDocumentOrder(t *testing.T) {
 2944	const in = `<p><a href='/one'>one</a></p>
 2945<table><tr><td><a href='/two'>two</a></td><td><a href='/three'>three</a></td></tr></table>
 2946<p><a href='/four'>four</a></p>`
 2947	_, hrefs := htmlToText(in, "2026", 60)
 2948	want := []string{"/one", "/two", "/three", "/four"}
 2949	if len(hrefs) != len(want) {
 2950		t.Fatalf("hrefs = %v", hrefs)
 2951	}
 2952	for i := range want {
 2953		if hrefs[i] != want[i] {
 2954			t.Errorf("hrefs[%d] = %q, want %q", i, hrefs[i], want[i])
 2955		}
 2956	}
 2957}
 2958
 2959func stripTagsLine(s string) string {
 2960	out := ""
 2961	for _, sg := range parseMarkup(s, styleText) {
 2962		out += string(sg.txt)
 2963	}
 2964	return out
 2965}
 2966
 2967// columnOf reports the printed column a word starts at, on the first line
 2968// that holds it.
 2969func columnOf(lines []string, word string) int {
 2970	for _, l := range lines {
 2971		plain := stripTagsLine(l)
 2972		if i := strings.Index(plain, word); i >= 0 {
 2973			return i
 2974		}
 2975	}
 2976	return -1
 2977}
 2978
 2979
 2980// ===== pkg/tui/kit.go =====
 2981// Package tui pkg/tui/kit.go β€” drawing primitives on tcell: a small
 2982// color-tag markup ([fg:bg:flags], as tview popularized), line printing
 2983// with alignment and wrapping, and bordered boxes. The TUI is written
 2984// straight on tcell v3 so it runs wherever the 0magnet ecosystem's
 2985// terminal stack does β€” natively today, in the browser via
 2986// tuiwasm/xtcell tomorrow.
 2987package tui
 2988
 2989import (
 2990	"fmt"
 2991	"strconv"
 2992	"strings"
 2993
 2994	"github.com/gdamore/tcell/v3"
 2995)
 2996
 2997type rect struct{ x, y, w, h int }
 2998
 2999// esc quotes user data so it never parses as a color tag: "[" doubles,
 3000// and the parser reads "[[" back as one literal bracket.
 3001func esc(s string) string {
 3002	return strings.ReplaceAll(s, "[", "[[")
 3003}
 3004
 3005// seg is a run of text in one style, optionally inside a link.
 3006type seg struct {
 3007	txt []rune
 3008	st  tcell.Style
 3009	// link is the 1-based index of the anchor this run belongs to, or 0
 3010	// for ordinary text. What it indexes is the drawing code's business:
 3011	// each page keeps its own table of what its links do. See linkTag.
 3012	link int
 3013}
 3014
 3015// namedColors are the palette names the markup uses; anything else goes
 3016// to tcell.GetColor (which handles #rrggbb and the W3C names).
 3017var namedColors = map[string]tcell.Color{
 3018	"aqua":   tcell.ColorAqua.TrueColor(),
 3019	"white":  tcell.ColorWhite.TrueColor(),
 3020	"black":  tcell.ColorBlack.TrueColor(),
 3021	"gray":   tcell.GetColor("#808080"),
 3022	"grey":   tcell.GetColor("#808080"),
 3023	"yellow": tcell.ColorYellow.TrueColor(),
 3024	"red":    tcell.ColorRed.TrueColor(),
 3025	"green":  tcell.ColorGreen.TrueColor(),
 3026	"orange": tcell.GetColor("#ffa500"),
 3027}
 3028
 3029func markupColor(name string) (tcell.Color, bool) {
 3030	if c, ok := namedColors[name]; ok {
 3031		return c, true
 3032	}
 3033	c := tcell.GetColor(name)
 3034	if c == tcell.ColorDefault && name != "default" {
 3035		return c, false
 3036	}
 3037	return c, true
 3038}
 3039
 3040// linkTag wraps text in an anchor: everything between [link=N] and
 3041// [/link] carries link index N, which the drawing code turns into a
 3042// clickable region. N is 1-based so that a zero index means "no link".
 3043//
 3044// The page's own markup is the only thing that may produce these β€” esc
 3045// doubles the bracket in anything that came from the catalog or from a
 3046// content file, so user data cannot open an anchor.
 3047func linkTag(n int, s string) string {
 3048	return fmt.Sprintf("[link=%d]%s[/link]", n, s)
 3049}
 3050
 3051// linkBody reads a [link=N] / [/link] tag, returning the index it opens
 3052// (0 for the closing tag) and whether the body was one of them at all.
 3053func linkBody(s string) (int, bool) {
 3054	if s == "/link" {
 3055		return 0, true
 3056	}
 3057	rest, ok := strings.CutPrefix(s, "link=")
 3058	if !ok {
 3059		return 0, false
 3060	}
 3061	n, err := strconv.Atoi(rest)
 3062	if err != nil || n < 1 {
 3063		return 0, false
 3064	}
 3065	return n, true
 3066}
 3067
 3068// tagBody reports whether the text between brackets looks like a color
 3069// tag: colors and flags only, at most two colons.
 3070func tagBody(s string) bool {
 3071	if s == "" {
 3072		return false
 3073	}
 3074	colons := 0
 3075	for _, r := range s {
 3076		switch {
 3077		case r == ':':
 3078			colons++
 3079		case r == '#' || r == '-':
 3080		case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
 3081		default:
 3082			return false
 3083		}
 3084	}
 3085	return colons <= 2
 3086}
 3087
 3088// applyTag folds one [fg:bg:flags] spec into a style. Empty parts keep,
 3089// "-" resets to the base.
 3090func applyTag(cur, base tcell.Style, body string) tcell.Style {
 3091	parts := strings.SplitN(body, ":", 3)
 3092	if len(parts) > 0 && parts[0] != "" {
 3093		if parts[0] == "-" {
 3094			cur = cur.Foreground(base.GetForeground())
 3095		} else if c, ok := markupColor(parts[0]); ok {
 3096			cur = cur.Foreground(c)
 3097		}
 3098	}
 3099	if len(parts) > 1 && parts[1] != "" {
 3100		if parts[1] == "-" {
 3101			cur = cur.Background(base.GetBackground())
 3102		} else if c, ok := markupColor(parts[1]); ok {
 3103			cur = cur.Background(c)
 3104		}
 3105	}
 3106	if len(parts) > 2 {
 3107		if parts[2] == "-" || parts[2] == "" {
 3108			cur = cur.Attributes(base.GetAttributes())
 3109		} else {
 3110			for _, f := range parts[2] {
 3111				switch f {
 3112				case 'b':
 3113					cur = cur.Bold(true)
 3114				case 'u':
 3115					cur = cur.Underline(true)
 3116				}
 3117			}
 3118		}
 3119	}
 3120	return cur
 3121}
 3122
 3123// parseMarkup splits a tagged string into styled segments.
 3124func parseMarkup(s string, base tcell.Style) []seg {
 3125	var out []seg
 3126	cur := base
 3127	link := 0
 3128	var run []rune
 3129	flush := func() {
 3130		if len(run) > 0 {
 3131			out = append(out, seg{run, cur, link})
 3132			run = nil
 3133		}
 3134	}
 3135	r := []rune(s)
 3136	for i := 0; i < len(r); i++ {
 3137		if r[i] != '[' {
 3138			run = append(run, r[i])
 3139			continue
 3140		}
 3141		if i+1 < len(r) && r[i+1] == '[' { // escaped literal bracket
 3142			run = append(run, '[')
 3143			i++
 3144			continue
 3145		}
 3146		end := -1
 3147		for j := i + 1; j < len(r) && j < i+24; j++ {
 3148			if r[j] == ']' {
 3149				end = j
 3150				break
 3151			}
 3152		}
 3153		if end < 0 {
 3154			run = append(run, r[i])
 3155			continue
 3156		}
 3157		body := string(r[i+1 : end])
 3158		if n, ok := linkBody(body); ok {
 3159			flush()
 3160			link = n
 3161			i = end
 3162			continue
 3163		}
 3164		if !tagBody(body) {
 3165			run = append(run, r[i])
 3166			continue
 3167		}
 3168		flush()
 3169		cur = applyTag(cur, base, body)
 3170		i = end
 3171	}
 3172	flush()
 3173	return out
 3174}
 3175
 3176// markupWidth is the printed width of a tagged string.
 3177func markupWidth(s string) int {
 3178	n := 0
 3179	for _, sg := range parseMarkup(s, tcell.StyleDefault) {
 3180		n += len(sg.txt)
 3181	}
 3182	return n
 3183}
 3184
 3185// printSegs draws segments on one row, clipped to maxW; returns cells drawn.
 3186func printSegs(sc tcell.Screen, x, y, maxW int, segs []seg) int {
 3187	return printSegsLinks(sc, x, y, maxW, segs, nil)
 3188}
 3189
 3190// printSegsLinks is printSegs, reporting each anchor it drew: onLink is
 3191// called once per run of link cells with the run's position, width and
 3192// link index, which is what turns markup into something clickable.
 3193//
 3194// Per run of cells and not per segment: an anchor split by a color
 3195// change inside it is still one link, and a link clipped away at maxW
 3196// must not leave a region covering cells that were never drawn.
 3197func printSegsLinks(sc tcell.Screen, x, y, maxW int, segs []seg, onLink func(x, y, w, link int)) int {
 3198	n := 0
 3199	run, runStart := 0, 0
 3200	flush := func() {
 3201		if run != 0 && onLink != nil {
 3202			onLink(x+runStart, y, n-runStart, run)
 3203		}
 3204		run = 0
 3205	}
 3206	for _, sg := range segs {
 3207		if sg.link != run {
 3208			flush()
 3209			run, runStart = sg.link, n
 3210		}
 3211		for _, r := range sg.txt {
 3212			if n >= maxW {
 3213				flush()
 3214				return n
 3215			}
 3216			sc.SetContent(x+n, y, r, nil, sg.st)
 3217			n++
 3218		}
 3219	}
 3220	flush()
 3221	return n
 3222}
 3223
 3224// printMarkup draws one tagged line, clipped to maxW.
 3225// The width it returns mirrors printSegs, which callers of that do use.
 3226func printMarkup(sc tcell.Screen, x, y, maxW int, s string, base tcell.Style) int { //nolint:unparam
 3227	return printSegs(sc, x, y, maxW, parseMarkup(s, base))
 3228}
 3229
 3230// printMarkupLinks draws one tagged line and reports its anchors.
 3231func printMarkupLinks(sc tcell.Screen, x, y, maxW int, s string, base tcell.Style, onLink func(x, y, w, link int)) {
 3232	printSegsLinks(sc, x, y, maxW, parseMarkup(s, base), onLink)
 3233}
 3234
 3235// printMarkupCenter draws one tagged line centered in w columns.
 3236func printMarkupCenter(sc tcell.Screen, x, y, w int, s string, base tcell.Style) {
 3237	pad := (w - markupWidth(s)) / 2
 3238	if pad < 0 {
 3239		pad = 0
 3240	}
 3241	printMarkup(sc, x+pad, y, w-pad, s, base)
 3242}
 3243
 3244// wrapSegs word-wraps parsed lines to width w.
 3245func wrapSegs(s string, w int, base tcell.Style, wrap bool) [][]seg {
 3246	if w < 1 {
 3247		w = 1
 3248	}
 3249	var out [][]seg
 3250	for _, line := range strings.Split(s, "\n") {
 3251		segs := parseMarkup(line, base)
 3252		if !wrap {
 3253			out = append(out, segs)
 3254			continue
 3255		}
 3256		// Flatten to styled runes, then break into rows at spaces.
 3257		var flat []styledRune
 3258		for _, sg := range segs {
 3259			for _, r := range sg.txt {
 3260				flat = append(flat, styledRune{r, sg.st, sg.link})
 3261			}
 3262		}
 3263		for len(flat) > w {
 3264			cut := -1
 3265			for i := w; i > 0; i-- {
 3266				if flat[i].r == ' ' {
 3267					cut = i
 3268					break
 3269				}
 3270			}
 3271			if cut <= 0 {
 3272				cut = w
 3273			}
 3274			out = append(out, packSegs(flat[:cut]))
 3275			for cut < len(flat) && flat[cut].r == ' ' {
 3276				cut++
 3277			}
 3278			flat = flat[cut:]
 3279		}
 3280		out = append(out, packSegs(flat))
 3281	}
 3282	return out
 3283}
 3284
 3285type styledRune struct {
 3286	r    rune
 3287	st   tcell.Style
 3288	link int
 3289}
 3290
 3291// keyRune reads the rune of a KeyRune event (v3 carries it as Str).
 3292func keyRune(ev *tcell.EventKey) rune {
 3293	if ev.Key() != tcell.KeyRune {
 3294		return 0
 3295	}
 3296	rs := []rune(ev.Str())
 3297	if len(rs) == 0 {
 3298		return 0
 3299	}
 3300	return rs[0]
 3301}
 3302
 3303func packSegs(flat []styledRune) []seg {
 3304	var segs []seg
 3305	for _, c := range flat {
 3306		if n := len(segs); n > 0 && segs[n-1].st == c.st && segs[n-1].link == c.link {
 3307			segs[n-1].txt = append(segs[n-1].txt, c.r)
 3308		} else {
 3309			segs = append(segs, seg{[]rune{c.r}, c.st, c.link})
 3310		}
 3311	}
 3312	return segs
 3313}
 3314
 3315// fillRect paints a rectangle with spaces in the given style.
 3316func fillRect(sc tcell.Screen, r rect, st tcell.Style) {
 3317	for y := r.y; y < r.y+r.h; y++ {
 3318		for x := r.x; x < r.x+r.w; x++ {
 3319			sc.SetContent(x, y, ' ', nil, st)
 3320		}
 3321	}
 3322}
 3323
 3324// drawRule draws a horizontal rule, the terminal stand-in for the 1px
 3325// table borders that close the page's header and footer.
 3326func drawRule(sc tcell.Screen, x, y, w int, st tcell.Style) {
 3327	for i := 0; i < w; i++ {
 3328		sc.SetContent(x+i, y, '─', nil, st)
 3329	}
 3330}
 3331
 3332// drawBox fills and frames a rectangle, with an optional title in the
 3333// top border.
 3334func drawBox(sc tcell.Screen, r rect, title string, border, fill tcell.Style) {
 3335	if r.w < 2 || r.h < 2 {
 3336		return
 3337	}
 3338	fillRect(sc, r, fill)
 3339	for x := r.x + 1; x < r.x+r.w-1; x++ {
 3340		sc.SetContent(x, r.y, '─', nil, border)
 3341		sc.SetContent(x, r.y+r.h-1, '─', nil, border)
 3342	}
 3343	for y := r.y + 1; y < r.y+r.h-1; y++ {
 3344		sc.SetContent(r.x, y, 'β”‚', nil, border)
 3345		sc.SetContent(r.x+r.w-1, y, 'β”‚', nil, border)
 3346	}
 3347	sc.SetContent(r.x, r.y, 'β”Œ', nil, border)
 3348	sc.SetContent(r.x+r.w-1, r.y, '┐', nil, border)
 3349	sc.SetContent(r.x, r.y+r.h-1, 'β””', nil, border)
 3350	sc.SetContent(r.x+r.w-1, r.y+r.h-1, 'β”˜', nil, border)
 3351	if title != "" {
 3352		printMarkup(sc, r.x+2, r.y, r.w-4, title, fill)
 3353	}
 3354}
 3355
 3356// inner is the area inside a box's border.
 3357func (r rect) inner() rect {
 3358	return rect{r.x + 1, r.y + 1, r.w - 2, r.h - 2}
 3359}
 3360
 3361func (r rect) contains(x, y int) bool {
 3362	return x >= r.x && y >= r.y && x < r.x+r.w && y < r.y+r.h
 3363}
 3364
 3365
 3366// ===== pkg/tui/kittyimg.go =====
 3367package tui
 3368
 3369import (
 3370	"bytes"
 3371	"encoding/base64"
 3372	"fmt"
 3373	"image"
 3374	"image/draw"
 3375	"image/png"
 3376	"os"
 3377	"strconv"
 3378
 3379	"github.com/0magnet/websh/progressive"
 3380)
 3381
 3382// Pictures in a terminal that is not websh: where the terminal speaks
 3383// kitty's graphics protocol (kitty, WezTerm, Ghostty, Konsole β€” the probe
 3384// asks), a product's photo is the photo itself, laid over the cells that
 3385// draw it in half blocks, as websh lays it with a placement. Widgets need a
 3386// page; in a terminal they stay cells.
 3387//
 3388// M2_IMAGES chooses: "place" (websh's placements), "kitty", or "cells".
 3389
 3390// imageMode is how pictures go beyond cells here: "place", "kitty" or "".
 3391func imageMode() string {
 3392	switch os.Getenv("M2_IMAGES") {
 3393	case "cells":
 3394		return ""
 3395	case "kitty":
 3396		return "kitty"
 3397	case "place":
 3398		return "place"
 3399	}
 3400	c := progressive.Current()
 3401	if !c.Has("place") && c.Has("kitty-graphics") {
 3402		return "kitty"
 3403	}
 3404	// A terminal that does not know OSC 7337 ignores it, so placements are
 3405	// the default even where nothing answered.
 3406	return "place"
 3407}
 3408
 3409// kittyID is the image id a placement's pictures go by.
 3410func (u *ui) kittyID(id string) uint32 {
 3411	if u.kittyIDs == nil {
 3412		u.kittyIDs = map[string]uint32{}
 3413	}
 3414	n, ok := u.kittyIDs[id]
 3415	if !ok {
 3416		n = uint32(len(u.kittyIDs) + 7337) //nolint:gosec // a few placements at most
 3417		u.kittyIDs[id] = n
 3418	}
 3419	return n
 3420}
 3421
 3422// kittyPlace lays p's photo over its cells, or does nothing for what is not
 3423// a photo.
 3424func (u *ui) kittyPlace(id string, p placed) {
 3425	if p.url == "" {
 3426		return
 3427	}
 3428	data, err := u.kittyPNG(p)
 3429	if err != nil {
 3430		return
 3431	}
 3432	enc := base64.StdEncoding.EncodeToString(data)
 3433	var b bytes.Buffer
 3434	fmt.Fprintf(&b, "\x1b7\x1b[%d;%dH", p.r.y+1, p.r.x+1) // save the cursor, go to the cells
 3435	first := true
 3436	for len(enc) > 0 || first {
 3437		n := min(len(enc), 4096)
 3438		more := "0"
 3439		if n < len(enc) {
 3440			more = "1"
 3441		}
 3442		if first {
 3443			fmt.Fprintf(&b, "\x1b_Ga=T,f=100,i=%d,c=%d,r=%d,C=1,q=2,m=%s;%s\x1b\\", u.kittyID(id), p.r.w, p.r.h, more, enc[:n])
 3444			first = false
 3445		} else {
 3446			fmt.Fprintf(&b, "\x1b_Gm=%s;%s\x1b\\", more, enc[:n])
 3447		}
 3448		enc = enc[n:]
 3449	}
 3450	b.WriteString("\x1b8") // and back
 3451	u.writeTerm(b.String())
 3452}
 3453
 3454// kittyRemove takes p's picture away.
 3455func (u *ui) kittyRemove(id string) {
 3456	u.writeTerm("\x1b_Ga=d,d=I,i=" + strconv.FormatUint(uint64(u.kittyID(id)), 10) + ",q=2\x1b\\")
 3457}
 3458
 3459// kittyPNG is p's photo as a PNG the shape of its cells β€” kitty stretches a
 3460// picture to the cells it is given β€” contained or, for "cover", cropped.
 3461func (u *ui) kittyPNG(p placed) ([]byte, error) {
 3462	key := fmt.Sprintf("%s|%dx%d|%s", p.url, p.r.w, p.r.h, p.fit)
 3463	if b, ok := u.kittyCache[key]; ok {
 3464		return b, nil
 3465	}
 3466	raw, err := getImageData(p.url)
 3467	if err != nil {
 3468		return nil, err
 3469	}
 3470	src, _, err := image.Decode(bytes.NewReader(raw))
 3471	if err != nil {
 3472		return nil, err
 3473	}
 3474	cw, ch := 1.0, 2.0 // a cell's shape where the terminal does not say
 3475	if c := progressive.Current(); c != nil && c.Cell.W > 0 && c.Cell.H > 0 {
 3476		cw, ch = c.Cell.W, c.Cell.H
 3477	}
 3478	aspect := float64(p.r.w) * cw / (float64(p.r.h) * ch)
 3479	sb := src.Bounds()
 3480	sw, sh := float64(sb.Dx()), float64(sb.Dy())
 3481	var dst *image.RGBA
 3482	if p.fit == "cover" { // crop the middle to the cells' shape
 3483		w, h := sw, sh
 3484		if sw/sh > aspect {
 3485			w = sh * aspect
 3486		} else {
 3487			h = sw / aspect
 3488		}
 3489		r := image.Rect(0, 0, int(w), int(h))
 3490		dst = image.NewRGBA(r)
 3491		off := image.Pt(sb.Min.X+int((sw-w)/2), sb.Min.Y+int((sh-h)/2))
 3492		draw.Draw(dst, r, src, off, draw.Src)
 3493	} else { // the whole of it, transparent around it
 3494		w, h := sw, sh
 3495		if sw/sh > aspect {
 3496			h = sw / aspect
 3497		} else {
 3498			w = sh * aspect
 3499		}
 3500		dst = image.NewRGBA(image.Rect(0, 0, int(w), int(h)))
 3501		at := image.Pt(int((w-sw)/2), int((h-sh)/2))
 3502		draw.Draw(dst, sb.Sub(sb.Min).Add(at), src, sb.Min, draw.Src)
 3503	}
 3504	var out bytes.Buffer
 3505	if err := png.Encode(&out, dst); err != nil {
 3506		return nil, err
 3507	}
 3508	if u.kittyCache == nil {
 3509		u.kittyCache = map[string][]byte{}
 3510	}
 3511	u.kittyCache[key] = out.Bytes()
 3512	return out.Bytes(), nil
 3513}
 3514
 3515
 3516// ===== pkg/tui/links_test.go =====
 3517package tui
 3518
 3519import (
 3520	"strings"
 3521	"testing"
 3522
 3523	"github.com/gdamore/tcell/v3"
 3524
 3525	"github.com/0magnet/m2/pkg/product"
 3526	"github.com/0magnet/m2/pkg/storepage"
 3527)
 3528
 3529// nullScreen is somewhere for the printers to put cells. Only SetContent
 3530// is implemented: tcell v3 ships no simulation screen, and the anchor
 3531// bookkeeping under test needs nothing else. The embedded nil interface
 3532// supplies the rest of the method set, so a test that reached for one
 3533// would panic rather than quietly pass.
 3534type nullScreen struct{ tcell.Screen }
 3535
 3536func (nullScreen) SetContent(int, int, rune, []rune, tcell.Style) {}
 3537
 3538// span is a drawn anchor, as printSegsLinks reports one.
 3539type span struct{ x, y, w, link int }
 3540
 3541// recorder collects the anchors a print reports, which is the whole of
 3542// what makes a link clickable.
 3543type recorder struct{ got []span }
 3544
 3545func (r *recorder) sink(x, y, w, link int) {
 3546	r.got = append(r.got, span{x, y, w, link})
 3547}
 3548
 3549// A link tag marks its text and nothing else, and the tag itself takes
 3550// up no room on the line.
 3551func TestLinkTagMarksOnlyItsText(t *testing.T) {
 3552	s := "Category: " + linkTag(1, "diode") + " end"
 3553	if w := markupWidth(s); w != len("Category: diode end") {
 3554		t.Errorf("width %d, want %d", w, len("Category: diode end"))
 3555	}
 3556	var linked string
 3557	for _, sg := range parseMarkup(s, tcell.StyleDefault) {
 3558		if sg.link == 1 {
 3559			linked += string(sg.txt)
 3560		}
 3561	}
 3562	if linked != "diode" {
 3563		t.Errorf("link 1 covers %q, want %q", linked, "diode")
 3564	}
 3565}
 3566
 3567// Color changes inside an anchor do not split it: the region has to
 3568// cover the whole of the link or half of it stops being clickable.
 3569func TestLinkSurvivesAColorChangeInside(t *testing.T) {
 3570	var r recorder
 3571	printMarkupLinks(nullScreen{}, 3, 7, 80, linkTag(1, "[aqua]dio[white]de[-]"), styleText, r.sink)
 3572	if len(r.got) != 1 {
 3573		t.Fatalf("%d regions, want 1: %+v", len(r.got), r.got)
 3574	}
 3575	if want := (span{x: 3, y: 7, w: 5, link: 1}); r.got[0] != want {
 3576		t.Errorf("region %+v, want %+v", r.got[0], want)
 3577	}
 3578}
 3579
 3580// A link clipped off the end of the line must not leave a region over
 3581// cells that were never drawn.
 3582func TestLinkRegionStopsAtTheClip(t *testing.T) {
 3583	var r recorder
 3584	printMarkupLinks(nullScreen{}, 0, 0, 6, "abc"+linkTag(1, "defghij"), styleText, r.sink)
 3585	if len(r.got) != 1 {
 3586		t.Fatalf("%d regions, want 1: %+v", len(r.got), r.got)
 3587	}
 3588	if got := r.got[0].x + r.got[0].w; got != 6 {
 3589		t.Errorf("region ends at %d, want 6 (the clip)", got)
 3590	}
 3591}
 3592
 3593// Two anchors on one line are two regions, in the order they were
 3594// numbered β€” which is what binds each to the right action.
 3595func TestTwoLinksOnOneLine(t *testing.T) {
 3596	var idx []int
 3597	printMarkupLinks(nullScreen{}, 0, 0, 80, linkTag(1, "one")+", "+linkTag(2, "two"), styleText,
 3598		func(_, _, _, link int) { idx = append(idx, link) })
 3599	if len(idx) != 2 || idx[0] != 1 || idx[1] != 2 {
 3600		t.Errorf("link indices %v, want [1 2]", idx)
 3601	}
 3602}
 3603
 3604// Catalog text is escaped before it reaches the parser, so a product
 3605// named with a bracket cannot open an anchor of its own.
 3606func TestEscapedTextCannotForgeALink(t *testing.T) {
 3607	for _, sg := range parseMarkup(esc("[link=1]nope[/link]"), tcell.StyleDefault) {
 3608		if sg.link != 0 {
 3609			t.Fatalf("escaped text produced link %d", sg.link)
 3610		}
 3611	}
 3612}
 3613
 3614// The stock pages are mostly anchors; htmlToText has to hand back where
 3615// each one goes, in the order its tags are numbered.
 3616func TestHTMLToTextReturnsHrefs(t *testing.T) {
 3617	in := `<p>see <a title='x' href="https://example.com/a">A</a> and ` +
 3618		`<a href='/cat/diode'>diodes</a> and <a>nowhere</a></p>`
 3619	text, hrefs := htmlToText(in, "2026", 80)
 3620	want := []string{"https://example.com/a", "/cat/diode", ""}
 3621	if len(hrefs) != len(want) {
 3622		t.Fatalf("%d hrefs, want %d: %q", len(hrefs), len(want), hrefs)
 3623	}
 3624	for i := range want {
 3625		if hrefs[i] != want[i] {
 3626			t.Errorf("href %d = %q, want %q", i, hrefs[i], want[i])
 3627		}
 3628	}
 3629	if !strings.Contains(text, "[link=2]") {
 3630		t.Errorf("text carries no anchor tags: %q", text)
 3631	}
 3632	if markupWidth(text) != len("see A and diodes and nowhere\n") {
 3633		t.Errorf("anchor tags left width %d in %q", markupWidth(text), text)
 3634	}
 3635}
 3636
 3637// An href is routed the way the site routes it: a path this store serves
 3638// is a page the TUI has, and anything else is a link out.
 3639func TestFollowRoutesSitePaths(t *testing.T) {
 3640	u := &ui{panels: map[string]panel{}, cat: newCatalogInfo(product.Products{
 3641		{Partno: "1N4001", Category: "diode", Subcategory: "1N40XX"},
 3642		{Partno: "1N4729", Category: "diode", Subcategory: "1N47XX"},
 3643	})}
 3644	if u.follow("") != nil {
 3645		t.Error("an empty href is not a link")
 3646	}
 3647	u.current = "content:about"
 3648	u.follow("/")()
 3649	if u.current != "home" {
 3650		t.Errorf("/ went to %q, want home", u.current)
 3651	}
 3652	u.follow("/cat/diode/1N47XX")()
 3653	if want := "list:diode|1N47XX"; u.current != want {
 3654		t.Errorf("subcategory path went to %q, want %q", u.current, want)
 3655	}
 3656	u.follow("/p/1N4001")()
 3657	if want := "prod:1N4001"; u.current != want {
 3658		t.Errorf("product path went to %q, want %q", u.current, want)
 3659	}
 3660	// A link out does not navigate; it hands over the address.
 3661	before := u.current
 3662	u.follow("https://example.com")()
 3663	if u.current != before {
 3664		t.Errorf("an external link navigated to %q", u.current)
 3665	}
 3666	if !strings.Contains(u.notice, "example.com") {
 3667		t.Errorf("external link gave no address: %q", u.notice)
 3668	}
 3669}
 3670
 3671// The pointer lights up whichever of the page's links it is over: the
 3672// header nav on the top row, the footer's cells on the bottom one, and
 3673// the panel's own anchors in between.
 3674func TestHoveredFindsEachKindOfLink(t *testing.T) {
 3675	const h = 20
 3676	u := &ui{hoverX: -1, hoverY: -1}
 3677	u.navRegions = append(u.navRegions, struct {
 3678		x1, x2 int
 3679		act    string
 3680	}{4, 14, "cats"})
 3681	u.footRegions = append(u.footRegions, struct {
 3682		x1, x2 int
 3683		act    string
 3684	}{2, 9, "cart"})
 3685	u.links = []linkRegion{{x: 6, y: 7, w: 5, act: func() {}}}
 3686
 3687	for _, c := range []struct {
 3688		name           string
 3689		x, y           int
 3690		wantX1, wantX2 int
 3691		want           bool
 3692	}{
 3693		{"nothing seen yet", -1, -1, 0, 0, false},
 3694		{"header entry", 5, 0, 4, 14, true},
 3695		{"header, past the entry", 30, 0, 0, 0, false},
 3696		{"footer cell", 3, h - 1, 2, 9, true},
 3697		{"panel anchor", 8, 7, 6, 11, true},
 3698		{"one cell past the anchor", 11, 7, 0, 0, false},
 3699		{"the row below it", 8, 8, 0, 0, false},
 3700	} {
 3701		u.hoverX, u.hoverY = c.x, c.y
 3702		x1, x2, _, ok := u.hovered(h)
 3703		if ok != c.want {
 3704			t.Errorf("%s: hovered=%v, want %v", c.name, ok, c.want)
 3705			continue
 3706		}
 3707		if ok && (x1 != c.wantX1 || x2 != c.wantX2) {
 3708			t.Errorf("%s: span %d..%d, want %d..%d", c.name, x1, x2, c.wantX1, c.wantX2)
 3709		}
 3710	}
 3711}
 3712
 3713// sampleTable is a two-column table whose first column is the row's link.
 3714func sampleTable() *table {
 3715	tbl := &table{cols: []tcol{
 3716		{title: "Name", link: true},
 3717		{title: "Price", width: 9, alignRight: true},
 3718	}}
 3719	tbl.setRows([]trow{
 3720		{cells: []tcell_{{text: "one"}, {text: "$1"}}},
 3721		{cells: []tcell_{{text: "two"}, {text: "$2"}}},
 3722	})
 3723	return tbl
 3724}
 3725
 3726// Every drawn row offers its name as an anchor, covering the name and
 3727// not the blank column beside it β€” that span is both what one click
 3728// follows and what the pointer lights up.
 3729func TestTableReportsEachRowsLinkCell(t *testing.T) {
 3730	tbl := sampleTable()
 3731	var got []span
 3732	tbl.onLink = func(x, y, w, row, _ int) { got = append(got, span{x, y, w, row}) }
 3733	tbl.draw(nullScreen{}, rect{x: 2, y: 4, w: 40, h: 10})
 3734
 3735	want := []span{{x: 2, y: 5, w: 3, link: 0}, {x: 2, y: 6, w: 3, link: 1}}
 3736	if len(got) != len(want) {
 3737		t.Fatalf("%d anchors, want %d: %+v", len(got), len(want), got)
 3738	}
 3739	for i := range want {
 3740		if got[i] != want[i] {
 3741			t.Errorf("anchor %d = %+v, want %+v", i, got[i], want[i])
 3742		}
 3743	}
 3744}
 3745
 3746// A column that is not the link column offers nothing, and neither does
 3747// the header row.
 3748func TestTableReportsOnlyTheLinkColumn(t *testing.T) {
 3749	tbl := sampleTable()
 3750	tbl.cols[0].link = false
 3751	n := 0
 3752	tbl.onLink = func(int, int, int, int, int) { n++ }
 3753	tbl.draw(nullScreen{}, rect{x: 0, y: 0, w: 40, h: 10})
 3754	if n != 0 {
 3755		t.Errorf("%d anchors from a table with no link column, want 0", n)
 3756	}
 3757}
 3758
 3759// A row has an anchor per link cell, and an empty one offers none β€”
 3760// which is how a product with no stock has nothing to add to a cart,
 3761// the page omitting its button for the same reason.
 3762func TestTableSkipsAnEmptyLinkCell(t *testing.T) {
 3763	tbl := &table{cols: []tcol{
 3764		{title: "Name", link: true},
 3765		{title: "Buy", width: 11, link: true},
 3766	}}
 3767	tbl.setRows([]trow{
 3768		{cells: []tcell_{{text: "in stock"}, {text: storepage.AddToCart}}},
 3769		{cells: []tcell_{{text: "sold out"}, {text: ""}}},
 3770	})
 3771	byCol := map[int]int{}
 3772	tbl.onLink = func(_, _, _, _, col int) { byCol[col]++ }
 3773	tbl.draw(nullScreen{}, rect{x: 0, y: 0, w: 40, h: 10})
 3774
 3775	if byCol[0] != 2 {
 3776		t.Errorf("%d name anchors, want one per row", byCol[0])
 3777	}
 3778	if byCol[1] != 1 {
 3779		t.Errorf("%d buy anchors, want one β€” the sold-out row has no button", byCol[1])
 3780	}
 3781}
 3782
 3783// The link cell never reaches the table's own mouse handling β€” the page
 3784// follows the anchor first. What is left is the rest of the row: it
 3785// selects, and a second click on the selected row opens it.
 3786func TestTableClickSelectsThenOpens(t *testing.T) {
 3787	tbl := sampleTable()
 3788	opened := -1
 3789	tbl.onActivate = func(i int) { opened = i }
 3790	click := tcell.NewEventMouse(0, 0, tcell.Button1, 0)
 3791
 3792	tbl.mouse(2, click) // row 1, which is not selected
 3793	if opened != -1 {
 3794		t.Errorf("the first click opened %d, want nothing", opened)
 3795	}
 3796	if tbl.sel != 1 {
 3797		t.Errorf("the first click selected row %d, want 1", tbl.sel)
 3798	}
 3799	tbl.mouse(2, click)
 3800	if opened != 1 {
 3801		t.Errorf("the second click opened %d, want 1", opened)
 3802	}
 3803}
 3804
 3805
 3806// ===== pkg/tui/mirror.go =====
 3807package tui
 3808
 3809import (
 3810	"fmt"
 3811	"html"
 3812	"net/url"
 3813	"sort"
 3814	"strings"
 3815
 3816	"github.com/0magnet/m2/pkg/storepage"
 3817)
 3818
 3819// The mirror (websh's PROTOCOL.md, Accessibility): where the host takes it,
 3820// the store publishes the page it shows as structure β€” the product, the list,
 3821// the categories β€” for a screen reader, which reads the cells as a grid of
 3822// characters. Its links go to the site's own pages.
 3823
 3824// mirrorLimit is how many products a list's mirror names.
 3825const mirrorLimit = 500
 3826
 3827// mirrorHTML is the page now showing, as an HTML fragment.
 3828func (u *ui) mirrorHTML() string {
 3829	esc := html.EscapeString
 3830	site := storeURL()
 3831	link := func(path, text string) string {
 3832		return `<a href="` + esc(site+path) + `">` + esc(text) + `</a>`
 3833	}
 3834	catPath := func(cat, sub string) string {
 3835		p := "/cat/" + url.PathEscape(cat)
 3836		if sub != "" {
 3837			p += "/" + url.PathEscape(storepage.EscapeSubcat(sub))
 3838		}
 3839		return p
 3840	}
 3841	var b strings.Builder
 3842	u.mu.Lock()
 3843	cur := u.current
 3844	u.mu.Unlock()
 3845	switch {
 3846	case strings.HasPrefix(cur, "prod:"):
 3847		p := u.cat.find(strings.TrimPrefix(cur, "prod:"))
 3848		if p == nil {
 3849			return ""
 3850		}
 3851		fmt.Fprintf(&b, "<h1>%s</h1><ul>", esc(p.Name))
 3852		for _, kv := range [][2]string{{"Price", "$" + strings.TrimPrefix(p.Price, "$")}, {"In stock", p.Quantity}, {"Part number", p.Partno}, {"Package", p.Packagetype}} {
 3853			if kv[1] != "" && kv[1] != "$" {
 3854				fmt.Fprintf(&b, "<li>%s: %s</li>", kv[0], esc(kv[1]))
 3855			}
 3856		}
 3857		if p.Category != "" {
 3858			b.WriteString("<li>Category: " + link(catPath(p.Category, ""), p.Category))
 3859			if p.Subcategory != "" {
 3860				b.WriteString(", " + link(catPath(p.Category, p.Subcategory), p.Subcategory))
 3861			}
 3862			b.WriteString("</li>")
 3863		}
 3864		b.WriteString("</ul>")
 3865		for _, d := range []string{p.Description1, p.Description2} {
 3866			if d != "" {
 3867				fmt.Fprintf(&b, "<p>%s</p>", esc(d))
 3868			}
 3869		}
 3870		b.WriteString("<p>" + link("/p/"+url.PathEscape(p.Partno), "This product on the website") + "</p>")
 3871	case strings.HasPrefix(cur, "list:"):
 3872		cat, sub, _ := strings.Cut(strings.TrimPrefix(cur, "list:"), "|")
 3873		title := "All products"
 3874		if cat != "" {
 3875			title = cat
 3876			if sub != "" {
 3877				title += ": " + sub
 3878			}
 3879		}
 3880		prods := u.cat.products(cat, sub)
 3881		fmt.Fprintf(&b, "<h1>%s</h1><p>%d products.</p><ul>", esc(title), len(prods))
 3882		for i, p := range prods {
 3883			if i == mirrorLimit {
 3884				fmt.Fprintf(&b, "<li>and %d more</li>", len(prods)-i)
 3885				break
 3886			}
 3887			fmt.Fprintf(&b, "<li>%s β€” $%s</li>", link("/p/"+url.PathEscape(p.Partno), p.Name), esc(strings.TrimPrefix(p.Price, "$")))
 3888		}
 3889		b.WriteString("</ul>")
 3890	case strings.HasPrefix(cur, "content:"):
 3891		name := strings.TrimPrefix(cur, "content:")
 3892		fmt.Fprintf(&b, "<h1>%s</h1>%s", esc(name), fetchContent(name)) // the site's own page; the host keeps only structure
 3893	default: // home
 3894		long := f.Sitelongname
 3895		if long == "" {
 3896			long = u.sitedomain
 3897		}
 3898		fmt.Fprintf(&b, "<h1>%s</h1>", esc(long))
 3899		if f.Sitetagline != "" {
 3900			fmt.Fprintf(&b, "<p>%s</p>", esc(f.Sitetagline))
 3901		}
 3902		cats := make([]string, 0, len(u.cat.catCounts))
 3903		for c := range u.cat.catCounts {
 3904			cats = append(cats, c)
 3905		}
 3906		sort.Strings(cats)
 3907		b.WriteString("<h2>Categories</h2><ul>")
 3908		for _, c := range cats {
 3909			fmt.Fprintf(&b, "<li>%s (%d)</li>", link(catPath(c, ""), c), u.cat.catCounts[c])
 3910		}
 3911		b.WriteString("</ul>")
 3912	}
 3913	return b.String()
 3914}
 3915
 3916
 3917// ===== pkg/tui/open_js.go =====
 3918//go:build js && wasm
 3919
 3920// Package tui pkg/tui/open_js.go β€” following a link in the browser
 3921// build. The TUI is a page, so a link that leaves the store can be
 3922// opened the way the page's own would be: in another tab.
 3923package tui
 3924
 3925import "syscall/js"
 3926
 3927// openURL opens a link the store cannot render itself β€” an external
 3928// site, or the PDF behind a datasheet.
 3929func (u *ui) openURL(href string) {
 3930	if href == "" {
 3931		return
 3932	}
 3933	w := js.Global().Get("window")
 3934	if !w.Truthy() {
 3935		u.notice = "[aqua]" + esc(href) + "[-]"
 3936		return
 3937	}
 3938	// noopener, or the opened page gets a handle on this one.
 3939	if opened := w.Call("open", href, "_blank", "noopener"); !opened.Truthy() {
 3940		// A blocked popup is silent otherwise; show the address instead
 3941		// so the link is still followable by hand.
 3942		u.notice = "[aqua]" + esc(href) + "[-]"
 3943	}
 3944}
 3945
 3946
 3947// ===== pkg/tui/open_native.go =====
 3948//go:build !js || !wasm
 3949
 3950// Package tui pkg/tui/open_native.go β€” following a link in a native
 3951// terminal, where there is no browser to hand it to.
 3952package tui
 3953
 3954// openURL shows a link the store cannot render itself β€” an external
 3955// site, or the PDF behind a datasheet. A terminal has nowhere to open
 3956// one, so it displays the address, which is what a terminal can do with
 3957// a link that a person can then act on.
 3958func (u *ui) openURL(href string) {
 3959	if href == "" {
 3960		return
 3961	}
 3962	u.notice = "[aqua]" + esc(href) + "[-]"
 3963}
 3964
 3965
 3966// ===== pkg/tui/pay_js.go =====
 3967//go:build js && wasm
 3968
 3969// Package tui pkg/tui/pay_js.go β€” the browser half of the payment seam.
 3970// The card form is Stripe's Payment Element: Stripe's fields in Stripe's
 3971// frame, so the card never passes through this program, which is why a
 3972// terminal cannot have one. Here the terminal is in a page, so the form is
 3973// laid over the checkout dialog's cells as a websh placement β€” a widget this
 3974// package offers as "stripe" β€” as the website's cart wasm opens the same
 3975// form in the page's dialog.
 3976//
 3977// A card is paid without leaving the terminal (redirect "if_required"):
 3978// the order goes to /submit-order, as /complete would send it, and the cart
 3979// is emptied. A payment method that must leave the page goes to /complete
 3980// as the website's does, and /complete submits the order it finds in
 3981// localStorage: so for as long as a payment is being confirmed, and only
 3982// then, the order is put there too (handOff). The cart itself is a file
 3983// (cartsave.go).
 3984package tui
 3985
 3986import (
 3987	"bytes"
 3988	"encoding/json"
 3989	"fmt"
 3990	"io"
 3991	"net/http"
 3992	"strings"
 3993	"sync"
 3994	"syscall/js"
 3995	"time"
 3996
 3997	"github.com/gdamore/tcell/v3"
 3998
 3999	"github.com/0magnet/websh/widget"
 4000)
 4001
 4002func init() {
 4003	widget.Register("stripe", mountCardForm)
 4004}
 4005
 4006// cardPay is the checkout the card form is for.
 4007type cardPay struct {
 4008	u      *ui
 4009	co     *checkoutOverlay
 4010	secret string
 4011	lines  []cartLine // the cart the payment intent was made for
 4012
 4013	mu       sync.Mutex
 4014	elements js.Value                 // the mounted form's Elements, undefined while none is
 4015	stripe   js.Value                 // Stripe.js, for that key
 4016	say      func(s string, bad bool) // the message under the form
 4017	busy     bool
 4018}
 4019
 4020var (
 4021	payMu  sync.Mutex
 4022	paying *cardPay
 4023)
 4024
 4025func (u *ui) payWithElements(co *checkoutOverlay, clientSecret string) {
 4026	if f.StripePK == "" {
 4027		co.ta.setContent("\n [yellow]this store has no Stripe key configured[-] β€” the card form\n cannot open. Esc closes; the cart is kept.\n")
 4028		return
 4029	}
 4030	if _, inPage := u.screen.(interface{ WriteRaw([]byte) }); !inPage {
 4031		co.ta.setContent("\n [yellow]the card form opens over the store's own terminal in its page[-]\n β€” complete this order at [aqua]https://" + esc(u.sitedomain) + "[-]\n")
 4032		return
 4033	}
 4034	st := &cardPay{u: u, co: co, secret: clientSecret, lines: u.cartLines()}
 4035	payMu.Lock()
 4036	paying = st
 4037	payMu.Unlock()
 4038	total, _ := u.cartTotal()
 4039	head := fmt.Sprintf("Pay [::b]$%d.%02d[-:-:-] by card", total/100, total%100)
 4040	if f.Teststripekey {
 4041		head += "  [yellow]test mode: 4242 4242 4242 4242, any future date[-]"
 4042	}
 4043	co.form = &cardForm{head: head, pay: func() { go st.confirm() }}
 4044	// Under the form, seen only where it is not laid over these cells.
 4045	co.ta.setContent("\n [gray]the card form goes here, over these cells.\n If the page's parts are hidden, w shows them again.[-]\n")
 4046}
 4047
 4048// mountCardForm fills a placement with the card form for the checkout in
 4049// progress.
 4050func mountCardForm(el js.Value) func() {
 4051	payMu.Lock()
 4052	st := paying
 4053	payMu.Unlock()
 4054	if st == nil {
 4055		return nil
 4056	}
 4057	doc := js.Global().Get("document")
 4058	mk := func(tag, css string) js.Value {
 4059		e := doc.Call("createElement", tag)
 4060		e.Get("style").Set("cssText", css)
 4061		return e
 4062	}
 4063	box := mk("div", "position:absolute;inset:0;overflow:auto;background:#000;padding:10px 14px;box-sizing:border-box")
 4064	form := mk("div", "min-height:120px")
 4065	btn := mk("button", "margin-top:12px;padding:6px 18px;font:inherit;color:#000;background:#00ffff;border:0;cursor:pointer")
 4066	btn.Set("textContent", "Pay now")
 4067	msg := mk("div", "margin-top:10px;color:#bbb;white-space:pre-wrap")
 4068	box.Call("append", form, btn, msg)
 4069	el.Call("append", box)
 4070
 4071	click := js.FuncOf(func(_ js.Value, args []js.Value) any {
 4072		args[0].Call("preventDefault")
 4073		go st.confirm()
 4074		return nil
 4075	})
 4076	btn.Call("addEventListener", "click", click)
 4077
 4078	var (
 4079		gone bool
 4080		pe   js.Value
 4081	)
 4082	say := func(s string, bad bool) {
 4083		color := "#bbb"
 4084		if bad {
 4085			color = "#ff6b6b"
 4086		}
 4087		msg.Get("style").Set("color", color)
 4088		msg.Set("textContent", s)
 4089	}
 4090	say("loading the card form …", false)
 4091	withStripe(f.StripePK, func(stripe js.Value, err string) {
 4092		if gone {
 4093			return
 4094		}
 4095		if err != "" {
 4096			say(err, true)
 4097			return
 4098		}
 4099		elements := stripe.Call("elements", map[string]any{
 4100			"clientSecret": st.secret,
 4101			"appearance": map[string]any{
 4102				"theme": "night",
 4103				"variables": map[string]any{
 4104					"colorPrimary":    "#00ffff",
 4105					"colorBackground": "#000000",
 4106					"fontFamily":      "ui-monospace, Menlo, Consolas, monospace",
 4107				},
 4108			},
 4109		})
 4110		pe = elements.Call("create", "payment", map[string]any{"layout": "tabs"})
 4111		pe.Call("mount", form)
 4112		st.mu.Lock()
 4113		st.stripe, st.elements, st.say = stripe, elements, say
 4114		st.mu.Unlock()
 4115		say("", false)
 4116	})
 4117	return func() {
 4118		gone = true
 4119		st.mu.Lock()
 4120		st.elements, st.say = js.Undefined(), nil
 4121		st.mu.Unlock()
 4122		if pe.Truthy() {
 4123			pe.Call("destroy")
 4124		}
 4125		btn.Call("removeEventListener", "click", click)
 4126		click.Release()
 4127		el.Set("textContent", "")
 4128	}
 4129}
 4130
 4131// confirm pays with what is in the form. It waits on Stripe, so it runs on
 4132// its own goroutine, never the event loop's.
 4133func (st *cardPay) confirm() {
 4134	st.mu.Lock()
 4135	if st.busy || !st.elements.Truthy() {
 4136		st.mu.Unlock()
 4137		return
 4138	}
 4139	st.busy = true
 4140	stripe, elements := st.stripe, st.elements
 4141	st.mu.Unlock()
 4142	defer func() {
 4143		st.mu.Lock()
 4144		st.busy = false
 4145		st.mu.Unlock()
 4146	}()
 4147	// What is said goes under whichever form is mounted when it is said.
 4148	tell := func(s string, bad bool) {
 4149		st.mu.Lock()
 4150		say := st.say
 4151		st.mu.Unlock()
 4152		if say != nil {
 4153			say(s, bad)
 4154		}
 4155	}
 4156	tell("paying …", false)
 4157	// If Stripe takes the page to /complete, that page submits this.
 4158	restore := handOff(st.lines)
 4159	origin := js.Global().Get("location").Get("origin").String()
 4160	res, err := await(stripe.Call("confirmPayment", map[string]any{
 4161		"elements":      elements,
 4162		"confirmParams": map[string]any{"return_url": origin + "/complete"},
 4163		"redirect":      "if_required",
 4164	}))
 4165	// Still here: the page did not leave, so /complete will not need it.
 4166	restore()
 4167	if err != "" {
 4168		tell("could not reach Stripe: "+err, true)
 4169		return
 4170	}
 4171	if e := res.Get("error"); e.Truthy() {
 4172		tell(e.Get("message").String(), true)
 4173		return
 4174	}
 4175	pi := res.Get("paymentIntent")
 4176	id, status := pi.Get("id").String(), pi.Get("status").String()
 4177	if status != "succeeded" {
 4178		tell("the payment is "+status+" β€” it has not gone through yet ("+id+")", true)
 4179		return
 4180	}
 4181	tell("paid β€” sending the order …", false)
 4182	serr := submitOrder(st.lines, id)
 4183	st.u.postWait(func() {
 4184		payMu.Lock()
 4185		if paying == st {
 4186			paying = nil
 4187		}
 4188		payMu.Unlock()
 4189		st.co.form = nil
 4190		if serr != nil {
 4191			// Paid, and the order not recorded: the cart stays, as the
 4192			// record of what was bought, and the payment's id is the way
 4193			// to find it.
 4194			st.co.ta.setContent(fmt.Sprintf("\n [green]payment succeeded[-] (%s)\n\n [red]but the order could not be sent:[-] %s\n\n please contact the store with the payment id above.\n The cart is kept.\n", esc(id), esc(serr.Error())))
 4195			return
 4196		}
 4197		st.u.emptyCart()
 4198		st.co.ta.setContent(fmt.Sprintf("\n [green]Payment succeeded β€” thank you for your order.[-]\n\n [gray]payment[-] %s\n\n The order has been sent to the store. Esc closes.\n", esc(id)))
 4199	})
 4200}
 4201
 4202// submitOrder sends the order as the website's /complete does: the cart as
 4203// it was saved, beside the payment's id, which the server checks with
 4204// Stripe before it records anything.
 4205func submitOrder(lines []cartLine, paymentIntentID string) error {
 4206	body, err := json.Marshal(map[string]any{
 4207		"localStorageData": map[string]any{completeKey: lines},
 4208		"paymentIntentId":  paymentIntentID,
 4209	})
 4210	if err != nil {
 4211		return err
 4212	}
 4213	client := &http.Client{Timeout: 30 * time.Second}
 4214	resp, err := client.Post(ordersURL()+"/submit-order", "application/json", bytes.NewReader(body))
 4215	if err != nil {
 4216		return err
 4217	}
 4218	defer resp.Body.Close() //nolint:errcheck // read-side close
 4219	if resp.StatusCode != http.StatusOK {
 4220		data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) //nolint:errcheck // the status says enough without it
 4221		return fmt.Errorf("%s: %s", resp.Status, strings.TrimSpace(string(data)))
 4222	}
 4223	return nil
 4224}
 4225
 4226// await waits for a promise, from a goroutine that is not the event loop's.
 4227func await(p js.Value) (js.Value, string) {
 4228	type result struct {
 4229		v   js.Value
 4230		err string
 4231	}
 4232	ch := make(chan result, 1)
 4233	var then, catch js.Func
 4234	then = js.FuncOf(func(_ js.Value, args []js.Value) any {
 4235		ch <- result{v: args[0]}
 4236		return nil
 4237	})
 4238	catch = js.FuncOf(func(_ js.Value, args []js.Value) any {
 4239		ch <- result{err: js.Global().Get("String").Invoke(args[0]).String()}
 4240		return nil
 4241	})
 4242	p.Call("then", then, catch)
 4243	r := <-ch
 4244	then.Release()
 4245	catch.Release()
 4246	return r.v, r.err
 4247}
 4248
 4249// Stripe.js is loaded once, from Stripe (it may not be bundled or served
 4250// from elsewhere), the first time a card form is shown.
 4251var (
 4252	stripeMu      sync.Mutex
 4253	stripeByKey   = map[string]js.Value{}
 4254	stripeLoading bool
 4255	stripeWaiting []func(string)
 4256)
 4257
 4258// withStripe calls fn with Stripe.js for key, once it has loaded, on the
 4259// event loop.
 4260func withStripe(key string, fn func(stripe js.Value, err string)) {
 4261	ready := func(err string) {
 4262		if err != "" {
 4263			fn(js.Value{}, err)
 4264			return
 4265		}
 4266		stripeMu.Lock()
 4267		s, ok := stripeByKey[key]
 4268		if !ok {
 4269			s = js.Global().Get("Stripe").Invoke(key)
 4270			stripeByKey[key] = s
 4271		}
 4272		stripeMu.Unlock()
 4273		fn(s, "")
 4274	}
 4275	if js.Global().Get("Stripe").Type() == js.TypeFunction {
 4276		ready("")
 4277		return
 4278	}
 4279	stripeMu.Lock()
 4280	stripeWaiting = append(stripeWaiting, ready)
 4281	if stripeLoading {
 4282		stripeMu.Unlock()
 4283		return
 4284	}
 4285	stripeLoading = true
 4286	stripeMu.Unlock()
 4287
 4288	doc := js.Global().Get("document")
 4289	script := doc.Call("createElement", "script")
 4290	script.Set("src", "https://js.stripe.com/v3/")
 4291	var onload, onerror js.Func
 4292	done := func(err string) {
 4293		onload.Release()
 4294		onerror.Release()
 4295		stripeMu.Lock()
 4296		waiting := stripeWaiting
 4297		stripeWaiting, stripeLoading = nil, false
 4298		stripeMu.Unlock()
 4299		for _, w := range waiting {
 4300			w(err)
 4301		}
 4302	}
 4303	onload = js.FuncOf(func(js.Value, []js.Value) any { done(""); return nil })
 4304	onerror = js.FuncOf(func(js.Value, []js.Value) any {
 4305		done("could not load Stripe.js β€” is js.stripe.com blocked?")
 4306		return nil
 4307	})
 4308	script.Call("addEventListener", "load", onload)
 4309	script.Call("addEventListener", "error", onerror)
 4310	doc.Get("head").Call("append", script)
 4311}
 4312
 4313// postWait is post for what must not be dropped β€” a payment's outcome:
 4314// it waits for room in the queue rather than giving up, until shutdown.
 4315func (u *ui) postWait(fn func()) {
 4316	for {
 4317		u.postMu.Lock()
 4318		if u.finished {
 4319			u.postMu.Unlock()
 4320			return
 4321		}
 4322		select {
 4323		case u.screen.EventQ() <- tcell.NewEventInterrupt(fn):
 4324			u.postMu.Unlock()
 4325			return
 4326		default:
 4327		}
 4328		u.postMu.Unlock()
 4329		time.Sleep(20 * time.Millisecond)
 4330	}
 4331}
 4332
 4333// completeKey is where the website's /complete page finds the order it
 4334// submits after a payment that left the page.
 4335const completeKey = "cartItems"
 4336
 4337// handOff puts the order where /complete looks for it, and returns what puts
 4338// back what was there β€” the website's own cart uses the same key.
 4339func handOff(lines []cartLine) (restore func()) {
 4340	restore = func() {}
 4341	defer func() { _ = recover() }() //nolint:errcheck // storage the browser refuses: /complete then finds nothing, as before
 4342	st := js.Global().Get("localStorage")
 4343	b, err := json.Marshal(lines)
 4344	if !st.Truthy() || err != nil {
 4345		return restore
 4346	}
 4347	was := st.Call("getItem", completeKey)
 4348	st.Call("setItem", completeKey, string(b))
 4349	return func() {
 4350		defer func() { _ = recover() }() //nolint:errcheck // as above
 4351		if was.Type() == js.TypeString {
 4352			st.Call("setItem", completeKey, was)
 4353		} else {
 4354			st.Call("removeItem", completeKey)
 4355		}
 4356	}
 4357}
 4358
 4359
 4360// ===== pkg/tui/pay_native.go =====
 4361//go:build !js || !wasm
 4362
 4363// Package tui pkg/tui/pay_native.go β€” the native half of the payment
 4364// seam. Card details can never be typed into a terminal (they would
 4365// pass through this program, which PCI forbids); the browser build of
 4366// this TUI lays the Stripe Payment Element over the checkout dialog
 4367// (pay_js.go). Here the native flow proves the whole pipeline β€” cart,
 4368// shipping, server validation, payment intent β€” and says where the last
 4369// step lives.
 4370package tui
 4371
 4372// payWithElements takes over the checkout dialog once the store server
 4373// has created the payment intent for this cart.
 4374func (u *ui) payWithElements(co *checkoutOverlay, clientSecret string) {
 4375	preview := clientSecret
 4376	if len(preview) > 12 {
 4377		preview = preview[:12] + "…"
 4378	}
 4379	co.ta.setContent("\n [green]the store server accepted the cart[-] and created the payment\n intent (" +
 4380		esc(preview) + ").\n\n" +
 4381		" [yellow]card entry needs the Stripe Payment Element[-], which a terminal\n" +
 4382		" cannot host. In the browser build of this TUI it opens right here,\n" +
 4383		" over the terminal β€” in a native terminal, complete this order at\n" +
 4384		" [aqua]https://" + esc(u.sitedomain) + "[-]\n\n" +
 4385		" [gray]Esc closes β€” the cart is kept[-]\n")
 4386}
 4387
 4388
 4389// ===== pkg/tui/place.go =====
 4390package tui
 4391
 4392import (
 4393	"encoding/base64"
 4394	"encoding/json"
 4395	"strings"
 4396)
 4397
 4398// The real thing over the cells, where the terminal can show it: websh's
 4399// placements (OSC 7337). The home panel's logo and turning globe have the
 4400// website's own β€” its logo.svg and its globe, a widget this program offers
 4401// as "globe" (widgets_js.go) β€” laid over the cells that draw them, and a product's photo has
 4402// the photo itself. A terminal without placements ignores the sequences, so
 4403// they are written wherever this runs, and the cells are drawn as ever; an
 4404// overlay (a menu, the cart, a notice) keeps the cells it crosses.
 4405
 4406// placementsShown is set where websh told this program, run as a process of
 4407// its own, that its terminal shows placements (widgets_js.go).
 4408var placementsShown = func() bool { return false }
 4409
 4410// A placed is what one placement shows over r.
 4411type placed struct {
 4412	r      rect
 4413	url    string // an image, or
 4414	widget string // a widget the page registered
 4415	fit    string
 4416	input  bool // the mouse over it is its own: the globe is turned by hand
 4417	// above is how many of the frame's overlays were drawn when it was
 4418	// asked for: one an overlay asks for lies on that overlay, and only
 4419	// what is drawn after it covers it.
 4420	above int
 4421}
 4422
 4423// want asks for p over its cells as id this frame.
 4424func (u *ui) want(id string, p placed) {
 4425	if p.r.w <= 0 || p.r.h <= 0 {
 4426		return
 4427	}
 4428	if p.url != "" && !strings.HasPrefix(p.url, "https://") && !strings.HasPrefix(p.url, "http://") {
 4429		return // a file here, not an image the terminal can fetch
 4430	}
 4431	p.above = u.overlaysDrawn
 4432	if u.placeWant == nil {
 4433		u.placeWant = map[string]placed{}
 4434	}
 4435	u.placeWant[id] = p
 4436}
 4437
 4438// placeSync, after a frame, tells the terminal what changed: what was wanted
 4439// and is not covered by an overlay is laid (or moved), and the rest taken
 4440// away β€” all of it when the person keeps to the cells (w).
 4441func (u *ui) placeSync(overlays []rect) {
 4442	if u.cellsOnly {
 4443		clear(u.placeWant)
 4444	}
 4445	for id, p := range u.placeWant {
 4446		for _, o := range overlays[min(p.above, len(overlays)):] {
 4447			if p.r.overlaps(o) {
 4448				delete(u.placeWant, id)
 4449				break
 4450			}
 4451		}
 4452	}
 4453	mode := imageMode()
 4454	for id := range u.placeSent {
 4455		if _, ok := u.placeWant[id]; !ok {
 4456			if mode == "kitty" {
 4457				u.kittyRemove(id)
 4458			} else {
 4459				u.writeTerm("\x1b]7337;remove;" + id + "\x1b\\")
 4460			}
 4461			delete(u.placeSent, id)
 4462		}
 4463	}
 4464	for id, p := range u.placeWant {
 4465		if s, ok := u.placeSent[id]; ok && s == p {
 4466			continue
 4467		}
 4468		if mode == "" {
 4469			continue
 4470		}
 4471		if mode == "kitty" {
 4472			u.kittyPlace(id, p)
 4473			if u.placeSent == nil {
 4474				u.placeSent = map[string]placed{}
 4475			}
 4476			u.placeSent[id] = p
 4477			continue
 4478		}
 4479		b, err := json.Marshal(struct {
 4480			Row    int    `json:"row"`
 4481			Col    int    `json:"col"`
 4482			W      int    `json:"w"`
 4483			H      int    `json:"h"`
 4484			URL    string `json:"url,omitempty"`
 4485			Widget string `json:"widget,omitempty"`
 4486			Fit    string `json:"fit,omitempty"`
 4487			Input  bool   `json:"input,omitempty"`
 4488		}{p.r.y, p.r.x, p.r.w, p.r.h, p.url, p.widget, p.fit, p.input})
 4489		if err != nil {
 4490			continue
 4491		}
 4492		u.writeTerm("\x1b]7337;place;" + id + ";" + base64.StdEncoding.EncodeToString(b) + "\x1b\\")
 4493		if u.placeSent == nil {
 4494			u.placeSent = map[string]placed{}
 4495		}
 4496		u.placeSent[id] = p
 4497	}
 4498	// The page's own terminal (a screen written straight into its emulator)
 4499	// surely shows placements, and so does a websh that says so to a program
 4500	// it runs; elsewhere the cells are the picture.
 4501	_, inPage := u.screen.(interface{ WriteRaw([]byte) })
 4502	u.homeCovered.Store((inPage || placementsShown()) && u.placeSent["home"].widget != "")
 4503	clear(u.placeWant)
 4504}
 4505
 4506// placeClear takes every placement away, as the program ends.
 4507func (u *ui) placeClear() {
 4508	if imageMode() == "kitty" {
 4509		for id := range u.placeSent {
 4510			u.kittyRemove(id)
 4511		}
 4512		return
 4513	}
 4514	if len(u.placeSent) > 0 {
 4515		u.writeTerm("\x1b]7337;clear\x1b\\")
 4516	}
 4517}
 4518
 4519// writeTerm writes s to the terminal itself, outside the screen's frames:
 4520// to the tty on a real terminal, or straight to the emulator in a page.
 4521func (u *ui) writeTerm(s string) {
 4522	if w, ok := u.screen.(interface{ WriteRaw([]byte) }); ok {
 4523		w.WriteRaw([]byte(s))
 4524		return
 4525	}
 4526	if tty, ok := u.screen.Tty(); ok {
 4527		_, _ = tty.Write([]byte(s)) //nolint:errcheck // a terminal that is gone shows nothing either
 4528	}
 4529}
 4530
 4531// overlaps reports whether r and o share a cell.
 4532func (r rect) overlaps(o rect) bool {
 4533	return r.x < o.x+o.w && o.x < r.x+r.w && r.y < o.y+o.h && o.y < r.y+r.h
 4534}
 4535
 4536
 4537// ===== pkg/tui/probe_js.go =====
 4538//go:build js
 4539
 4540package tui
 4541
 4542// probeTerminal: in a browser the host is asked otherwise β€” the page hands a
 4543// program in it the answer (storepane), and a program run from websh's
 4544// filesystem asks through its terminal (websh's childtty).
 4545func probeTerminal() {}
 4546
 4547
 4548// ===== pkg/tui/probe_other.go =====
 4549//go:build !js
 4550
 4551package tui
 4552
 4553import (
 4554	"os"
 4555	"time"
 4556
 4557	"golang.org/x/term"
 4558
 4559	"github.com/0magnet/websh/progressive"
 4560)
 4561
 4562// probeTerminal asks the terminal what it can do beyond cells β€” kitty
 4563// graphics, say β€” before tcell takes it, so the store can show real
 4564// pictures where the terminal can. It asks on /dev/tty, in raw mode, and
 4565// closes it after, which ends the read the probe may still have waiting:
 4566// no key is taken from tcell.
 4567func probeTerminal() {
 4568	tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
 4569	if err != nil {
 4570		return
 4571	}
 4572	defer tty.Close()   //nolint:errcheck // done with it either way
 4573	fd := int(tty.Fd()) //nolint:gosec // a file descriptor fits an int
 4574	old, err := term.MakeRaw(fd)
 4575	if err != nil {
 4576		return
 4577	}
 4578	defer term.Restore(fd, old)                        //nolint:errcheck // back as it was, as far as it goes
 4579	progressive.Probe(tty, tty, 1500*time.Millisecond) //nolint:errcheck,gosec // no answer is cells
 4580}
 4581
 4582
 4583// ===== pkg/tui/remote.go =====
 4584// Package tui pkg/tui/remote.go β€” the TUI as a store client. With
 4585// STOREURL set (m2 tui --storeurl https://magnetosphere.net) nothing is
 4586// read from disk: the catalog comes from /api/products, images from /i,
 4587// the logo from /logo.jpg, and checkout from the same origin β€” exactly
 4588// what the browser build of this TUI will do from the page the store
 4589// serves it on. Without it, the TUI reads the deployment's own files as
 4590// before.
 4591package tui
 4592
 4593import (
 4594	"encoding/json"
 4595	"fmt"
 4596	"io"
 4597	"net/http"
 4598	"net/url"
 4599	"os"
 4600	"strings"
 4601	"sync"
 4602	"time"
 4603
 4604	"github.com/0magnet/m2/pkg/product"
 4605)
 4606
 4607// storeURL is the remote store, normalized; empty means local files.
 4608func storeURL() string {
 4609	return strings.TrimRight(f.Storeurl, "/")
 4610}
 4611
 4612// FetchCatalog loads the product catalog from a store's /api/products.
 4613func FetchCatalog(store string) (product.Products, error) {
 4614	client := &http.Client{Timeout: 30 * time.Second}
 4615	resp, err := client.Get(strings.TrimRight(store, "/") + "/api/products")
 4616	if err != nil {
 4617		return nil, err
 4618	}
 4619	defer resp.Body.Close() //nolint:errcheck // read-side close
 4620	if resp.StatusCode != http.StatusOK {
 4621		return nil, fmt.Errorf("%s from %s/api/products", resp.Status, store)
 4622	}
 4623	var prods product.Products
 4624	if err := json.NewDecoder(io.LimitReader(resp.Body, 32<<20)).Decode(&prods); err != nil {
 4625		return nil, err
 4626	}
 4627	return prods, nil
 4628}
 4629
 4630// FetchSite fills the site identity (name, tagline, telegram links) from
 4631// a store's /api/site β€” the browser build has no MENV file to source.
 4632func FetchSite(store string) error {
 4633	client := &http.Client{Timeout: 15 * time.Second}
 4634	resp, err := client.Get(strings.TrimRight(store, "/") + "/api/site")
 4635	if err != nil {
 4636		return err
 4637	}
 4638	defer resp.Body.Close() //nolint:errcheck // read-side close
 4639	if resp.StatusCode != http.StatusOK {
 4640		return fmt.Errorf("%s from %s/api/site", resp.Status, store)
 4641	}
 4642	var site struct {
 4643		Sitename, Siteext, Sitelongname, Sitetagline string
 4644		Tgcontact, Tgchannel                         string
 4645		Teststripekey                                bool
 4646		Sitemeta, Sitedomain                         string
 4647		Siteprettyname, Siteprettynamecap            string
 4648		Siteprettynamecaps, SiteASCIILogo            string
 4649		Stripepk                                     string
 4650		NavLinks                                     []navLink
 4651		Coin                                         string
 4652	}
 4653	if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&site); err != nil {
 4654		return err
 4655	}
 4656	f.Sitename, f.Siteext = site.Sitename, site.Siteext
 4657	f.Sitelongname, f.Sitetagline = site.Sitelongname, site.Sitetagline
 4658	f.Tgcontact, f.Tgchannel = site.Tgcontact, site.Tgchannel
 4659	f.Teststripekey = site.Teststripekey
 4660	f.Sitemeta, f.Sitedomain = site.Sitemeta, site.Sitedomain
 4661	f.Siteprettyname, f.Siteprettynamecap = site.Siteprettyname, site.Siteprettynamecap
 4662	f.Siteprettynamecaps, f.SiteASCIILogo = site.Siteprettynamecaps, site.SiteASCIILogo
 4663	if site.Stripepk != "" {
 4664		f.StripePK = site.Stripepk
 4665	}
 4666	navLinks = site.NavLinks
 4667	coinOffered = site.Coin != ""
 4668	return nil
 4669}
 4670
 4671// navLink is an entry a server-side drop-in added to the site navigation β€”
 4672// pkg/web's NavLink, as /api/site serves it.
 4673//
 4674// The Etc menu was written out twice, once in htmpl/header.html and once in
 4675// openEtc, and only the template knew about these: the store's Software page
 4676// was in the page's menu and missing from the terminal's. A store client
 4677// cannot see pkg/web's registry, so the server hands it over instead.
 4678type navLink struct {
 4679	Title, Href, Desc string
 4680}
 4681
 4682// coinOffered is whether the store takes coin, which the cart offers as k.
 4683// A store reports it in /api/site; the local TUI, beside its own server,
 4684// offers it and lets the server answer.
 4685var coinOffered = true
 4686
 4687// navLinks is what /api/site last reported. Empty in a deployment with no
 4688// drop-ins, and empty in a local TUI with no store to ask.
 4689var navLinks []navLink
 4690
 4691// fetchContent reads a stock page (about/policy/links): from the store's
 4692// /api/content in client mode, from the deployment's content/ otherwise.
 4693func fetchContent(name string) string {
 4694	s := storeURL()
 4695	if s == "" {
 4696		return contentFile("content/" + name + ".html")
 4697	}
 4698	client := &http.Client{Timeout: 15 * time.Second}
 4699	resp, err := client.Get(s + "/api/content/" + url.PathEscape(name))
 4700	if err != nil {
 4701		return ""
 4702	}
 4703	defer resp.Body.Close() //nolint:errcheck // read-side close
 4704	if resp.StatusCode != http.StatusOK {
 4705		return ""
 4706	}
 4707	data, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
 4708	if err != nil {
 4709		return ""
 4710	}
 4711	return string(data)
 4712}
 4713
 4714// imageCache holds fetched remote images; the render worker re-reads an
 4715// image at every pane size, and the network should pay only once.
 4716var (
 4717	imageCacheMu sync.Mutex
 4718	imageCache   = map[string][]byte{}
 4719)
 4720
 4721// getImageData reads an image by path or URL.
 4722func getImageData(path string) ([]byte, error) {
 4723	if !strings.HasPrefix(path, "http://") && !strings.HasPrefix(path, "https://") {
 4724		return os.ReadFile(path) //nolint:gosec // paths come from the catalog, not the user
 4725	}
 4726	imageCacheMu.Lock()
 4727	data, ok := imageCache[path]
 4728	imageCacheMu.Unlock()
 4729	if ok {
 4730		return data, nil
 4731	}
 4732	client := &http.Client{Timeout: 20 * time.Second}
 4733	// Fetching the URL it was asked for is the point of this function.
 4734	resp, err := client.Get(path) //nolint:gosec
 4735	if err != nil {
 4736		return nil, err
 4737	}
 4738	defer resp.Body.Close() //nolint:errcheck // read-side close
 4739	if resp.StatusCode != http.StatusOK {
 4740		return nil, fmt.Errorf("%s", resp.Status)
 4741	}
 4742	data, err = io.ReadAll(io.LimitReader(resp.Body, 16<<20))
 4743	if err != nil {
 4744		return nil, err
 4745	}
 4746	imageCacheMu.Lock()
 4747	if len(imageCache) > 64 {
 4748		imageCache = map[string][]byte{}
 4749	}
 4750	imageCache[path] = data
 4751	imageCacheMu.Unlock()
 4752	return data, nil
 4753}
 4754
 4755
 4756// ===== pkg/tui/render.go =====
 4757// Package tui pkg/tui/render.go β€” images, the globe, and text conversion.
 4758// Everything renders to cells: product photos become pixel buffers
 4759// painted as β–€ half-blocks (or libcaca canvases blitted glyph by glyph),
 4760// and tcell diffs frames so only changed cells reach the terminal.
 4761package tui
 4762
 4763import (
 4764	"bytes"
 4765	"fmt"
 4766	"image"
 4767	"image/color"
 4768	_ "image/gif"
 4769	_ "image/jpeg"
 4770	_ "image/png"
 4771	"net/url"
 4772	"os"
 4773	"path/filepath"
 4774	"regexp"
 4775	"strconv"
 4776	"strings"
 4777
 4778	xhtml "html"
 4779
 4780	"github.com/gdamore/tcell/v3"
 4781	"golang.org/x/image/draw"
 4782
 4783	"github.com/0magnet/chaosrack/pkg/geom"
 4784	"github.com/0magnet/chaosrack/pkg/rasterview"
 4785	"github.com/0magnet/img2txt-go/caca"
 4786
 4787	"github.com/0magnet/m2/pkg/product"
 4788)
 4789
 4790// imgMode selects how product photos are painted.
 4791type imgMode int
 4792
 4793const (
 4794	modeHalfBlock imgMode = iota // truecolor, two pixels per cell via β–€
 4795	modeCaca                     // libcaca ANSI art, as /logo renders it
 4796)
 4797
 4798// artwork is a rendered product photo: a pixel buffer (half-block mode),
 4799// a cell canvas (caca mode), or a message when there is nothing to show.
 4800type artwork struct {
 4801	rgba  *image.RGBA
 4802	cells *cellArt
 4803	msg   string
 4804}
 4805
 4806// cellArt is a libcaca canvas as terminal cells.
 4807type cellArt struct {
 4808	w, h int
 4809	ch   []rune
 4810	st   []tcell.Style
 4811}
 4812
 4813// imagePath resolves a product photo the way the templates do:
 4814// img/<category>/<image1> (from the store's /i in client mode), falling
 4815// back locally to img/<image1>. A nil product (nothing selected yet β€”
 4816// an empty table's preview) has no image.
 4817func imagePath(p *product.Product) string {
 4818	if p == nil || p.Image1 == "" {
 4819		return ""
 4820	}
 4821	if s := storeURL(); s != "" {
 4822		return s + "/i/" + url.PathEscape(p.Category) + "/" + url.PathEscape(p.Image1)
 4823	}
 4824	for _, path := range []string{
 4825		filepath.Join("img", p.Category, p.Image1),
 4826		filepath.Join("img", p.Image1),
 4827	} {
 4828		if _, err := os.Stat(path); err == nil {
 4829			return path
 4830		}
 4831	}
 4832	return ""
 4833}
 4834
 4835// datasheetURL resolves a product datasheet the way product.html does:
 4836// <ImgSRC>/pdf/<name>, which is /i/pdf/<name> on the store's own origin.
 4837// Without a store there is no origin to build on, so the local path the
 4838// file would be served from is what a click has to offer instead.
 4839func datasheetURL(name string) string {
 4840	if name == "" {
 4841		return ""
 4842	}
 4843	if s := storeURL(); s != "" {
 4844		return s + "/i/pdf/" + url.PathEscape(name)
 4845	}
 4846	return filepath.Join("img", "pdf", name)
 4847}
 4848
 4849func loadImage(path string) (image.Image, error) {
 4850	data, err := getImageData(path)
 4851	if err != nil {
 4852		return nil, err
 4853	}
 4854	img, _, err := image.Decode(bytes.NewReader(data))
 4855	return img, err
 4856}
 4857
 4858// scaleToFit scales img into a pixel buffer no larger than maxW x maxH,
 4859// preserving aspect. Half-block pixels are roughly square.
 4860func scaleToFit(img image.Image, maxW, maxH int) *image.RGBA {
 4861	b := img.Bounds()
 4862	pw, ph := b.Dx(), b.Dy()
 4863	if pw*maxH > ph*maxW {
 4864		ph = ph * maxW / pw
 4865		pw = maxW
 4866	} else {
 4867		pw = pw * maxH / ph
 4868		ph = maxH
 4869	}
 4870	if pw < 1 {
 4871		pw = 1
 4872	}
 4873	if ph < 1 {
 4874		ph = 1
 4875	}
 4876	dst := image.NewRGBA(image.Rect(0, 0, pw, ph))
 4877	draw.ApproxBiLinear.Scale(dst, dst.Bounds(), img, b, draw.Src, nil)
 4878	return dst
 4879}
 4880
 4881// renderArt produces a product photo sized to fit cols x rows cells.
 4882func renderArt(path string, cols, rows int, mode imgMode) artwork {
 4883	if path == "" {
 4884		return artwork{msg: "no image"}
 4885	}
 4886	if cols < 2 || rows < 2 {
 4887		return artwork{msg: "pane too small"}
 4888	}
 4889	if mode == modeCaca {
 4890		cells, err := renderCaca(path, cols, rows)
 4891		if err != nil {
 4892			return artwork{msg: err.Error()}
 4893		}
 4894		return artwork{cells: cells}
 4895	}
 4896	img, err := loadImage(path)
 4897	if err != nil {
 4898		return artwork{msg: err.Error()}
 4899	}
 4900	if img.Bounds().Dx() == 0 || img.Bounds().Dy() == 0 {
 4901		return artwork{msg: "empty image"}
 4902	}
 4903	return artwork{rgba: scaleToFit(img, cols, rows*2)}
 4904}
 4905
 4906// renderCaca draws an image as libcaca ANSI art β€” the same renderer
 4907// behind the /logo endpoint, via the pure-Go img2txt port β€” and reads
 4908// the canvas cells straight into terminal cells.
 4909func renderCaca(path string, cols, rows int) (*cellArt, error) {
 4910	data, err := getImageData(path)
 4911	if err != nil {
 4912		return nil, err
 4913	}
 4914	im, err := caca.DecodeImage(bytes.NewReader(data))
 4915	if err != nil {
 4916		return nil, err
 4917	}
 4918	if im.W == 0 || im.H == 0 {
 4919		return nil, fmt.Errorf("bad dimensions")
 4920	}
 4921	// img2txt's 6x10 font-cell aspect, constrained by both axes.
 4922	lines := cols * im.H * 6 / im.W / 10
 4923	if lines > rows {
 4924		lines = rows
 4925		cols = lines * im.W * 10 / im.H / 6
 4926	}
 4927	if lines < 1 || cols < 1 {
 4928		return nil, fmt.Errorf("pane too small")
 4929	}
 4930	cv := caca.NewCanvas(cols, lines)
 4931	cv.SetColorANSI(caca.Default, caca.Transparent)
 4932	cv.Clear()
 4933	im.Dither.SetAlgorithm("fstein")
 4934	cv.DitherBitmap(0, 0, cols, lines, im.Dither, im.Pixels)
 4935
 4936	art := &cellArt{w: cv.Width, h: cv.Height,
 4937		ch: make([]rune, cv.Width*cv.Height), st: make([]tcell.Style, cv.Width*cv.Height)}
 4938	for i, ch := range cv.Chars {
 4939		art.ch[i] = ch
 4940		art.st[i] = tcell.StyleDefault.
 4941			Foreground(rgb12(caca.AttrToRGB12Fg(cv.Attrs[i]))).
 4942			Background(rgb12(caca.AttrToRGB12Bg(cv.Attrs[i])))
 4943	}
 4944	return art, nil
 4945}
 4946
 4947// rgb12 expands libcaca's 12-bit 0xRGB into a tcell color.
 4948func rgb12(v uint16) tcell.Color {
 4949	return tcell.NewRGBColor(
 4950		int32((v>>8&0xF)*17), int32((v>>4&0xF)*17), int32((v&0xF)*17))
 4951}
 4952
 4953// drawArtwork paints a rendered photo centered in r, and says which cells
 4954// the photo took (false when there was no photo to draw).
 4955func drawArtwork(sc tcell.Screen, r rect, art *artwork) (rect, bool) {
 4956	switch {
 4957	case art == nil:
 4958	case art.rgba != nil:
 4959		rows := (art.rgba.Bounds().Dy() + 1) / 2
 4960		yoff := (r.h - rows) / 2
 4961		if yoff < 0 {
 4962			yoff = 0
 4963		}
 4964		drawRGBA(sc, r.x, r.y+yoff, r.w, r.h-yoff, art.rgba)
 4965		pw := art.rgba.Bounds().Dx()
 4966		xoff := 0
 4967		if r.w > pw {
 4968			xoff = (r.w - pw) / 2
 4969		}
 4970		return rect{r.x + xoff, r.y + yoff, min(pw, r.w), min(rows, r.h-yoff)}, true
 4971	case art.cells != nil:
 4972		xoff := (r.w - art.cells.w) / 2
 4973		yoff := (r.h - art.cells.h) / 2
 4974		if xoff < 0 {
 4975			xoff = 0
 4976		}
 4977		if yoff < 0 {
 4978			yoff = 0
 4979		}
 4980		for y := 0; y < art.cells.h && y < r.h; y++ {
 4981			for x := 0; x < art.cells.w && x < r.w; x++ {
 4982				i := y*art.cells.w + x
 4983				sc.SetContent(r.x+xoff+x, r.y+yoff+y, art.cells.ch[i], nil, art.cells.st[i])
 4984			}
 4985		}
 4986		return rect{r.x + xoff, r.y + yoff, min(art.cells.w, r.w), min(art.cells.h, r.h)}, true
 4987	case art.msg != "":
 4988		printMarkupCenter(sc, r.x, r.y+r.h/2, r.w, "[gray]"+esc(art.msg)+"[-]", styleText)
 4989	}
 4990	return rect{}, false
 4991}
 4992
 4993// The home panel: a wireframe globe turning over the site logo, as the
 4994// website's landing view renders its wasm globe over the svg logo.
 4995
 4996// makeBackdrop scales the logo to one panel size.
 4997func makeBackdrop(logo image.Image, cols, rows int) *image.RGBA {
 4998	if logo == nil || cols <= 0 || rows <= 0 {
 4999		if cols < 1 {
 5000			cols = 1
 5001		}
 5002		if rows < 1 {
 5003			rows = 1
 5004		}
 5005		return image.NewRGBA(image.Rect(0, 0, cols, rows*2))
 5006	}
 5007	// Full brightness, as the website shows it: the svg behind the canvas
 5008	// is not dimmed there, so its white stays white here too. The globe
 5009	// reads on top by its own saturation, the way it does on the page.
 5010	return scaleToFit(logo, cols, rows*2)
 5011}
 5012
 5013// globeLines is the globe the website's attractor engine uploads to
 5014// WebGL β€” same geometry, from the same package.
 5015var globeLines = geom.Globe(18, 36, 60)
 5016
 5017// globeFrame composites one animation frame: the dimmed logo with the
 5018// chaosrack globe drawn over it by chaosrack's own software renderer, in
 5019// its default look — random pose, red→blue gradient along model z (see
 5020// rasterview.DefaultGradient). BackDim is the one departure from the
 5021// WebGL look: an unantialiased raster needs the depth cue.
 5022func globeFrame(backdrop *image.RGBA, ax, ay, az float64) *image.RGBA {
 5023	frame := image.NewRGBA(backdrop.Bounds())
 5024	copy(frame.Pix, backdrop.Pix)
 5025	view := rasterview.View{AngleX: ax, AngleY: ay, AngleZ: az, Dist: 3.2, BackDim: 0.5}
 5026	view.Render(frame, globeLines.Vertices, globeLines.Indices, rasterview.DefaultGradient())
 5027	return frame
 5028}
 5029
 5030// drawRGBA paints a pixel buffer into terminal cells at x0,y0, two pixels
 5031// per cell via β–€, centered in cols columns. Cells unchanged since the
 5032// last frame cost nothing: tcell diffs them away.
 5033func drawRGBA(sc tcell.Screen, x0, y0, cols, maxRows int, img *image.RGBA) {
 5034	pw, ph := img.Bounds().Dx(), img.Bounds().Dy()
 5035	xoff := 0
 5036	if cols > pw {
 5037		xoff = (cols - pw) / 2
 5038	}
 5039	for y := 0; y < ph; y += 2 {
 5040		if y/2 >= maxRows {
 5041			break
 5042		}
 5043		for x := 0; x < pw; x++ {
 5044			if xoff+x >= cols {
 5045				break
 5046			}
 5047			top := img.RGBAAt(x, y)
 5048			var bot color.RGBA
 5049			if y+1 < ph {
 5050				bot = img.RGBAAt(x, y+1)
 5051			}
 5052			st := tcell.StyleDefault.
 5053				Foreground(tcell.NewRGBColor(step16(top.R), step16(top.G), step16(top.B))).
 5054				Background(tcell.NewRGBColor(step16(bot.R), step16(bot.G), step16(bot.B)))
 5055			sc.SetContent(x0+xoff+x, y0+y/2, 'β–€', nil, st)
 5056		}
 5057	}
 5058}
 5059
 5060var (
 5061	tmplAction = regexp.MustCompile(`\{\{[^}]*\}\}`)
 5062	tagAnchor  = regexp.MustCompile(`(?is)<a\b([^>]*)>(.*?)</a>`)
 5063	attrHref   = regexp.MustCompile(`(?is)\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))`)
 5064	tagBreak   = regexp.MustCompile(`(?i)<br\s*/?>`)
 5065	tagBlock   = regexp.MustCompile(`(?i)</(p|h1|h2|h3|div|tr|li|ul|table|pre)>`)
 5066	tagHeading = regexp.MustCompile(`(?is)<h[1-3][^>]*>(.*?)</h[1-3]>`)
 5067	tagLi      = regexp.MustCompile(`(?i)<li[^>]*>`)
 5068	tagCell    = regexp.MustCompile(`(?i)</t[dh]>`)
 5069	tagAny     = regexp.MustCompile(`(?s)<[^>]*>`)
 5070	manyBlank  = regexp.MustCompile(`\n{3,}`)
 5071)
 5072
 5073// htmlToText renders a stock-page fragment (about/policy/links) as
 5074// terminal text: links cyan like the site, headings bold, tags dropped.
 5075//
 5076// It also returns each anchor's href, in the order the [link=N] tags it
 5077// wrote number them, so the page can make them followable. The Links
 5078// page is a page of nothing but links; cyan text that does nothing is
 5079// not a rendering of it.
 5080//
 5081// width is the column count the caller will draw into; tables are laid out
 5082// to fit it. See htmltable.go.
 5083func htmlToText(s, year string, width int) (string, []string) {
 5084	s = strings.ReplaceAll(s, "{{.Page.Year}}", year)
 5085	s = tmplAction.ReplaceAllString(s, "")
 5086	s = strings.ReplaceAll(s, "[", "[[") // user HTML must not become tags
 5087	s = tagHeading.ReplaceAllString(s, "\n[::b]$1[-:-:-]\n")
 5088	var hrefs []string
 5089	s = tagAnchor.ReplaceAllStringFunc(s, func(m string) string {
 5090		g := tagAnchor.FindStringSubmatch(m)
 5091		href := ""
 5092		if a := attrHref.FindStringSubmatch(g[1]); a != nil {
 5093			href = xhtml.UnescapeString(a[1] + a[2] + a[3]) // only one group matches
 5094		}
 5095		hrefs = append(hrefs, href)
 5096		return linkTag(len(hrefs), "[aqua]"+g[2]+"[-]")
 5097	})
 5098	// Tables come out whole and are drawn last. The flattening below would
 5099	// otherwise reduce their cells to runs of spaces, and drawing them first
 5100	// would feed the finished columns straight back into it.
 5101	var tables []string
 5102	s = tagTable.ReplaceAllStringFunc(s, func(m string) string {
 5103		tables = append(tables, tagTable.FindStringSubmatch(m)[1])
 5104		return "\x00T" + strconv.Itoa(len(tables)-1) + "\x00"
 5105	})
 5106	s = tagBreak.ReplaceAllString(s, "\n")
 5107	s = tagLi.ReplaceAllString(s, "  * ")
 5108	s = tagCell.ReplaceAllString(s, "  ")
 5109	s = tagBlock.ReplaceAllString(s, "\n")
 5110	s = tagAny.ReplaceAllString(s, "")
 5111	s = xhtml.UnescapeString(s)
 5112	s = manyBlank.ReplaceAllString(s, "\n\n")
 5113	for i, inner := range tables {
 5114		s = strings.Replace(s, "\x00T"+strconv.Itoa(i)+"\x00", "\n"+renderTable(inner, width), 1)
 5115	}
 5116	return strings.TrimSpace(s) + "\n", hrefs
 5117}
 5118
 5119// step16 snaps a channel to 16 levels, spanning the full range: 0x00 stays
 5120// 0x00 and 0xFF stays 0xFF, so the logo's black and white are untouched and
 5121// only the interpolated greys move, by at most 1/32 of the range.
 5122//
 5123// This is a rendering cost, not an aesthetic choice. The WebGL renderer
 5124// caches rasterised glyphs under a key that includes both colors, so a
 5125// truecolor half-block is a distinct glyph per color pair. A full-screen
 5126// gradient at ~11k cells hands it ~11k keys a frame against an atlas of
 5127// ~18k slots; it fills, clears, and re-rasterises everything, every frame.
 5128// Quantising collapses the key space enough for the cache to hold, which
 5129// measured out at roughly half the CPU and less than half the worst-case
 5130// frame time.
 5131func step16(v uint8) int32 { return int32(v&0xF0 | v>>4) }
 5132
 5133
 5134// ===== pkg/tui/tui.go =====
 5135// Package tui pkg/tui/tui.go β€” a terminal view of the web store, derived
 5136// from the website itself: the same masthead, the catsubcats navigation
 5137// tree, one panel at a time like the front page's :target panels, the
 5138// category tables, product pages in template order, the stock content
 5139// pages, and the footer bar. The landing view is the site's landing
 5140// view: a wireframe globe turning over the logo.
 5141//
 5142// Written directly on tcell v3 with the small widget kit in widgets.go β€”
 5143// the same terminal stack as the rest of the 0magnet ecosystem, so the
 5144// browser build can ride tuiwasm/xtcell.
 5145package tui
 5146
 5147import (
 5148	"context"
 5149	"embed"
 5150	"fmt"
 5151	"image"
 5152	"math"
 5153	"math/rand"
 5154	"os"
 5155	"strconv"
 5156	"strings"
 5157	"sync"
 5158	"sync/atomic"
 5159	"time"
 5160
 5161	"github.com/gdamore/tcell/v3"
 5162
 5163	"github.com/0magnet/calvin"
 5164
 5165	"github.com/0magnet/m2/pkg/config"
 5166	"github.com/0magnet/m2/pkg/product"
 5167	"github.com/0magnet/m2/pkg/storepage"
 5168)
 5169
 5170//go:embed *.go
 5171var Source embed.FS
 5172
 5173// f aliases the shared configuration, as in pkg/web.
 5174var f = &config.F
 5175
 5176// site styles, from content/style.css
 5177var (
 5178	styleText  = tcell.StyleDefault.Foreground(tcell.ColorWhite.TrueColor()).Background(tcell.ColorBlack.TrueColor())
 5179	styleLink  = styleText.Foreground(tcell.ColorAqua.TrueColor())                                                 // a{color:cyan}
 5180	colorHover = tcell.ColorGreen.TrueColor()                                                                      // a:hover{color:green}
 5181	styleSel   = tcell.StyleDefault.Foreground(tcell.GetColor("#ffa500")).Background(tcell.ColorWhite.TrueColor()) // a.cur
 5182	// .✞ thead{background-color:#6a1b9a;color:white} β€” a filled band, not
 5183	// underlined text. Underlined purple read as a link, which those
 5184	// column titles are not.
 5185	styleThead  = tcell.StyleDefault.Foreground(tcell.ColorWhite.TrueColor()).Background(tcell.GetColor("#6a1b9a")).Bold(true)
 5186	styleBorder = styleText.Foreground(tcell.ColorBlue.TrueColor())
 5187	styleField  = tcell.StyleDefault.Foreground(tcell.ColorWhite.TrueColor()).Background(tcell.GetColor("#000087"))
 5188	styleButton = tcell.StyleDefault.Foreground(tcell.ColorWhite.TrueColor()).Background(tcell.GetColor("#005f87"))
 5189
 5190	// The header and footer tables are closed by a 1px blue border on the
 5191	// page; their cell edges are white. Terminal rules stand in for both.
 5192	ruleBlue  = styleText.Foreground(tcell.GetColor("#0000ff"))
 5193	ruleWhite = styleText.Foreground(tcell.ColorWhite.TrueColor())
 5194)
 5195
 5196// panel is one of the site's :target panels.
 5197type panel interface {
 5198	draw(u *ui, sc tcell.Screen, r rect)
 5199	key(u *ui, ev *tcell.EventKey) bool
 5200	mouse(u *ui, r rect, ev *tcell.EventMouse) bool
 5201}
 5202
 5203// overlayW is a commerce overlay (cart / shipping / checkout).
 5204type overlayW interface {
 5205	draw(u *ui, sc tcell.Screen, r rect)
 5206	key(u *ui, ev *tcell.EventKey) bool
 5207	size() (w, h int)
 5208	title() string
 5209}
 5210
 5211// linkRegion is one anchor on the drawn page: the cells it occupies and
 5212// what following it does.
 5213type linkRegion struct {
 5214	x, y, w int
 5215	act     func()
 5216}
 5217
 5218// dropState is an open header dropdown.
 5219type dropState struct {
 5220	kind    string // "cats" | "etc"
 5221	lst     *list
 5222	targets []navTarget // cats only
 5223	width   int
 5224}
 5225
 5226// artHolder carries one asynchronously rendered product photo.
 5227type artHolder struct {
 5228	key  string // what art holds
 5229	want string // what was last requested
 5230	art  *artwork
 5231}
 5232
 5233type artReq struct {
 5234	key    string
 5235	path   string
 5236	w, h   int
 5237	mode   imgMode
 5238	holder *artHolder
 5239}
 5240
 5241type ui struct {
 5242	screen tcell.Screen
 5243	cat    *catalogInfo
 5244	quit   bool
 5245
 5246	panels  map[string]panel
 5247	history []string
 5248	fwd     []string
 5249
 5250	drop     *dropState
 5251	commerce overlayW
 5252	notice   string
 5253
 5254	// postMu gates post against RunOn's shutdown closing the queue.
 5255	postMu   sync.Mutex
 5256	finished bool
 5257
 5258	// header nav and footer summary click regions, rebuilt each draw
 5259	navRegions []struct {
 5260		x1, x2 int
 5261		act    string
 5262	}
 5263	footRegions []struct {
 5264		x1, x2 int
 5265		act    string
 5266	}
 5267
 5268	// links are the anchors the visible page drew, rebuilt every draw and
 5269	// consulted before the panel sees a click. On the website every one of
 5270	// these is an <a>; here it is a run of cells with something to do.
 5271	links []linkRegion
 5272
 5273	// hoverX, hoverY is the cell the pointer was last seen over, which is
 5274	// what a:hover needs. -1 means the pointer has not been anywhere yet.
 5275	hoverX, hoverY int
 5276
 5277	// bare drops the pisano frame around the product table, giving its
 5278	// rows back to the table. Toggled with d.
 5279	bare bool
 5280
 5281	// image rendering
 5282	mode  imgMode
 5283	reqCh chan artReq
 5284
 5285	// expanded holds which categories' branches are open in the
 5286	// Categories dropdown, like the template's <details open>.
 5287	expanded map[string]bool
 5288
 5289	// home animation. The ticker goroutine computes frame (a pixel
 5290	// buffer) and the home panel paints it; both sides go through mu.
 5291	// current names the visible panel; the goroutine reads it too.
 5292	mu       sync.Mutex
 5293	current  string
 5294	logo     image.Image
 5295	homeW    int
 5296	homeH    int
 5297	frame    *image.RGBA
 5298	backdrop *image.RGBA
 5299	bdW, bdH int
 5300	// pose randomized at startup like chaosrack's randomizeOrientation,
 5301	// then tumbled by a random rate on every axis.
 5302	poseX, poseY, poseZ float64
 5303	rateX, rateY, rateZ float64
 5304	done                chan struct{}
 5305	// Placements (place.go): what this frame wants over its cells, and
 5306	// what the terminal has.
 5307	placeWant, placeSent map[string]placed
 5308	// cellsOnly keeps to the cells: no placements, as in a terminal without
 5309	// them. Toggled with w.
 5310	cellsOnly bool
 5311	// overlaysDrawn counts the overlays drawn so far this frame; see placed.above.
 5312	overlaysDrawn int
 5313	// homeCovered: the page's globe is over the home panel's cells (set by
 5314	// placeSync, read by animateHome), so they need not turn.
 5315	homeCovered atomic.Bool
 5316
 5317	asciiLines []string
 5318	caps       string
 5319
 5320	// cart, as the footer's View Cart
 5321	cartOrder []string
 5322	cartQty   map[string]int
 5323	shipping  *storepage.Shipping
 5324
 5325	sitedomain string
 5326	// announced is the page path last told to the host (address.go).
 5327	announced string
 5328	// kittyIDs and kittyCache: pictures for kitty's graphics protocol
 5329	// (kittyimg.go).
 5330	kittyIDs   map[string]uint32
 5331	kittyCache map[string][]byte
 5332	masthead   [3]string
 5333}
 5334
 5335// screenKey carries an alternative way of getting a screen.
 5336type screenKey struct{}
 5337
 5338// WithScreen tells Run how to obtain its screen, for a host where
 5339// tcell's own answer is the wrong one.
 5340//
 5341// This is what lets `m2 tui` be the same command in both places. In a
 5342// terminal it opens a tcell screen on the tty and there is nothing to
 5343// say; in a browser the screen has to be an xtcell one bound to the
 5344// particular terminal and element the command was typed into, which
 5345// only the page knows. Rather than a second command for the browser β€”
 5346// which is what there used to be, and which promptly drifted β€” the page
 5347// puts the answer here and runs the real one.
 5348//
 5349// A function and not a screen: most commands never need one, and
 5350// building it takes the terminal over.
 5351func WithScreen(ctx context.Context, newScreen func() (tcell.Screen, error)) context.Context {
 5352	return context.WithValue(ctx, screenKey{}, newScreen)
 5353}
 5354
 5355// screenFrom returns the screen ctx asks for, or nil for tcell's own.
 5356func screenFrom(ctx context.Context) func() (tcell.Screen, error) {
 5357	if ctx == nil {
 5358		return nil
 5359	}
 5360	fn, _ := ctx.Value(screenKey{}).(func() (tcell.Screen, error))
 5361	return fn
 5362}
 5363
 5364// Run browses the store in a full-screen terminal UI until the user
 5365// quits, on the screen ctx names or on a new tcell one.
 5366func Run(ctx context.Context, prods product.Products) error {
 5367	if newScreen := screenFrom(ctx); newScreen != nil {
 5368		sc, err := newScreen()
 5369		if err != nil {
 5370			return err
 5371		}
 5372		return RunOn(sc, prods)
 5373	}
 5374	probeTerminal() // what this terminal can do beyond cells (probe_other.go)
 5375	sc, err := tcell.NewScreen()
 5376	if err != nil {
 5377		return err
 5378	}
 5379	if err := sc.Init(); err != nil {
 5380		return err
 5381	}
 5382	return RunOn(sc, prods)
 5383}
 5384
 5385// RunOn drives the store UI on an initialized screen until the user
 5386// quits, then finalizes it.
 5387func RunOn(sc tcell.Screen, prods product.Products) error {
 5388	u := &ui{
 5389		cat:      newCatalogInfo(prods),
 5390		panels:   map[string]panel{},
 5391		mode:     modeHalfBlock,
 5392		reqCh:    make(chan artReq, 1),
 5393		expanded: map[string]bool{},
 5394		done:     make(chan struct{}),
 5395		cartQty:  map[string]int{},
 5396		hoverX:   -1,
 5397		hoverY:   -1,
 5398	}
 5399	u.sitedomain = f.Sitename + f.Siteext
 5400	if u.sitedomain == "" {
 5401		u.sitedomain = "m2"
 5402	}
 5403	u.loadCart()
 5404	u.offerWidgets()
 5405	u.asciiLines = strings.Split(strings.TrimRight(calvin.AsciiFont(u.sitedomain), "\n"), "\n")
 5406	// The page sets this line in blackboard bold, and the terminal cannot:
 5407	// the double-struck capitals live outside the BMP and no monospace font
 5408	// here carries them, so every one of them falls back to a face whose
 5409	// glyph is nearly twice the cell it is given and collides with its
 5410	// neighbor. Plain capitals are the same words, drawn on the grid.
 5411	u.caps = strings.ToUpper(u.sitedomain)
 5412	long := f.Sitelongname
 5413	if long == "" {
 5414		long = u.sitedomain
 5415	}
 5416	tag := ""
 5417	if f.Sitetagline != "" {
 5418		tag = "- " + esc(f.Sitetagline) + " -"
 5419	}
 5420	u.masthead = [3]string{"[::b]" + esc(long) + "[-:-:-]", "[gray]" + tag + "[-]", checkerboard(u.sitedomain)}
 5421
 5422	// A fresh random starting pose each run, as the website gives each
 5423	// page load β€” and a random spin rate per axis, each with a floor so
 5424	// all three visibly contribute, the vector normalized so the overall
 5425	// speed matches auto-rotate's.
 5426	u.poseX = rand.Float64()*2*math.Pi - math.Pi //nolint:gosec
 5427	u.poseY = rand.Float64()*2*math.Pi - math.Pi //nolint:gosec
 5428	u.poseZ = rand.Float64()*2*math.Pi - math.Pi //nolint:gosec
 5429	randRate := func() float64 {
 5430		r := 0.35 + 0.65*rand.Float64() //nolint:gosec
 5431		if rand.Intn(2) == 0 {          //nolint:gosec
 5432			return -r
 5433		}
 5434		return r
 5435	}
 5436	u.rateX, u.rateY, u.rateZ = randRate(), randRate(), randRate()
 5437	norm := math.Sqrt(u.rateX*u.rateX + u.rateY*u.rateY + u.rateZ*u.rateZ)
 5438	const pace = 0.042 // auto-rotate's 0.3 rad/s at the 140ms ticker
 5439	u.rateX, u.rateY, u.rateZ = u.rateX/norm*pace, u.rateY/norm*pace, u.rateZ/norm*pace
 5440
 5441	logos := []string{"logo.jpg", "logo.png"}
 5442	if s := storeURL(); s != "" {
 5443		logos = []string{s + "/logo.jpg", s + "/logo.png"}
 5444	}
 5445	for _, p := range logos {
 5446		if img, err := loadImage(p); err == nil {
 5447			u.logo = img
 5448			break
 5449		}
 5450	}
 5451
 5452	u.screen = sc
 5453	sc.SetStyle(styleText)
 5454	sc.EnableMouse()
 5455
 5456	u.panels["home"] = &homePanel{}
 5457	u.current = "home"
 5458
 5459	go u.renderWorker()
 5460	go u.animateHome()
 5461	if p := u.pageFromPath(StartPath); p != "" && p != "home" {
 5462		u.show(p) // a link opened the store at this page
 5463	}
 5464	StartPath = "" // once: the store run again starts at home
 5465
 5466	defer func() {
 5467		// Stop accepting posts before the screen closes the event queue
 5468		// under them β€” a ticker mid-post must not race the close.
 5469		u.postMu.Lock()
 5470		u.finished = true
 5471		u.postMu.Unlock()
 5472		u.placeClear() // the placements go with the program
 5473		sc.Fini()
 5474		close(u.done)
 5475	}()
 5476	events := sc.EventQ()
 5477	u.draw()
 5478	for !u.quit {
 5479		ev, ok := <-events
 5480		if !ok {
 5481			break
 5482		}
 5483		switch e := ev.(type) {
 5484		case *tcell.EventInterrupt:
 5485			if fn, ok := e.Data().(func()); ok && fn != nil {
 5486				fn()
 5487			}
 5488		case *tcell.EventResize:
 5489			sc.Sync()
 5490			clear(u.placeSent) // the full redraw erased what was laid over the cells; lay it again
 5491		case *tcell.EventKey:
 5492			// v3 reports key releases too where the terminal can; a
 5493			// shortcut must fire once, on the press.
 5494			if e.Pressed() {
 5495				u.handleKey(e)
 5496			}
 5497		case *tcell.EventMouse:
 5498			u.handleMouse(e)
 5499		}
 5500		u.draw()
 5501	}
 5502	return nil
 5503}
 5504
 5505// animation ticker use it. Non-blocking and refused after shutdown, so
 5506// a producer can never race the queue's close nor wedge against a loop
 5507// that has already exited.
 5508func (u *ui) post(fn func()) {
 5509	u.postMu.Lock()
 5510	defer u.postMu.Unlock()
 5511	if u.finished {
 5512		return
 5513	}
 5514	select {
 5515	case u.screen.EventQ() <- tcell.NewEventInterrupt(fn):
 5516	default:
 5517	}
 5518}
 5519
 5520// ---- drawing ----
 5521
 5522// linkSink returns the callback the markup printers report anchors to,
 5523// binding each link index to the page's own table of what its links do.
 5524// A page builds that table as it builds its markup; see linkTag.
 5525func (u *ui) linkSink(acts []func()) func(x, y, w, link int) {
 5526	return func(x, y, w, link int) {
 5527		if w <= 0 || link < 1 || link > len(acts) || acts[link-1] == nil {
 5528			return
 5529		}
 5530		u.links = append(u.links, linkRegion{x, y, w, acts[link-1]})
 5531	}
 5532}
 5533
 5534// hovered is the run of cells the pointer is over, if it is over one of
 5535// the page's links: the header nav, the footer's two cells, or an anchor
 5536// the visible panel drew.
 5537func (u *ui) hovered(h int) (x1, x2, y int, ok bool) {
 5538	switch {
 5539	case u.hoverY < 0:
 5540	case u.hoverY == 0:
 5541		for _, reg := range u.navRegions {
 5542			if u.hoverX >= reg.x1 && u.hoverX < reg.x2 {
 5543				return reg.x1, reg.x2, 0, true
 5544			}
 5545		}
 5546	case u.hoverY == h-1:
 5547		for _, reg := range u.footRegions {
 5548			if u.hoverX >= reg.x1 && u.hoverX < reg.x2 {
 5549				return reg.x1, reg.x2, h - 1, true
 5550			}
 5551		}
 5552	default:
 5553		for _, l := range u.links {
 5554			if u.hoverY == l.y && u.hoverX >= l.x && u.hoverX < l.x+l.w {
 5555				return l.x, l.x + l.w, l.y, true
 5556			}
 5557		}
 5558	}
 5559	return 0, 0, 0, false
 5560}
 5561
 5562// paintHover recolors the link under the pointer, as a:hover{color:green}
 5563// does on the page.
 5564//
 5565// It repaints cells the page has already drawn rather than styling them
 5566// as they go, because which cells belong to a link is only known once it
 5567// is drawn β€” and this way no drawing code has to know where the pointer
 5568// is. It runs before the overlays, so an open dropdown covers it rather
 5569// than being recolored by it.
 5570func (u *ui) paintHover(sc tcell.Screen, h int) {
 5571	x1, x2, y, ok := u.hovered(h)
 5572	if !ok {
 5573		return
 5574	}
 5575	for x := x1; x < x2; x++ {
 5576		str, st, _ := sc.Get(x, y)
 5577		sc.Put(x, y, str, st.Foreground(colorHover))
 5578	}
 5579}
 5580
 5581func (u *ui) draw() {
 5582	sc := u.screen
 5583	w, h := sc.Size()
 5584	if w < 4 || h < 8 {
 5585		sc.Show()
 5586		return
 5587	}
 5588	sc.HideCursor()
 5589	u.links = u.links[:0]
 5590	u.overlaysDrawn = 0
 5591	fillRect(sc, rect{0, 0, w, h}, styleText)
 5592	u.drawHeader(sc, w)
 5593	// Row 1 is the header table's bottom border, so the masthead starts
 5594	// below it, as the page's title block sits under the nav rule.
 5595	for i, line := range u.masthead {
 5596		printMarkupCenter(sc, 0, 2+i, w, line, styleText)
 5597	}
 5598	content := rect{0, 5, w, h - 8}
 5599	u.mu.Lock()
 5600	cur := u.current
 5601	u.mu.Unlock()
 5602	if p := u.panels[cur]; p != nil {
 5603		p.draw(u, sc, content)
 5604	}
 5605	u.drawFooter(sc, w, h)
 5606	u.paintHover(sc, h)
 5607	var overlays []rect // what is drawn over the panel, which no placement may cover
 5608	if u.commerce != nil {
 5609		r := u.commerceRect()
 5610		overlays = append(overlays, r)
 5611		drawBox(sc, r, "[white] "+u.commerce.title()+" [-]", styleText.Foreground(tcell.ColorWhite.TrueColor()), styleText)
 5612		u.overlaysDrawn = len(overlays)
 5613		u.commerce.draw(u, sc, r.inner())
 5614	}
 5615	if u.drop != nil {
 5616		r := u.dropRect()
 5617		overlays = append(overlays, r)
 5618		drawBox(sc, r, "[white] "+map[string]string{"cats": "Categories", "etc": "Etc..."}[u.drop.kind]+" [-]", styleBorder, styleText)
 5619		u.drop.lst.draw(sc, r.inner())
 5620	}
 5621	if u.notice != "" {
 5622		nw := markupWidth(u.notice) + 6
 5623		if nw > w-4 {
 5624			nw = w - 4
 5625		}
 5626		r := rect{(w - nw) / 2, h/2 - 2, nw, 5}
 5627		overlays = append(overlays, r)
 5628		drawBox(sc, r, "", styleText.Foreground(tcell.ColorWhite.TrueColor()), styleText)
 5629		printMarkupCenter(sc, r.x+1, r.y+2, r.w-2, u.notice, styleText)
 5630	}
 5631	sc.Show()
 5632	u.placeSync(overlays)
 5633	u.announcePage()
 5634}
 5635
 5636// drawHeader lays the nav out as the website's header table does: three
 5637// equal columns β€” Categories, Home, Etc... β€” each label centered in its
 5638// third, with its dropdown opening beneath it. The current section is
 5639// cyan and underlined and the rest are white, as the page renders its
 5640// links, and the row is closed by the header table's blue bottom border.
 5641func (u *ui) drawHeader(sc tcell.Screen, w int) {
 5642	entries := []struct{ label, act string }{
 5643		{"Categories", "cats"}, {"Home", "home"}, {"Etc...", "etc"},
 5644	}
 5645	active := u.navActive()
 5646	u.navRegions = u.navRegions[:0]
 5647	third := w / 3
 5648	for i, e := range entries {
 5649		x := i*third + (third-len(e.label))/2
 5650		if x < 0 {
 5651			x = 0
 5652		}
 5653		tag := "[white]"
 5654		if e.act == active {
 5655			tag = "[aqua::u]"
 5656		}
 5657		printMarkup(sc, x, 0, w-x, tag+e.label+"[-:-:-]", styleText)
 5658		u.navRegions = append(u.navRegions, struct {
 5659			x1, x2 int
 5660			act    string
 5661		}{x, x + len(e.label), e.act})
 5662	}
 5663	drawRule(sc, 0, 1, w, ruleBlue)
 5664}
 5665
 5666// navActive names the header entry for the page on screen, the way the
 5667// site marks the section you are in: the catalog pages belong to
 5668// Categories, the stock pages to Etc..., everything else to Home.
 5669func (u *ui) navActive() string {
 5670	u.mu.Lock()
 5671	cur := u.current
 5672	u.mu.Unlock()
 5673	switch {
 5674	case strings.HasPrefix(cur, "list:"), strings.HasPrefix(cur, "prod:"):
 5675		return "cats"
 5676	case strings.HasPrefix(cur, "content:"):
 5677		return "etc"
 5678	}
 5679	return "home"
 5680}
 5681
 5682// drawFooter is the website's fixed footer: the status flash, address
 5683// and key hints on one row, then the footer table itself β€” View Cart on
 5684// the left half and Add Shipping Info on the right, each centered in its
 5685// cell, ruled above and divided down the middle as the page's is.
 5686func (u *ui) drawFooter(sc tcell.Screen, w, h int) {
 5687	status := "[#FF10F0:#009EFF] Open For Business [-:-]"
 5688	if f.Teststripekey {
 5689		status = "[yellow:red] Test Mode Active - No Orders Processed [-:-]"
 5690	}
 5691	arrows := ""
 5692	if len(u.history) > 0 {
 5693		arrows += "←"
 5694	}
 5695	if len(u.fwd) > 0 {
 5696		arrows += "β†’"
 5697	}
 5698	if arrows != "" {
 5699		arrows = "[gray]" + arrows + "[-] "
 5700	}
 5701	line := fmt.Sprintf(" %s  %s[aqua]%s[-]  [gray]⌫ back · f fwd · c cats · h home · e etc · b buy · v cart · i img · d deco · w page · q quit[-]",
 5702		status, arrows, u.pagePath())
 5703	printMarkup(sc, 0, h-3, w, line, styleText)
 5704
 5705	// The footer table: a white rule for its top edge, then two equal
 5706	// cells split by the divider the page draws between them.
 5707	drawRule(sc, 0, h-2, w, ruleWhite)
 5708	total, count := u.cartTotal()
 5709	cart := fmt.Sprintf("β–΄ View Cart Total: $%d.%02d", total/100, total%100)
 5710	if count > 0 {
 5711		cart = fmt.Sprintf("β–΄ View Cart (%d) Total: $%d.%02d", count, total/100, total%100)
 5712	}
 5713	ship := "β–΄ Add Shipping Info"
 5714	u.footRegions = u.footRegions[:0]
 5715	half := w / 2
 5716	for i, e := range []struct{ label, act string }{{cart, "cart"}, {ship, "shipping"}} {
 5717		x := i*half + (half-len([]rune(e.label)))/2
 5718		if x < 0 {
 5719			x = 0
 5720		}
 5721		printMarkup(sc, x, h-1, w-x, "[aqua]"+esc(e.label)+"[-]", styleText)
 5722		u.footRegions = append(u.footRegions, struct {
 5723			x1, x2 int
 5724			act    string
 5725		}{x, x + len([]rune(e.label)), e.act})
 5726	}
 5727	sc.SetContent(half, h-1, 'β”‚', nil, ruleWhite)
 5728}
 5729
 5730func (u *ui) cartTotal() (cents, count int) {
 5731	for partno, qty := range u.cartQty {
 5732		if p := u.cat.find(partno); p != nil {
 5733			cents += priceCents(p.Price) * qty
 5734			count += qty
 5735		}
 5736	}
 5737	if u.shipping != nil {
 5738		cents += u.shipping.Cents
 5739	}
 5740	return cents, count
 5741}
 5742
 5743func priceCents(s string) int {
 5744	s = strings.TrimPrefix(s, "$")
 5745	fl, err := strconv.ParseFloat(s, 64)
 5746	if err != nil {
 5747		return 0
 5748	}
 5749	return int(fl*100 + 0.5)
 5750}
 5751
 5752// ---- input routing ----
 5753
 5754func (u *ui) handleKey(ev *tcell.EventKey) {
 5755	if ev.Key() == tcell.KeyCtrlC {
 5756		u.quit = true
 5757		return
 5758	}
 5759	if u.notice != "" {
 5760		u.notice = ""
 5761		return
 5762	}
 5763	if u.drop != nil {
 5764		if u.drop.lst.key(ev) {
 5765			return
 5766		}
 5767		r := keyRune(ev)
 5768		if ev.Key() == tcell.KeyEscape ||
 5769			(r == 'c' && u.drop.kind == "cats") || (r == 'e' && u.drop.kind == "etc") {
 5770			u.drop = nil
 5771			return
 5772		}
 5773	}
 5774	if u.commerce != nil {
 5775		if u.commerce.key(u, ev) {
 5776			return
 5777		}
 5778		if ev.Key() == tcell.KeyEscape {
 5779			u.closeCommerce()
 5780			return
 5781		}
 5782	}
 5783	switch ev.Key() {
 5784	case tcell.KeyEscape:
 5785		u.back()
 5786		return
 5787	case tcell.KeyBackspace, tcell.KeyBackspace2:
 5788		u.back()
 5789		return
 5790	}
 5791	if ev.Key() == tcell.KeyRune {
 5792		switch keyRune(ev) {
 5793		case 'q':
 5794			u.quit = true
 5795			return
 5796		case 'c':
 5797			u.openCategories()
 5798			return
 5799		case 'h':
 5800			u.show("home")
 5801			return
 5802		case 'e':
 5803			u.openEtc()
 5804			return
 5805		case 'f':
 5806			u.forward()
 5807			return
 5808		case 'v':
 5809			u.toggleCart()
 5810			return
 5811		case 'i':
 5812			if u.mode == modeHalfBlock {
 5813				u.mode = modeCaca
 5814			} else {
 5815				u.mode = modeHalfBlock
 5816			}
 5817			return // the changed mode re-keys art requests at next draw
 5818		case 'w':
 5819			u.cellsOnly = !u.cellsOnly
 5820			return
 5821		case 'd':
 5822			// The frame is drawn out of the table's own area, so it
 5823			// costs rows of products. Worth having and worth being able
 5824			// to put away, which the page never needs to offer.
 5825			u.bare = !u.bare
 5826			return
 5827		}
 5828	}
 5829	if u.drop != nil || u.commerce != nil {
 5830		return
 5831	}
 5832	u.mu.Lock()
 5833	cur := u.current
 5834	u.mu.Unlock()
 5835	if p := u.panels[cur]; p != nil {
 5836		p.key(u, ev)
 5837	}
 5838}
 5839
 5840func (u *ui) handleMouse(ev *tcell.EventMouse) {
 5841	x, y := ev.Position()
 5842	w, h := u.screen.Size()
 5843	click := ev.Buttons()&tcell.Button1 != 0
 5844	// Where the pointer is, for a:hover. Every event carries it, so bare
 5845	// motion is not the only thing that keeps it current.
 5846	u.hoverX, u.hoverY = x, y
 5847	if u.notice != "" {
 5848		if click {
 5849			u.notice = ""
 5850		}
 5851		return
 5852	}
 5853	wasOpen := ""
 5854	if u.drop != nil {
 5855		r := u.dropRect().inner()
 5856		if r.contains(x, y) {
 5857			u.drop.lst.mouse(y-r.y, ev)
 5858			return
 5859		}
 5860		if !click {
 5861			return
 5862		}
 5863		// Close, and let the click land: clicking the other header label
 5864		// switches menus in one click, as details dropdowns do β€” but a
 5865		// click on this menu's own label must collapse it, not reopen it.
 5866		wasOpen = u.drop.kind
 5867		u.drop = nil
 5868	}
 5869	if click && y == 0 {
 5870		for _, reg := range u.navRegions {
 5871			if x >= reg.x1 && x < reg.x2 {
 5872				switch reg.act {
 5873				case "cats":
 5874					if wasOpen != "cats" {
 5875						u.openCategories()
 5876					}
 5877				case "home":
 5878					u.show("home")
 5879				case "etc":
 5880					if wasOpen != "etc" {
 5881						u.openEtc()
 5882					}
 5883				}
 5884				return
 5885			}
 5886		}
 5887		return
 5888	}
 5889	if click && y == h-1 {
 5890		for _, reg := range u.footRegions {
 5891			if x >= reg.x1 && x < reg.x2 {
 5892				switch reg.act {
 5893				case "cart":
 5894					u.toggleCart()
 5895				case "shipping":
 5896					if _, open := u.commerce.(*shippingOverlay); open {
 5897						u.closeCommerce()
 5898					} else {
 5899						u.openShipping()
 5900					}
 5901				}
 5902				return
 5903			}
 5904		}
 5905		return
 5906	}
 5907	if u.commerce != nil {
 5908		r := u.commerceRect()
 5909		if !r.contains(x, y) && click {
 5910			u.closeCommerce()
 5911		}
 5912		return
 5913	}
 5914	// An anchor first: on the page these are <a>, and a click on one
 5915	// follows it rather than reaching the widget it was drawn over.
 5916	if click {
 5917		for _, l := range u.links {
 5918			if y == l.y && x >= l.x && x < l.x+l.w {
 5919				l.act()
 5920				return
 5921			}
 5922		}
 5923	}
 5924	content := rect{0, 5, w, h - 8}
 5925	if content.contains(x, y) {
 5926		u.mu.Lock()
 5927		cur := u.current
 5928		u.mu.Unlock()
 5929		if p := u.panels[cur]; p != nil {
 5930			p.mouse(u, content, ev)
 5931		}
 5932	}
 5933}
 5934
 5935// ---- navigation ----
 5936
 5937func (u *ui) show(page string) {
 5938	u.drop = nil
 5939	u.closeCommerce()
 5940	u.mu.Lock()
 5941	if page == u.current {
 5942		u.mu.Unlock()
 5943		return
 5944	}
 5945	u.history = append(u.history, u.current)
 5946	u.current = page
 5947	u.mu.Unlock()
 5948	u.fwd = nil
 5949	u.ensurePanel(page)
 5950}
 5951
 5952func (u *ui) back() {
 5953	u.mu.Lock()
 5954	if len(u.history) == 0 {
 5955		u.mu.Unlock()
 5956		return
 5957	}
 5958	page := u.history[len(u.history)-1]
 5959	u.history = u.history[:len(u.history)-1]
 5960	u.fwd = append(u.fwd, u.current)
 5961	u.current = page
 5962	u.mu.Unlock()
 5963}
 5964
 5965func (u *ui) forward() {
 5966	u.mu.Lock()
 5967	if len(u.fwd) == 0 {
 5968		u.mu.Unlock()
 5969		return
 5970	}
 5971	page := u.fwd[len(u.fwd)-1]
 5972	u.fwd = u.fwd[:len(u.fwd)-1]
 5973	u.history = append(u.history, u.current)
 5974	u.current = page
 5975	u.mu.Unlock()
 5976	u.ensurePanel(page)
 5977}
 5978
 5979func (u *ui) ensurePanel(page string) {
 5980	if _, ok := u.panels[page]; ok {
 5981		return
 5982	}
 5983	switch {
 5984	case strings.HasPrefix(page, "list:"):
 5985		spec := strings.TrimPrefix(page, "list:")
 5986		parts := strings.SplitN(spec, "|", 2)
 5987		cat, sub := parts[0], ""
 5988		if len(parts) == 2 {
 5989			sub = parts[1]
 5990		}
 5991		u.panels[page] = newListPanel(u, page, cat, sub)
 5992	case strings.HasPrefix(page, "prod:"):
 5993		u.panels[page] = newProductPanel(u, strings.TrimPrefix(page, "prod:"))
 5994	case strings.HasPrefix(page, "content:"):
 5995		u.panels[page] = newContentPanel(u, strings.TrimPrefix(page, "content:"))
 5996	}
 5997}
 5998
 5999func listPageID(t navTarget) string {
 6000	if t.cat == "" {
 6001		return "list:"
 6002	}
 6003	if t.subcat == "" {
 6004		return "list:" + t.cat
 6005	}
 6006	return "list:" + t.cat + "|" + t.subcat
 6007}
 6008
 6009// pagePath renders the current panel as the URL path the website would
 6010// give it β€” the footer shows it like an address bar.
 6011func (u *ui) pagePath() string {
 6012	u.mu.Lock()
 6013	cur := u.current
 6014	u.mu.Unlock()
 6015	switch {
 6016	case cur == "home":
 6017		return "/"
 6018	case strings.HasPrefix(cur, "content:"):
 6019		return "/#" + strings.TrimPrefix(cur, "content:")
 6020	case strings.HasPrefix(cur, "prod:"):
 6021		return "/p/" + strings.TrimPrefix(cur, "prod:")
 6022	case strings.HasPrefix(cur, "list:"):
 6023		spec := strings.TrimPrefix(cur, "list:")
 6024		if spec == "" {
 6025			return "/cat"
 6026		}
 6027		return "/cat/" + strings.ReplaceAll(spec, "|", "/")
 6028	}
 6029	return "/"
 6030}
 6031
 6032// ---- header dropdowns ----
 6033
 6034// dropRect puts an open header menu against the edge its entry is
 6035// nearest: Categories at the left, Etc at the right.
 6036//
 6037// The page does this as a consequence of how its header is built β€” each
 6038// menu is absolutely positioned inside its own cell of a full-width
 6039// table, so the first lands at the far left and the last at the far
 6040// right β€” and the result reads as deliberate: a pair of menus bracketing
 6041// the header rather than two labels with boxes hanging under them. The
 6042// terminal now does it on purpose.
 6043func (u *ui) dropRect() rect {
 6044	sw, sh := u.screen.Size()
 6045	x := 0
 6046	if u.drop.kind == "etc" {
 6047		x = sw - u.drop.width
 6048	}
 6049	if x < 0 {
 6050		x = 0
 6051	}
 6052	h := len(u.drop.lst.items) + 2
 6053	if max := sh - 4; h > max {
 6054		h = max
 6055	}
 6056	return rect{x, 2, u.drop.width, h}
 6057}
 6058
 6059// openCategories drops down the catsubcats tree. Like the template's
 6060// nested <details>, categories open and close: the current category
 6061// starts open, Enter on a closed category opens it (Enter again
 6062// navigates), β†’/space toggle, ← closes.
 6063func (u *ui) openCategories() {
 6064	if u.drop != nil && u.drop.kind == "cats" {
 6065		u.drop = nil
 6066		return
 6067	}
 6068	if cat, _ := u.currentTarget(); cat != "" {
 6069		u.expanded[cat] = true
 6070	}
 6071	// Width from the fully expanded tree, so the box doesn't resize as
 6072	// branches open and close.
 6073	width := 20
 6074	for _, it := range u.cat.buildTree(nil) {
 6075		if w := len([]rune(it.label)) + 4; w > width {
 6076			width = w
 6077		}
 6078	}
 6079	d := &dropState{kind: "cats", lst: &list{}, width: width}
 6080	u.drop = d
 6081	rebuild := func(selCat string) {
 6082		items := u.cat.buildTree(u.expanded)
 6083		d.lst.items = d.lst.items[:0]
 6084		d.targets = d.targets[:0]
 6085		sel := 0
 6086		for i, it := range items {
 6087			d.lst.items = append(d.lst.items, esc(it.label))
 6088			d.targets = append(d.targets, it.target)
 6089			if it.target.cat == selCat && it.target.subcat == "" {
 6090				sel = i
 6091			}
 6092		}
 6093		d.lst.sel = sel
 6094	}
 6095	rebuild("")
 6096	itemAt := func(i int) (navTarget, bool) {
 6097		if i < 0 || i >= len(d.targets) {
 6098			return navTarget{}, false
 6099		}
 6100		return d.targets[i], true
 6101	}
 6102	hasSubs := func(t navTarget) bool {
 6103		return t.cat != "" && t.subcat == "" && len(u.cat.subcatsByCat[t.cat]) > 0
 6104	}
 6105	d.lst.onPick = func(i int) {
 6106		t, ok := itemAt(i)
 6107		if !ok {
 6108			return
 6109		}
 6110		if hasSubs(t) && !u.expanded[t.cat] {
 6111			u.expanded[t.cat] = true
 6112			rebuild(t.cat)
 6113			return
 6114		}
 6115		u.show(listPageID(t))
 6116	}
 6117	d.lst.onKey = func(ev *tcell.EventKey, sel int) bool {
 6118		t, ok := itemAt(sel)
 6119		if !ok {
 6120			return false
 6121		}
 6122		switch {
 6123		case ev.Key() == tcell.KeyRight:
 6124			if hasSubs(t) && !u.expanded[t.cat] {
 6125				u.expanded[t.cat] = true
 6126				rebuild(t.cat)
 6127			}
 6128			return true
 6129		case ev.Key() == tcell.KeyLeft:
 6130			if t.cat != "" && (u.expanded[t.cat] || t.subcat != "") {
 6131				u.expanded[t.cat] = false
 6132				rebuild(t.cat)
 6133			}
 6134			return true
 6135		case keyRune(ev) == ' ':
 6136			if t.cat == "" {
 6137				return true
 6138			}
 6139			if t.subcat != "" {
 6140				u.expanded[t.cat] = false
 6141			} else if hasSubs(t) {
 6142				u.expanded[t.cat] = !u.expanded[t.cat]
 6143			}
 6144			rebuild(t.cat)
 6145			return true
 6146		}
 6147		return false
 6148	}
 6149}
 6150
 6151// currentTarget reports which category/subcategory panel is showing.
 6152func (u *ui) currentTarget() (string, string) {
 6153	u.mu.Lock()
 6154	cur := u.current
 6155	u.mu.Unlock()
 6156	if !strings.HasPrefix(cur, "list:") {
 6157		return "", ""
 6158	}
 6159	parts := strings.SplitN(strings.TrimPrefix(cur, "list:"), "|", 2)
 6160	if len(parts) == 2 {
 6161		return parts[0], parts[1]
 6162	}
 6163	return parts[0], ""
 6164}
 6165
 6166// openEtc drops down the Etc... menu: About, Policy, Telegram, Contact, Links.
 6167func (u *ui) openEtc() {
 6168	if u.drop != nil && u.drop.kind == "etc" {
 6169		u.drop = nil
 6170		return
 6171	}
 6172	type entry struct {
 6173		label string
 6174		fn    func()
 6175	}
 6176	var entries []entry
 6177	add := func(label string, fn func()) { entries = append(entries, entry{label, fn}) }
 6178	add("About", func() { u.show("content:about") })
 6179	add("Policy", func() { u.show("content:policy") })
 6180	// The Telegram entries are links out, and now follow like any other:
 6181	// the browser build opens the chat, and in a terminal openURL shows
 6182	// the address, which is what this did in both places before.
 6183	if f.Tgchannel != "" {
 6184		href := "https://t.me/" + f.Tgchannel
 6185		add("Telegram", func() { u.drop = nil; u.openURL(href) })
 6186	}
 6187	if f.Tgcontact != "" {
 6188		href := "https://t.me/" + f.Tgcontact
 6189		add("Contact", func() { u.drop = nil; u.openURL(href) })
 6190	}
 6191	add("Links", func() { u.show("content:links") })
 6192	// Whatever the deployment's drop-ins added to the page's menu, in the
 6193	// same place and the same order the header puts them: after Links.
 6194	// follow decides what each one means here β€” a page the TUI has of its
 6195	// own, or a link out.
 6196	for _, nl := range navLinks {
 6197		act := u.follow(nl.Href)
 6198		if act == nil {
 6199			continue
 6200		}
 6201		add(nl.Title, func() { u.drop = nil; act() })
 6202	}
 6203	lst := &list{}
 6204	for _, e := range entries {
 6205		lst.items = append(lst.items, esc(e.label))
 6206	}
 6207	lst.onPick = func(i int) { entries[i].fn() }
 6208	u.drop = &dropState{kind: "etc", lst: lst, width: 30}
 6209}
 6210
 6211// ---- the render worker ----
 6212
 6213func (u *ui) requestArt(h *artHolder, path string, w, ht int) {
 6214	key := fmt.Sprintf("%s|%d|%d|%d", path, w, ht, u.mode)
 6215	if h.key == key || h.want == key {
 6216		return
 6217	}
 6218	h.want = key
 6219	req := artReq{key: key, path: path, w: w, h: ht, mode: u.mode, holder: h}
 6220	select {
 6221	case u.reqCh <- req:
 6222	default:
 6223		select { // replace the stale pending request
 6224		case <-u.reqCh:
 6225		default:
 6226		}
 6227		u.reqCh <- req
 6228	}
 6229}
 6230
 6231func (u *ui) renderWorker() {
 6232	cache := map[string]artwork{}
 6233	for req := range u.reqCh {
 6234		art, ok := cache[req.key]
 6235		if !ok {
 6236			art = renderArt(req.path, req.w, req.h, req.mode)
 6237			if len(cache) > 128 {
 6238				cache = map[string]artwork{}
 6239			}
 6240			cache[req.key] = art
 6241		}
 6242		r := req
 6243		a := art
 6244		u.post(func() {
 6245			r.holder.key = r.key
 6246			r.holder.art = &a
 6247		})
 6248	}
 6249}
 6250
 6251// ---- the home panel ----
 6252
 6253type homePanel struct{}
 6254
 6255func (hp *homePanel) draw(u *ui, sc tcell.Screen, r rect) {
 6256	u.mu.Lock()
 6257	u.homeW, u.homeH = r.w, r.h
 6258	frame := u.frame
 6259	u.mu.Unlock()
 6260	if frame == nil {
 6261		return
 6262	}
 6263	rows := (frame.Bounds().Dy() + 1) / 2
 6264	drawRGBA(sc, r.x, r.y, r.w, r.h, frame)
 6265	// The website's own logo and globe over these cells, where the page
 6266	// offers its globe (place.go).
 6267	// Across the panel's whole width, the black either side of the logo
 6268	// included: the picture there is the widget's, so a click on it is too
 6269	// (it stops the globe). The logo is letterboxed to the same place.
 6270	u.want("home", placed{r: rect{r.x, r.y, r.w, min(rows, r.h)}, widget: "globe", input: true})
 6271	if rows < r.h {
 6272		ty := r.y + rows
 6273		for i, l := range u.asciiLines {
 6274			if ty+i >= r.y+r.h {
 6275				break
 6276			}
 6277			printMarkupCenter(sc, r.x, ty+i, r.w, esc(l), styleText)
 6278		}
 6279		if ty+len(u.asciiLines)+1 < r.y+r.h {
 6280			printMarkupCenter(sc, r.x, ty+len(u.asciiLines)+1, r.w, esc(u.caps), styleText)
 6281			// The page's double-struck capitals over them, where a page can
 6282			// set them (the "caps" widget, widgets_js.go); a terminal keeps these.
 6283			u.want("caps", placed{r: rect{r.x, ty + len(u.asciiLines) + 1, r.w, 1}, widget: "caps"})
 6284		}
 6285	}
 6286}
 6287
 6288func (hp *homePanel) key(*ui, *tcell.EventKey) bool           { return false }
 6289func (hp *homePanel) mouse(*ui, rect, *tcell.EventMouse) bool { return false }
 6290
 6291// animateHome turns the globe over the logo while the home panel is
 6292// shown, like the website's landing animation over the svg.
 6293func (u *ui) animateHome() {
 6294	ticker := time.NewTicker(140 * time.Millisecond)
 6295	defer ticker.Stop()
 6296	for {
 6297		select {
 6298		case <-u.done:
 6299			return
 6300		case <-ticker.C:
 6301		}
 6302		u.mu.Lock()
 6303		cur, w, h := u.current, u.homeW, u.homeH
 6304		u.mu.Unlock()
 6305		if cur != "home" || w <= 0 || h <= 0 {
 6306			continue
 6307		}
 6308		globeRows := h - len(u.asciiLines) - 2
 6309		if globeRows < 4 {
 6310			globeRows = h
 6311		}
 6312		if u.backdrop == nil || u.bdW != w || u.bdH != globeRows {
 6313			u.backdrop = makeBackdrop(u.logo, w, globeRows)
 6314			u.bdW, u.bdH = w, globeRows
 6315		}
 6316		if u.homeCovered.Load() {
 6317			// The page's globe turns over these cells: drawing ours under
 6318			// it would cost the tab a core for nothing seen. The logo alone,
 6319			// once, which then costs nothing.
 6320			u.mu.Lock()
 6321			if u.frame != u.backdrop {
 6322				u.frame = u.backdrop
 6323				u.mu.Unlock()
 6324				u.post(nil)
 6325			} else {
 6326				u.mu.Unlock()
 6327			}
 6328			continue
 6329		}
 6330		u.poseX += u.rateX
 6331		u.poseY += u.rateY
 6332		u.poseZ += u.rateZ
 6333		frame := globeFrame(u.backdrop, u.poseX, u.poseY, u.poseZ)
 6334		u.mu.Lock()
 6335		u.frame = frame
 6336		u.mu.Unlock()
 6337		u.post(nil) // just redraw
 6338	}
 6339}
 6340
 6341// ---- category / subcategory listings ----
 6342
 6343// listPanel renders a category page: category.html's h1, the sidebar's
 6344// list of subcategories, and the product table, with the selected row's
 6345// image beside it (the terminal's stand-in for the table's image column).
 6346type listPanel struct {
 6347	page, cat, sub string
 6348	heading        []string
 6349	links          []func()           // what this page's anchors do; see linkTag
 6350	cols           []storepage.Col    // the shared column set, image included
 6351	shown          []storepage.Col    // the ones this view draws, in table order
 6352	prods          []*product.Product // panel's full set
 6353	rows           []*product.Product // filtered
 6354	tbl            *table
 6355	art            artHolder
 6356	filterOn       bool
 6357	filter         finput
 6358}
 6359
 6360// link registers an action for this page and returns the markup that
 6361// wraps s in it.
 6362func (lp *listPanel) link(s string, act func()) string {
 6363	lp.links = append(lp.links, act)
 6364	return linkTag(len(lp.links), s)
 6365}
 6366
 6367func newListPanel(u *ui, page, cat, sub string) *listPanel {
 6368	lp := &listPanel{page: page, cat: cat, sub: sub}
 6369	all := u.cat.products(cat, sub)
 6370	for i := range all {
 6371		lp.prods = append(lp.prods, &all[i])
 6372	}
 6373	// category.html's h1, carrying the same links: the category name goes
 6374	// to the category β€” which is how a subcategory page climbs back β€” and
 6375	// each name links to its own page as the template's do.
 6376	catLink := func(t navTarget, label string) string {
 6377		return lp.link("[aqua]"+esc(label)+"[-]", func() { u.show(listPageID(t)) })
 6378	}
 6379	title := "All Products"
 6380	switch {
 6381	case sub != "":
 6382		title = "Category: " + catLink(navTarget{cat: cat}, cat) +
 6383			" | Subcategory: " + catLink(navTarget{cat: cat, subcat: sub}, sub)
 6384	case cat != "":
 6385		title = "Category: " + catLink(navTarget{cat: cat}, cat)
 6386	}
 6387	lp.heading = []string{"[::b]" + title + "[-:-:-]"}
 6388	// The page's aside lists the category's subcategories under a
 6389	// "Subcategories:" heading; the Categories dropdown is the terminal's
 6390	// aside, so the list goes on the page where it can be reached.
 6391	if cat != "" && sub == "" {
 6392		if subs := u.cat.subcatsByCat[cat]; len(subs) > 0 {
 6393			var parts []string
 6394			for _, s := range subs {
 6395				target := navTarget{cat: cat, subcat: s}
 6396				parts = append(parts, fmt.Sprintf("%s (%d)",
 6397					lp.link("[aqua]"+esc(s)+"[-]", func() { u.show(listPageID(target)) }),
 6398					u.cat.subcatCounts[cat][s]))
 6399			}
 6400			lp.heading = append(lp.heading, "Subcategories: "+strings.Join(parts, ", "))
 6401		}
 6402	}
 6403	// The columns are storepage's, so the page and the terminal list the
 6404	// same things in the same order. Image is the one column a terminal
 6405	// answers differently β€” it has one picture to spend, beside the
 6406	// table, not a thumbnail per row β€” so it is dropped here and the art
 6407	// panel stands in for it. Subcategory only where the rows can differ
 6408	// in it, which is what storepage is being asked.
 6409	lp.cols = storepage.Columns(sub == "")
 6410	var cols []tcol
 6411	for _, c := range lp.cols {
 6412		if c.Kind == storepage.ColImage {
 6413			continue
 6414		}
 6415		cols = append(cols, tcol{
 6416			title:      c.Title,
 6417			width:      c.Width,
 6418			alignRight: c.Align == storepage.AlignRight,
 6419			link:       c.Kind == storepage.ColName || c.Kind == storepage.ColSubcategory || c.Kind == storepage.ColBuy,
 6420		})
 6421		lp.shown = append(lp.shown, c)
 6422	}
 6423	lp.tbl = &table{cols: cols}
 6424	lp.tbl.onActivate = func(i int) {
 6425		if i >= 0 && i < len(lp.rows) {
 6426			u.show("prod:" + lp.rows[i].Partno)
 6427		}
 6428	}
 6429	lp.fill("")
 6430	return lp
 6431}
 6432
 6433func (lp *listPanel) fill(query string) {
 6434	lp.rows = lp.rows[:0]
 6435	var rows []trow
 6436	query = strings.ToLower(query)
 6437	for _, p := range lp.prods {
 6438		if query != "" && !strings.Contains(strings.ToLower(
 6439			p.Partno+" "+p.Name+" "+p.Subcategory+" "+p.Description1), query) {
 6440			continue
 6441		}
 6442		row := trow{}
 6443		for _, c := range lp.shown {
 6444			cell := storepage.CellFor(c, p)
 6445			row.cells = append(row.cells, tcell_{cell.Text, cellStyle(c, p)})
 6446		}
 6447		rows = append(rows, row)
 6448		lp.rows = append(lp.rows, p)
 6449	}
 6450	lp.tbl.setRows(rows)
 6451}
 6452
 6453// cellStyle dresses a shared cell in the site's colors: links cyan, the
 6454// buy button a button, and an out-of-stock name grey β€” which is the page
 6455// greying a row it will not sell.
 6456func cellStyle(c storepage.Col, p *product.Product) tcell.Style {
 6457	switch c.Kind {
 6458	case storepage.ColName:
 6459		if !storepage.InStock(p) {
 6460			return styleText.Foreground(tcell.GetColor("#808080"))
 6461		}
 6462		return styleLink
 6463	case storepage.ColSubcategory:
 6464		return styleLink
 6465	case storepage.ColBuy:
 6466		return styleButton
 6467	}
 6468	return styleText
 6469}
 6470
 6471func (lp *listPanel) selected() *product.Product {
 6472	if lp.tbl.sel >= 0 && lp.tbl.sel < len(lp.rows) {
 6473		return lp.rows[lp.tbl.sel]
 6474	}
 6475	return nil
 6476}
 6477
 6478// layout places the panel's parts. tblR is where the table's cells go,
 6479// which is inside the frame when there is room for one β€” brd and brdR
 6480// carry that frame and where it is drawn. Both draw and mouse take the
 6481// table's rectangle from here, or a click would land a row or two off
 6482// whatever the frame pushed the table to.
 6483func (lp *listPanel) layout(u *ui, r rect) (head, tblR, artR, filtR, brdR rect, brd *borderArt) {
 6484	hh := len(lp.heading)
 6485	head = rect{r.x + 1, r.y, r.w - 2, hh}
 6486	body := rect{r.x, r.y + hh + 1, r.w, r.h - hh - 1}
 6487	fh := 0
 6488	if lp.filterOn {
 6489		fh = 1
 6490	}
 6491	tw := body.w * 3 / 5
 6492	tblR = rect{body.x + 1, body.y, tw - 2, body.h - fh}
 6493	artR = rect{body.x + tw, body.y, body.w - tw, body.h - fh}
 6494	filtR = rect{body.x + 1, body.y + body.h - 1, body.w - 2, 1}
 6495	if !u.bare {
 6496		tblR, brd, brdR = framed(tblR)
 6497	}
 6498	return
 6499}
 6500
 6501func (lp *listPanel) draw(u *ui, sc tcell.Screen, r rect) {
 6502	head, tblR, artR, filtR, brdR, brd := lp.layout(u, r)
 6503	if brd != nil {
 6504		brd.draw(sc, brdR)
 6505	}
 6506	sink := u.linkSink(lp.links)
 6507	for i, line := range lp.heading {
 6508		printMarkupLinks(sc, head.x, head.y+i, head.w, line, styleText, sink)
 6509	}
 6510	// A row has the two the page's row has: the name opens the product,
 6511	// as the product image does there, and Buy adds it to the cart.
 6512	// Registering them as anchors is what makes one click do it and the
 6513	// pointer light them up. Both select the row first, so the photo
 6514	// beside the table shows what was just acted on.
 6515	lp.tbl.onLink = func(x, y, w, row, col int) {
 6516		if col >= len(lp.shown) {
 6517			return
 6518		}
 6519		kind := lp.shown[col].Kind
 6520		u.links = append(u.links, linkRegion{x, y, w, func() {
 6521			lp.tbl.sel = row
 6522			p := lp.selected()
 6523			if p == nil {
 6524				return
 6525			}
 6526			// What the cell says it is, rather than what column it
 6527			// landed in: the shared cell already carries where it goes
 6528			// and whether it is the row's buy.
 6529			cell := storepage.CellFor(storepage.Col{Kind: kind}, p)
 6530			switch {
 6531			case cell.Buy:
 6532				u.addToCart(p)
 6533			case cell.Href != "":
 6534				if act := u.follow(cell.Href); act != nil {
 6535					act()
 6536				}
 6537			}
 6538		}})
 6539	}
 6540	lp.tbl.draw(sc, tblR)
 6541	if p := lp.selected(); p != nil {
 6542		u.requestArt(&lp.art, imagePath(p), artR.w, artR.h)
 6543	}
 6544	if ar, ok := drawArtwork(sc, artR, lp.art.art); ok && lp.selected() != nil {
 6545		u.want("photo", placed{r: ar, url: imagePath(lp.selected()), fit: "cover"})
 6546	}
 6547	if lp.filterOn {
 6548		printMarkup(sc, filtR.x, filtR.y, 3, "[aqua]/ [-]", styleText)
 6549		curX, _ := lp.filter.drawValue(sc, filtR.x+2, filtR.y, filtR.w-2, true)
 6550		sc.ShowCursor(curX, filtR.y)
 6551	}
 6552}
 6553
 6554func (lp *listPanel) key(u *ui, ev *tcell.EventKey) bool {
 6555	if lp.filterOn {
 6556		switch ev.Key() {
 6557		case tcell.KeyEscape:
 6558			lp.filterOn = false
 6559			lp.filter.text = nil
 6560			lp.filter.cur = 0
 6561			lp.fill("")
 6562			return true
 6563		case tcell.KeyEnter:
 6564			lp.filterOn = false
 6565			return true
 6566		}
 6567		if lp.filter.key(ev) {
 6568			lp.fill(string(lp.filter.text))
 6569			return true
 6570		}
 6571		return ev.Key() == tcell.KeyRune
 6572	}
 6573	if ev.Key() == tcell.KeyRune {
 6574		switch keyRune(ev) {
 6575		case '/':
 6576			lp.filterOn = true
 6577			lp.filter = finput{width: 40}
 6578			return true
 6579		case 'b':
 6580			if p := lp.selected(); p != nil {
 6581				u.addToCart(p)
 6582			}
 6583			return true
 6584		}
 6585	}
 6586	return lp.tbl.key(ev)
 6587}
 6588
 6589func (lp *listPanel) mouse(u *ui, r rect, ev *tcell.EventMouse) bool {
 6590	_, tblR, _, _, _, _ := lp.layout(u, r)
 6591	x, y := ev.Position()
 6592	if ev.Buttons()&(tcell.WheelUp|tcell.WheelDown) != 0 {
 6593		// the wheel drives the table wherever it is rolled, the picture
 6594		// side included
 6595		return lp.tbl.mouse(1, ev)
 6596	}
 6597	if tblR.contains(x, y) {
 6598		return lp.tbl.mouse(y-tblR.y, ev)
 6599	}
 6600	return false
 6601}
 6602
 6603// ---- the product panel ----
 6604
 6605type productPanel struct {
 6606	prod  *product.Product
 6607	ta    *textArea
 6608	art   artHolder
 6609	links []func() // what this page's anchors do; see linkTag
 6610}
 6611
 6612func newProductPanel(u *ui, partno string) panel {
 6613	p := u.cat.find(partno)
 6614	if p == nil {
 6615		return newContentPanel(u, "") // empty
 6616	}
 6617	pp := &productPanel{prod: p}
 6618	pp.ta = newTextArea(productLines(u, p, func(text string, act func()) string {
 6619		pp.links = append(pp.links, act)
 6620		return linkTag(len(pp.links), text)
 6621	}), true)
 6622	return pp
 6623}
 6624
 6625func (pp *productPanel) draw(u *ui, sc tcell.Screen, r rect) {
 6626	ih := r.h * 3 / 5
 6627	imgR := rect{r.x, r.y, r.w, ih}
 6628	txtR := rect{r.x + 1, r.y + ih + 1, r.w - 2, r.h - ih - 1}
 6629	u.requestArt(&pp.art, imagePath(pp.prod), imgR.w, imgR.h)
 6630	if ar, ok := drawArtwork(sc, imgR, pp.art.art); ok {
 6631		u.want("photo", placed{r: ar, url: imagePath(pp.prod), fit: "cover"})
 6632	}
 6633	pp.ta.onLink = u.linkSink(pp.links)
 6634	pp.ta.draw(sc, txtR)
 6635}
 6636
 6637func (pp *productPanel) key(u *ui, ev *tcell.EventKey) bool {
 6638	if ev.Key() == tcell.KeyEnter || keyRune(ev) == 'b' {
 6639		u.addToCart(pp.prod)
 6640		return true
 6641	}
 6642	return pp.ta.key(ev)
 6643}
 6644
 6645func (pp *productPanel) mouse(u *ui, r rect, ev *tcell.EventMouse) bool {
 6646	switch {
 6647	case ev.Buttons()&tcell.WheelUp != 0:
 6648		pp.ta.wheel(true)
 6649	case ev.Buttons()&tcell.WheelDown != 0:
 6650		pp.ta.wheel(false)
 6651	default:
 6652		return false
 6653	}
 6654	return true
 6655}
 6656
 6657func (u *ui) addToCart(p *product.Product) {
 6658	if p.Quantity == "0" {
 6659		u.notice = esc(p.Name) + " is out of stock"
 6660		return
 6661	}
 6662	if _, ok := u.cartQty[p.Partno]; !ok {
 6663		u.cartOrder = append(u.cartOrder, p.Partno)
 6664	}
 6665	u.cartQty[p.Partno]++
 6666	u.cartChanged()
 6667}
 6668
 6669// ---- content pages: about, policy, links ----
 6670
 6671type contentPanel struct {
 6672	ta    *textArea
 6673	links []func() // what this page's anchors do; see linkTag
 6674	// The page is re-rendered when the pane changes width, because the
 6675	// tables in it are laid out to a column count. raw is kept so that
 6676	// costs a re-render and not a re-fetch.
 6677	u     *ui
 6678	raw   string
 6679	year  string
 6680	lastW int
 6681}
 6682
 6683// contentFile mirrors pkg/web: the deployment-local file when present,
 6684// falling back to the committed .example.
 6685func contentFile(path string) string {
 6686	if data, err := os.ReadFile(path); err == nil { //nolint
 6687		return string(data)
 6688	}
 6689	data, err := os.ReadFile(path + ".example") //nolint
 6690	if err != nil {
 6691		return ""
 6692	}
 6693	return string(data)
 6694}
 6695
 6696func newContentPanel(u *ui, name string) *contentPanel {
 6697	cp := &contentPanel{
 6698		u:    u,
 6699		raw:  fetchContent(name),
 6700		year: fmt.Sprintf("%d", time.Now().Year()),
 6701		ta:   newTextArea("[gray]no content/"+esc(name)+".html[-]", true),
 6702	}
 6703	return cp
 6704}
 6705
 6706// render lays the page out for a pane w columns wide, keeping the reader
 6707// where they were: setContent rewinds to the top, which is the right thing
 6708// on a new page and the wrong thing on a resize.
 6709func (cp *contentPanel) render(w int) {
 6710	cp.lastW = w
 6711	if cp.raw == "" {
 6712		return
 6713	}
 6714	text, hrefs := htmlToText(cp.raw, cp.year, w)
 6715	at := cp.ta.scroll
 6716	cp.ta.setContent(text)
 6717	cp.ta.scroll = at
 6718	cp.links = cp.links[:0]
 6719	for _, href := range hrefs {
 6720		cp.links = append(cp.links, cp.u.follow(href))
 6721	}
 6722}
 6723
 6724// follow is what clicking an href does: a path this store serves is a
 6725// page the TUI has of its own, and anything else is a link out.
 6726func (u *ui) follow(href string) func() {
 6727	switch {
 6728	case href == "", href == "#":
 6729		return nil
 6730	case href == "/", href == "/#", href == "#home", href == "/#home":
 6731		return func() { u.show("home") }
 6732	// The site's own menu spells these "/#about" so that they work from a
 6733	// page other than the front one, where a bare "#about" would only
 6734	// append a fragment to the path already there. Both forms are the
 6735	// same page here.
 6736	case href == "#about", href == "#policy", href == "#links",
 6737		href == "/#about", href == "/#policy", href == "/#links":
 6738		page := "content:" + strings.TrimPrefix(strings.TrimPrefix(href, "/"), "#")
 6739		return func() { u.show(page) }
 6740	case strings.HasPrefix(href, "/p/"):
 6741		if p := u.cat.find(strings.TrimPrefix(href, "/p/")); p != nil {
 6742			partno := p.Partno
 6743			return func() { u.show("prod:" + partno) }
 6744		}
 6745		abs := storeURL() + href
 6746		return func() { u.openURL(abs) }
 6747	case strings.HasPrefix(href, "/cat/"):
 6748		// The site spells a subcategory in a URL through escapesubcat
 6749		// ("ΒΌ watt 5%" is quarter-watt-5-pct), so a path only names a
 6750		// page here when the catalog has those names as written. When it
 6751		// does not, the store's own URL resolves it and the link goes
 6752		// out rather than landing on an empty table.
 6753		if t, ok := u.cat.resolve(strings.TrimPrefix(href, "/cat/")); ok {
 6754			return func() { u.show(listPageID(t)) }
 6755		}
 6756		abs := storeURL() + href
 6757		return func() { u.openURL(abs) }
 6758	case strings.HasPrefix(href, "/"):
 6759		// Some other page of this site β€” /logo, /sitemap.xml. The TUI has
 6760		// no view of it, so hand over the address on the store's origin.
 6761		abs := storeURL() + href
 6762		return func() { u.openURL(abs) }
 6763	}
 6764	return func() { u.openURL(href) }
 6765}
 6766
 6767func (cp *contentPanel) draw(u *ui, sc tcell.Screen, r rect) {
 6768	inner := rect{r.x + 1, r.y, r.w - 2, r.h}
 6769	if inner.w > 0 && inner.w != cp.lastW {
 6770		cp.render(inner.w)
 6771	}
 6772	cp.ta.onLink = u.linkSink(cp.links)
 6773	cp.ta.draw(sc, inner)
 6774}
 6775
 6776func (cp *contentPanel) key(u *ui, ev *tcell.EventKey) bool {
 6777	return cp.ta.key(ev)
 6778}
 6779
 6780func (cp *contentPanel) mouse(u *ui, r rect, ev *tcell.EventMouse) bool {
 6781	switch {
 6782	case ev.Buttons()&tcell.WheelUp != 0:
 6783		cp.ta.wheel(true)
 6784	case ev.Buttons()&tcell.WheelDown != 0:
 6785		cp.ta.wheel(false)
 6786	default:
 6787		return false
 6788	}
 6789	return true
 6790}
 6791
 6792
 6793// ===== pkg/tui/widgets.go =====
 6794// Package tui pkg/tui/widgets.go β€” the widget kit: a table, a list, a
 6795// scrollable text area, and a form, written directly on tcell. Small on
 6796// purpose: these are the site's shapes (the category table, the
 6797// catsubcats dropdown, product pages, Add Shipping Info), not a general
 6798// toolkit.
 6799package tui
 6800
 6801import (
 6802	"strings"
 6803
 6804	"github.com/gdamore/tcell/v3"
 6805)
 6806
 6807// ---- textArea: scrollable, optionally word-wrapped markup text ----
 6808
 6809type textArea struct {
 6810	content string
 6811	base    tcell.Style
 6812	wrap    bool
 6813	scroll  int
 6814	lines   [][]seg
 6815	lastW   int
 6816	// onLink, when set, is told where each anchor in the text landed on
 6817	// the screen, so the page can make it clickable. Set before every
 6818	// draw: what is on screen changes with the scroll.
 6819	onLink func(x, y, w, link int)
 6820}
 6821
 6822func newTextArea(content string, wrap bool) *textArea {
 6823	return &textArea{content: content, base: styleText, wrap: wrap}
 6824}
 6825
 6826func (t *textArea) setContent(s string) {
 6827	t.content = s
 6828	t.lastW = 0
 6829	t.scroll = 0
 6830}
 6831
 6832func (t *textArea) draw(sc tcell.Screen, r rect) {
 6833	if r.w != t.lastW || t.lines == nil {
 6834		t.lines = wrapSegs(t.content, r.w, t.base, t.wrap)
 6835		t.lastW = r.w
 6836	}
 6837	if max := len(t.lines) - r.h; t.scroll > max {
 6838		t.scroll = max
 6839	}
 6840	if t.scroll < 0 {
 6841		t.scroll = 0
 6842	}
 6843	for i := 0; i < r.h; i++ {
 6844		li := t.scroll + i
 6845		if li >= len(t.lines) {
 6846			break
 6847		}
 6848		printSegsLinks(sc, r.x, r.y+i, r.w, t.lines[li], t.onLink)
 6849	}
 6850}
 6851
 6852func (t *textArea) key(ev *tcell.EventKey) bool {
 6853	switch ev.Key() {
 6854	case tcell.KeyUp:
 6855		t.scroll--
 6856	case tcell.KeyDown:
 6857		t.scroll++
 6858	case tcell.KeyPgUp:
 6859		t.scroll -= 10
 6860	case tcell.KeyPgDn:
 6861		t.scroll += 10
 6862	case tcell.KeyHome:
 6863		t.scroll = 0
 6864	case tcell.KeyEnd:
 6865		t.scroll = len(t.lines)
 6866	default:
 6867		return false
 6868	}
 6869	return true
 6870}
 6871
 6872func (t *textArea) wheel(up bool) {
 6873	if up {
 6874		t.scroll -= 3
 6875	} else {
 6876		t.scroll += 3
 6877	}
 6878}
 6879
 6880// ---- table: the site's product table ----
 6881
 6882type tcol struct {
 6883	title      string
 6884	width      int // 0 = expands
 6885	alignRight bool
 6886	// link marks the column the row's <a> lives in. Clicking a cell there
 6887	// opens the row, as clicking the link on the page does; clicking any
 6888	// other column selects the row, which is what shows its picture.
 6889	link bool
 6890}
 6891
 6892type tcell_ struct {
 6893	text  string
 6894	style tcell.Style
 6895}
 6896
 6897type trow struct {
 6898	cells []tcell_
 6899}
 6900
 6901type table struct {
 6902	cols       []tcol
 6903	rows       []trow
 6904	sel        int
 6905	offset     int
 6906	onSelect   func(int)
 6907	onActivate func(int)
 6908	// onLink, when set, is told where each drawn link cell landed and
 6909	// which row and column it belongs to, so the page can register it as
 6910	// an anchor β€” which is what makes one click act on it and the pointer
 6911	// light it up. A row has more than one: its name opens the product
 6912	// and its Buy cell adds it to the cart, as the page's row does. Set
 6913	// before every draw, since the rows move as the table scrolls.
 6914	onLink func(x, y, w, row, col int)
 6915}
 6916
 6917func (t *table) setRows(rows []trow) {
 6918	t.rows = rows
 6919	t.offset = 0
 6920	if len(rows) == 0 {
 6921		t.sel = -1
 6922		return
 6923	}
 6924	t.sel = 0
 6925	if t.onSelect != nil {
 6926		t.onSelect(0)
 6927	}
 6928}
 6929
 6930// colWidths distributes r.w across the columns; width-0 columns share
 6931// the remainder.
 6932func (t *table) colWidths(w int) []int {
 6933	ws := make([]int, len(t.cols))
 6934	fixed, flex := 0, 0
 6935	for i, c := range t.cols {
 6936		ws[i] = c.width
 6937		if c.width == 0 {
 6938			flex++
 6939		} else {
 6940			fixed += c.width + 1
 6941		}
 6942	}
 6943	if flex > 0 {
 6944		share := (w - fixed - flex) / flex
 6945		if share < 4 {
 6946			share = 4
 6947		}
 6948		for i, c := range t.cols {
 6949			if c.width == 0 {
 6950				ws[i] = share
 6951			}
 6952		}
 6953	}
 6954	return ws
 6955}
 6956
 6957func (t *table) draw(sc tcell.Screen, r rect) {
 6958	if r.h < 2 {
 6959		return
 6960	}
 6961	ws := t.colWidths(r.w)
 6962	// The page's thead is a filled purple band, so the band is the header
 6963	// rather than only the words in it.
 6964	fillRect(sc, rect{r.x, r.y, r.w, 1}, styleThead)
 6965	x := r.x
 6966	for i, c := range t.cols {
 6967		st := styleThead
 6968		txt := c.title
 6969		if len(txt) > ws[i] {
 6970			txt = txt[:ws[i]]
 6971		}
 6972		pad := ws[i] - len(txt)
 6973		if c.alignRight {
 6974			printMarkup(sc, x+pad, r.y, ws[i], esc(txt), st)
 6975		} else {
 6976			printMarkup(sc, x, r.y, ws[i], esc(txt), st)
 6977		}
 6978		x += ws[i] + 1
 6979	}
 6980	visible := r.h - 1
 6981	if t.sel >= 0 {
 6982		if t.sel < t.offset {
 6983			t.offset = t.sel
 6984		}
 6985		if t.sel >= t.offset+visible {
 6986			t.offset = t.sel - visible + 1
 6987		}
 6988	}
 6989	for row := 0; row < visible; row++ {
 6990		ri := t.offset + row
 6991		if ri >= len(t.rows) {
 6992			break
 6993		}
 6994		y := r.y + 1 + row
 6995		selected := ri == t.sel
 6996		if selected {
 6997			fillRect(sc, rect{r.x, y, r.w, 1}, styleSel)
 6998		}
 6999		x = r.x
 7000		for i, c := range t.rows[ri].cells {
 7001			if i >= len(ws) {
 7002				break
 7003			}
 7004			st := c.style
 7005			if selected {
 7006				st = styleSel
 7007			}
 7008			txt := c.text
 7009			if len(txt) > ws[i] {
 7010				txt = txt[:ws[i]]
 7011			}
 7012			pad := 0
 7013			if t.cols[i].alignRight {
 7014				pad = ws[i] - len(txt)
 7015			}
 7016			n := printMarkup(sc, x+pad, y, ws[i]-pad, esc(txt), st)
 7017			// The anchor covers the text, not the empty column beside
 7018			// it: a link that lights up out to the price would say the
 7019			// blank cells were clickable too. An empty cell offers
 7020			// nothing, which is how an out-of-stock row has no Buy.
 7021			if t.cols[i].link && t.onLink != nil && n > 0 {
 7022				t.onLink(x+pad, y, n, ri, i)
 7023			}
 7024			x += ws[i] + 1
 7025		}
 7026	}
 7027}
 7028
 7029func (t *table) move(d int) {
 7030	if len(t.rows) == 0 {
 7031		return
 7032	}
 7033	t.sel += d
 7034	if t.sel < 0 {
 7035		t.sel = 0
 7036	}
 7037	if t.sel >= len(t.rows) {
 7038		t.sel = len(t.rows) - 1
 7039	}
 7040	if t.onSelect != nil {
 7041		t.onSelect(t.sel)
 7042	}
 7043}
 7044
 7045func (t *table) key(ev *tcell.EventKey) bool {
 7046	switch ev.Key() {
 7047	case tcell.KeyUp:
 7048		t.move(-1)
 7049	case tcell.KeyDown:
 7050		t.move(1)
 7051	case tcell.KeyPgUp:
 7052		t.move(-10)
 7053	case tcell.KeyPgDn:
 7054		t.move(10)
 7055	case tcell.KeyHome:
 7056		t.move(-len(t.rows))
 7057	case tcell.KeyEnd:
 7058		t.move(len(t.rows))
 7059	case tcell.KeyEnter:
 7060		if t.sel >= 0 && t.onActivate != nil {
 7061			t.onActivate(t.sel)
 7062		}
 7063	default:
 7064		return false
 7065	}
 7066	return true
 7067}
 7068
 7069// mouse handles a click or wheel at a position local to the table rect.
 7070//
 7071// A click on the row's link cell never gets here β€” that cell is an anchor
 7072// and the page follows it first. What is left is the rest of the row:
 7073// clicking it selects, which is what puts the row's photo beside the
 7074// table, and clicking the selected row again opens it.
 7075func (t *table) mouse(localY int, ev *tcell.EventMouse) bool {
 7076	btn := ev.Buttons()
 7077	switch {
 7078	// One product per notch. Three is the usual step for scrolling TEXT
 7079	// and wrong here, because the wheel moves the SELECTION: the row
 7080	// picked is the one whose photo is shown and whose name a second
 7081	// click opens, so three at a time skips two products rather than
 7082	// covering ground faster. The dropdown has always moved by one.
 7083	case btn&tcell.WheelUp != 0:
 7084		t.move(-1)
 7085	case btn&tcell.WheelDown != 0:
 7086		t.move(1)
 7087	case btn&tcell.Button1 != 0:
 7088		ri := t.offset + localY - 1 // row 0 is the header
 7089		if localY < 1 || ri < 0 || ri >= len(t.rows) {
 7090			return false
 7091		}
 7092		if ri == t.sel {
 7093			if t.onActivate != nil {
 7094				t.onActivate(ri)
 7095			}
 7096			break
 7097		}
 7098		t.sel = ri
 7099		if t.onSelect != nil {
 7100			t.onSelect(ri)
 7101		}
 7102	default:
 7103		return false
 7104	}
 7105	return true
 7106}
 7107
 7108// ---- list: the dropdown menus ----
 7109
 7110type list struct {
 7111	items  []string // markup labels
 7112	sel    int
 7113	offset int
 7114	onPick func(int)
 7115	// onKey sees keys first β€” the categories tree uses it for its
 7116	// expand/collapse keys.
 7117	onKey func(ev *tcell.EventKey, sel int) bool
 7118}
 7119
 7120func (l *list) draw(sc tcell.Screen, r rect) {
 7121	if l.sel >= 0 {
 7122		if l.sel < l.offset {
 7123			l.offset = l.sel
 7124		}
 7125		if l.sel >= l.offset+r.h {
 7126			l.offset = l.sel - r.h + 1
 7127		}
 7128	}
 7129	for i := 0; i < r.h; i++ {
 7130		li := l.offset + i
 7131		if li >= len(l.items) {
 7132			break
 7133		}
 7134		st := styleLink
 7135		if li == l.sel {
 7136			st = styleSel
 7137			fillRect(sc, rect{r.x, r.y + i, r.w, 1}, styleSel)
 7138		}
 7139		printMarkup(sc, r.x, r.y+i, r.w, l.items[li], st)
 7140	}
 7141}
 7142
 7143func (l *list) key(ev *tcell.EventKey) bool {
 7144	if l.onKey != nil && l.onKey(ev, l.sel) {
 7145		return true
 7146	}
 7147	switch ev.Key() {
 7148	case tcell.KeyUp:
 7149		if l.sel > 0 {
 7150			l.sel--
 7151		}
 7152	case tcell.KeyDown:
 7153		if l.sel < len(l.items)-1 {
 7154			l.sel++
 7155		}
 7156	case tcell.KeyHome:
 7157		l.sel = 0
 7158	case tcell.KeyEnd:
 7159		l.sel = len(l.items) - 1
 7160	case tcell.KeyEnter:
 7161		if l.onPick != nil && l.sel >= 0 {
 7162			l.onPick(l.sel)
 7163		}
 7164	default:
 7165		return false
 7166	}
 7167	return true
 7168}
 7169
 7170func (l *list) mouse(localY int, ev *tcell.EventMouse) bool {
 7171	btn := ev.Buttons()
 7172	switch {
 7173	case btn&tcell.WheelUp != 0:
 7174		if l.sel > 0 {
 7175			l.sel--
 7176		}
 7177	case btn&tcell.WheelDown != 0:
 7178		if l.sel < len(l.items)-1 {
 7179			l.sel++
 7180		}
 7181	case btn&tcell.Button1 != 0:
 7182		li := l.offset + localY
 7183		if li < 0 || li >= len(l.items) {
 7184			return false
 7185		}
 7186		l.sel = li
 7187		if l.onPick != nil {
 7188			l.onPick(li)
 7189		}
 7190	default:
 7191		return false
 7192	}
 7193	return true
 7194}
 7195
 7196// ---- form: Add Shipping Info ----
 7197
 7198type formItem interface {
 7199	label() string
 7200	// drawValue paints the value cell; the form places the cursor.
 7201	drawValue(sc tcell.Screen, x, y, w int, focused bool) (curX int, showCur bool)
 7202	key(ev *tcell.EventKey) bool
 7203	value() string
 7204}
 7205
 7206type finput struct {
 7207	lbl   string
 7208	text  []rune
 7209	cur   int
 7210	width int
 7211}
 7212
 7213func (f *finput) label() string { return f.lbl }
 7214func (f *finput) value() string { return strings.TrimSpace(string(f.text)) }
 7215
 7216func (f *finput) drawValue(sc tcell.Screen, x, y, w int, focused bool) (int, bool) {
 7217	if f.width < w {
 7218		w = f.width
 7219	}
 7220	st := styleField
 7221	fillRect(sc, rect{x, y, w, 1}, st)
 7222	start := 0
 7223	if f.cur >= w {
 7224		start = f.cur - w + 1
 7225	}
 7226	for i := 0; i < w && start+i < len(f.text); i++ {
 7227		sc.SetContent(x+i, y, f.text[start+i], nil, st)
 7228	}
 7229	return x + f.cur - start, focused
 7230}
 7231
 7232func (f *finput) key(ev *tcell.EventKey) bool {
 7233	switch ev.Key() {
 7234	case tcell.KeyRune:
 7235		ins := []rune(ev.Str())
 7236		f.text = append(f.text[:f.cur], append(ins, f.text[f.cur:]...)...)
 7237		f.cur += len(ins)
 7238	case tcell.KeyBackspace, tcell.KeyBackspace2:
 7239		if f.cur > 0 {
 7240			f.text = append(f.text[:f.cur-1], f.text[f.cur:]...)
 7241			f.cur--
 7242		}
 7243	case tcell.KeyDelete:
 7244		if f.cur < len(f.text) {
 7245			f.text = append(f.text[:f.cur], f.text[f.cur+1:]...)
 7246		}
 7247	case tcell.KeyLeft:
 7248		if f.cur > 0 {
 7249			f.cur--
 7250		}
 7251	case tcell.KeyRight:
 7252		if f.cur < len(f.text) {
 7253			f.cur++
 7254		}
 7255	case tcell.KeyHome:
 7256		f.cur = 0
 7257	case tcell.KeyEnd:
 7258		f.cur = len(f.text)
 7259	default:
 7260		return false
 7261	}
 7262	return true
 7263}
 7264
 7265// fdropdown selects among options with ←/β†’, or by typing a prefix (so
 7266// "t","x" lands on TX).
 7267type fdropdown struct {
 7268	lbl  string
 7269	opts []string
 7270	sel  int
 7271}
 7272
 7273func (f *fdropdown) label() string { return f.lbl }
 7274func (f *fdropdown) value() string { return f.opts[f.sel] }
 7275
 7276func (f *fdropdown) drawValue(sc tcell.Screen, x, y, w int, focused bool) (int, bool) {
 7277	st := styleField
 7278	label := f.opts[f.sel]
 7279	if label == "" {
 7280		label = "β€”"
 7281	}
 7282	txt := "β—‚ " + label + " β–Έ"
 7283	if len(txt) > w {
 7284		txt = txt[:w]
 7285	}
 7286	fillRect(sc, rect{x, y, w, 1}, st)
 7287	printMarkup(sc, x, y, w, esc(txt), st)
 7288	return x, false
 7289}
 7290
 7291func (f *fdropdown) key(ev *tcell.EventKey) bool {
 7292	switch {
 7293	case ev.Key() == tcell.KeyLeft:
 7294		if f.sel > 0 {
 7295			f.sel--
 7296		}
 7297	case ev.Key() == tcell.KeyRight:
 7298		if f.sel < len(f.opts)-1 {
 7299			f.sel++
 7300		}
 7301	case ev.Key() == tcell.KeyRune:
 7302		want := strings.ToUpper(ev.Str())
 7303		for i := 1; i <= len(f.opts); i++ {
 7304			o := f.opts[(f.sel+i)%len(f.opts)]
 7305			if strings.HasPrefix(strings.ToUpper(o), want) {
 7306				f.sel = (f.sel + i) % len(f.opts)
 7307				break
 7308			}
 7309		}
 7310	default:
 7311		return false
 7312	}
 7313	return true
 7314}
 7315
 7316type fbutton struct {
 7317	lbl string
 7318	fn  func()
 7319}
 7320
 7321// form lays items out one per row with a button row below, inside a
 7322// bordered box drawn by its owner.
 7323type form struct {
 7324	items    []formItem
 7325	buttons  []fbutton
 7326	focus    int // 0..len(items)-1, then buttons
 7327	cancel   func()
 7328	labelCol int
 7329}
 7330
 7331func newForm(items []formItem, buttons []fbutton, cancel func()) *form {
 7332	f := &form{items: items, buttons: buttons, cancel: cancel}
 7333	for _, it := range items {
 7334		if n := len(it.label()); n > f.labelCol {
 7335			f.labelCol = n
 7336		}
 7337	}
 7338	return f
 7339}
 7340
 7341func (f *form) draw(sc tcell.Screen, r rect) {
 7342	sc.HideCursor()
 7343	for i, it := range f.items {
 7344		y := r.y + i*2
 7345		if y >= r.y+r.h-1 {
 7346			break
 7347		}
 7348		printMarkup(sc, r.x, y, r.w, esc(it.label()), styleLink)
 7349		curX, show := it.drawValue(sc, r.x+f.labelCol+1, y, r.w-f.labelCol-1, f.focus == i)
 7350		if show && f.focus == i {
 7351			sc.ShowCursor(curX, y)
 7352		}
 7353	}
 7354	by := r.y + len(f.items)*2
 7355	x := r.x + 2
 7356	for i, b := range f.buttons {
 7357		st := styleButton
 7358		if f.focus == len(f.items)+i {
 7359			st = styleSel
 7360		}
 7361		lbl := "  " + b.lbl + "  "
 7362		printMarkup(sc, x, by, r.w, esc(lbl), st)
 7363		x += len(lbl) + 2
 7364	}
 7365}
 7366
 7367func (f *form) key(ev *tcell.EventKey) bool {
 7368	switch ev.Key() {
 7369	case tcell.KeyEscape:
 7370		if f.cancel != nil {
 7371			f.cancel()
 7372		}
 7373		return true
 7374	case tcell.KeyTab, tcell.KeyDown:
 7375		f.focus = (f.focus + 1) % (len(f.items) + len(f.buttons))
 7376		return true
 7377	case tcell.KeyBacktab, tcell.KeyUp:
 7378		f.focus--
 7379		if f.focus < 0 {
 7380			f.focus = len(f.items) + len(f.buttons) - 1
 7381		}
 7382		return true
 7383	case tcell.KeyEnter:
 7384		if f.focus >= len(f.items) {
 7385			f.buttons[f.focus-len(f.items)].fn()
 7386		} else {
 7387			f.focus++
 7388		}
 7389		return true
 7390	}
 7391	if f.focus < len(f.items) {
 7392		if f.items[f.focus].key(ev) {
 7393			return true
 7394		}
 7395	}
 7396	// Swallow stray runes so shortcuts never fire while a form is up.
 7397	return ev.Key() == tcell.KeyRune
 7398}
 7399
 7400
 7401// ===== pkg/tui/widgets_js.go =====
 7402//go:build js && wasm
 7403
 7404package tui
 7405
 7406import (
 7407	"image"
 7408	"math"
 7409	"strconv"
 7410	"strings"
 7411	"syscall/js"
 7412
 7413	"github.com/0magnet/calvin"
 7414	"github.com/0magnet/chaosrack/pkg/rasterview"
 7415	"github.com/0magnet/chaosrack/pkg/stlview"
 7416	"github.com/0magnet/websh/widget"
 7417)
 7418
 7419// The parts of the store that a page can show and a terminal cannot are this
 7420// program's own widgets, registered by the program itself (here, and the card
 7421// form in pay_js.go) rather than by the page it runs in: any websh that runs
 7422// this TUI in its page has them, and a terminal without placements shows the
 7423// cells drawn under them instead.
 7424
 7425func init() {
 7426	registerGlobe()
 7427	placementsShown = widget.Shown // asked each frame: the host answers childtty's probe after init
 7428}
 7429
 7430// offerWidgets registers the widgets that need to know the store.
 7431func (u *ui) offerWidgets() { registerCaps(u.sitedomain) }
 7432
 7433// siteURL is the store's address: the one being browsed, else the page's own.
 7434func siteURL() string {
 7435	if s := storeURL(); s != "" {
 7436		return s
 7437	}
 7438	return js.Global().Get("location").Get("origin").String()
 7439}
 7440
 7441// registerGlobe offers the website's landing picture as the widget "globe":
 7442// the home panel lays it over the cells where it draws its own logo and globe
 7443// (websh's placements). From the bottom up:
 7444//
 7445//   - the panel's own cells β€” the half-block logo β€” where nothing above
 7446//     covers them;
 7447//   - the site's logo.svg;
 7448//   - the globe above it, drawn again by the panel's own code in its cells'
 7449//     half blocks: the same mesh, pose and camera, so it lies exactly behind
 7450//     that globe and shows through its wireframe;
 7451//   - the landing view's globe β€” the same renderer, shaders and sphere
 7452//     (chaosrack's stlview) β€” which the person turns by dragging and zooms
 7453//     with the wheel.
 7454//
 7455// And it answers the pointer (see look):
 7456//
 7457//   - a click on the black around the logo stops it all β€” no frames, so no
 7458//     work on the CPU or the GPU β€” and a click on the logo or the globe
 7459//     starts it again;
 7460//   - a double click (or tap) on the logo hides it, showing the panel's own
 7461//     half-block logo under it, and another brings it back;
 7462//   - a double click on the globe steps through: the wireframe alone; the
 7463//     panel's globe alone, turning; no globe; and back to both.
 7464func registerGlobe() {
 7465	widget.Register("globe", mountGlobe)
 7466}
 7467
 7468// look is how the person has left the picture. It is the page's, not one
 7469// placement's: the TUI takes the placement away whenever a menu or the cart
 7470// crosses it, and puts a new one back after.
 7471var look struct {
 7472	paused     bool
 7473	logoHidden bool
 7474	stage      int // globeBoth, globeWire, globeCells, globeNone
 7475}
 7476
 7477const (
 7478	globeBoth = iota
 7479	globeWire
 7480	globeCells
 7481	globeNone
 7482)
 7483
 7484func mountGlobe(el js.Value) func() {
 7485	doc := js.Global().Get("document")
 7486	layer := func(tag, css string) js.Value {
 7487		e := doc.Call("createElement", tag)
 7488		e.Get("style").Set("cssText", "position:absolute;inset:0;width:100%;height:100%;"+css)
 7489		el.Call("append", e)
 7490		return e
 7491	}
 7492	logo := layer("img", "object-fit:contain")
 7493	logo.Set("src", siteURL()+"/i/logo.svg")
 7494	logo.Set("alt", "")
 7495
 7496	// The panel's globe: one pixel per half cell, shown as blocks, as the
 7497	// panel draws it.
 7498	shadow := layer("canvas", "image-rendering:pixelated;opacity:.85")
 7499	ctx := shadow.Call("getContext", "2d")
 7500
 7501	canvas := layer("canvas", "")
 7502	dpr := js.Global().Get("devicePixelRatio").Float()
 7503	if dpr <= 0 {
 7504		dpr = 1
 7505	}
 7506	canvas.Set("width", int(el.Get("clientWidth").Float()*dpr))
 7507	canvas.Set("height", int(el.Get("clientHeight").Float()*dpr))
 7508	g, err := stlview.NewGlobe(canvas)
 7509	if err != nil {
 7510		js.Global().Get("console").Call("warn", "globe: "+err.Error())
 7511		return func() { el.Set("textContent", "") }
 7512	}
 7513	g.Interact(canvas)
 7514
 7515	var img *image.RGBA
 7516	var buf js.Value
 7517	drawShadow := func() {
 7518		cols, _ := strconv.Atoi(el.Get("dataset").Get("cols").String()) //nolint:errcheck // websh sets it; zero draws nothing
 7519		rows, _ := strconv.Atoi(el.Get("dataset").Get("rows").String()) //nolint:errcheck // as above
 7520		if cols <= 0 || rows <= 0 {
 7521			return
 7522		}
 7523		w, h := cols, rows*2
 7524		if img == nil || img.Bounds().Dx() != w || img.Bounds().Dy() != h {
 7525			img = image.NewRGBA(image.Rect(0, 0, w, h))
 7526			buf = js.Global().Get("Uint8ClampedArray").New(len(img.Pix))
 7527			shadow.Set("width", w)
 7528			shadow.Set("height", h)
 7529		}
 7530		// A half cell is as wide as the terminal's font makes it, not
 7531		// square: its width over its height on the page.
 7532		aspect := 1.0
 7533		if ew, eh := el.Get("clientWidth").Float(), el.Get("clientHeight").Float(); ew > 0 && eh > 0 {
 7534			aspect = (ew / float64(w)) / (eh / float64(h))
 7535		}
 7536		// The globe's size on the page: its share of half the height, in a
 7537		// frame whose shorter side, measured on the page, sets the scale.
 7538		scale := g.Radius() * float64(h) / math.Min(float64(w)*aspect, float64(h))
 7539		ax, ay, az := g.Pose()
 7540		verts, lines := g.Mesh()
 7541		globeAt(img, verts, lines, ax, ay, az, g.Distance(), scale, aspect)
 7542		js.CopyBytesToJS(buf, img.Pix)
 7543		ctx.Call("putImageData", js.Global().Get("ImageData").New(buf, w, h), 0, 0)
 7544	}
 7545
 7546	// The panel's globe turns at the panel's own pace (its 140ms tick): it
 7547	// is the panel's globe, and every display frame would cost the tab for
 7548	// it. Its frames run only while it is seen and turning.
 7549	var (
 7550		gone     bool
 7551		shadowOn bool
 7552		raf      js.Value
 7553		frame    js.Func
 7554		last     float64
 7555	)
 7556	frame = js.FuncOf(func(_ js.Value, args []js.Value) any {
 7557		if gone || !shadowOn {
 7558			return nil
 7559		}
 7560		raf = js.Global().Call("requestAnimationFrame", frame)
 7561		if now := args[0].Float(); now-last >= 140 {
 7562			last = now
 7563			drawShadow()
 7564		}
 7565		return nil
 7566	})
 7567	runShadow := func(on bool) {
 7568		if on == shadowOn {
 7569			return
 7570		}
 7571		shadowOn = on
 7572		if on {
 7573			raf = js.Global().Call("requestAnimationFrame", frame)
 7574		} else {
 7575			js.Global().Call("cancelAnimationFrame", raf)
 7576		}
 7577	}
 7578
 7579	shown := func(e js.Value, on bool) {
 7580		v := "hidden"
 7581		if on {
 7582			v = ""
 7583		}
 7584		e.Get("style").Set("visibility", v)
 7585	}
 7586	apply := func() {
 7587		wire := look.stage == globeBoth || look.stage == globeWire
 7588		cells := look.stage == globeBoth || look.stage == globeCells
 7589		shown(logo, !look.logoHidden)
 7590		shown(canvas, wire)
 7591		shown(shadow, cells)
 7592		// Not drawn, the wireframe still turns, for the panel's globe to
 7593		// follow; with no globe at all, nothing runs.
 7594		g.SetDrawn(wire)
 7595		if look.paused || look.stage == globeNone {
 7596			g.Pause()
 7597		} else {
 7598			g.Resume()
 7599		}
 7600		runShadow(cells && !look.paused)
 7601		if cells {
 7602			drawShadow() // as it is now, turning or not
 7603		}
 7604	}
 7605
 7606	// Where on the picture a point (in the element's own pixels) is.
 7607	const (
 7608		onBlack = iota
 7609		onLogo
 7610		onGlobe
 7611	)
 7612	region := func(x, y float64) int {
 7613		w, h := el.Get("clientWidth").Float(), el.Get("clientHeight").Float()
 7614		if r := g.Radius() * h / 2; math.Hypot(x-w/2, y-h/2) <= r {
 7615			return onGlobe
 7616		}
 7617		lx, ly, lw, lh := 0.0, 0.0, w, h
 7618		if nw, nh := logo.Get("naturalWidth").Float(), logo.Get("naturalHeight").Float(); nw > 0 && nh > 0 {
 7619			s := math.Min(w/nw, h/nh) // object-fit: contain
 7620			lw, lh = nw*s, nh*s
 7621			lx, ly = (w-lw)/2, (h-lh)/2
 7622		}
 7623		if x >= lx && x < lx+lw && y >= ly && y < ly+lh {
 7624			return onLogo
 7625		}
 7626		return onBlack
 7627	}
 7628
 7629	// Clicks are counted here rather than taken from dblclick, which a
 7630	// double tap does not reliably give; and a drag that turned the globe is
 7631	// not a click.
 7632	var (
 7633		downX, downY float64
 7634		lastT        float64
 7635		lastAt       = -1
 7636	)
 7637	type handler struct {
 7638		name string
 7639		fn   js.Func
 7640	}
 7641	var handlers []handler
 7642	on := func(name string, f func(e js.Value)) {
 7643		fn := js.FuncOf(func(_ js.Value, args []js.Value) any {
 7644			f(args[0])
 7645			return nil
 7646		})
 7647		el.Call("addEventListener", name, fn, true)
 7648		handlers = append(handlers, handler{name, fn})
 7649	}
 7650	on("pointerdown", func(e js.Value) {
 7651		downX, downY = e.Get("clientX").Float(), e.Get("clientY").Float()
 7652	})
 7653	on("click", func(e js.Value) {
 7654		cx, cy := e.Get("clientX").Float(), e.Get("clientY").Float()
 7655		if math.Hypot(cx-downX, cy-downY) > 5 {
 7656			return // a drag
 7657		}
 7658		box := el.Call("getBoundingClientRect")
 7659		at := region(cx-box.Get("left").Float(), cy-box.Get("top").Float())
 7660		look.paused = at == onBlack
 7661		if t := e.Get("timeStamp").Float(); at == lastAt && t-lastT < 400 {
 7662			switch at {
 7663			case onGlobe:
 7664				look.stage = (look.stage + 1) % 4
 7665			case onLogo:
 7666				look.logoHidden = !look.logoHidden
 7667			}
 7668			lastAt = -1
 7669		} else {
 7670			lastT, lastAt = t, at
 7671		}
 7672		apply()
 7673	})
 7674	apply()
 7675
 7676	return func() {
 7677		gone = true
 7678		runShadow(false)
 7679		frame.Release()
 7680		for _, h := range handlers {
 7681			el.Call("removeEventListener", h.name, h.fn, true)
 7682			h.fn.Release()
 7683		}
 7684		g.Stop()
 7685		el.Set("textContent", "")
 7686	}
 7687}
 7688
 7689// registerCaps offers the domain in the page's double-struck capitals as the
 7690// widget "caps", for the line the home panel prints it on. A terminal has
 7691// them only in fonts that draw them twice the width of a cell, so the panel
 7692// prints plain capitals; a page can set them as the website does, over them.
 7693func registerCaps(domain string) {
 7694	text := calvin.BlackboardBold(strings.ToUpper(domain))
 7695	widget.Register("caps", func(el js.Value) func() {
 7696		el.Get("style").Set("containerType", "size")
 7697		d := js.Global().Get("document").Call("createElement", "div")
 7698		d.Get("style").Set("cssText", "position:absolute;inset:0;display:flex;align-items:center;justify-content:center;"+
 7699			"background:#000;color:#fff;white-space:nowrap;line-height:1;font-size:85cqh;font-family:inherit")
 7700		d.Set("textContent", text)
 7701		el.Call("append", d)
 7702		return func() { el.Set("textContent", "") }
 7703	})
 7704}
 7705
 7706// globeAt draws a globe in the home panel's manner β€” its depth cue, and its
 7707// colors in the panel's sixteen steps β€” over a clear dst. The lines are the
 7708// mesh given (vertices as xyz, indices as endpoint pairs), posed by the angles
 7709// of RxΒ·RyΒ·Rz, seen from dist (0 for none) and filling scale of half the frame,
 7710// in pixels aspect as wide as they are tall, colored by y from red to blue: stlview's Globe gives all of these as it
 7711// draws itself (Mesh, Pose, Distance, Radius), so a page draws the panel's
 7712// version exactly behind its own β€” the "globe" widget does (widgets_js.go).
 7713func globeAt(dst *image.RGBA, vertices []float32, indices []uint16, ax, ay, az, dist, scale, aspect float64) {
 7714	clear(dst.Pix)
 7715	view := rasterview.View{AngleX: ax, AngleY: ay, AngleZ: az, Dist: dist, BackDim: 0.5, Scale: scale, PixelAspect: aspect}
 7716	g := rasterview.DefaultGradient()
 7717	g.Source = 1 // y, as the page's shader colors it
 7718	view.Render(dst, vertices, indices, g)
 7719	for i := 0; i+3 < len(dst.Pix); i += 4 {
 7720		if dst.Pix[i+3] != 0 {
 7721			for k := range 3 {
 7722				dst.Pix[i+k] = dst.Pix[i+k]&0xF0 | dst.Pix[i+k]>>4 // step16
 7723			}
 7724		}
 7725	}
 7726}
 7727
 7728
 7729// ===== pkg/tui/widgets_native.go =====
 7730//go:build !js || !wasm
 7731
 7732package tui
 7733
 7734// offerWidgets has nothing to offer where there is no page: the cells are
 7735// the picture. See widgets_js.go.
 7736func (u *ui) offerWidgets() {}
 7737
 7738
 7739// ===== pkg/web/app.go =====
 7740// Package web pkg/web/app.go β€” the fiber application, buildable for two hosts.
 7741//
 7742// Serve (native) and the in-tab site server (js/wasm β€” pkg/storepane's
 7743// `serve` command, listening on the bottle vnet loopback) construct the same
 7744// application from the same templates and handlers. AppOpts carries the only
 7745// differences: where the request log goes, and the origin to lean on for
 7746// everything a browser tab must not or cannot hold β€” the Stripe secret key,
 7747// files on disk, and the toolchain that compiles wasm drop-ins.
 7748package web
 7749
 7750import (
 7751	"context"
 7752	"errors"
 7753	"fmt"
 7754	"io"
 7755	"net/http"
 7756	"os"
 7757	"strings"
 7758	"time"
 7759
 7760	"github.com/gofiber/fiber/v3"
 7761	"github.com/gofiber/fiber/v3/middleware/static"
 7762
 7763	"github.com/0magnet/bottle"
 7764)
 7765
 7766// AppOpts selects the host the app is being built for.
 7767type AppOpts struct {
 7768	// LogOutput receives the request log; nil keeps the native default
 7769	// (stdout). The in-tab server points this at its shell's terminal, so
 7770	// browsing the vnet site scrolls an access log like any server.
 7771	LogOutput io.Writer
 7772
 7773	// ProxyOrigin, when non-empty, marks the in-tab role. Routes that need
 7774	// the host machine (wasm compilation, source tarballs, images and fonts
 7775	// on disk, the logo pipeline, CUPS printing) are not registered β€” a GET
 7776	// that matches nothing is fetched from this origin instead β€” and the
 7777	// payment endpoints forward there, so the tab renders the store but
 7778	// never holds a secret key.
 7779	ProxyOrigin string
 7780}
 7781
 7782// NewApp builds the store's fiber application. It does not listen; Serve
 7783// (native) and the in-tab server each own their listener.
 7784func NewApp(o AppOpts) *fiber.App {
 7785	initTMPL()
 7786	logOut := o.LogOutput
 7787	if logOut == nil {
 7788		logOut = os.Stdout
 7789	}
 7790	inTab := o.ProxyOrigin != ""
 7791	// In the tab the drop-ins are named from the origin, not from a disk
 7792	// that carries none of them.
 7793	if inTab {
 7794		serveFromOrigin(o.ProxyOrigin)
 7795	}
 7796
 7797	r := fiber.New(fiber.Config{
 7798		ErrorHandler: func(c fiber.Ctx, err error) error {
 7799			code := fiber.StatusInternalServerError
 7800			var e *fiber.Error
 7801			if errors.As(err, &e) {
 7802				code = e.Code
 7803			}
 7804			c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
 7805			return c.Status(code).SendString(err.Error())
 7806		},
 7807	})
 7808
 7809	r.Use(func(c fiber.Ctx) error {
 7810		start := time.Now()
 7811		err := c.Next()
 7812		status := c.Response().StatusCode()
 7813		// Unmatched routes and handler errors are turned into their real
 7814		// status by the app ErrorHandler after this middleware returns, so
 7815		// c.Response() still reads 200 here. Recover the true code from the
 7816		// error so 404s (and 5xx) are logged accurately rather than as 200.
 7817		if err != nil {
 7818			var fe *fiber.Error
 7819			if errors.As(err, &fe) {
 7820				status = fe.Code
 7821			} else {
 7822				status = fiber.StatusInternalServerError
 7823			}
 7824		}
 7825		lat := time.Since(start)
 7826		colors := c.App().Config().ColorScheme
 7827		ip := fmt.Sprintf("%*s", 15, c.IP())
 7828		ipsStr := strings.Join(c.IPs(), ", ")
 7829		ips := fmt.Sprintf("%*s", 15, ipsStr)
 7830		method := fmt.Sprintf("%-*s", 6, c.Method())
 7831		statCol := statusColor(status, colors) + fmt.Sprintf("%3d", status) + colors.Reset
 7832		methCol := methodColor(c.Method(), colors) + method + colors.Reset
 7833		fmt.Fprintf(logOut, "%s | %s | %12s | %s | %s | %s | %s\n", time.Now().Format("2006-01-02 15:04:05"), statCol, lat, ip, ips, methCol, c.Path()) //nolint:errcheck,gosec
 7834		return err
 7835	})
 7836
 7837	// What anyone may read β€” the catalog, the site, its pages, its images and
 7838	// the store program β€” any origin may read too: a websh served elsewhere
 7839	// runs the store TUI against this site (wasm/store). Nothing that writes,
 7840	// and nothing behind a cookie, is shared this way.
 7841	r.Use(func(c fiber.Ctx) error {
 7842		if m := c.Method(); (m == fiber.MethodGet || m == fiber.MethodHead) && sharedRead(c.Path()) {
 7843			c.Set(fiber.HeaderAccessControlAllowOrigin, "*")
 7844		}
 7845		return c.Next()
 7846	})
 7847
 7848	if !inTab {
 7849		// The desk page's OS layer and browser engine, served ahead of any
 7850		// wasm module: jsfs/vnet must exist before Go captures globalThis.fs.
 7851		jsAsset := func(body []byte) fiber.Handler {
 7852			return func(c fiber.Ctx) error {
 7853				c.Set(fiber.HeaderContentType, "text/javascript; charset=utf-8")
 7854				c.Set(fiber.HeaderCacheControl, "public, max-age=3600")
 7855				return c.Send(body)
 7856			}
 7857		}
 7858		r.Get("/bottle/jsfs.js", jsAsset(bottle.JSFS()))
 7859		r.Get("/bottle/vnet.js", jsAsset(bottle.VNetJS()))
 7860		r.Get("/bottle/proc.js", jsAsset(bottle.ProcJS()))
 7861		serveSourceCode(r)
 7862		serveWASM(r)
 7863		serveBrowseResponder(r)
 7864		r.Get("/logo", logo)
 7865		r.Get("/logo/:width", logo)
 7866		r.Get("/logo/:width/:height", logo)
 7867		// The ports that draw: a product image as ANSI art turned into HTML, and
 7868		// a heading as a FIGlet banner. Nothing on the site links to either β€”
 7869		// they are here for the decorator on /test to call.
 7870		r.Get("/art/:cat/:name", art)
 7871		r.Get("/banner", banner)
 7872		r.Get("/logo.png", sendFile)
 7873		r.Get("/logo.html", sendFile)
 7874		r.Get("/mobilelogo.html", sendFile)
 7875		r.Get("/logolarge.html", sendFile)
 7876		r.Get("/favicon.ico", sendImage)
 7877		if f.Siteimagesrc == "" {
 7878			r.Use("/i", static.New("./img"))
 7879			r.Use("/img", static.New("./img"))
 7880		}
 7881		r.Use("/font", static.New("./font"))
 7882		r.Get("/stl/:filename", func(c fiber.Ctx) error {
 7883			name := c.Params("filename")
 7884			if strings.ContainsAny(name, "/\\..") || strings.Contains(name, "..") {
 7885				return c.SendStatus(fiber.StatusBadRequest)
 7886			}
 7887			return c.SendFile("./img/stl/" + name)
 7888		})
 7889		r.Get("/stl/base64/:filename", stlbase64)
 7890		r.Get("/tui", tuipage)
 7891		r.Get("/desk", deskpage)
 7892	}
 7893
 7894	r.Get("/robots.txt", robots)
 7895	r.Get("/site.webmanifest", func(c fiber.Ctx) error {
 7896		return c.JSON(fiber.Map{
 7897			"name":       f.Sitelongname,
 7898			"short_name": f.Sitename,
 7899			"icons": []fiber.Map{
 7900				{"src": f.Siteimagesrc + "/i/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png"},
 7901				{"src": f.Siteimagesrc + "/i/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png"},
 7902			},
 7903			"theme_color":      "#ffffff",
 7904			"background_color": "#ffffff",
 7905			"display":          "standalone",
 7906		})
 7907	})
 7908	r.Get("/api/products", apiproducts)
 7909	r.Get("/api/site", apisite)
 7910	r.Get("/api/content/:name", apicontent)
 7911	r.Get("/sitemap", sitemap)
 7912	r.Get("/sitemap.xml", sitemap)
 7913	r.Get("/", homepage)
 7914	r.Get("/p/:partno", productpage)
 7915	r.Get("/post/:partno", handlecat)
 7916	r.Get("/p", handlecat)
 7917	r.Get("/cat", handlecat)
 7918	r.Get("/cat/:cat", handlecat)
 7919	r.Get("/cat/:cat/:subcat", handlecat)
 7920	r.Get("/style.css", style)
 7921	r.Get("/font.css", fontcss)
 7922
 7923	if !inTab {
 7924		for _, register := range extraRoutes {
 7925			register(r)
 7926		}
 7927		handleOrder(r)
 7928		// Crypto checkout, if a wallet is configured; the poller that prints
 7929		// a receipt when a payment confirms starts with it.
 7930		handleInvoice(r)
 7931		go WatchInvoices(context.Background())
 7932	} else {
 7933		// The drop-ins this binary carries, ahead of the catch-all so they are
 7934		// answered from the embed rather than fetched back off the origin.
 7935		serveCarriedWASM(r)
 7936		// Money crosses back to the real server: the tab renders the cart,
 7937		// the origin holds the Stripe key and the order book.
 7938		r.Post("/create-payment-intent", proxyToOrigin(o.ProxyOrigin))
 7939		r.Post("/submit-order", proxyToOrigin(o.ProxyOrigin))
 7940		// Everything the tab does not carry β€” images, fonts, STL models,
 7941		// the logo pipeline, /complete's Stripe lookup β€” reads through to
 7942		// the origin. Registered last, so it is the 404 path.
 7943		r.Use(proxyToOrigin(o.ProxyOrigin))
 7944	}
 7945	return r
 7946}
 7947
 7948// proxyToOrigin relays the request to the real origin and copies the answer
 7949// back. Under js/wasm net/http rides the browser's fetch, so a same-origin
 7950// relay needs no credentials or CORS ceremony.
 7951func proxyToOrigin(origin string) fiber.Handler {
 7952	return func(c fiber.Ctx) error {
 7953		method := c.Method()
 7954		if method != fiber.MethodGet && method != fiber.MethodPost {
 7955			return fiber.ErrNotFound
 7956		}
 7957		var body io.Reader
 7958		if method == fiber.MethodPost {
 7959			body = strings.NewReader(string(c.Body()))
 7960		}
 7961		req, err := http.NewRequest(method, origin+c.OriginalURL(), body)
 7962		if err != nil {
 7963			return fiber.ErrBadGateway
 7964		}
 7965		if ct := c.Get(fiber.HeaderContentType); ct != "" {
 7966			req.Header.Set(fiber.HeaderContentType, ct)
 7967		}
 7968		resp, err := http.DefaultClient.Do(req)
 7969		if err != nil {
 7970			return fiber.ErrBadGateway
 7971		}
 7972		defer resp.Body.Close() //nolint:errcheck,gosec
 7973		payload, err := io.ReadAll(resp.Body)
 7974		if err != nil {
 7975			return fiber.ErrBadGateway
 7976		}
 7977		if ct := resp.Header.Get("Content-Type"); ct != "" {
 7978			c.Set(fiber.HeaderContentType, ct)
 7979		}
 7980		return c.Status(resp.StatusCode).Send(payload)
 7981	}
 7982}
 7983
 7984// sharedRead reports whether path is public, read-only data any origin may
 7985// fetch.
 7986func sharedRead(path string) bool {
 7987	if path == "/api/products" || path == "/api/site" || path == "/font.css" || path == "/store.wasm" || path == "/store-tiny.wasm" {
 7988		return true
 7989	}
 7990	for _, p := range []string{"/api/content/", "/i/", "/img/"} {
 7991		if strings.HasPrefix(path, p) {
 7992			return true
 7993		}
 7994	}
 7995	return false
 7996}
 7997
 7998
 7999// ===== pkg/web/art.go =====
 8000package web
 8001
 8002// Two endpoints that draw with the ports: a product image as ANSI art turned
 8003// into HTML, and a heading as a FIGlet banner.
 8004//
 8005// Server side rather than in the wasm decorator, for two different reasons.
 8006// The image art needs a JPEG decode and a dither per product, which is real
 8007// work to do fourteen times in a browser and is the same answer every time β€”
 8008// so it is done once here and cached. The banner needs the FIGlet fonts, and
 8009// there are eight hundred kilobytes of them; embedding that in a decorator to
 8010// draw a dozen headings is the wrong trade, while here they are already in the
 8011// binary.
 8012//
 8013// Nothing on the site links to either. They exist for the decorator on /test to
 8014// call, so the served HTML is unchanged and a browser that does not run the
 8015// wasm sees the page it always saw.
 8016
 8017import (
 8018	"bytes"
 8019	"fmt"
 8020	"io"
 8021	"net/http"
 8022	"os"
 8023	"path/filepath"
 8024	"strconv"
 8025	"strings"
 8026	"sync"
 8027	"time"
 8028
 8029	"github.com/gofiber/fiber/v3"
 8030
 8031	"github.com/0magnet/ansifilter-go/ansifilter"
 8032	"github.com/0magnet/img2txt-go/caca"
 8033	"github.com/0magnet/toilet-go/figlet"
 8034	"github.com/0magnet/toilet-go/fonts"
 8035)
 8036
 8037// artCache keys rendered art by everything that changes it. Both renders are
 8038// pure functions of their inputs and neither is cheap, so nothing is drawn
 8039// twice.
 8040var (
 8041	artCache = map[string]string{}
 8042	artMu    sync.Mutex
 8043)
 8044
 8045func cached(key string, make func() (string, error)) (string, error) {
 8046	artMu.Lock()
 8047	hit, ok := artCache[key]
 8048	artMu.Unlock()
 8049	if ok {
 8050		return hit, nil
 8051	}
 8052	out, err := make()
 8053	if err != nil {
 8054		return "", err
 8055	}
 8056	artMu.Lock()
 8057	artCache[key] = out
 8058	artMu.Unlock()
 8059	return out, nil
 8060}
 8061
 8062// artWidth keeps a request from asking for a canvas big enough to be a denial
 8063// of service. Eighty is a terminal; two hundred is already wider than the
 8064// column the page gives it.
 8065const (
 8066	artMin = 8
 8067	artMax = 200
 8068)
 8069
 8070// art renders one product image as ANSI art and exports it as HTML.
 8071//
 8072// The chain is the one the store already had most of: img2txt-go decodes and
 8073// dithers the image into a libcaca canvas, which exports ANSI β€” the same
 8074// renderer the terminal UI draws products with β€” and ansifilter-go turns that
 8075// ANSI into HTML. Going straight from the canvas to caca's own HTML export
 8076// would skip the middle step and lose the point: the ANSI is what the terminal
 8077// shows, so passing it through is what makes the two agree.
 8078func art(c fiber.Ctx) error {
 8079	cols := 60
 8080	if v, err := strconv.Atoi(c.Query("cols")); err == nil {
 8081		cols = v
 8082	}
 8083	cols = min(max(cols, artMin), artMax)
 8084	cat, name := c.Params("cat"), c.Params("name")
 8085	if !safeSegment(cat) || !safeSegment(name) {
 8086		return c.Status(fiber.StatusBadRequest).SendString("bad path")
 8087	}
 8088	src := imgPath(cat, name)
 8089	out, err := cached(fmt.Sprintf("art\x00%s\x00%d", src, cols), func() (string, error) {
 8090		return renderArt(src, cols)
 8091	})
 8092	if err != nil {
 8093		return c.Status(fiber.StatusNotFound).SendString(err.Error())
 8094	}
 8095	c.Set("Content-Type", "text/html;charset=utf-8")
 8096	c.Set("Cache-Control", "public, max-age=86400")
 8097	return c.SendString(out)
 8098}
 8099
 8100func renderArt(src string, cols int) (string, error) {
 8101	data, err := fetch(src)
 8102	if err != nil {
 8103		return "", err
 8104	}
 8105	im, err := caca.DecodeImage(bytes.NewReader(data))
 8106	if err != nil {
 8107		return "", err
 8108	}
 8109	if im.W == 0 || im.H == 0 {
 8110		return "", fmt.Errorf("image has no size")
 8111	}
 8112	// img2txt's 6x10 font cell, so the art keeps the picture's proportions
 8113	// instead of coming out squashed by the shape of a character.
 8114	rows := cols * im.H * 6 / im.W / 10
 8115	if rows < 1 {
 8116		rows = 1
 8117	}
 8118	cv := caca.NewCanvas(cols, rows)
 8119	cv.SetColorANSI(caca.Default, caca.Transparent)
 8120	cv.Clear()
 8121	im.Dither.SetAlgorithm("fstein")
 8122	cv.DitherBitmap(0, 0, cols, rows, im.Dither, im.Pixels)
 8123	ansi, ok := cv.Export("ansi")
 8124	if !ok {
 8125		return "", fmt.Errorf("canvas will not export ansi")
 8126	}
 8127	g := ansifilter.New(ansifilter.HTML)
 8128	g.SetFragmentCode(true)
 8129	return g.GenerateString(string(ansi)), nil
 8130}
 8131
 8132// banner renders a line of text as a FIGlet banner.
 8133func banner(c fiber.Ctx) error {
 8134	// A query parameter, not a path segment. Fiber hands back path params still
 8135	// percent-encoded, and rendering those gives a banner reading
 8136	// DIODE%20%E2%86%92%201N40XX, which is exactly what went live.
 8137	text := c.Query("text")
 8138	if text == "" {
 8139		return c.Status(fiber.StatusBadRequest).SendString("no text")
 8140	}
 8141	if len(text) > 64 {
 8142		text = text[:64]
 8143	}
 8144	font := c.Query("font")
 8145	if font == "" {
 8146		font = "future"
 8147	}
 8148	width := 200
 8149	if v, err := strconv.Atoi(c.Query("width")); err == nil && v > 0 {
 8150		width = min(v, 400)
 8151	}
 8152	out, err := cached(fmt.Sprintf("banner\x00%s\x00%s\x00%d", text, font, width), func() (string, error) {
 8153		return renderBanner(text, font, width)
 8154	})
 8155	if err != nil {
 8156		return c.Status(fiber.StatusNotFound).SendString(err.Error())
 8157	}
 8158	c.Set("Content-Type", "text/plain;charset=utf-8")
 8159	c.Set("Cache-Control", "public, max-age=86400")
 8160	return c.SendString(out)
 8161}
 8162
 8163func renderBanner(text, font string, width int) (string, error) {
 8164	raw, ok := fonts.Get(font)
 8165	if !ok {
 8166		return "", fmt.Errorf("no font %q", font)
 8167	}
 8168	fnt, err := figlet.ParseFont(raw)
 8169	if err != nil {
 8170		return "", err
 8171	}
 8172	r := figlet.NewRenderer(fnt)
 8173	r.SetWidth(width)
 8174	r.SetMode(figlet.ParseMode("smush"))
 8175	for _, ch := range text {
 8176		r.PutChar(ch)
 8177	}
 8178	cv := r.Flush()
 8179	if cv == nil {
 8180		return "", fmt.Errorf("nothing rendered")
 8181	}
 8182	utf8, ok := cv.Export("utf8")
 8183	if !ok {
 8184		return "", fmt.Errorf("canvas will not export utf8")
 8185	}
 8186	return strings.TrimRight(string(utf8), "\n"), nil
 8187}
 8188
 8189// safeSegment rejects anything that could climb out of the image directory.
 8190// The two path parts come off the wire, so they are the one thing here a
 8191// visitor controls.
 8192func safeSegment(s string) bool {
 8193	if s == "" || s == "." || s == ".." {
 8194		return false
 8195	}
 8196	return !strings.ContainsAny(s, "/\\") && !strings.Contains(s, "..")
 8197}
 8198
 8199// imgPath is where a product image lives: on disk under ./img when the site
 8200// serves its own, and behind Siteimagesrc when it does not β€” the same two cases
 8201// the template's ImgSRC covers.
 8202func imgPath(cat, name string) string {
 8203	if f.Siteimagesrc == "" {
 8204		return filepath.Join("img", cat, name)
 8205	}
 8206	return f.Siteimagesrc + "/i/" + cat + "/" + name
 8207}
 8208
 8209// fetch reads an image from wherever imgPath put it, on disk or over http.
 8210func fetch(src string) ([]byte, error) {
 8211	if !strings.HasPrefix(src, "http://") && !strings.HasPrefix(src, "https://") {
 8212		return os.ReadFile(src) //nolint:gosec // safeSegment has already refused anything with a separator in it
 8213	}
 8214	client := &http.Client{Timeout: 20 * time.Second}
 8215	resp, err := client.Get(src) //nolint:gosec // ImgSRC is the operator's own
 8216	if err != nil {
 8217		return nil, err
 8218	}
 8219	defer resp.Body.Close() //nolint:errcheck // read-side close
 8220	if resp.StatusCode != http.StatusOK {
 8221		return nil, fmt.Errorf("%s", resp.Status)
 8222	}
 8223	return io.ReadAll(io.LimitReader(resp.Body, 16<<20))
 8224}
 8225
 8226
 8227// ===== pkg/web/art_test.go =====
 8228package web
 8229
 8230import (
 8231	"os"
 8232	"path/filepath"
 8233	"strings"
 8234	"testing"
 8235)
 8236
 8237// The two renders have to work end to end, since nothing else exercises the
 8238// chain: img2txt decodes and dithers, the canvas exports ANSI, ansifilter turns
 8239// that into HTML.
 8240func TestRenderArt(t *testing.T) {
 8241	var sample string
 8242	err := filepath.WalkDir("../../img", func(p string, d os.DirEntry, err error) error {
 8243		if err != nil || d.IsDir() || sample != "" {
 8244			return nil //nolint:nilerr // a missing image directory is not this test's business
 8245		}
 8246		switch strings.ToLower(filepath.Ext(p)) {
 8247		case ".jpg", ".jpeg", ".png", ".gif":
 8248			sample = p
 8249		}
 8250		return nil
 8251	})
 8252	if err != nil || sample == "" {
 8253		t.Skip("no product images on disk to render")
 8254	}
 8255	out, err := renderArt(sample, 40)
 8256	if err != nil {
 8257		t.Fatalf("rendering %s: %v", sample, err)
 8258	}
 8259	if !strings.Contains(out, "<span") {
 8260		t.Errorf("no colored spans in the output; ansifilter produced %.80q", out)
 8261	}
 8262	if n := strings.Count(out, "\n"); n < 4 {
 8263		t.Errorf("only %d lines of art, expected the image to fill a few", n)
 8264	}
 8265}
 8266
 8267// A banner has to come back as several lines of art, not as the text itself.
 8268func TestRenderBanner(t *testing.T) {
 8269	out, err := renderBanner("resistor", "future", 200)
 8270	if err != nil {
 8271		t.Fatal(err)
 8272	}
 8273	lines := strings.Split(out, "\n")
 8274	if len(lines) < 3 {
 8275		t.Fatalf("a banner should be several rows tall, got %d: %q", len(lines), out)
 8276	}
 8277	if strings.Contains(out, "resistor") {
 8278		t.Error("the banner is the literal text, so no font was applied")
 8279	}
 8280	t.Logf("banner is %d lines, widest %d", len(lines), widest(lines))
 8281	for _, l := range lines {
 8282		t.Log(l)
 8283	}
 8284}
 8285
 8286func widest(lines []string) int {
 8287	w := 0
 8288	for _, l := range lines {
 8289		if n := len([]rune(l)); n > w {
 8290			w = n
 8291		}
 8292	}
 8293	return w
 8294}
 8295
 8296// The path guard is the only place a visitor's input reaches the filesystem.
 8297func TestSafeSegment(t *testing.T) {
 8298	for _, bad := range []string{"", ".", "..", "../etc", "a/b", `a\b`, "x..y"} {
 8299		if safeSegment(bad) {
 8300			t.Errorf("%q was accepted", bad)
 8301		}
 8302	}
 8303	for _, good := range []string{"diode", "1N4148.jpg", "a-b_c.png"} {
 8304		if !safeSegment(good) {
 8305			t.Errorf("%q was refused", good)
 8306		}
 8307	}
 8308}
 8309
 8310
 8311// ===== pkg/web/browse.go =====
 8312package web
 8313
 8314// The real-origin browse substrate: giving the desk's nested browser a genuine
 8315// origin to render the in-tab store at.
 8316//
 8317// netscrape renders a fetched page into a sandboxed srcdoc with an opaque
 8318// origin, which cannot reach the tab's virtual loopback at all; its relay
 8319// carries stylesheets and images and nothing else. That is enough for a page
 8320// of text and pictures and is structurally unable to run a wasm drop-in: a
 8321// <script src> is never fetched, and an in-page WebAssembly.instantiateStreaming
 8322// could not reach vnet even if it were. So the store inside the desk had a
 8323// cart that could not add and a logo the attractor never drew over.
 8324//
 8325// github.com/0magnet/realorigin fixes the class of problem rather than the
 8326// symptom. The store gets its own browser origin B, distinct from the site's
 8327// origin V, whose network layer is a service worker relaying every fetch back
 8328// to a responder on V β€” which answers it from the in-tab server over vnet. The
 8329// page is then an ordinary document: native subresource loading, real fetch,
 8330// real wasm, cookies and storage of its own.
 8331//
 8332// Two things make it cheap here where it is expensive in general:
 8333//
 8334//   - There is exactly ONE target, the tab's own store, so there is exactly one
 8335//     browse origin. realorigin content-addresses origins precisely so a
 8336//     wildcard can cover a whole address space; one fixed target hashes to one
 8337//     fixed hostname, which needs one ordinary certificate and no wildcard, no
 8338//     DNS-01 and no DNS provider credentials.
 8339//   - B must be a different REGISTRABLE domain from V, not a subdomain, so that
 8340//     untrusted content is fully cross-site from the site holding the keys.
 8341//     That is a deployment fact, and BrowseSuffix is where it is stated.
 8342//
 8343// Everything here is inert until BrowseSuffix and BrowseAddr are both set.
 8344
 8345import (
 8346	"context"
 8347	"github.com/gofiber/fiber/v3"
 8348	"log"
 8349	"net/url"
 8350	"strconv"
 8351	"strings"
 8352
 8353	"github.com/0magnet/realorigin"
 8354
 8355	"github.com/0magnet/m2/pkg/config"
 8356)
 8357
 8358// browseResponderPath is where the app origin serves realorigin's responder β€”
 8359// the trust boundary, which must run first-party on V. The desk page loads it
 8360// before the wasm that configures it.
 8361const browseResponderPath = "/browse-responder.js"
 8362
 8363// browseEnabled reports whether the deployment configured the substrate.
 8364func browseEnabled() bool {
 8365	return config.F.BrowseSuffix != "" && config.F.BrowseAddr != ""
 8366}
 8367
 8368// browseScheme and browsePort describe how a browse origin is reached.
 8369//
 8370// Hosted, that is https on 443 and the port is not in the URL. The local
 8371// model is the exception the platform itself makes: *.localhost resolves to
 8372// the loopback without any DNS and counts as a secure context, so a service
 8373// worker runs there over plain http. It is how realorigin and skywire are
 8374// developed against (.mesh.localhost), and it is the only way to exercise the
 8375// whole path without a wildcard certificate.
 8376func browseScheme() string {
 8377	if isLoopbackSuffix(config.F.BrowseSuffix) {
 8378		return "http"
 8379	}
 8380	return "https"
 8381}
 8382
 8383// browsePort is the port a browse origin is served on, empty when it is the
 8384// scheme's default.
 8385func browsePort() string {
 8386	if !isLoopbackSuffix(config.F.BrowseSuffix) {
 8387		return ""
 8388	}
 8389	if i := strings.LastIndex(config.F.BrowseAddr, ":"); i >= 0 {
 8390		return config.F.BrowseAddr[i+1:]
 8391	}
 8392	return ""
 8393}
 8394
 8395// isLoopbackSuffix reports whether the browse domain is the browser's own
 8396// loopback namespace rather than a real one.
 8397func isLoopbackSuffix(suffix string) bool {
 8398	return strings.HasSuffix(strings.TrimSuffix(suffix, "."), ".localhost")
 8399}
 8400
 8401// appOrigin is V: the origin the responder runs on, and the exact string the
 8402// browse origin posts its handshake to. It has to be what the BROWSER sees,
 8403// not what the site calls itself.
 8404//
 8405// Those are the same thing hosted, where SITEDOMAIN is both the brand and the
 8406// address. They are not the same under the loopback model: SITEDOMAIN is
 8407// assembled from SITENAME and SITEEXT for the masthead, so a development run
 8408// still calls itself magnetosphere.net while the browser is on
 8409// localhost:<WEBPORT>. Posting the handshake to the brand would silently drop
 8410// it and hang the frame on its interstitial forever, which is a bad way to
 8411// find out.
 8412func appOrigin() string {
 8413	if isLoopbackSuffix(config.F.BrowseSuffix) {
 8414		return "http://localhost:" + strconv.Itoa(config.F.WebPort)
 8415	}
 8416	raw := config.F.Sitedomain
 8417	if raw == "" {
 8418		return ""
 8419	}
 8420	if !strings.Contains(raw, "://") {
 8421		raw = "https://" + raw
 8422	}
 8423	u, err := url.Parse(raw)
 8424	if err != nil || u.Host == "" {
 8425		return ""
 8426	}
 8427	return u.Scheme + "://" + u.Host
 8428}
 8429
 8430// serveBrowseResponder puts realorigin's responder β€” the trust boundary β€” on
 8431// the app origin, where it has to run first-party. An earlier design in that
 8432// project put it in a cross-origin helper instead and Storage Partitioning
 8433// stranded it in another partition, which is worth not rediscovering.
 8434func serveBrowseResponder(r *fiber.App) {
 8435	if !browseEnabled() {
 8436		return
 8437	}
 8438	body := realorigin.ResponderJS()
 8439	r.Get(browseResponderPath, func(c fiber.Ctx) error {
 8440		c.Set(fiber.HeaderContentType, "text/javascript; charset=utf-8")
 8441		return c.Send(body)
 8442	})
 8443}
 8444
 8445// StartBrowseOrigin runs the browse-origin bootstrap listener.
 8446//
 8447// It serves two static files and never proxies anything: the service worker at
 8448// its own path and the bootstrap shell at every other. All content travels the
 8449// other way, through the visitor's own tab, so this listener sees none of it
 8450// and nothing about it scales with use.
 8451//
 8452// Returns immediately when the substrate is not configured, which is the
 8453// state of any deployment that has not set up the second domain.
 8454func StartBrowseOrigin(ctx context.Context) {
 8455	if !browseEnabled() {
 8456		return
 8457	}
 8458	app := appOrigin()
 8459	if app == "" {
 8460		log.Printf("browse: SITEURL is not a URL; the browse origin needs one to name its app origin")
 8461		return
 8462	}
 8463	cfg := realorigin.Config{
 8464		Addr:      config.F.BrowseAddr,
 8465		Suffix:    config.F.BrowseSuffix,
 8466		AppOrigin: app,
 8467	}
 8468	if _, err := realorigin.Handler(cfg); err != nil {
 8469		log.Printf("browse: %v", err)
 8470		return
 8471	}
 8472	log.Printf("browse: serving %s on %s for %s β€” each target the tab reaches gets its own origin",
 8473		config.F.BrowseSuffix, config.F.BrowseAddr, app)
 8474	go func() {
 8475		if err := cfg.ListenAndServe(ctx); err != nil && ctx.Err() == nil {
 8476			log.Printf("browse: %v", err)
 8477		}
 8478	}()
 8479}
 8480
 8481
 8482// ===== pkg/web/browse_test.go =====
 8483package web
 8484
 8485import (
 8486	"strings"
 8487	"testing"
 8488)
 8489
 8490// The substrate is off unless BOTH the domain and the listener are named. A
 8491// deployment with neither must behave exactly as it did before.
 8492func TestBrowseIsOffUntilConfigured(t *testing.T) {
 8493	saved := *f
 8494	t.Cleanup(func() { *f = saved })
 8495
 8496	for _, tc := range []struct {
 8497		suffix, addr string
 8498		want         bool
 8499	}{
 8500		{"", "", false},
 8501		{".haltingstate.net", "", false},
 8502		{"", "127.0.0.1:7997", false},
 8503		{".haltingstate.net", "127.0.0.1:7997", true},
 8504	} {
 8505		f.BrowseSuffix, f.BrowseAddr = tc.suffix, tc.addr
 8506		if got := browseEnabled(); got != tc.want {
 8507			t.Errorf("browseEnabled(%q,%q) = %v, want %v", tc.suffix, tc.addr, got, tc.want)
 8508		}
 8509	}
 8510}
 8511
 8512// Hosted is https on the default port; the loopback model is plain http on
 8513// the listener's own port, which is the only way to exercise the whole path
 8514// without a wildcard certificate.
 8515func TestBrowseSchemeAndPort(t *testing.T) {
 8516	saved := *f
 8517	t.Cleanup(func() { *f = saved })
 8518	for _, tc := range []struct {
 8519		suffix, addr string
 8520		scheme, port string
 8521	}{
 8522		{".haltingstate.net", "127.0.0.1:7997", "https", ""},
 8523		{".mesh.localhost", "127.0.0.1:7997", "http", "7997"},
 8524		{".mesh.localhost", ":7997", "http", "7997"},
 8525	} {
 8526		f.BrowseSuffix, f.BrowseAddr = tc.suffix, tc.addr
 8527		if got := browseScheme(); got != tc.scheme {
 8528			t.Errorf("browseScheme(%q) = %q, want %q", tc.suffix, got, tc.scheme)
 8529		}
 8530		if got := browsePort(); got != tc.port {
 8531			t.Errorf("browsePort(%q,%q) = %q, want %q", tc.suffix, tc.addr, got, tc.port)
 8532		}
 8533	}
 8534}
 8535
 8536// B must be a different registrable domain from V, not a subdomain of it:
 8537// different hosts are merely cross-origin, and only different registrable
 8538// domains make untrusted content fully cross-site from the site holding the
 8539// Stripe key.
 8540func TestBrowseSuffixIsNotUnderTheSiteDomain(t *testing.T) {
 8541	saved := *f
 8542	t.Cleanup(func() { *f = saved })
 8543	f.Sitedomain = "magnetosphere.net"
 8544	f.BrowseSuffix, f.BrowseAddr = ".haltingstate.net", "127.0.0.1:7997"
 8545
 8546	if got := appOrigin(); got != "https://magnetosphere.net" {
 8547		t.Errorf("appOrigin() = %q", got)
 8548	}
 8549	if strings.HasSuffix(f.BrowseSuffix, "."+f.Sitedomain) {
 8550		t.Error("the browse domain is under the site's own; it must be a separate registrable domain")
 8551	}
 8552}
 8553
 8554func TestAppOriginFromSiteDomain(t *testing.T) {
 8555	saved := *f
 8556	t.Cleanup(func() { *f = saved })
 8557	f.BrowseSuffix = ".haltingstate.net" // hosted: the brand IS the address
 8558	for _, tc := range []struct{ in, want string }{
 8559		{"magnetosphere.net", "https://magnetosphere.net"},
 8560		{"https://magnetosphere.net", "https://magnetosphere.net"},
 8561		{"http://localhost:8099", "http://localhost:8099"},
 8562		{"", ""},
 8563	} {
 8564		f.Sitedomain = tc.in
 8565		if got := appOrigin(); got != tc.want {
 8566			t.Errorf("appOrigin(%q) = %q, want %q", tc.in, got, tc.want)
 8567		}
 8568	}
 8569}
 8570
 8571// Under the loopback model V is wherever the browser actually has it, not
 8572// what the masthead says. SITEDOMAIN is assembled from SITENAME and SITEEXT
 8573// for branding, so a development run calls itself magnetosphere.net while
 8574// being served from localhost β€” and a handshake posted to the brand is
 8575// silently dropped.
 8576func TestAppOriginIsTheServingOriginUnderLoopback(t *testing.T) {
 8577	saved := *f
 8578	t.Cleanup(func() { *f = saved })
 8579	f.Sitedomain, f.WebPort = "magnetosphere.net", 8099
 8580	f.BrowseSuffix, f.BrowseAddr = ".mesh.localhost", "127.0.0.1:7997"
 8581
 8582	if got, want := appOrigin(), "http://localhost:8099"; got != want {
 8583		t.Errorf("appOrigin() = %q, want %q β€” the brand is not the address here", got, want)
 8584	}
 8585	f.BrowseSuffix = ".haltingstate.net"
 8586	if got, want := appOrigin(), "https://magnetosphere.net"; got != want {
 8587		t.Errorf("hosted appOrigin() = %q, want %q", got, want)
 8588	}
 8589}
 8590
 8591
 8592// ===== pkg/web/catalog.go =====
 8593// Package web pkg/web/catalog.go β€” the in-memory catalog the server sells from.
 8594package web
 8595
 8596import (
 8597	"log"
 8598	"os"
 8599	"sync"
 8600	"time"
 8601
 8602	p "github.com/0magnet/m2/pkg/product"
 8603)
 8604
 8605var (
 8606	allproducts   p.Products
 8607	allproductsMu sync.RWMutex
 8608)
 8609
 8610var lastModTime time.Time
 8611
 8612// LoadCatalog reads the products CSV named in the config into memory,
 8613// logging any data-integrity warnings.
 8614func LoadCatalog() error {
 8615	fileInfo, err := os.Stat(f.ProductsCSV)
 8616	if err != nil {
 8617		return err
 8618	}
 8619	lastModTime = fileInfo.ModTime()
 8620	prods := p.ReadCSV(f.ProductsCSV)
 8621	if warnings := p.ValidateCSV(prods); len(warnings) > 0 {
 8622		for _, w := range warnings {
 8623			log.Println("CSV warning:", w)
 8624		}
 8625	}
 8626	allproductsMu.Lock()
 8627	allproducts = prods
 8628	allproductsMu.Unlock()
 8629	return nil
 8630}
 8631
 8632// WatchCatalog polls the products CSV and reloads it when it changes.
 8633// Run it in a goroutine; it never returns.
 8634func WatchCatalog() {
 8635	for {
 8636		fileInfo, err := os.Stat(f.ProductsCSV)
 8637		if err != nil {
 8638			log.Println("Error getting file info:", err)
 8639			time.Sleep(10 * time.Second)
 8640			continue
 8641		}
 8642
 8643		currentModTime := fileInfo.ModTime()
 8644		if currentModTime != lastModTime {
 8645			log.Println("CSV file has been modified!")
 8646			if err := LoadCatalog(); err != nil {
 8647				log.Println("Error reloading catalog:", err)
 8648			}
 8649		}
 8650
 8651		time.Sleep(10 * time.Second)
 8652	}
 8653}
 8654
 8655// SetCatalog replaces the in-memory catalog directly, for hosts that have no
 8656// CSV on disk to load β€” the in-tab site server seeds this from /api/products,
 8657// which is already the public projection (no cost, location or source info).
 8658func SetCatalog(prods p.Products) {
 8659	allproductsMu.Lock()
 8660	allproducts = prods
 8661	allproductsMu.Unlock()
 8662}
 8663
 8664
 8665// ===== pkg/web/catsubcats_test.go =====
 8666package web
 8667
 8668import (
 8669	"bytes"
 8670	"flag"
 8671	htmpl "html/template"
 8672	"os"
 8673	"strings"
 8674	"testing"
 8675
 8676	p "github.com/0magnet/m2/pkg/product"
 8677)
 8678
 8679// sampleProducts is a catalog small enough to write the expected menu out by
 8680// hand, and shaped to exercise the alignment: counts of one, two and three
 8681// digits, a category with subcategories and one without.
 8682func sampleProducts() p.Products {
 8683	var out p.Products
 8684	add := func(cat, sub string, n int) {
 8685		for i := 0; i < n; i++ {
 8686			out = append(out, p.Product{Enable: "TRUE", Category: cat, Subcategory: sub,
 8687				Partno: cat + sub + string(rune('a'+i%26))})
 8688		}
 8689	}
 8690	add("resistor", "quarter watt 5%", 67)
 8691	add("resistor", "ceramic", 1)
 8692	add("inductor", "", 34)
 8693	return out
 8694}
 8695
 8696// renderMenu runs just the category menu, the way a page does.
 8697func renderMenu(t *testing.T, prods p.Products) string {
 8698	t.Helper()
 8699	saved := allproducts
 8700	allproducts = prods
 8701	defer func() { allproducts = saved }()
 8702	data := htmlTemplateData{}
 8703	data.LenAllProducts = len(prods)
 8704	data.CatsCounts, data.Cats, data.SubCatsCounts, data.SubCatsByCat = getcategories(prods)
 8705	data.Page = "front"
 8706	tmpl := htmpl.New("catsubcats").Funcs(funcs)
 8707	// Read by path rather than through h: the accessors resolve relative to
 8708	// the server's working directory, which is not a test's.
 8709	src, err := os.ReadFile("../../htmpl/catsubcats.html")
 8710	if err != nil {
 8711		t.Fatalf("reading catsubcats: %v", err)
 8712	}
 8713	if _, err := tmpl.Parse(string(src)); err != nil {
 8714		t.Fatalf("parsing catsubcats: %v", err)
 8715	}
 8716	var b bytes.Buffer
 8717	if err := tmpl.Execute(&b, map[string]interface{}{"Page": data, "Category": "", "Subcategory": ""}); err != nil {
 8718		t.Fatalf("executing catsubcats: %v", err)
 8719	}
 8720	return b.String()
 8721}
 8722
 8723// The menu's counts are right-aligned by padding the branch rule itself, so
 8724// every count ends in the same column. These glyph runs are what the site drew
 8725// before the layout moved to tallytree.
 8726func TestCatSubCatsDrawing(t *testing.T) {
 8727	out := renderMenu(t, sampleProducts())
 8728	for _, want := range []string{
 8729		"β”œβ”€β”€102&nbsp;",      // All Products
 8730		"β”œβ”€β”¬β”€68&nbsp;",      // resistor, has subcategories
 8731		"β”‚&nbsp;β”œβ”€67&nbsp;", // quarter watt 5%
 8732		"β”‚&nbsp;└──1&nbsp;", // ceramic, last subcategory, rule stretched two
 8733		"└───34&nbsp;",      // inductor, no subcategories, last category
 8734	} {
 8735		if !strings.Contains(out, want) {
 8736			t.Errorf("menu is missing %q\n\n%s", want, out)
 8737		}
 8738	}
 8739}
 8740
 8741// TestCatSubCatsGolden holds the menu's whole markup, not just its glyphs.
 8742// The layout moved out to tallytree; the anchors, titles, classes and nesting
 8743// around it must not have moved with it.
 8744//
 8745// Regenerate with: go test ./pkg/web -run CatSubCatsGolden -update
 8746func TestCatSubCatsGolden(t *testing.T) {
 8747	got := renderMenu(t, sampleProducts())
 8748	const golden = "testdata/catsubcats.golden"
 8749	if *update {
 8750		if err := os.WriteFile(golden, []byte(got), 0o600); err != nil {
 8751			t.Fatal(err)
 8752		}
 8753		t.Log("golden rewritten")
 8754		return
 8755	}
 8756	want, err := os.ReadFile(golden)
 8757	if err != nil {
 8758		t.Fatalf("%v (run with -update to create it)", err)
 8759	}
 8760	if got != string(want) {
 8761		t.Errorf("menu markup changed.\n got:\n%s\nwant:\n%s", got, want)
 8762	}
 8763}
 8764
 8765var update = flag.Bool("update", false, "rewrite the golden files")
 8766
 8767
 8768// ===== pkg/web/dropinfs.go =====
 8769package web
 8770
 8771// The drop-ins the binary carries, for a server whose disk has none.
 8772//
 8773// The store runs in three places and only two of them have the binaries on a
 8774// filesystem. On a host, CompileWASM writes them next to the working directory
 8775// and the routes read them from there. In a browser tab β€” pkg/storepane's
 8776// `serve`, behind the desk's netscrape window β€” there is no toolchain to build
 8777// them with and no disk that carries them, and the sources are not there
 8778// either, so nothing will ever produce them locally.
 8779//
 8780// So the build stages them into dropins/ and they ride along inside the
 8781// binary. A tab then serves the same cart, attractor and border decoration the
 8782// origin serves, out of itself, with no round trip.
 8783//
 8784// The page binaries (term, and the separate tui/desk) are deliberately NOT
 8785// staged. term.wasm is the binary doing the embedding, and a file cannot
 8786// contain itself; the tab does not serve /tui or /desk anyway β€” those routes
 8787// belong to the host role.
 8788//
 8789// An empty stage is fine and is what a fresh checkout builds: the directive
 8790// matches dropins/README.md, nothing is carried, and the lookup below simply
 8791// says no. The disk and then the origin answer instead.
 8792
 8793import (
 8794	"embed"
 8795	"io/fs"
 8796	"path"
 8797	"strings"
 8798)
 8799
 8800//go:embed dropins
 8801var dropinFS embed.FS
 8802
 8803// embeddedDropin returns the staged bytes of one drop-in, by the file name a
 8804// page asks for ("stl2-tiny.wasm").
 8805func embeddedDropin(file string) ([]byte, bool) {
 8806	// Only the binaries, by a bare name. The directory also holds its own
 8807	// README, which is not something a page may ask this for.
 8808	if file == "" || !strings.HasSuffix(file, ".wasm") || strings.ContainsAny(file, `/\`) {
 8809		return nil, false
 8810	}
 8811	b, err := dropinFS.ReadFile(path.Join("dropins", file))
 8812	if err != nil {
 8813		return nil, false
 8814	}
 8815	return b, true
 8816}
 8817
 8818// embeddedDropinNames lists the staged binaries, so the routes can be
 8819// registered for exactly what the build carried.
 8820func embeddedDropinNames() []string {
 8821	ents, err := fs.ReadDir(dropinFS, "dropins")
 8822	if err != nil {
 8823		return nil
 8824	}
 8825	var out []string
 8826	for _, e := range ents {
 8827		if !e.IsDir() && strings.HasSuffix(e.Name(), ".wasm") {
 8828			out = append(out, e.Name())
 8829		}
 8830	}
 8831	return out
 8832}
 8833
 8834// lookupDropin is embeddedDropin behind a variable so a test can say what the
 8835// build carried. Whether a binary carries a drop-in is fixed at compile time,
 8836// which makes every test of the pick order depend on what happened to be
 8837// staged in the tree it was compiled from; this makes that an input.
 8838var lookupDropin = embeddedDropin
 8839
 8840
 8841// ===== pkg/web/dropinfs_test.go =====
 8842package web
 8843
 8844import (
 8845	"os"
 8846	"path/filepath"
 8847	"testing"
 8848)
 8849
 8850// A fresh checkout stages nothing, and that has to build and behave β€” the
 8851// directive matches only the README, and every lookup says no.
 8852func TestEmbeddedDropinOnAnEmptyStage(t *testing.T) {
 8853	// Whatever this working tree has staged, README.md is never a drop-in.
 8854	if _, ok := embeddedDropin("README.md"); ok {
 8855		t.Error("README.md should not be reachable as a drop-in")
 8856	}
 8857	for _, bad := range []string{"", "../wasmpick.go", "sub/x.wasm", `a\b.wasm`} {
 8858		if _, ok := embeddedDropin(bad); ok {
 8859			t.Errorf("embeddedDropin(%q) should be refused", bad)
 8860		}
 8861	}
 8862	for _, n := range embeddedDropinNames() {
 8863		if filepath.Ext(n) != ".wasm" {
 8864			t.Errorf("staged name %q is not a .wasm", n)
 8865		}
 8866	}
 8867}
 8868
 8869// pageWasm is what keeps term.wasm from being staged into itself.
 8870func TestPageBinariesAreNotStaged(t *testing.T) {
 8871	for _, name := range []string{"term", "tui", "desk"} {
 8872		if !pageWasm[name] {
 8873			t.Errorf("%s must be treated as a page binary, not a drop-in", name)
 8874		}
 8875	}
 8876	for _, name := range []string{"cart", "stl2", "deco"} {
 8877		if pageWasm[name] {
 8878			t.Errorf("%s is a drop-in and must be stageable", name)
 8879		}
 8880	}
 8881}
 8882
 8883// stageDropins takes the smallest build of each drop-in and clears what a
 8884// previous run left, so a source dropped from WasmSRC stops being carried.
 8885func TestStageDropinsTakesTheSmallBuildAndClearsStale(t *testing.T) {
 8886	t.Chdir(t.TempDir())
 8887	dir := filepath.Join("pkg", "web", "dropins")
 8888	if err := os.MkdirAll(dir, 0o750); err != nil {
 8889		t.Fatal(err)
 8890	}
 8891	write := func(name, body string) {
 8892		if err := os.WriteFile(name, []byte(body), 0o600); err != nil {
 8893			t.Fatal(err)
 8894		}
 8895	}
 8896	// Left over from a run when "gone" was still configured.
 8897	write(filepath.Join(dir, "gone-tiny.wasm"), "stale")
 8898
 8899	write("cart.wasm", "cart-go")
 8900	write("cart-tiny.wasm", "cart-tiny")
 8901	write("deco.wasm", "deco-go") // no tinygo build of this one
 8902
 8903	stageDropins([]string{"wasm/cart", "wasm/deco", "wasm/never"})
 8904
 8905	got := map[string]string{}
 8906	ents, err := os.ReadDir(dir)
 8907	if err != nil {
 8908		t.Fatal(err)
 8909	}
 8910	for _, e := range ents {
 8911		b, rerr := os.ReadFile(filepath.Join(dir, e.Name())) //nolint:gosec // test tree
 8912		if rerr != nil {
 8913			t.Fatal(rerr)
 8914		}
 8915		got[e.Name()] = string(b)
 8916	}
 8917	want := map[string]string{
 8918		"cart-tiny.wasm": "cart-tiny", // small build preferred
 8919		"deco.wasm":      "deco-go",   // only build there is
 8920	}
 8921	if len(got) != len(want) {
 8922		t.Fatalf("staged %v, want %v", got, want)
 8923	}
 8924	for k, v := range want {
 8925		if got[k] != v {
 8926			t.Errorf("staged %s = %q, want %q", k, got[k], v)
 8927		}
 8928	}
 8929}
 8930
 8931
 8932// ===== pkg/web/invoice.go =====
 8933package web
 8934
 8935// Crypto checkout: the other button beside Stripe.
 8936//
 8937// The two paths meet where it matters. Both validate every line against the
 8938// server's own catalog before believing a total, and both end by writing an
 8939// order into orders/ and printing a receipt β€” so a paid order looks the same
 8940// however it was paid, and the page in the tray keeps meaning what it means.
 8941//
 8942// Where they differ is when. A card authorizes while the reader waits; a
 8943// chain payment arrives minutes later, from a page that may already be
 8944// closed. So the receipt prints on CONFIRMATION, not on placement β€”
 8945// otherwise every abandoned checkout would put paper in the tray and the
 8946// glance-at-the-printer workflow would stop working.
 8947
 8948import (
 8949	"context"
 8950	"crypto/rand"
 8951	"encoding/hex"
 8952	"encoding/json"
 8953	"fmt"
 8954	"log"
 8955	"os"
 8956	"path/filepath"
 8957	"time"
 8958
 8959	"github.com/gofiber/fiber/v3"
 8960
 8961	"github.com/0magnet/m2/pkg/pay"
 8962	"github.com/0magnet/m2/pkg/pay/sky"
 8963	"github.com/0magnet/m2/pkg/storepage"
 8964)
 8965
 8966// cryptoPay is the configured crypto checkout, or nil when the deployment
 8967// has no wallet set. Nil means the button is not offered at all, which is
 8968// what a store with nothing configured should show rather than an error.
 8969var cryptoPay *cryptoConfig
 8970
 8971type cryptoConfig struct {
 8972	store    *pay.Store
 8973	rater    *pay.Rater
 8974	backend  pay.Backend
 8975	window   time.Duration
 8976	ordersIn string
 8977}
 8978
 8979// initCrypto builds the crypto checkout from the configuration, and reports
 8980// whether it is on.
 8981func initCrypto(ordersDir string) *cryptoConfig {
 8982	if f.SkyXpub == "" {
 8983		return nil
 8984	}
 8985	store, err := pay.NewStore(ordersDir)
 8986	if err != nil {
 8987		log.Printf("crypto checkout off: %v", err)
 8988		return nil
 8989	}
 8990
 8991	// A fixed rate REPLACES the exchange rather than joining it: the
 8992	// lowest-wins rule settles a disagreement between exchanges quoting one
 8993	// market, and a manual figure mixed in just wins whenever it is lower.
 8994	var sources []pay.RateSource
 8995	switch {
 8996	case f.SkyRateFix != "":
 8997		sources = []pay.RateSource{pay.Fixed{Label: "operator", USD: f.SkyRateFix}}
 8998	case f.SkyRatePair != "":
 8999		sources = []pay.RateSource{pay.LBank{Pair: f.SkyRatePair}}
 9000	default:
 9001		log.Printf("crypto checkout off: set a rate pair or a fixed rate")
 9002		return nil
 9003	}
 9004
 9005	mins := f.InvoiceMins
 9006	if mins <= 0 {
 9007		mins = 15
 9008	}
 9009	return &cryptoConfig{
 9010		store:    store,
 9011		rater:    &pay.Rater{Sources: sources, Min: f.RateMin, Max: f.RateMax},
 9012		backend:  &sky.Backend{Xpub: f.SkyXpub, NodeURL: f.SkyNodeURL},
 9013		window:   time.Duration(mins) * time.Minute,
 9014		ordersIn: ordersDir,
 9015	}
 9016}
 9017
 9018// handleInvoice registers the crypto endpoints, if crypto is configured.
 9019func handleInvoice(r *fiber.App) {
 9020	cryptoPay = initCrypto("./orders")
 9021	if cryptoPay == nil {
 9022		return
 9023	}
 9024	r.Post("/create-invoice", createInvoice)
 9025	r.Get("/invoice/:id", invoiceStatus)
 9026	log.Printf("crypto checkout on: skycoin, %s window", cryptoPay.window)
 9027}
 9028
 9029// invoiceRequest is what the cart posts: the same items it sends Stripe.
 9030type invoiceRequest struct {
 9031	Coin  string     `json:"coin"`
 9032	Items []pay.Item `json:"items"`
 9033}
 9034
 9035// createInvoice prices an order in coin and hands back an address.
 9036func createInvoice(c fiber.Ctx) error {
 9037	var req invoiceRequest
 9038	if err := json.Unmarshal(c.Body(), &req); err != nil {
 9039		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid JSON"})
 9040	}
 9041	if len(req.Items) == 0 {
 9042		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "No items in request"})
 9043	}
 9044	if req.Coin != "" && pay.Coin(req.Coin) != cryptoPay.backend.Coin() {
 9045		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Unsupported coin"})
 9046	}
 9047
 9048	total, err := validateOrderItems(req.Items)
 9049	if err != nil {
 9050		log.Printf("invoice validation failed: %v", err)
 9051		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Item validation failed"})
 9052	}
 9053	if total < 50 {
 9054		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Order total must be at least $0.50"})
 9055	}
 9056
 9057	ctx, cancel := context.WithTimeout(c.Context(), 20*time.Second)
 9058	defer cancel()
 9059
 9060	rate, source, err := cryptoPay.rater.Quote(ctx)
 9061	if err != nil {
 9062		// No usable price means no sale. Quoting anyway is the mistake the
 9063		// rate layer exists to prevent.
 9064		log.Printf("invoice refused, no rate: %v", err)
 9065		return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "No price available right now"})
 9066	}
 9067	coin := cryptoPay.backend.Coin()
 9068	atomic, err := pay.AtomicFor(total, rate, coin.Decimals())
 9069	if err != nil {
 9070		log.Printf("invoice pricing failed: %v", err)
 9071		return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Unable to price the order"})
 9072	}
 9073
 9074	now := time.Now()
 9075	taken, err := cryptoPay.store.TakenAddresses(coin, now)
 9076	if err != nil {
 9077		log.Printf("invoice address reservation failed: %v", err)
 9078		return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Unable to allocate an address"})
 9079	}
 9080	addr, err := cryptoPay.backend.NextAddress(ctx, taken)
 9081	if err != nil {
 9082		log.Printf("invoice address allocation failed: %v", err)
 9083		return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Unable to allocate an address"})
 9084	}
 9085
 9086	inv := &pay.Invoice{
 9087		ID: newInvoiceID(), Coin: coin, Address: addr, Atomic: atomic,
 9088		FiatCents: total, Rate: rate.FloatString(10), RateSource: source,
 9089		Items: req.Items, Created: now, Expires: now.Add(cryptoPay.window),
 9090		Status: pay.StatusPending,
 9091	}
 9092	if err := cryptoPay.store.Put(inv); err != nil {
 9093		log.Printf("invoice save failed: %v", err)
 9094		return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Unable to save the invoice"})
 9095	}
 9096	return c.JSON(invoiceView(inv))
 9097}
 9098
 9099// invoiceStatus reports one invoice, polling the chain first so a reader
 9100// watching the page sees the payment as soon as it confirms.
 9101func invoiceStatus(c fiber.Ctx) error {
 9102	inv, err := cryptoPay.store.Get(c.Params("id"))
 9103	if err != nil {
 9104		return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "No such invoice"})
 9105	}
 9106	if inv.Status != pay.StatusPaid {
 9107		ctx, cancel := context.WithTimeout(c.Context(), 15*time.Second)
 9108		defer cancel()
 9109		if err := refreshInvoice(ctx, inv); err != nil {
 9110			log.Printf("invoice %s refresh failed: %v", inv.ID, err)
 9111		}
 9112	}
 9113	return c.JSON(invoiceView(inv))
 9114}
 9115
 9116// invoiceView is what a client is told: never the whole record, because the
 9117// items and the rate source are the store's business, not the page's.
 9118func invoiceView(inv *pay.Invoice) fiber.Map {
 9119	return fiber.Map{
 9120		"id":          inv.ID,
 9121		"coin":        string(inv.Coin),
 9122		"address":     inv.Address,
 9123		"amount":      pay.AmountString(inv.Atomic, inv.Coin.Decimals()),
 9124		"uri":         inv.URI(),
 9125		"fiat_cents":  inv.FiatCents,
 9126		"status":      string(inv.Status),
 9127		"paid":        pay.AmountString(inv.PaidAtomic, inv.Coin.Decimals()),
 9128		"outstanding": pay.AmountString(inv.Outstanding(), inv.Coin.Decimals()),
 9129		"expires":     inv.Expires.Format(time.RFC3339),
 9130		"txid":        inv.TxID,
 9131	}
 9132}
 9133
 9134// refreshInvoice asks the chain, records what it finds, and β€” the first time
 9135// an invoice settles β€” writes the order and prints the receipt.
 9136func refreshInvoice(ctx context.Context, inv *pay.Invoice) error {
 9137	atomic, txid, confirmed, err := cryptoPay.backend.Received(ctx, inv.Address)
 9138	if err != nil {
 9139		return err
 9140	}
 9141	changed := inv.Apply(atomic, txid, confirmed, time.Now())
 9142	settled := inv.Status == pay.StatusPaid && !inv.Printed
 9143	if settled {
 9144		if err := completeInvoice(inv); err != nil {
 9145			// The money arrived; failing to print must not lose that.
 9146			log.Printf("invoice %s settled but could not be completed: %v", inv.ID, err)
 9147		} else {
 9148			inv.Printed = true
 9149			changed = true
 9150		}
 9151	}
 9152	if changed {
 9153		return cryptoPay.store.Put(inv)
 9154	}
 9155	return nil
 9156}
 9157
 9158// completeInvoice writes the order beside the card orders and prints it.
 9159func completeInvoice(inv *pay.Invoice) error {
 9160	items := make([]map[string]any, 0, len(inv.Items))
 9161	for _, it := range inv.Items {
 9162		items = append(items, map[string]any{"id": it.ID, "amount": it.Amount, "quantity": 1})
 9163	}
 9164	local := map[string]any{"items": items}
 9165	orderData := map[string]any{
 9166		"clientData":    local,
 9167		"verifiedCents": inv.FiatCents,
 9168		"currency":      "usd",
 9169		"cryptoStatus":  string(inv.Status),
 9170		"coin":          string(inv.Coin),
 9171		"coinAmount":    pay.AmountString(inv.Atomic, inv.Coin.Decimals()),
 9172		"coinPaid":      pay.AmountString(inv.PaidAtomic, inv.Coin.Decimals()),
 9173		"rateUSD":       inv.Rate,
 9174		"rateSource":    inv.RateSource,
 9175		"address":       inv.Address,
 9176		"txid":          inv.TxID,
 9177		"submittedAt":   time.Now().Format(time.RFC3339),
 9178	}
 9179	data, err := json.MarshalIndent(orderData, "", "  ")
 9180	if err != nil {
 9181		return err
 9182	}
 9183	id := string(inv.Coin) + "-" + inv.ID
 9184	path := filepath.Join(cryptoPay.ordersIn, id+".json")
 9185	if err := os.WriteFile(path, data, 0o600); err != nil {
 9186		return err
 9187	}
 9188	receipt, err := buildReceipt(local, id)
 9189	if err != nil {
 9190		return err
 9191	}
 9192	if err := sendToCUPS(receipt, "Order "+id); err != nil {
 9193		_ = os.WriteFile(filepath.Join(cryptoPay.ordersIn, id+".print_failed"), []byte(err.Error()), 0o600) //nolint:errcheck,gosec // best-effort marker; the failure is logged
 9194		return err
 9195	}
 9196	return nil
 9197}
 9198
 9199// WatchInvoices polls live invoices until ctx is done.
 9200//
 9201// The page polls too, but a reader who closes the tab still bought
 9202// something: without this, an order paid after the last poll would sit in
 9203// orders/ unprinted and unnoticed.
 9204func WatchInvoices(ctx context.Context) {
 9205	if cryptoPay == nil {
 9206		return
 9207	}
 9208	t := time.NewTicker(time.Minute)
 9209	defer t.Stop()
 9210	for {
 9211		select {
 9212		case <-ctx.Done():
 9213			return
 9214		case <-t.C:
 9215			live, err := cryptoPay.store.Live()
 9216			if err != nil {
 9217				log.Printf("invoice poll failed: %v", err)
 9218				continue
 9219			}
 9220			for _, inv := range live {
 9221				if err := refreshInvoice(ctx, inv); err != nil {
 9222					log.Printf("invoice %s poll failed: %v", inv.ID, err)
 9223				}
 9224			}
 9225		}
 9226	}
 9227}
 9228
 9229// validateOrderItems totals an order against the server's own catalog.
 9230//
 9231// Shared with the Stripe path: a client is never believed about a price in
 9232// either. The shipping line is the one the client sets, so it is held to the
 9233// same minimum the form enforces β€” otherwise a hand-written request could
 9234// ship for a cent.
 9235func validateOrderItems(items []pay.Item) (int64, error) {
 9236	total := int64(0)
 9237	for _, it := range items {
 9238		if it.Amount <= 0 {
 9239			return 0, fmt.Errorf("non-positive amount for %q", it.ID)
 9240		}
 9241		if storepage.IsShippingID(it.ID) {
 9242			if it.Amount < int64(storepage.MinShippingCents) {
 9243				return 0, fmt.Errorf("shipping below the minimum: %d cents", it.Amount)
 9244			}
 9245			total += it.Amount
 9246			continue
 9247		}
 9248		expected, err := validateItemAmount(it.ID, it.Amount)
 9249		if err != nil {
 9250			return 0, err
 9251		}
 9252		total += expected
 9253	}
 9254	return total, nil
 9255}
 9256
 9257// newInvoiceID is a random id: it names a file and appears in a URL, and
 9258// must not be guessable from an order number.
 9259func newInvoiceID() string {
 9260	var b [12]byte
 9261	if _, err := rand.Read(b[:]); err != nil {
 9262		return fmt.Sprintf("%d", time.Now().UnixNano())
 9263	}
 9264	return hex.EncodeToString(b[:])
 9265}
 9266
 9267
 9268// ===== pkg/web/invoice_test.go =====
 9269package web
 9270
 9271import (
 9272	"testing"
 9273
 9274	"github.com/0magnet/m2/pkg/pay"
 9275	"github.com/0magnet/m2/pkg/storepage"
 9276)
 9277
 9278// Both payment paths total an order through this, so a client is never
 9279// believed about a price in one and checked in the other.
 9280func TestValidateOrderItemsHoldsShippingToTheMinimum(t *testing.T) {
 9281	ship := storepage.Shipping{
 9282		Cents: storepage.MinShippingCents, Name: "A", City: "Dallas",
 9283		State: "TX", Country: storepage.DefaultCountry,
 9284	}
 9285
 9286	// A shipping line at the minimum is fine.
 9287	total, err := validateOrderItems([]pay.Item{
 9288		{ID: ship.ID(), Amount: int64(storepage.MinShippingCents)},
 9289	})
 9290	if err != nil {
 9291		t.Fatalf("the minimum was refused: %v", err)
 9292	}
 9293	if total != int64(storepage.MinShippingCents) {
 9294		t.Errorf("total = %d, want %d", total, storepage.MinShippingCents)
 9295	}
 9296
 9297	// A cent of shipping is not. The form enforces this in the browser;
 9298	// a hand-written request does not go through the form.
 9299	if _, err := validateOrderItems([]pay.Item{
 9300		{ID: ship.ID(), Amount: 1},
 9301	}); err == nil {
 9302		t.Error("a one-cent shipping line was accepted")
 9303	}
 9304}
 9305
 9306func TestValidateOrderItemsRejectsNonPositive(t *testing.T) {
 9307	if _, err := validateOrderItems([]pay.Item{{ID: "X-1 X 1", Amount: 0}}); err == nil {
 9308		t.Error("a zero-amount line was accepted")
 9309	}
 9310	if _, err := validateOrderItems([]pay.Item{{ID: "X-1 X 1", Amount: -500}}); err == nil {
 9311		t.Error("a negative line was accepted")
 9312	}
 9313}
 9314
 9315// An unknown part cannot be priced, so it cannot be sold.
 9316func TestValidateOrderItemsRejectsUnknownPart(t *testing.T) {
 9317	if _, err := validateOrderItems([]pay.Item{
 9318		{ID: "NO-SUCH-PART-12345 X 1", Amount: 100},
 9319	}); err == nil {
 9320		t.Error("an unknown part was priced")
 9321	}
 9322}
 9323
 9324// Crypto is off unless a wallet is configured: a store with nothing set
 9325// should show no button rather than one that errors.
 9326func TestCryptoOffWithoutAnXpub(t *testing.T) {
 9327	saved := f.SkyXpub
 9328	f.SkyXpub = ""
 9329	defer func() { f.SkyXpub = saved }()
 9330	if got := initCrypto(t.TempDir()); got != nil {
 9331		t.Error("crypto checkout came up with no xpub configured")
 9332	}
 9333}
 9334
 9335// ...and off when there is a wallet but no way to price it, rather than
 9336// quoting from a guess.
 9337func TestCryptoOffWithoutARate(t *testing.T) {
 9338	savedX, savedP, savedF := f.SkyXpub, f.SkyRatePair, f.SkyRateFix
 9339	f.SkyXpub = "xpub6DH2sNie8sDh7cUbZRXeMEHaJrU72g8UZ45Z8VS26oUc8NMZrfbgpWH1U1osnJDisro6sUDVNL6MbnsAmfDNXDLjC8UfRLuM2YSoPMeY4DS"
 9340	f.SkyRatePair, f.SkyRateFix = "", ""
 9341	defer func() { f.SkyXpub, f.SkyRatePair, f.SkyRateFix = savedX, savedP, savedF }()
 9342	if got := initCrypto(t.TempDir()); got != nil {
 9343		t.Error("crypto checkout came up with no rate source")
 9344	}
 9345}
 9346
 9347
 9348// ===== pkg/web/navmenu.go =====
 9349package web
 9350
 9351// The category menu's layout.
 9352//
 9353// The drawing β€” β”œ/β”” branches, a ┬ where a category opens into subcategories,
 9354// and the counts right-aligned by padding the rule itself β€” is tallytree's. It
 9355// used to be written out twice: once here in htmpl/catsubcats.html with the
 9356// repeat and sub template functions, and once again in pkg/tui for the terminal,
 9357// whose comment read "reproduces htmpl/catsubcats.html". Two implementations of
 9358// one picture, kept in agreement by hand and tested by neither.
 9359//
 9360// What stays here is the markup. The template asks for the rows and wraps each
 9361// one in the disclosure elements and anchors it needs; it no longer works out
 9362// where anything goes.
 9363
 9364import (
 9365	htmpl "html/template"
 9366
 9367	"github.com/0magnet/tallytree"
 9368)
 9369
 9370// navRow is one line of the menu: the glyphs that lead up to it, its count,
 9371// and what it points at.
 9372//
 9373// Prefix is HTML because its blanks are non-breaking spaces. An ordinary space
 9374// there would collapse and the tree would lose its columns.
 9375type navRow struct {
 9376	Prefix  htmpl.HTML
 9377	Count   int
 9378	Label   string
 9379	HasSubs bool
 9380}
 9381
 9382// navCat is a category row together with the subcategory rows beneath it, which
 9383// is the shape the markup needs: each category is a <details> holding its own
 9384// subcategories.
 9385type navCat struct {
 9386	navRow
 9387	Subs []navRow
 9388}
 9389
 9390// navMenu is the whole menu: the "everything" row, then the categories.
 9391type navMenu struct {
 9392	All  navRow
 9393	Cats []navCat
 9394}
 9395
 9396// navmenu lays the menu out for a page.
 9397//
 9398// Depth is pinned to two because the menu always COULD have subcategories, so
 9399// it reserves room for them even in a catalog where nothing does β€” otherwise a
 9400// flat catalog would draw a narrower tree than the same shop with one
 9401// subcategory added, and the menu would change shape as stock came and went.
 9402//
 9403// Every branch is drawn open: the disclosure elements do the hiding, so the
 9404// rows are always all there.
 9405func navmenu(d htmlTemplateData) navMenu {
 9406	forest := []tallytree.Node{{Label: "All Products", Count: d.LenAllProducts}}
 9407	for _, cat := range d.Cats {
 9408		n := tallytree.Node{Label: cat, Count: d.CatsCounts[cat]}
 9409		for _, sub := range d.SubCatsByCat[cat] {
 9410			n.Children = append(n.Children, tallytree.Node{Label: sub, Count: d.SubCatsCounts[cat][sub]})
 9411		}
 9412		forest = append(forest, n)
 9413	}
 9414	rows := tallytree.Rows(forest, tallytree.Options{
 9415		Depth:  2,
 9416		Glyphs: tallytree.GlyphSet{Blank: "&nbsp;"},
 9417	})
 9418	var m navMenu
 9419	for i, r := range rows {
 9420		row := navRow{Prefix: htmpl.HTML(r.Prefix), Count: r.Count, Label: r.Label, HasSubs: r.HasChildren} //nolint:gosec // the prefix is glyphs this package just produced
 9421		switch {
 9422		case i == 0:
 9423			m.All = row
 9424		case r.Depth == 0:
 9425			m.Cats = append(m.Cats, navCat{navRow: row})
 9426		default:
 9427			m.Cats[len(m.Cats)-1].Subs = append(m.Cats[len(m.Cats)-1].Subs, row)
 9428		}
 9429	}
 9430	return m
 9431}
 9432
 9433
 9434// ===== pkg/web/order.go =====
 9435// Package web pkg/web/order.go β€” checkout, order persistence, receipt printing.
 9436package web
 9437
 9438import (
 9439	"bytes"
 9440	"encoding/json"
 9441	"fmt"
 9442	htmpl "html/template"
 9443	"log"
 9444	"os"
 9445	"path/filepath"
 9446	"regexp"
 9447	"strconv"
 9448	"strings"
 9449	"time"
 9450
 9451	"github.com/bitfield/script"
 9452	"github.com/gofiber/fiber/v3"
 9453	"github.com/stripe/stripe-go/v81"
 9454	"github.com/stripe/stripe-go/v81/paymentintent"
 9455
 9456	"github.com/0magnet/m2/pkg/pay"
 9457)
 9458
 9459// validPIID matches Stripe PaymentIntent IDs: "pi_" followed by alphanumeric chars.
 9460// Also allows plain alphanumeric+underscore+hyphen for test order IDs.
 9461var validPIID = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
 9462
 9463func handleOrder(r *fiber.App) {
 9464	r.Get("/checkout.css", func(c fiber.Ctx) error {
 9465		c.Set("Content-Type", "text/css;charset=utf-8")
 9466		_, err := c.Status(fiber.StatusOK).Write([]byte(h.CheckoutCSS()))
 9467		return err
 9468	})
 9469
 9470	r.Get("/complete", func(c fiber.Ctx) error {
 9471		// Complete template
 9472		completetmpl := htmpl.New("index")
 9473		if _, err := completetmpl.Parse(h.CompletePage()); err != nil {
 9474			msg := fmt.Sprintf("Error parsing complete page template: %v", err)
 9475			log.Println(msg)
 9476			return c.Status(fiber.StatusInternalServerError).SendString(msg)
 9477		}
 9478		if _, err := completetmpl.New("wasm").Parse(h.Wasm()); err != nil {
 9479			log.Println("Error parsing wasm template:", err)
 9480			msg := fmt.Sprintf("Error parsing wasm template: %v", err)
 9481			log.Println(msg)
 9482			return c.Status(fiber.StatusInternalServerError).SendString(msg)
 9483		}
 9484		h1 := htmlPageTemplateData
 9485		// The checkout completion page is what the cart wasm drives: it
 9486		// fills in the payment status icon, text and intent id.
 9487		h1.WasmBinary = wasmFor("cart")
 9488		/*
 9489			proto := "http"
 9490			if c.Secure() {
 9491				proto += "s"
 9492			}
 9493		*/
 9494		proto := "https"
 9495		h1.Canonical = proto + `://` + c.Hostname() + c.OriginalURL()
 9496		h1.BaseURL = proto + `://` + c.Hostname()
 9497		h1.RequestHost = c.Hostname()
 9498		h1.Protocol = proto
 9499		h1.Time = time.Now().Format(time.RFC3339Nano)
 9500		h1.Year = fmt.Sprintf("%v", time.Now().Year())
 9501		tmplData := map[string]interface{}{
 9502			"Page": h1,
 9503		}
 9504		var result bytes.Buffer
 9505		err := completetmpl.Execute(&result, tmplData)
 9506		if err != nil {
 9507			msg := fmt.Sprintf("Could not execute html template %v", err)
 9508			log.Println(msg)
 9509			return c.Status(fiber.StatusInternalServerError).SendString(msg)
 9510		}
 9511		c.Set("Content-Type", "text/html;charset=utf-8")
 9512		return c.Status(fiber.StatusOK).Send(result.Bytes())
 9513	})
 9514
 9515	r.Get("/order/:piid", func(c fiber.Ctx) error {
 9516		piid := c.Params("piid")
 9517		if !validPIID.MatchString(piid) {
 9518			return c.Status(fiber.StatusBadRequest).SendString("Invalid order ID")
 9519		}
 9520		order, err := script.File("orders/" + piid + ".json").Bytes()
 9521		if err != nil {
 9522			return c.Status(fiber.StatusNotFound).SendString("Order not found")
 9523		}
 9524		return c.Status(fiber.StatusOK).Send(order)
 9525	})
 9526
 9527	r.Get("/order/:piid/html", func(c fiber.Ctx) error {
 9528		piid := c.Params("piid")
 9529		if !validPIID.MatchString(piid) {
 9530			return c.Status(fiber.StatusBadRequest).SendString("Invalid order ID")
 9531		}
 9532		order, err := script.File("orders/" + piid + ".json").Bytes()
 9533		if err != nil {
 9534			return c.Status(fiber.StatusNotFound).SendString("Order not found")
 9535		}
 9536		var m map[string]interface{}
 9537		if err := json.Unmarshal(order, &m); err != nil {
 9538			return c.Status(500).SendString("failed to unmarshal order json: " + err.Error())
 9539		}
 9540		receipt, err := buildReceipt(m, piid)
 9541		if err != nil {
 9542			return c.Status(500).SendString("failed to build receipt: " + err.Error())
 9543		}
 9544		return c.Status(200).SendString(string(receipt))
 9545	})
 9546
 9547	r.Post("/create-payment-intent", func(c fiber.Ctx) error {
 9548		rawBody := c.Body()
 9549		if rawBody == nil {
 9550			log.Printf("Failed to read raw request body")
 9551			return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to read request body"})
 9552		}
 9553
 9554		var req struct {
 9555			Items []item `json:"items"`
 9556		}
 9557		if err := json.Unmarshal(rawBody, &req); err != nil {
 9558			log.Printf("Failed to parse JSON: %v", err)
 9559			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
 9560		}
 9561
 9562		if len(req.Items) == 0 {
 9563			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "No items in request"})
 9564		}
 9565
 9566		// Validate each item's amount against the server-side product
 9567		// catalog. Shared with the crypto path (see invoice.go) so a client
 9568		// is never believed about a price in one and checked in the other.
 9569		items := make([]pay.Item, 0, len(req.Items))
 9570		for _, it := range req.Items {
 9571			items = append(items, pay.Item{ID: it.ID, Amount: it.Amount})
 9572		}
 9573		total, err := validateOrderItems(items)
 9574		if err != nil {
 9575			log.Printf("Item validation failed: %v", err)
 9576			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Item validation failed"})
 9577		}
 9578
 9579		if total < 50 {
 9580			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Order total must be at least $0.50"})
 9581		}
 9582
 9583		params := &stripe.PaymentIntentParams{
 9584			Amount:   stripe.Int64(total),
 9585			Currency: stripe.String(string(stripe.CurrencyUSD)),
 9586		}
 9587		pi, err := paymentintent.New(params)
 9588		if err != nil {
 9589			log.Printf("Failed to create PaymentIntent: %v", err)
 9590			return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
 9591		}
 9592
 9593		log.Printf("Created PaymentIntent %s for %d cents", pi.ID, total)
 9594		return c.Status(fiber.StatusOK).JSON(fiber.Map{
 9595			"clientSecret":   pi.ClientSecret,
 9596			"dpmCheckerLink": fmt.Sprintf("https://dashboard.stripe.com/settings/payment_methods/review?transaction_id=%s", pi.ID),
 9597		})
 9598	})
 9599
 9600	r.Post("/submit-order", func(c fiber.Ctx) error {
 9601		var requestData struct {
 9602			LocalStorageData map[string]interface{} `json:"localStorageData"`
 9603			PaymentIntentID  string                 `json:"paymentIntentId"`
 9604		}
 9605
 9606		if err := c.Bind().Body(&requestData); err != nil {
 9607			log.Println(err)
 9608			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request data"})
 9609		}
 9610
 9611		if !validPIID.MatchString(requestData.PaymentIntentID) {
 9612			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payment intent ID"})
 9613		}
 9614
 9615		log.Printf("Received payment intent ID: %s\n", requestData.PaymentIntentID)
 9616
 9617		paymentIntent, err := paymentintent.Get(requestData.PaymentIntentID, nil)
 9618		if err != nil {
 9619			log.Printf("Error retrieving payment intent: %v", err)
 9620			return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Unable to verify payment"})
 9621		}
 9622		if paymentIntent.Status != stripe.PaymentIntentStatusSucceeded {
 9623			log.Printf("Payment was not successful, status: %s", paymentIntent.Status)
 9624			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Payment not successful"})
 9625		}
 9626
 9627		ordersDir := "./orders"
 9628		if err := os.MkdirAll(ordersDir, 0o750); err != nil {
 9629			log.Printf("Error creating orders directory: %v", err)
 9630			return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Unable to save order"})
 9631		}
 9632
 9633		filePath := filepath.Join(ordersDir, fmt.Sprintf("%s.json", requestData.PaymentIntentID))
 9634
 9635		// Idempotency: if the order file already exists, don't overwrite or reprint
 9636		if _, err := os.Stat(filePath); err == nil {
 9637			log.Printf("Order %s already exists, skipping duplicate submission", requestData.PaymentIntentID)
 9638			return c.Status(fiber.StatusOK).JSON(fiber.Map{"message": "Order already submitted"})
 9639		}
 9640
 9641		// Include the verified Stripe amount alongside the client-supplied data
 9642		orderData := map[string]interface{}{
 9643			"clientData":    requestData.LocalStorageData,
 9644			"verifiedCents": paymentIntent.Amount,
 9645			"currency":      string(paymentIntent.Currency),
 9646			"stripeStatus":  string(paymentIntent.Status),
 9647			"submittedAt":   time.Now().Format(time.RFC3339),
 9648		}
 9649
 9650		data, err := json.MarshalIndent(orderData, "", "  ")
 9651		if err != nil {
 9652			log.Printf("Error marshaling data to json: %v", err)
 9653			return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Unable to save order"})
 9654		}
 9655		if err := os.WriteFile(filePath, data, 0o600); err != nil {
 9656			log.Printf("Error writing data to file: %v", err)
 9657			return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Unable to save order"})
 9658		}
 9659
 9660		// ---- Print receipt via CUPS (non-blocking so your response is snappy)
 9661		go func(pid string, local map[string]interface{}) {
 9662			receipt, err := buildReceipt(local, pid)
 9663			if err != nil {
 9664				log.Printf("build receipt failed: %v", err)
 9665				return
 9666			}
 9667			if err := sendToCUPS(receipt, "Order "+pid); err != nil {
 9668				log.Printf("print failed: %v", err)
 9669				_ = os.WriteFile(filepath.Join(ordersDir, pid+".print_failed"), []byte(err.Error()), 0o600) //nolint:errcheck // a best-effort marker that the receipt did not print; the failure is already logged
 9670			}
 9671		}(requestData.PaymentIntentID, requestData.LocalStorageData)
 9672
 9673		return c.Status(fiber.StatusOK).JSON(fiber.Map{"message": "Order submitted successfully"})
 9674	})
 9675
 9676	/*
 9677			r.Post("/reprint/:pid", func(c fiber.Ctx) error {
 9678		    pid := c.Params("pid")
 9679		    b, err := os.ReadFile(filepath.Join("./orders", pid+".json"))
 9680		    if err != nil { return c.Status(404).SendString("not found") }
 9681		    var m map[string]interface{}
 9682		    if err := json.Unmarshal(b, &m); err != nil { return c.Status(500).SendString(err.Error()) }
 9683		    receipt, err := buildReceipt(m, pid)
 9684		    if err != nil { return c.Status(500).SendString(err.Error()) }
 9685		    if err := sendToCUPS(receipt, "Order "+pid); err != nil {
 9686		        return c.Status(500).SendString(err.Error())
 9687		    }
 9688		    return c.SendStatus(204)
 9689		})
 9690	*/
 9691}
 9692
 9693func buildReceipt(local map[string]interface{}, paymentIntentID string) ([]byte, error) {
 9694	// Pretty JSON body from what you already persisted
 9695	body, err := json.MarshalIndent(local, "", "  ")
 9696	if err != nil {
 9697		return nil, err
 9698	}
 9699	// Simple text receipt header
 9700	ts := time.Now().Format("2006-01-02 15:04:05")
 9701	hdr := fmt.Sprintf(
 9702		"==================== ORDER ====================\n"+
 9703			"PaymentIntent: %s\nTime: %s\n===============================================\n\n",
 9704		paymentIntentID, ts,
 9705	)
 9706	// Footer (optional)
 9707	ftr := "\n\n---------------------- END ---------------------\n"
 9708	receipt := append([]byte(hdr), body...)
 9709	receipt = append(receipt, []byte(ftr)...)
 9710	return receipt, nil
 9711}
 9712
 9713// serverPriceCents looks up a product's price from the server-side catalog by part number.
 9714func serverPriceCents(partno string) (int64, error) {
 9715	allproductsMu.RLock()
 9716	prods := allproducts
 9717	allproductsMu.RUnlock()
 9718	for _, prod := range prods {
 9719		if prod.Partno == partno {
 9720			return parsePriceCents(prod.Price), nil
 9721		}
 9722	}
 9723	return 0, fmt.Errorf("product %q not found in catalog", partno)
 9724}
 9725
 9726// parsePriceCents converts a price string like "$1.23" or "1.23" to cents.
 9727func parsePriceCents(s string) int64 {
 9728	if s == "" {
 9729		return 0
 9730	}
 9731	s = strings.TrimPrefix(s, "$")
 9732	f, err := strconv.ParseFloat(s, 64)
 9733	if err != nil {
 9734		return 0
 9735	}
 9736	if f < 0 {
 9737		return -int64(-f*100 + 0.5)
 9738	}
 9739	return int64(f*100 + 0.5)
 9740}
 9741
 9742// validateItemAmount parses a client item ID ("partno X qty"), looks up the
 9743// server-side price, computes the expected total, and returns it. If the
 9744// client-supplied amount doesn't match, an error is returned.
 9745func validateItemAmount(itemID string, clientAmount int64) (int64, error) {
 9746	// Parse "partno X qty"
 9747	parts := strings.SplitN(itemID, " X ", 2)
 9748	if len(parts) != 2 {
 9749		return 0, fmt.Errorf("unexpected item ID format: %q", itemID)
 9750	}
 9751	partno := parts[0]
 9752	qty, err := strconv.Atoi(parts[1])
 9753	if err != nil || qty <= 0 {
 9754		return 0, fmt.Errorf("invalid quantity in item ID %q", itemID)
 9755	}
 9756
 9757	unitCents, err := serverPriceCents(partno)
 9758	if err != nil {
 9759		return 0, err
 9760	}
 9761	expected := unitCents * int64(qty)
 9762	if expected != clientAmount {
 9763		return 0, fmt.Errorf("amount mismatch for %q: client sent %d cents, server expects %d cents", partno, clientAmount, expected)
 9764	}
 9765	return expected, nil
 9766}
 9767
 9768// escape for inclusion inside *double quotes* in a bash command string
 9769func bashEscapeDoubleQuoted(s string) string {
 9770	s = strings.ReplaceAll(s, `\`, `\\`)
 9771	s = strings.ReplaceAll(s, `"`, `\"`)
 9772	s = strings.ReplaceAll(s, "$", `\$`)
 9773	s = strings.ReplaceAll(s, "`", "\\`")
 9774	return s
 9775}
 9776
 9777func sendToCUPS(receipt []byte, title string) error {
 9778	if title == "" {
 9779		title = "Order"
 9780	}
 9781	var cmd strings.Builder
 9782	cmd.WriteString("lp")
 9783
 9784	if f.PrinterName != "" {
 9785		cmd.WriteString(` -d "`)
 9786		cmd.WriteString(bashEscapeDoubleQuoted(f.PrinterName))
 9787		cmd.WriteString(`"`)
 9788	}
 9789
 9790	cmd.WriteString(` -t "`)
 9791	cmd.WriteString(bashEscapeDoubleQuoted(title))
 9792	cmd.WriteString(`"`)
 9793
 9794	if f.CupsOptions != "" {
 9795		for _, opt := range strings.Split(f.CupsOptions, ",") {
 9796			opt = strings.TrimSpace(opt)
 9797			if opt == "" {
 9798				continue
 9799			}
 9800			cmd.WriteString(` -o "`)
 9801			cmd.WriteString(bashEscapeDoubleQuoted(opt))
 9802			cmd.WriteString(`"`)
 9803		}
 9804	}
 9805
 9806	full := fmt.Sprintf(`bash -lc %q`, cmd.String())
 9807
 9808	_, err := script.Echo(string(receipt)).Exec(full).Stdout()
 9809	if err != nil {
 9810		return fmt.Errorf("lp failed: %v", err)
 9811	}
 9812	return nil
 9813}
 9814
 9815
 9816// ===== pkg/web/other.go =====
 9817// Package web pkg/web/other.go β€” site-specific drop-in routes (not
 9818// committed). Registers via the extraRoutes registry in server.go;
 9819// deleting this file removes these routes with no other code changes.
 9820// See other.go.example.
 9821package web
 9822
 9823import (
 9824	"bytes"
 9825	"fmt"
 9826	"log"
 9827	"strconv"
 9828	"strings"
 9829
 9830	"github.com/gofiber/fiber/v3"
 9831)
 9832
 9833func init() {
 9834	extraRoutes = append(extraRoutes, handleOthers)
 9835	// The software page is the only one here worth crawling; see demospage.
 9836	// /demos is kept as the older spelling of the same page.
 9837	extraSitemapURLs = append(extraSitemapURLs, "/software")
 9838	extraNavLinks = append(extraNavLinks, NavLink{
 9839		Title: "Software",
 9840		Href:  "/software",
 9841		Desc:  "programs written here, running in your browser",
 9842	})
 9843}
 9844
 9845func handleOthers(r *fiber.App) {
 9846	r.Get("/coffee", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusTeapot) })
 9847	r.Get("/clock", clock)
 9848	r.Get("/attractors", attractorspage)
 9849	r.Get("/COVID", covidpage)
 9850	r.Get("/test", testpage)
 9851	r.Get("/software", demospage)
 9852	r.Get("/software/", demospage)
 9853	// The page was published as /demos first and is linked from the READMEs
 9854	// of several repositories, so that spelling keeps working.
 9855	r.Get("/demos", demospage)
 9856	r.Get("/demos/", demospage)
 9857}
 9858
 9859func clock(c fiber.Ctx) error {
 9860	c.Set("Content-Type", "text/html;charset=utf-8")
 9861	_, err := c.Status(fiber.StatusOK).Write([]byte(mustReadFileToString("content/clock.html")))
 9862	return err
 9863}
 9864
 9865func covidpage(c fiber.Ctx) error {
 9866	tmpl, err := mainTmpl()
 9867	if err != nil {
 9868		msg := fmt.Sprintf("Error parse html template: %v", err)
 9869		log.Println(msg)
 9870		return c.Status(fiber.StatusInternalServerError).SendString(msg)
 9871	}
 9872	tmpl0, err := tmpl.Clone()
 9873	if err != nil {
 9874		msg := fmt.Sprintf("Error cloning template: %v", err)
 9875		log.Println(msg)
 9876		return c.Status(fiber.StatusInternalServerError).SendString(msg)
 9877	}
 9878	_, err = tmpl0.New("main").Parse(mustReadFileToString("content/mementomori.html"))
 9879	if err != nil {
 9880		msg := fmt.Sprintf("Error parsing main template: %v", err)
 9881		log.Println(msg)
 9882		return c.Status(fiber.StatusInternalServerError).SendString(msg)
 9883	}
 9884	tmpl = tmpl0
 9885	log.Println(c.Get("User-Agent"))
 9886	c.Set("Content-Type", "text/html;charset=utf-8")
 9887	h1 := pageMeta(c, htmlPageTemplateData)
 9888	h1.Page = "hidden"
 9889	h1.MetaDesc = "The COVID  ΜΆvΜΆaΜΆcΜΆcΜΆiΜΆnΜΆeΜΆ bioweapon injection genocide and the new dark age of humanity"
 9890	//		h1.Mobile = strings.Contains(strings.ToLower(c.Get("User-Agent")), "mobile")
 9891	tmplData := map[string]interface{}{
 9892		"Page":  h1,
 9893		"Prods": allproducts,
 9894	}
 9895	var result bytes.Buffer
 9896	err = tmpl.Execute(&result, tmplData)
 9897	if err != nil {
 9898		msg := fmt.Sprintf("Error executing template: %v", err)
 9899		log.Println(msg)
 9900		return c.Status(fiber.StatusInternalServerError).SendString(msg)
 9901	}
 9902	_, err = c.Status(fiber.StatusOK).Write(collapseNewlines.ReplaceAll(result.Bytes(), []byte("\n")))
 9903	return err
 9904}
 9905
 9906// attractorspage renders a chromeless fullscreen page for the
 9907// strange-attractor visualizer (no header, footer, cart, or store
 9908// nav). Reuses the existing stl2 wasm β€” its URL-path dispatcher
 9909// sees "/attractors" and falls into the default branch which
 9910// invokes attractor.Run(). Loads the TinyGo or stdlib wasm based
 9911// on f.UseTinygo.
 9912func attractorspage(c fiber.Ctx) error {
 9913	// Newest build wins, with the shim that matches it β€” the same rule the
 9914	// template pages use. Hardcoding "-tiny.wasm" here meant this page served
 9915	// whatever the last tinygo run produced even when the Go build was newer,
 9916	// and paired it with the site-global shim rather than its own.
 9917	pick, ok := pickWasm("stl2")
 9918	if !ok {
 9919		return c.Status(fiber.StatusNotFound).SendString("no stl2 wasm build available")
 9920	}
 9921	wasmFile := pick.File
 9922	html := fmt.Sprintf(`<!DOCTYPE html>
 9923<html lang="en">
 9924<head>
 9925<meta charset="utf-8">
 9926<meta name="viewport" content="width=device-width, initial-scale=1">
 9927<title>Strange Attractors β€” %s</title>
 9928<meta name="description" content="Interactive 3D strange-attractor visualizer with mouse-drag rotation. Lorenz, Rossler, Chua, Aizawa, Sprott, Lissajous, Thomas, Halvorsen, Chen, Dadras, Rabinovich-Fabrikant, Burke-Shaw, Platonic solids, globe, sphere, torus, magnetosphere.">
 9929<meta name="robots" content="index, follow">
 9930<style>html,body{margin:0;padding:0;width:100%%;height:100%%;background:#000;color:#fff;overflow:hidden;}#gocanvas{position:fixed;top:0;left:0;width:100%%;height:100%%;display:block;}</style>
 9931<script src="%s"></script>
 9932<script>
 9933if (!WebAssembly.instantiateStreaming) {
 9934  WebAssembly.instantiateStreaming = async (resp, importObject) => {
 9935    const source = await (await resp).arrayBuffer();
 9936    return await WebAssembly.instantiate(source, importObject);
 9937  };
 9938}
 9939const go = new Go();
 9940WebAssembly.instantiateStreaming(fetch("/%s"), go.importObject).then((result) => {
 9941  go.run(result.instance);
 9942}).catch((err) => { console.error("Failed to run WASM:", err); });
 9943</script>
 9944</head>
 9945<body>
 9946<canvas id="gocanvas"></canvas>
 9947</body>
 9948</html>`, f.Sitelongname, f.WasmExecPath, wasmFile)
 9949	c.Set("Content-Type", "text/html; charset=utf-8")
 9950	return c.Status(fiber.StatusOK).SendString(html)
 9951}
 9952
 9953// ---------------------------------------------------------------------------
 9954// /demos β€” the live demos hosted on magnetosphere.net subdomains.
 9955//
 9956// Every one of these projects publishes its demo from GitHub Pages under a
 9957// CNAME of this domain (chaosrack.magnetosphere.net and so on), and until now
 9958// nothing on the web linked to them. A page nobody links to is a page a
 9959// crawler reaches only through the certificate transparency logs, if at all β€”
 9960// so the subdomains existed but were effectively unfindable.
 9961//
 9962// This is the index that fixes that. It is a real page at a real URL rather
 9963// than another anchor on the front page, for two reasons: an anchor section
 9964// shares the front page's <title> and description, and this content is not
 9965// about electronic surplus; and the front page hides every non-target section
 9966// with CSS, so the text would be in the markup but never rendered to a reader.
 9967//
 9968// It lives in the drop-in rather than in the m2 repo because it is specific to
 9969// this deployment β€” no other m2 site has these subdomains. The one piece that
 9970// did go upstream is extraSitemapURLs, which is the generic half: a drop-in
 9971// route that wants to be crawled needs to be in the sitemap, and there was no
 9972// way to say so.
 9973// ---------------------------------------------------------------------------
 9974
 9975// demoAlt is a second build of the same demo, linked beside the first. Several
 9976// of these ship a TinyGo build and a standard-Go build of the same program;
 9977// the difference is size against completeness, and both are worth reaching.
 9978type demoAlt struct {
 9979	Label string
 9980	Path  string
 9981}
 9982
 9983// demoSite is one demo: where it runs, where its source is, and one sentence
 9984// saying what it actually does. The sentence is the part that matters β€” it is
 9985// what a search result shows and the only description of these subdomains that
 9986// exists anywhere.
 9987type demoSite struct {
 9988	Name string
 9989	Host string
 9990	Repo string
 9991	What string
 9992	Alts []demoAlt
 9993
 9994	// Pending holds a demo back until its subdomain actually answers over
 9995	// HTTPS. GitHub issues a Pages certificate some time after the DNS lands,
 9996	// and until it does the link is a TLS error rather than a page.
 9997	//
 9998	// The entry stays here rather than being deleted and rewritten later,
 9999	// because the description is the part that took the thought. Clearing this
10000	// flag is the whole of the work when the certificate arrives β€” and a page
10001	// whose entire purpose is to be crawled must not be the thing that teaches
10002	// a crawler these hostnames are broken.
10003	Pending bool
10004}
10005
10006// demoGroup is a heading over a run of demos.
10007type demoGroup struct {
10008	Title string
10009	Note  string
10010	Sites []demoSite
10011}
10012
10013var demoGroups = []demoGroup{{
10014	Title: "Applications",
10015	Note:  "Whole programs, running in the tab. Nothing is uploaded and nothing runs on a server.",
10016	Sites: []demoSite{
10017		{
10018			Name: "chaosrack",
10019			Host: "chaosrack.magnetosphere.net",
10020			What: "An analog computer in the browser: 66 dynamical-system models β€” Lorenz, RΓΆssler, Chua, all twenty Sprott cases, HΓ©non and the other maps β€” on an instrument rack with knobs, seven-segment readouts and toggle switches. Live audio as a spectrogram, an XY scope or a delay embedding, and a ripple tank to look through.",
10021		},
10022		{
10023			Name: "shipyard",
10024			Host: "shipyard.magnetosphere.net",
10025			What: "A Go workstation in a browser tab. The Go toolchain is on the PATH: type go build in the shell, against the same in-memory filesystem the compiler reads, and run what comes out.",
10026		},
10027		{
10028			Name: "shipwright",
10029			Host: "shipwright.magnetosphere.net",
10030			What: "The Go toolchain itself, running in the tab. Edit a program and press build and run: cmd/compile and cmd/link do the work client-side, compiling the standard library from source, and nothing after page load touches the server.",
10031		},
10032		{
10033			Name: "netscrape",
10034			Host: "netscrape.magnetosphere.net",
10035			What: "A web browser written in Go and wasm β€” tab strip, address bar, history, back and forward β€” rendering a site served beside it, with the stylesheet inlined and images relayed back as data URIs. The same seam dials the dmsg mesh in skywire's wasm visor.",
10036		},
10037		{
10038			Name: "desk",
10039			Host: "desk.magnetosphere.net",
10040			What: "A desktop shell for the browser β€” windows, a taskbar, an applications menu β€” where a window is any Go program that renders into a DOM element.",
10041			Alts: []demoAlt{{Label: "standard Go build", Path: "/go/"}},
10042		},
10043		{
10044			Name: "websh",
10045			Host: "websh.magnetosphere.net",
10046			What: "A bash-like shell running entirely in your browser. Real pipes, redirections, globbing, functions, heredocs and arithmetic, over a filesystem persisted to IndexedDB, so your files survive a reload. No server, no container, no emulator.",
10047			Alts: []demoAlt{{Label: "standard Go build", Path: "/go/"}},
10048		},
10049		{
10050			Name: "dict",
10051			Host: "dict.magnetosphere.net",
10052			What: "Dictionary lookup and spell-check in a terminal, with Webster's 1913, WordNet, Wiktionary and Wikipedia. It finds the word you meant when a transposition or a doubled letter means a fuzzy matcher finds nothing at all.",
10053		},
10054		{
10055			Name: "pisano",
10056			Host: "pisano.magnetosphere.net",
10057			What: "Designs made by reducing an integer sequence modulo m β€” the Pisano period made visible β€” with a shell to drive the renderer.",
10058			Alts: []demoAlt{{Label: "standard Go build", Path: "/go/"}},
10059		},
10060	},
10061}, {
10062	Title: "Libraries ported to Go and WebAssembly",
10063	Note:  "JavaScript libraries reimplemented in Go, so a Go program in a tab can use them without a JavaScript dependency.",
10064	Sites: []demoSite{
10065		{
10066			Name: "xterm-go",
10067			Host: "xterm-go.magnetosphere.net",
10068			What: "A Go port of xterm.js 6.0.0 β€” the terminal component behind VS Code and Hyper. The full escape-sequence machinery, a WebGL2 renderer, scrollback with reflow, and IME support. The VT core is pure Go and runs headless anywhere.",
10069		},
10070		{
10071			Name: "winbox-go",
10072			Host: "winbox-go.magnetosphere.net",
10073			What: "A Go port of WinBox.js, the HTML5 window manager: drag, eight-direction resize, minimize into a split-screen taskbar, maximize, fullscreen, modals and every lifecycle callback. The stylesheet is embedded in the binary, so there is nothing else to serve.",
10074		},
10075		{
10076			Name: "cosmos-go",
10077			Host: "cosmos-go.magnetosphere.net",
10078			What: "A Go port of cosmos.gl 2.6.3, the GPU force-directed graph engine behind Cosmograph and the Skywire network visualizer. The simulation and the drawing both run in shaders, which is how it holds hundreds of thousands of points at frame rate.",
10079		},
10080		{
10081			Name: "tuiwasm",
10082			Host: "tuiwasm.magnetosphere.net",
10083			What: "Go terminal-UI libraries running in the browser on top of xterm-go β€” tcell, lipgloss, glamour, go-pretty, pterm and asciigraph β€” plus 21 terminal animations and a tcell space shooter, each in a window of its own.",
10084			Alts: []demoAlt{{Label: "TinyGo build", Path: "/tinygo/"}},
10085		},
10086	},
10087}, {
10088	Title: "Command-line tools ported to Go",
10089	Note:  "Each of these is a faithful port of a C or Go program, and each demo runs the port itself rather than an approximation of it.",
10090	Sites: []demoSite{
10091		{
10092			Name: "plot-go",
10093			Host: "plot-go.magnetosphere.net",
10094			What: "Line graphs on the command line. The demo follows two counters a browser tab actually has β€” the gap between animation frames, and how late a zero-millisecond timeout runs β€” and redraws them live in a terminal.",
10095		},
10096		{
10097			Name: "img2txt-go",
10098			Host: "img2txt-go.magnetosphere.net",
10099			What: "libcaca's img2txt in pure Go, with no cgo and no Imlib2: images to colored ASCII and ANSI art, in every text format libcaca supports. Drop in a picture, pick a width and a dither; the image never leaves the tab.",
10100		},
10101		{
10102			Name: "ansifilter-go",
10103			Host: "ansifilter-go.magnetosphere.net",
10104			What: "A Go port of ansifilter 2.23: text with ANSI terminal escape codes converted to HTML, LaTeX, RTF, SVG, Pango markup or BBCode. The demo shows the same input in a real terminal and in a real browser at once.",
10105		},
10106		{
10107			Name: "calvin",
10108			Host: "calvin.magnetosphere.net",
10109			What: "Text converted to the Calvin S box-drawing font. Type in the box and watch the letters turn.",
10110		},
10111	},
10112}, {
10113	Title: "Interface libraries written here",
10114	Note:  "Not ports of anything β€” original libraries, each with a demo that is the library doing its one job.",
10115	Sites: []demoSite{
10116		{
10117			Name: "rack-go",
10118			Host: "rack-go.magnetosphere.net",
10119			What: "Panel modules laid out the way a physical equipment rack does: side by side, each a whole number of slots wide, wrapping into rows and reflowing when one is taken out. Drag a module by its header to move it, or switch one away and watch the rest close up.",
10120		},
10121	},
10122}, {
10123	// Not written here: these are upstream projects this site's author is a
10124	// contributor to. They are on the software page because the page is the
10125	// honest answer to "what does whoever runs this store work on", and
10126	// leaving them off made that answer look smaller than it is.
10127	//
10128	// Repo is set on both β€” demoRepo defaults to github.com/0magnet/<Name>,
10129	// which is wrong for anything upstream.
10130	Title: "Upstream projects contributed to",
10131	Note:  "Not written here, and not WebAssembly. Listed because a good deal of the work behind this site went into them.",
10132	Sites: []demoSite{{
10133		Name: "skycoin",
10134		Host: "skycoin.magnetosphere.net",
10135		Repo: "https://github.com/skycoin/skycoin",
10136		What: "A cryptocurrency with no mining and no transaction fees, and the consensus algorithm behind it. The link goes to a Skycoin block explorer running on this host, against a full node it keeps in sync.",
10137	}, {
10138		Name: "skywire",
10139		Host: "theskywirenetwork.net",
10140		Repo: "https://github.com/skycoin/skywire",
10141		What: "A mesh network that routes traffic over its own transports rather than the public internet, paying node operators for uptime. The browser pane in this site's desktop runs over it.",
10142	}},
10143}}
10144
10145// demoRepo is the source URL for a demo. All of these live under the same
10146// GitHub account and the repository is named after the program.
10147func (d demoSite) demoRepo() string {
10148	if d.Repo != "" {
10149		return d.Repo
10150	}
10151	return "https://github.com/0magnet/" + d.Name
10152}
10153
10154// demosBody renders the page. It is plain markup in the vocabulary the rest of
10155// the site already uses β€” the α›­ table and the ❀ heading from the links page β€”
10156// so it inherits the stylesheet rather than carrying one of its own.
10157func demosBody() string {
10158	var b strings.Builder
10159	// A column of its own. main is full-bleed and centered, which is right for
10160	// the store front but runs prose to both edges of a wide window and puts the
10161	// last table column under the scrollbar.
10162	b.WriteString("<div style='max-width:1200px;margin:0 auto;padding:0 14px'>\n")
10163	b.WriteString("<h1>Software</h1>\n")
10164	b.WriteString("<p>Software written here, running in your browser. ")
10165	b.WriteString("Every one of these is Go compiled to WebAssembly, served from a subdomain of this site, ")
10166	b.WriteString("and open source. Nothing below needs an account, an install, or a round trip to a server.</p>\n")
10167
10168	for _, g := range demoGroups {
10169		// A group every one of whose demos is still waiting on a certificate
10170		// would render as a heading over nothing.
10171		if live(g.Sites) == 0 {
10172			continue
10173		}
10174		b.WriteString("<h2 class='❀'>" + g.Title + "</h2>\n")
10175		if g.Note != "" {
10176			b.WriteString("<p>" + g.Note + "</p>\n")
10177		}
10178		b.WriteString("<table class='α›­'><thead><tr><th style='width:12%'><pre>Demo</pre></th><th style='width:62%'><pre>What it is</pre></th><th style='width:26%'><pre>Source</pre></th></tr></thead>\n<tbody>\n")
10179		for _, d := range g.Sites {
10180			if d.Pending {
10181				continue
10182			}
10183			b.WriteString("<tr><td style='width:12%'><pre><a title='" + d.Host + "' href='https://" + d.Host + "/'>" + d.Name + "</a>")
10184			for _, a := range d.Alts {
10185				b.WriteString("<br><a title='" + d.Host + a.Path + "' href='https://" + d.Host + a.Path + "'>" + a.Label + "</a>")
10186			}
10187			b.WriteString("</pre></td>")
10188			b.WriteString("<td style='width:62%;padding-right:16px'><pre style='white-space:pre-wrap'>" + d.What + "</pre></td>")
10189			b.WriteString("<td style='width:26%'><pre><a title='source code for " + d.Name + "' href='" + d.demoRepo() + "'>" + strings.TrimPrefix(d.demoRepo(), "https://") + "</a></pre></td></tr>\n")
10190		}
10191		b.WriteString("</tbody></table>\n")
10192	}
10193	b.WriteString(deepBody())
10194	b.WriteString("<p><a href='/'>Back to the store</a></p>\n")
10195	b.WriteString("</div>\n")
10196	return b.String()
10197}
10198
10199// demosLD is the structured data for the page: an ItemList of the demos, each
10200// a SoftwareApplication at its own URL.
10201//
10202// This is the half a crawler reads. The list gives every subdomain a name, a
10203// description and a URL in one document, which is the fastest way to get a set
10204// of never-before-linked hostnames into an index.
10205func demosLD(canonical string) string {
10206	var b strings.Builder
10207	b.WriteString("<script type='application/ld+json'>\n{\n")
10208	b.WriteString("\"@context\": \"https://schema.org\",\n")
10209	b.WriteString("\"@type\": \"ItemList\",\n")
10210	b.WriteString("\"name\": \"Demos\",\n")
10211	b.WriteString("\"url\": \"" + canonical + "\",\n")
10212	b.WriteString("\"itemListElement\": [\n")
10213	pos := 0
10214	for _, g := range demoGroups {
10215		for _, d := range g.Sites {
10216			if d.Pending {
10217				continue
10218			}
10219			if pos > 0 {
10220				b.WriteString(",\n")
10221			}
10222			pos++
10223			b.WriteString("{\"@type\": \"ListItem\", \"position\": " + strconv.Itoa(pos) + ", \"item\": {")
10224			b.WriteString("\"@type\": \"SoftwareApplication\", ")
10225			b.WriteString("\"name\": \"" + d.Name + "\", ")
10226			b.WriteString("\"url\": \"https://" + d.Host + "/\", ")
10227			b.WriteString("\"applicationCategory\": \"DeveloperApplication\", ")
10228			b.WriteString("\"operatingSystem\": \"Any (WebAssembly)\", ")
10229			b.WriteString("\"codeRepository\": \"" + d.demoRepo() + "\", ")
10230			b.WriteString("\"description\": \"" + jsonString(d.What) + "\"}}")
10231		}
10232	}
10233	b.WriteString("\n]\n}\n</script>\n")
10234	return b.String()
10235}
10236
10237// jsonString escapes the characters a description could plausibly contain and
10238// JSON cannot carry raw. The blurbs above are written by hand and hold none of
10239// them today; this is here so that editing one cannot silently produce invalid
10240// structured data, which a crawler drops without reporting.
10241func jsonString(s string) string {
10242	r := strings.NewReplacer(`\`, `\\`, `"`, `\"`, "\n", " ", "\t", " ")
10243	return r.Replace(s)
10244}
10245
10246// demospage serves /demos.
10247//
10248// It borrows the site chrome β€” head, header, footer β€” and replaces the "schema"
10249// partial with the ItemList above, rather than the front page's Organization
10250// block.
10251//
10252// It states WasmBinary as empty. That is the default now β€” a page opts in
10253// with wasmFor β€” but saying so here keeps the intent readable: this page is
10254// text, and a crawler meant to read it quickly should not be handed the STL
10255// viewer. Emptying the list is how the templates ask for that; overriding the
10256// "wasm" partial with an empty body does NOT work, because Go templates treat
10257// a whitespace-only body as no definition at all and leave the existing one
10258// standing.
10259func demospage(c fiber.Ctx) error {
10260	tmpl, err := mainTmpl()
10261	if err != nil {
10262		msg := fmt.Sprintf("Error parsing html template: %v", err)
10263		log.Println(msg)
10264		return c.Status(fiber.StatusInternalServerError).SendString(msg)
10265	}
10266	tmpl0, err := tmpl.Clone()
10267	if err != nil {
10268		msg := fmt.Sprintf("Error cloning template: %v", err)
10269		log.Println(msg)
10270		return c.Status(fiber.StatusInternalServerError).SendString(msg)
10271	}
10272	h1 := pageMeta(c, htmlPageTemplateData)
10273	h1.Page = "software"
10274	// One canonical URL for two routes. /demos was the first spelling and is
10275	// linked from several repository READMEs, so it still answers β€” but it
10276	// must not compete with /software for the same content, which is exactly
10277	// what a self-referencing canonical on each would set up.
10278	h1.Canonical = h1.BaseURL + "/software"
10279	h1.Title = "Software β€” Go and WebAssembly in the browser | " + f.Sitelongname
10280	h1.MetaDesc = "Live demos: a shell, a desktop, a Go toolchain, a dictionary, a terminal emulator, a window manager, a GPU force graph and an analog computer β€” all Go compiled to WebAssembly, running in the browser."
10281	h1.KeyWords = "Go, WebAssembly, wasm, TinyGo, browser demo, xterm.js port, WinBox port, cosmos.gl port, in-browser shell, strange attractor, libcaca img2txt, ansifilter"
10282	h1.WasmBinary = nil
10283
10284	for _, part := range []struct{ name, body string }{
10285		{"main", demosBody()},
10286		{"schema", demosLD(h1.Canonical)},
10287	} {
10288		if _, err := tmpl0.New(part.name).Parse(part.body); err != nil {
10289			msg := fmt.Sprintf("Error parsing %s template: %v", part.name, err)
10290			log.Println(msg)
10291			return c.Status(fiber.StatusInternalServerError).SendString(msg)
10292		}
10293	}
10294
10295	c.Set("Content-Type", "text/html;charset=utf-8")
10296	tmplData := map[string]interface{}{
10297		"Page":  h1,
10298		"Prods": allproducts,
10299	}
10300	var result bytes.Buffer
10301	if err := tmpl0.Execute(&result, tmplData); err != nil {
10302		msg := fmt.Sprintf("Error executing template: %v", err)
10303		log.Println(msg)
10304		return c.Status(fiber.StatusInternalServerError).SendString(msg)
10305	}
10306	_, err = c.Status(fiber.StatusOK).Write(collapseNewlines.ReplaceAll(result.Bytes(), []byte("\n")))
10307	return err
10308}
10309
10310// ---------------------------------------------------------------------------
10311// Deep links.
10312//
10313// Three of the demos take a parameter, so a link can open on a particular
10314// thing rather than on the front of the app. tuiwasm is the striking case: one
10315// wasm binary holds 31 separate programs, and without this the page links to
10316// the binary and every program inside it is invisible.
10317//
10318// This is the cheapest indexable surface here by a distance. Each of these is
10319// a distinct URL that renders distinct content, and the link text is the
10320// demo's own name β€” which is what someone searching for "terminal matrix
10321// animation" or "Langton's ant in the browser" actually types.
10322// ---------------------------------------------------------------------------
10323
10324// deepLink is one parameterized entry point: what to call it, what it opens,
10325// and the query that gets there.
10326type deepLink struct {
10327	Label string
10328	Desc  string
10329	Query string
10330}
10331
10332// deepGroup is a run of deep links into one demo.
10333type deepGroup struct {
10334	Host  string
10335	Title string
10336	Note  string
10337	Links []deepLink
10338}
10339
10340var deepGroups = []deepGroup{{
10341	Host:  "tuiwasm.magnetosphere.net",
10342	Title: "tuiwasm β€” 43 terminal-UI programs, one per link",
10343	Note:  "Terminal animations and TUI library demos, each drawn into a real terminal emulator in the page. The animations come from termanim; the rest exercise tcell, lipgloss, glamour, go-pretty and asciigraph.",
10344	Links: []deepLink{
10345		{"aquarium", "fish swimming past swaying seaweed", "demo=aquarium"},
10346		{"aurora", "curtains of light, folding, with the creases where they turn", "demo=aurora"},
10347		{"boids", "flocking by separation, alignment and cohesion", "demo=boids"},
10348		{"bonsai", "a bonsai tree growing branch by branch", "demo=bonsai"},
10349		{"bounce", "the screensaver logo, and the wait for it to hit a corner", "demo=bounce"},
10350		{"boxes", "tcell's own boxes demo β€” random boxes, timed", "demo=boxes"},
10351		{"charts", "asciigraph β€” a line plot in cells", "demo=charts"},
10352		{"clock", "an analog clock, after aclock", "demo=clock"},
10353		{"colors", "tcell's own colors demo β€” boxes cycling through the palette", "demo=colors"},
10354		{"cube", "a rotating wireframe solid, shaded by depth", "demo=cube"},
10355		{"donut", "a lit torus with a z-buffer", "demo=donut"},
10356		{"fire", "a heat grid seeded with noise and cooled upward", "demo=fire"},
10357		{"fireworks", "shells that rise, burst and droop into willows", "demo=fireworks"},
10358		{"flow", "particles carried through a divergence-free curl-noise field", "demo=flow"},
10359		{"frost", "a crystal growing by diffusion-limited aggregation", "demo=frost"},
10360		{"julia", "a Julia set morphing as its parameter walks the cardioid", "demo=julia"},
10361		{"langton", "Langton's ants: chaos, then the highway", "demo=langton"},
10362		{"lavalamp", "wax that heats, rises, cools and sinks", "demo=lavalamp"},
10363		{"life", "Conway's life, colored by how long a cell has lived", "demo=life"},
10364		{"lightning", "a branching discharge: leader, return stroke, afterglow", "demo=lightning"},
10365		{"matrix", "falling columns of glyphs", "demo=matrix"},
10366		{"maze", "a maze carved by backtracking, then solved", "demo=maze"},
10367		{"metaballs", "blobs that bulge and merge as they approach", "demo=metaballs"},
10368		{"moire", "two drifting ripples interfering", "demo=moire"},
10369		{"parrot", "the party parrot, rolling once around the hue wheel", "demo=parrot"},
10370		{"pendulum", "double pendulums released together, shearing apart", "demo=pendulum"},
10371		{"physarum", "slime mold laying trails and building a transport network", "demo=physarum"},
10372		{"pipes", "pipes growing and turning, with correct elbows", "demo=pipes"},
10373		{"plasma", "summed sine waves of position, offset by time", "demo=plasma"},
10374		{"proxima", "Escape from Proxima 5 β€” gdamore's tcell space shooter", "demo=proxima"},
10375		{"proxima2", "Escape from Proxima 5 on tcell v2 β€” the same game, one major back", "demo=proxima2"},
10376		{"rain", "drops with depth, slant, streaks and splashes", "demo=rain"},
10377		{"reaction", "Gray-Scott reaction-diffusion: spots that divide into a labyrinth", "demo=reaction"},
10378		{"ripple", "a ripple tank: rain falling, rings meeting and interfering", "demo=ripple"},
10379		{"sand", "grains heaping at their angle of repose", "demo=sand"},
10380		{"snow", "flakes that sway, settle and drift into banks", "demo=snow"},
10381		{"starfield", "stars streaming past the viewer", "demo=starfield"},
10382		{"styles", "lipgloss β€” borders, color, alignment", "demo=styles"},
10383		{"tables", "go-pretty β€” the wasm compatibility matrix", "demo=tables"},
10384		{"tunnel", "flying down a textured tube", "demo=tunnel"},
10385		{"unicode", "tcell's own unicode demo β€” wide, combining and emoji glyphs", "demo=unicode"},
10386		{"widgets", "tview β€” a flexbox of lists, tables and text views on tcell v2", "demo=widgets"},
10387		{"wolfram", "elementary cellular automata scrolling upward - 30, 90, 110", "demo=wolfram"},
10388	},
10389}, {
10390	Host:  "plot-go.magnetosphere.net",
10391	Title: "plot-go β€” the same counters through different pipelines",
10392	Note:  "The processing pipeline comes from the query, exactly as the command line takes it. Each of these plots the same two live counters a different way.",
10393	Links: []deepLink{
10394		{"avg:5", "a five-sample moving average β€” the default", "p=avg:5"},
10395		{"roc:5", "rate of change over five samples", "p=roc:5"},
10396		{"avg:5|roc:5", "smoothed first, then differentiated", "p=avg:5%7Croc:5"},
10397		{"cma", "a cumulative moving average over the whole run", "p=cma"},
10398	},
10399}, {
10400	Host:  "dict.magnetosphere.net",
10401	Title: "dict β€” a word, looked up on arrival",
10402	Note:  "The terminal runs whatever the link asks for, so each of these opens on a finished lookup rather than a prompt. One of the words is misspelled on purpose β€” that is the point of it.",
10403	Links: []deepLink{
10404		{"serendipity", "Webster's 1913 on serendipity", "run=dict%20serendipity"},
10405		{"rain", "every sense of rain, across all four sources", "run=dict%20%3Arain"},
10406		{"recieve", "a transposition: the misspelling a subsequence matcher cannot find", "run=dict%20recieve"},
10407		{"aluminium", "the British spelling, folded to the American entry", "run=dict%20aluminium"},
10408	},
10409}}
10410
10411// deepBody renders the deep-link section: one paragraph of links per demo,
10412// rather than a table. A table of 31 one-word rows is mostly borders.
10413func deepBody() string {
10414	var b strings.Builder
10415	b.WriteString("<h2 class='❀'>Straight to one thing</h2>\n")
10416	b.WriteString("<p>Some of these take a parameter, so a link can open on a particular demo, ")
10417	b.WriteString("pipeline or word instead of the front of the app.</p>\n")
10418	for _, g := range deepGroups {
10419		b.WriteString("<h3 style='font-size:10pt'>" + g.Title + "</h3>\n")
10420		b.WriteString("<p style='max-width:70em;margin:0 auto'>" + g.Note + "</p>\n")
10421		b.WriteString("<p style='max-width:70em;margin:6px auto;line-height:1.9'>")
10422		for i, l := range g.Links {
10423			if i > 0 {
10424				b.WriteString(" &middot; ")
10425			}
10426			b.WriteString("<a title=\"" + l.Desc + "\" href='https://" + g.Host + "/?" + l.Query + "'>" + l.Label + "</a>")
10427		}
10428		b.WriteString("</p>\n")
10429	}
10430	return b.String()
10431}
10432
10433// live counts the demos in a group that are ready to be linked.
10434func live(sites []demoSite) int {
10435	n := 0
10436	for _, d := range sites {
10437		if !d.Pending {
10438			n++
10439		}
10440	}
10441	return n
10442}
10443
10444// ---------------------------------------------------------------------------
10445// /test β€” the site with the decoration wasm loaded.
10446//
10447// Deliberately not a gallery. The thing to judge is the site, so this parses
10448// exactly what homepage parses and differs from it in one line: the wasm list.
10449// Diffing /test against / therefore shows the decoration and nothing else, and
10450// when a piece of it is worth keeping the change is to add it to homepage.
10451
10452// testpage serves /test.
10453func testpage(c fiber.Ctx) error {
10454	tmpl, err := mainTmpl()
10455	if err != nil {
10456		return c.Status(fiber.StatusInternalServerError).SendString(fmt.Sprintf("template: %v", err))
10457	}
10458	tmpl0, err := tmpl.Clone()
10459	if err != nil {
10460		return c.Status(fiber.StatusInternalServerError).SendString(fmt.Sprintf("clone: %v", err))
10461	}
10462	for _, part := range []struct{ name, body string }{
10463		{"main", h.FrontPage()},
10464		{"about", h.AboutPage()},
10465		{"policy", h.PolicyPage()},
10466		{"links", h.LinksPage()},
10467	} {
10468		if _, err := tmpl0.New(part.name).Parse(part.body); err != nil {
10469			return c.Status(fiber.StatusInternalServerError).SendString(fmt.Sprintf("%s: %v", part.name, err))
10470		}
10471	}
10472	h1 := pageMeta(c, htmlPageTemplateData)
10473	h1.Page = "front"
10474	h1.Canonical = h1.BaseURL + "/test"
10475	h1.Title = "sandbox β€” " + f.Sitelongname
10476	// The front page's own canvas, plus the decorator. This is the whole of
10477	// the difference from homepage.
10478	h1.WasmBinary = wasmFor("stl2", "deco")
10479	c.Set("Content-Type", "text/html;charset=utf-8")
10480	c.Set("Cache-Control", "no-store")
10481	var out bytes.Buffer
10482	if err := tmpl0.Execute(&out, map[string]interface{}{"Page": h1, "Prods": allproducts}); err != nil {
10483		return c.Status(fiber.StatusInternalServerError).SendString(fmt.Sprintf("execute: %v", err))
10484	}
10485	body := collapseNewlines.ReplaceAll(out.Bytes(), []byte("\n"))
10486	// Injected rather than added to style.css: the rules are generated from the
10487	// live category list, which a static file cannot be. If this graduates it
10488	// wants a template hook rather than a string splice.
10489	body = bytes.Replace(body, []byte("</head>"), []byte(catTargetCSS()+"</head>"), 1)
10490	_, err = c.Status(fiber.StatusOK).Write(body)
10491	return err
10492}
10493
10494// catTargetCSS makes a targeted category show its subcategories' sections too.
10495//
10496// The problem it solves: sections are flat siblings under .✝ and only the one
10497// matching :target is shown, so #cat-resistor displays its heading, its list of
10498// subcategory links, and the "Other Products" table β€” which holds only the
10499// products that have no subcategory. Every resistor has one, so the category
10500// view is a page with no products on it.
10501//
10502// The fix is one rule per category, naming that category's subcategory
10503// sections by the prefix their ids already share:
10504//
10505//	[id="cat-resistor"]:target ~ [id^="cat-resistor-"]{display:block}
10506//
10507// Nesting the sections and using :has() would also work and reads better, but
10508// :has() only reached Firefox in December 2023 while :target has worked since
10509// about 2010 β€” and a browser without :has() would find the nested section
10510// inside a display:none parent, which breaks navigation rather than degrading
10511// it. This uses :target, ~ and [attr^=] only, so it works wherever the
10512// existing navigation does, and needs no second copy of the markup.
10513func catTargetCSS() string {
10514	cats := htmlPageTemplateData.Cats
10515	var b strings.Builder
10516	b.WriteString("<style>/* a targeted category shows its subcategories too */\n")
10517	for _, cat := range cats {
10518		if len(htmlPageTemplateData.SubCatsByCat[cat]) == 0 {
10519			continue
10520		}
10521		// A category whose name plus a dash begins another category's name
10522		// would have its rule match that one's sections as well. Skip it
10523		// rather than emit a rule that reveals the wrong products.
10524		clash := false
10525		for _, other := range cats {
10526			if other != cat && strings.HasPrefix(other, cat+"-") {
10527				clash = true
10528				break
10529			}
10530		}
10531		if clash {
10532			b.WriteString("/* skipped " + cat + ": prefixes another category */\n")
10533			continue
10534		}
10535		id := "cat-" + cat
10536		b.WriteString(`[id="` + id + `"]:target ~ [id^="` + id + `-"]{display:block}` + "\n")
10537	}
10538	b.WriteString("</style>")
10539	return b.String()
10540}
10541
10542
10543// ===== pkg/web/server.go =====
10544// Package web pkg/web/server.go β€” the store server: routes and page handlers.
10545package web
10546
10547import (
10548	"bytes"
10549	"context"
10550	"encoding/base64"
10551	"fmt"
10552	"log"
10553	"os"
10554	"path/filepath"
10555	"regexp"
10556	"sort"
10557	"strconv"
10558	"strings"
10559	"sync"
10560
10561	"github.com/gofiber/fiber/v3"
10562
10563	"github.com/bitfield/script"
10564
10565	"github.com/0magnet/m2/pkg/config"
10566	p "github.com/0magnet/m2/pkg/product"
10567	"github.com/0magnet/m2/pkg/storepage"
10568)
10569
10570// f aliases the shared configuration; the name keeps the handler code
10571// identical to its pre-refactor form at the repo root, and drop-in
10572// other.go files keep working unchanged.
10573var f = &config.F
10574
10575var collapseNewlines = regexp.MustCompile(`\n{2,}`)
10576
10577func methodColor(method string, colors fiber.Colors) string {
10578	switch method {
10579	case fiber.MethodGet:
10580		return colors.Cyan
10581	case fiber.MethodPost:
10582		return colors.Green
10583	case fiber.MethodPut:
10584		return colors.Yellow
10585	case fiber.MethodDelete:
10586		return colors.Red
10587	case fiber.MethodPatch:
10588		return colors.White
10589	case fiber.MethodHead:
10590		return colors.Magenta
10591	case fiber.MethodOptions:
10592		return colors.Blue
10593	default:
10594		return colors.Reset
10595	}
10596}
10597
10598func statusColor(code int, colors fiber.Colors) string {
10599	switch {
10600	case code >= fiber.StatusOK && code < fiber.StatusMultipleChoices:
10601		return colors.Green
10602	case code >= fiber.StatusMultipleChoices && code < fiber.StatusBadRequest:
10603		return colors.Blue
10604	case code >= fiber.StatusBadRequest && code < fiber.StatusInternalServerError:
10605		return colors.Yellow
10606	default:
10607		return colors.Red
10608	}
10609}
10610
10611// Serve runs the web store: templates, routes, wasm compilation, and the
10612// http listener. It blocks for the life of the server.
10613func Serve() {
10614	wg := new(sync.WaitGroup)
10615	wg.Add(1)
10616	r := NewApp(AppOpts{})
10617	// The browse origin the desk's nested browser renders the store at. Its
10618	// own listener, because it is a different domain: a wholly static pair of
10619	// files that never sees any of the content it makes renderable.
10620	StartBrowseOrigin(context.Background())
10621	go func() {
10622		err := r.Listen(fmt.Sprintf(":%d", f.WebPort))
10623		if err != nil {
10624			log.Println("Error serving http: ", err)
10625		}
10626		wg.Done()
10627	}()
10628	CompileWASM()
10629	wg.Wait()
10630}
10631
10632func sitemap(c fiber.Ctx) error {
10633	c.Type("xml", "utf-8")
10634	return c.SendString(generateSitemapXML())
10635}
10636
10637// apisite serves the site identity for store clients β€” the browser tui
10638// has no MENV file to source its masthead from.
10639func apisite(c fiber.Ctx) error {
10640	return c.JSON(fiber.Map{
10641		"Sitename":      f.Sitename,
10642		"Siteext":       f.Siteext,
10643		"Sitelongname":  f.Sitelongname,
10644		"Sitetagline":   f.Sitetagline,
10645		"Tgcontact":     f.Tgcontact,
10646		"Tgchannel":     f.Tgchannel,
10647		"Teststripekey": f.Teststripekey,
10648		// The identity fields below appear verbatim in every rendered page,
10649		// so serving them here reveals nothing new; they let the in-tab site
10650		// server render the same masthead and metadata the origin does.
10651		"Sitemeta":           f.Sitemeta,
10652		"Sitedomain":         f.Sitedomain,
10653		"Siteprettyname":     f.Siteprettyname,
10654		"Siteprettynamecap":  f.Siteprettynamecap,
10655		"Siteprettynamecaps": f.Siteprettynamecaps,
10656		"SiteASCIILogo":      f.SiteASCIILogo,
10657		"Stripepk":           f.StripePK,
10658		// The drop-in navigation entries, so a client β€” the terminal build,
10659		// and the TUI served into a browser tab β€” can offer the same Etc menu
10660		// the page does instead of a shorter one missing every drop-in page.
10661		"NavLinks": extraNavLinks,
10662		// The coin the store takes, or "" where it takes none: the page offers
10663		// "Pay in <coin>" only then, and so does the terminal.
10664		"Coin": htmlTemplateData{}.CoinName(),
10665	})
10666}
10667
10668// apicontent serves the stock-page fragments (about/policy/links) raw,
10669// for the tui to render as text.
10670func apicontent(c fiber.Ctx) error {
10671	name := c.Params("name")
10672	switch name {
10673	case "about", "policy", "links":
10674	default:
10675		return c.SendStatus(fiber.StatusNotFound)
10676	}
10677	c.Set("Content-Type", "text/html;charset=utf-8")
10678	return c.SendString(contentFile("content/" + name + ".html"))
10679}
10680
10681// tuipage serves the terminal storefront: the same store TUI `m2 tui`
10682// runs natively, compiled to wasm (the wasm/tui drop-in, listed in
10683// WASMSRC) and drawn into an xterm-go terminal filling the page.
10684func tuipage(c fiber.Ctx) error {
10685	// The joint build where the deployment has one, else this page's own.
10686	// See pageBinary.
10687	binary, execPath := pageBinary("term", "tui")
10688	html := fmt.Sprintf(`<!DOCTYPE html>
10689<html lang="en">
10690<head>
10691<meta charset="utf-8">
10692<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content">
10693<title>%s β€” terminal</title>
10694<meta name="description" content="%s as a terminal: browse the catalog, view products, and fill a cart in a TUI running in your browser.">
10695<meta name="robots" content="index, follow">
10696<link rel="stylesheet" href="/font.css">
10697<style>html,body{margin:0;padding:0;width:100%%;height:100%%;background:#000;overflow:hidden;}#terminal{position:fixed;inset:0;}</style>
10698<script src="/bottle/jsfs.js"></script>
10699<script src="%s"></script>
10700`+procScripts()+`
10701<script>
10702if (!WebAssembly.instantiateStreaming) {
10703  WebAssembly.instantiateStreaming = async (resp, importObject) => {
10704    const source = await (await resp).arrayBuffer();
10705    return await WebAssembly.instantiate(source, importObject);
10706  };
10707}
10708const go = new Go();
10709`+homeReadyJS+`
10710homeReady.then(() => WebAssembly.instantiateStreaming(fetch("/%s"), go.importObject)).then((result) => {
10711  go.run(result.instance);
10712}).catch((err) => { console.error("Failed to run WASM:", err); });
10713</script>
10714</head>
10715<body>
10716<div id="terminal"></div>
10717</body>
10718</html>`, f.Sitedomain, f.Sitelongname, execPath, binary)
10719	c.Set("Content-Type", "text/html; charset=utf-8")
10720	return c.Status(fiber.StatusOK).SendString(html)
10721}
10722
10723// procScripts lets a page's shells run programs from their filesystem as
10724// processes of their own (bottle's proc): the store program, wasm/store, or
10725// any other, built by Go or by TinyGo.
10726func procScripts() string {
10727	return fmt.Sprintf(`<script src="/bottle/proc.js"></script>
10728<script>proc.assets.wasmExecGo = %q; proc.assets.wasmExecTinyGo = %q;</script>`, f.WasmExecPathGo, f.WasmExecPathTinyGo)
10729}
10730
10731// deskpage serves the site as a desktop: the website in a maximized window
10732// and the storefront terminal in another, both launchable from the panel.
10733// Same drop-in mechanism as tuipage, against the 'wasm/desk' source.
10734func deskpage(c fiber.Ctx) error {
10735	// The joint build where the deployment has one, else this page's own.
10736	binary, execPath := pageBinary("term", "desk")
10737	// ?bare / ?fsonly / ?nonet split the page's script layer for debugging:
10738	// bare = none of jsfs/vnet, fsonly = jsfs alone, nonet = no browser.
10739	//
10740	// netscrape is no longer among these. It used to be a JS engine served at
10741	// /netscrape.js; it is now a Go browser compiled INTO the desk wasm, so
10742	// there is no script to leave out β€” ?nonet is honored by the desk itself,
10743	// which reads it from location.search and does not open the window.
10744	scripts := `<script src="/bottle/jsfs.js"></script>
10745<script src="/bottle/vnet.js"></script>` + "\n" + procScripts()
10746	// The browse substrate, when the deployment has a second domain for it.
10747	// The responder must be first-party here and must exist before the wasm
10748	// configures it, so it goes in with the rest of the page scripts; the
10749	// config object tells the desk which origin to render the store at.
10750	if browseEnabled() {
10751		scripts += fmt.Sprintf("\n<script>window.__M2_BROWSE__={suffix:%q,scheme:%q,port:%q};</script>"+
10752			"\n<script src=%q></script>", config.F.BrowseSuffix, browseScheme(), browsePort(), browseResponderPath)
10753	}
10754	switch {
10755	case c.Query("bare") != "":
10756		scripts = ""
10757	case c.Query("fsonly") != "":
10758		scripts = `<script src="/bottle/jsfs.js"></script>`
10759	}
10760	html := fmt.Sprintf(`<!DOCTYPE html>
10761<html lang="en">
10762<head>
10763<meta charset="utf-8">
10764<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content">
10765<title>%s β€” desktop</title>
10766<meta name="description" content="%s as a desktop: the website and the storefront terminal, each in a window.">
10767<meta name="robots" content="index, follow">
10768<link rel="stylesheet" href="/font.css">
10769<style>
10770html,body{margin:0;padding:0;width:100%%;height:100%%;background:#000;overflow:hidden;
10771  font-family:mononokiregular,ui-monospace,monospace;color:#fff;}
10772/* Windows are positioned against this, so it is the element that fills the
10773   page rather than the body. */
10774#desktop{position:fixed;inset:0;}
10775#boot{padding:14px;color:#777;font-size:13px;}
10776</style>
10777%s
10778<script>
10779// Channel providers for the netscrape browser window the desk opens. The
10780// clearnet channel is the page's own fetch β€” enough for this origin and for
10781// anything CORS permits; there is no skysocks here. dmsg has no transport in
10782// this page at all, so it answers 502 rather than hanging.
10783globalThis.__m2Glue = {
10784  fetchClearnet: async function (exit, method, url, body, winId, headers) {
10785    try {
10786      const r = await fetch(url, { method: method || "GET", body: body || undefined, headers: headers || undefined });
10787      const buf = new Uint8Array(await r.arrayBuffer());
10788      const hs = {};
10789      r.headers.forEach(function (v, k) { hs[k] = v; });
10790      return { status: r.status, body: buf, headers: hs };
10791    } catch (e) {
10792      return { status: 502, body: new Uint8Array(), headers: {} };
10793    }
10794  },
10795  fetchDmsg: async function () { return { status: 502, body: new Uint8Array(), headers: {} }; },
10796};
10797
10798// netscrape asks globalThis.__netscrapeFetch(url) for every page and
10799// subresource, and expects a Response. Without one it falls back to a
10800// same-origin /fetch proxy this server does not have, so the transport is
10801// wired here rather than left to that default.
10802//
10803// Note the argument order: netscrape's own loader.js calls
10804// fetchClearnet(url, method, body), but __m2Glue predates it and takes
10805// (exit, method, url, ...). The glue is what the rest of this page uses, so
10806// the shim adapts to the glue rather than the other way round.
10807globalThis.__netscrapeFetch = function (url) {
10808  var u;
10809  try { u = new URL(url, location.href); } catch (e) { return fetch(url); }
10810  var path = (u.pathname || "/") + (u.search || "");
10811  var mesh = /\.(dmsg|skysocks|skynet)$/i.test(u.hostname) || /^[0-9a-f]{66}$/i.test(u.hostname);
10812  // vnet:<port> is the site running INSIDE this tab, on the page's virtual
10813  // loopback. It needs its own name: the desk's own origin is a real
10814  // 127.0.0.1:<port>, so addressing the in-tab server as 127.0.0.1 too sent
10815  // it to the page's ordinary fetch and out to the host, which answered
10816  // ERR_CONNECTION_REFUSED. Nothing outside this page can be reached at
10817  // "vnet", so the routing is unambiguous.
10818  var call;
10819  if (u.hostname === "vnet") {
10820    call = globalThis.vnet.httpFetch(parseInt(u.port || "80", 10), "GET", path, null);
10821  } else if (mesh) {
10822    call = globalThis.__m2Glue.fetchDmsg(u.hostname, "GET", path, null);
10823  } else {
10824    call = globalThis.__m2Glue.fetchClearnet(null, "GET", u.href, null, null, null);
10825  }
10826  return Promise.resolve(call).then(function (r) {
10827    var h = new Headers();
10828    if (r && r.headers) { for (var k in r.headers) { try { h.set(k, r.headers[k]); } catch (e) {} } }
10829    return new Response((r && r.body) || new Uint8Array(0), { status: (r && r.status) || 200, headers: h });
10830  });
10831};
10832</script>
10833<script src="%s"></script>
10834<script>
10835if (!WebAssembly.instantiateStreaming) {
10836  WebAssembly.instantiateStreaming = async (resp, importObject) => {
10837    const source = await (await resp).arrayBuffer();
10838    return await WebAssembly.instantiate(source, importObject);
10839  };
10840}
10841const go = new Go();
10842`+homeReadyJS+`
10843homeReady.then(() => WebAssembly.instantiateStreaming(fetch("/%s"), go.importObject)).then((result) => {
10844  const b = document.getElementById("boot"); if (b) b.remove();
10845  go.run(result.instance);
10846}).catch((err) => { console.error("Failed to run WASM:", err); });
10847</script>
10848</head>
10849<body>
10850<div id="desktop"><div id="boot">loading…</div></div>
10851</body>
10852</html>`, f.Sitedomain, f.Sitelongname, scripts, execPath, binary)
10853	c.Set("Content-Type", "text/html; charset=utf-8")
10854	return c.Status(fiber.StatusOK).SendString(html)
10855}
10856
10857// apiproducts serves the catalog as JSON for store clients (the tui's
10858// --store mode, and the browser tui to come). The business-sensitive
10859// columns the HTML never renders stay private.
10860func apiproducts(c fiber.Ctx) error {
10861	allproductsMu.RLock()
10862	prods := make(p.Products, len(allproducts))
10863	copy(prods, allproducts)
10864	allproductsMu.RUnlock()
10865	for i := range prods {
10866		prods[i].Cost = ""
10867		prods[i].Location = ""
10868		prods[i].Sourceinfo = ""
10869	}
10870	return c.JSON(prods)
10871}
10872
10873// extraRoutes collects route registrars from optional drop-in files
10874// (see other.go.example). A drop-in appends its registrar from init();
10875// deleting the file removes its routes with no other code changes.
10876var extraRoutes []func(*fiber.App)
10877
10878// extraSitemapURLs collects site-relative paths that drop-in routes want
10879// listed in /sitemap.xml. A route that is not linked from anywhere and not
10880// in the sitemap is one a crawler has no way to reach, which for a page that
10881// exists to be found is the whole of the problem.
10882//
10883// Paths only, each beginning with "/": the sitemap is per-host and the base
10884// URL is the deployment's own, so a drop-in cannot and should not name it.
10885var extraSitemapURLs []string
10886
10887// NavLink is an entry a drop-in adds to the site navigation.
10888type NavLink struct {
10889	// Title is the link text, Href where it goes, and Desc the tooltip.
10890	Title, Href, Desc string
10891}
10892
10893// extraNavLinks collects navigation entries from drop-in files, the way
10894// extraRoutes collects their routes.
10895//
10896// A drop-in route that nothing links to is reachable only by someone who
10897// already knows the path, and the stock header cannot name it: the header is
10898// shared by every deployment, and a hardcoded entry would be a broken link
10899// everywhere the drop-in is absent. Registering it here keeps the link and the
10900// route in the same file, so deleting that file removes both.
10901var extraNavLinks []NavLink
10902
10903func logo(c fiber.Ctx) error {
10904	tmpl, err := auxTmpl()
10905	if err != nil {
10906		msg := fmt.Sprintf("Error parsing html template: %v", err)
10907		log.Println(msg)
10908		return c.Status(fiber.StatusInternalServerError).SendString(msg)
10909	}
10910	tmpl0, err := tmpl.Clone()
10911	if err != nil {
10912		msg := fmt.Sprintf("Error cloning template: %v", err)
10913		log.Println(msg)
10914		return c.Status(fiber.StatusInternalServerError).SendString(msg)
10915	}
10916	_, err = tmpl0.New("main").Parse(h.Logo())
10917	if err != nil {
10918		msg := fmt.Sprintf("Error parsing product page template: %v", err)
10919		log.Println(msg)
10920		return c.Status(fiber.StatusInternalServerError).SendString(msg)
10921	}
10922	tmpl = tmpl0
10923	c.Set("Content-Type", "text/html;charset=utf-8")
10924
10925	img2txtFlags := ""
10926	if w, err := strconv.Atoi(c.Params("width")); err == nil {
10927		img2txtFlags = fmt.Sprintf("--width=%d ", w)
10928	}
10929	if h, err := strconv.Atoi(c.Params("height")); err == nil {
10930		img2txtFlags = fmt.Sprintf("--height=%d ", h)
10931	}
10932
10933	logoHTMLslice, err := script.Exec(fmt.Sprintf("bash -c 'img2txt %s logo.jpg | ansifilter -H'", img2txtFlags)).Slice()
10934	if err != nil {
10935		log.Println("error: ", err)
10936		_, err = c.Status(fiber.StatusInternalServerError).Write([]byte(err.Error() + "/n" + strings.Join(logoHTMLslice, "\n")))
10937		return err
10938	}
10939	if len(logoHTMLslice) > 2 {
10940		logoHTMLslice = logoHTMLslice[:len(logoHTMLslice)-3]
10941	}
10942	if len(logoHTMLslice) > 18 {
10943		logoHTMLslice = logoHTMLslice[19:]
10944	}
10945
10946	var result bytes.Buffer
10947	h1 := pageMeta(c, htmlTemplateData{})
10948	h1.Page = "logo"
10949	h1.Title = "logo"
10950	tmplData := map[string]interface{}{
10951		"Content": strings.Join(logoHTMLslice, "\n"),
10952	}
10953	err = tmpl.Execute(&result, tmplData)
10954	if err != nil {
10955		log.Println("error: ", err)
10956		_, err = c.Status(fiber.StatusInternalServerError).Write(result.Bytes())
10957		return err
10958	}
10959	_, err = c.Status(fiber.StatusOK).Write(collapseNewlines.ReplaceAll(result.Bytes(), []byte("\n")))
10960	return err
10961}
10962
10963func robots(c fiber.Ctx) error {
10964	c.Set("Content-Type", "text/plain;charset=utf-8")
10965	// "User-agent: *" alone is a group with no rules in it. That is legal and
10966	// means allow everything, but it says so by omission β€” and a group that
10967	// states nothing is one whose meaning a later edit can change by accident.
10968	// Allow: / states it. The trailing newline is so the Sitemap line is a
10969	// line, which some parsers care about.
10970	_, err := c.Status(fiber.StatusOK).Write([]byte(fmt.Sprintf(
10971		"User-agent: *\nAllow: /\n\nSitemap: https://%s/sitemap.xml\n", c.Hostname())))
10972	return err
10973}
10974
10975func style(c fiber.Ctx) error {
10976	c.Set("Content-Type", "text/css;charset=utf-8")
10977	_, err := c.Status(fiber.StatusOK).Write([]byte(h.StyleCSS()))
10978	return err
10979}
10980
10981// fontcss serves just the @font-face block, so the terminal page can have
10982// the site's face without pulling in the whole stylesheet. The TUI measures
10983// its cell from this font when it opens, which is why it is a separate,
10984// cacheable route rather than something inlined per page load.
10985func fontcss(c fiber.Ctx) error {
10986	c.Set("Content-Type", "text/css;charset=utf-8")
10987	c.Set("Cache-Control", "public, max-age=86400")
10988	_, err := c.Status(fiber.StatusOK).Write([]byte(h.FontCSS()))
10989	return err
10990}
10991
10992func serveWASM(r *fiber.App) {
10993	if f.WasmExecPath != "" {
10994		_, err := script.File(f.WasmExecPath).Bytes()
10995		if err != nil {
10996			log.Printf("Error reading %s: %v\n", f.WasmExecPath, err)
10997		} else { //the wasm exec must be present or none of the webassembly stuff will work ; provided by the golang installaton
10998			r.Get(f.WasmExecPathTinyGo, func(c fiber.Ctx) error {
10999				wasmExecData, err := script.File(f.WasmExecPathTinyGo).Bytes()
11000				if err != nil {
11001					log.Printf("Error reading %s: %v\n", f.WasmExecPathTinyGo, err)
11002					return c.SendStatus(fiber.StatusNotFound)
11003				}
11004				c.Set("Content-Type", "application/js")
11005				_, err = c.Status(fiber.StatusOK).Write(wasmExecData)
11006				return err
11007			})
11008
11009			r.Get(f.WasmExecPathGo, func(c fiber.Ctx) error {
11010				wasmExecData, err := script.File(f.WasmExecPathGo).Bytes()
11011				if err != nil {
11012					log.Printf("Error reading %s: %v\n", f.WasmExecPathGo, err)
11013					return c.SendStatus(fiber.StatusNotFound)
11014				}
11015				c.Set("Content-Type", "application/js")
11016				_, err = c.Status(fiber.StatusOK).Write(wasmExecData)
11017				return err
11018			})
11019
11020			// Register both variants per source: a drop-in tinygo cannot
11021			// compile still serves its Go build, and pages pick whichever
11022			// binary exists.
11023			for _, wasmSRC := range f.WasmSRC {
11024				base := strings.TrimSuffix(filepath.Base(wasmSRC), filepath.Ext(wasmSRC))
11025				for _, suffix := range []string{".wasm", "-tiny.wasm"} {
11026					outputFile := base + suffix
11027					r.Get("/"+outputFile, func(c fiber.Ctx) error {
11028						// A binary this size must revalidate, not cache
11029						// blindly: a browser holding yesterday's wasm
11030						// makes a deploy look like nothing changed.
11031						fi, err := os.Stat(outputFile)
11032						if err != nil {
11033							return c.SendStatus(fiber.StatusInternalServerError)
11034						}
11035						etag := fmt.Sprintf(`"%x-%x"`, fi.ModTime().Unix(), fi.Size())
11036						c.Set("Cache-Control", "no-cache")
11037						c.Set("ETag", etag)
11038						if c.Get("If-None-Match") == etag {
11039							return c.SendStatus(fiber.StatusNotModified)
11040						}
11041						data, err := script.File(outputFile).Bytes()
11042						if err != nil {
11043							script.File(outputFile).Stdout() //nolint
11044							return c.SendStatus(fiber.StatusInternalServerError)
11045						}
11046						c.Set("Content-Type", "application/wasm")
11047						return c.Status(fiber.StatusOK).Send(data)
11048					})
11049				}
11050			}
11051		}
11052	}
11053}
11054
11055func sendFile(c fiber.Ctx) error {
11056	return c.SendFile("." + c.Path())
11057}
11058func sendImage(c fiber.Ctx) error {
11059	c.Set("Content-Type", "image/jpeg")
11060	return c.SendFile("./img" + c.Path())
11061}
11062
11063func stlbase64(c fiber.Ctx) error {
11064	name := c.Params("filename")
11065	if strings.ContainsAny(name, "/\\..") || strings.Contains(name, "..") {
11066		return c.SendStatus(fiber.StatusBadRequest)
11067	}
11068	stlfile, err := script.File("img/stl/" + name).Bytes()
11069	if err != nil {
11070		return c.SendStatus(fiber.StatusNotFound)
11071	}
11072	_, err = c.Status(fiber.StatusOK).Write([]byte("data:model/stl;base64," + base64.StdEncoding.EncodeToString(stlfile)))
11073	return err
11074}
11075
11076type item struct {
11077	ID     string
11078	Amount int64
11079}
11080
11081func cathtmlfunc(c fiber.Ctx) error {
11082	tmpl, err := mainTmpl()
11083	if err != nil {
11084		msg := fmt.Sprintf("Error parsing html template: %v", err)
11085		log.Println(msg)
11086		return c.Status(fiber.StatusInternalServerError).SendString(msg)
11087	}
11088	tmpl0, err := tmpl.Clone()
11089	if err != nil {
11090		msg := fmt.Sprintf("Error cloning html template: %v", err)
11091		log.Println(msg)
11092		return c.Status(fiber.StatusInternalServerError).SendString(msg)
11093	}
11094	_, err = tmpl0.New("main").Parse(h.CategoryPage())
11095	if err != nil {
11096		msg := fmt.Sprintf("Error parsing Category page template: %v", err)
11097		log.Println(msg)
11098		return c.Status(fiber.StatusInternalServerError).SendString(msg)
11099	}
11100	tmpl = tmpl0
11101	var tmplData map[string]interface{}
11102	var result bytes.Buffer
11103	var categoryproducts p.Products
11104	c.Set("Content-Type", "text/html;charset=utf-8")
11105	h1 := pageMeta(c, htmlPageTemplateData)
11106	h1.Title = fmt.Sprintf("%s | %s", func() string {
11107		var str string
11108		if c.Params("partno") != "" {
11109			return "No product matching partno.: " + c.Params("partno") + " | Showing All Products"
11110		}
11111		if c.Params("cat") == "" {
11112			return "All Products"
11113		}
11114		str = fmt.Sprintf("Category: %s", c.Params("cat"))
11115		if c.Params("subcat") != "" {
11116			str += fmt.Sprintf("; Subcategory: %s", c.Params("subcat"))
11117		}
11118		return str
11119	}(), h1.Title)
11120	h1.Page = "category"
11121	// The listing has an Add to cart on every row, and the cart is what
11122	// defines addToCart. Without it every one of those buttons was inert
11123	// β€” the page rendered them, the click found no such function, and
11124	// nothing reached the cart from a category page at all. The small
11125	// build, because this is carried by every listing view.
11126	h1.WasmBinary = wasmSmallFor("cart")
11127	if c.Params("cat") == "" && c.Params("subcat") == "" {
11128		tmplData = map[string]interface{}{
11129			"Products":    allproducts,
11130			"Page":        h1,
11131			"Category":    c.Params("cat"),
11132			"Subcategory": c.Params("subcat"),
11133			"Prods":       allproducts,
11134			"Product":     c.Params("partno"),
11135		}
11136	} else {
11137
11138		for _, prod := range allproducts {
11139			if strings.EqualFold(prod.Category, c.Params("cat")) && (c.Params("subcat") == "" || strings.EqualFold(escapesubcat(prod.Subcategory), c.Params("subcat"))) {
11140				categoryproducts = append(categoryproducts, prod)
11141			}
11142		}
11143		tmplData = map[string]interface{}{
11144			"Products":    categoryproducts,
11145			"Page":        h1,
11146			"Category":    c.Params("cat"),
11147			"Subcategory": c.Params("subcat"),
11148			"Prods":       allproducts,
11149		}
11150	}
11151	err = tmpl.Execute(&result, tmplData)
11152	if err != nil {
11153		msg := fmt.Sprintf("Error execute html template: %v", err)
11154		log.Println(msg)
11155		return c.Status(fiber.StatusInternalServerError).SendString(msg)
11156	}
11157	_, err = c.Status(fiber.StatusOK).Write(collapseNewlines.ReplaceAll(result.Bytes(), []byte("\n")))
11158	return err
11159}
11160
11161func getcats() (cats []string) {
11162	var catsMap = make(map[string]int)
11163	for _, prod := range allproducts {
11164		catsMap[prod.Category]++
11165	}
11166	for cat := range catsMap {
11167		cats = append(cats, cat)
11168	}
11169	return cats
11170}
11171func contains(slice []string, str string) bool {
11172	for _, s := range slice {
11173		if s == str {
11174			return true
11175		}
11176	}
11177	return false
11178}
11179func getcategories(allproducts p.Products) (map[string]int, []string, map[string]map[string]int, map[string][]string) {
11180	categoryCounts := make(map[string]int)
11181	subcategoryCounts := make(map[string]map[string]int)
11182	subcategoriesByCategory := make(map[string][]string)
11183
11184	for _, prod := range allproducts {
11185		if prod.Category != "" {
11186			categoryCounts[prod.Category]++
11187			if prod.Subcategory != "" {
11188				if subcategoryCounts[prod.Category] == nil {
11189					subcategoryCounts[prod.Category] = make(map[string]int)
11190				}
11191				subcategoryCounts[prod.Category][prod.Subcategory]++
11192				if !contains(subcategoriesByCategory[prod.Category], prod.Subcategory) {
11193					subcategoriesByCategory[prod.Category] = append(subcategoriesByCategory[prod.Category], prod.Subcategory)
11194				}
11195			}
11196		}
11197	}
11198
11199	var sortableCategories []struct {
11200		Name  string
11201		Count int
11202	}
11203	for cat, count := range categoryCounts {
11204		sortableCategories = append(sortableCategories, struct {
11205			Name  string
11206			Count int
11207		}{Name: cat, Count: count})
11208	}
11209	sort.Slice(sortableCategories, func(i, j int) bool {
11210		return sortableCategories[i].Count > sortableCategories[j].Count
11211	})
11212	var sortedCategories []string
11213	for _, cat := range sortableCategories {
11214		sortedCategories = append(sortedCategories, cat.Name)
11215		var sortableSubcategories []struct {
11216			Name  string
11217			Count int
11218		}
11219		for subcat, count := range subcategoryCounts[cat.Name] {
11220			sortableSubcategories = append(sortableSubcategories, struct {
11221				Name  string
11222				Count int
11223			}{Name: subcat, Count: count})
11224		}
11225		sort.Slice(sortableSubcategories, func(i, j int) bool {
11226			return sortableSubcategories[i].Count > sortableSubcategories[j].Count
11227		})
11228		var sortedSubcategories []string
11229		for _, subcat := range sortableSubcategories {
11230			sortedSubcategories = append(sortedSubcategories, subcat.Name)
11231		}
11232		subcategoriesByCategory[cat.Name] = sortedSubcategories
11233	}
11234	return categoryCounts, sortedCategories, subcategoryCounts, subcategoriesByCategory
11235}
11236
11237func getsubcats(cat string) (subcats []string) {
11238	var subcatsMap = make(map[string]int)
11239	for _, prod := range allproducts {
11240		if cat == "" || strings.EqualFold(cat, prod.Category) {
11241			if prod.Subcategory != "" {
11242				subcatsMap[escapesubcat(prod.Subcategory)]++
11243			}
11244		}
11245	}
11246	for subcat := range subcatsMap {
11247		subcats = append(subcats, subcat)
11248	}
11249	return subcats
11250}
11251
11252// escapesubcat spells a subcategory the way it appears in a URL.
11253//
11254// The rules live in pkg/storepage now, because both views need them and
11255// only this one had them: the terminal could recognize no /cat/x/y path
11256// whose subcategory had been escaped, so it handed those links to the
11257// browser rather than opening the listing it has.
11258func escapesubcat(sc string) string { return storepage.EscapeSubcat(sc) }
11259
11260func handlecat(c fiber.Ctx) error {
11261	if c.Params("cat") == "" && c.Params("subcat") == "" {
11262		return cathtmlfunc(c)
11263	}
11264	var catexists bool
11265	var subcatexists bool
11266	catexists = false
11267	for _, cat := range getcats() {
11268		if strings.EqualFold(cat, c.Params("cat")) {
11269			catexists = true
11270			break
11271		}
11272	}
11273	subcatexists = false
11274	if c.Params("subcat") != "" {
11275		for _, subcat := range getsubcats("") {
11276			if strings.EqualFold(escapesubcat(subcat), c.Params("subcat")) {
11277				subcatexists = true
11278				break
11279			}
11280		}
11281	}
11282	if c.Params("subcat") != "" && !subcatexists {
11283		log.Printf("subcategory %s does not match any existing subcategory\n", c.Params("subcat"))
11284		return c.Redirect().To("/cat/" + c.Params("cat"))
11285	}
11286	if !catexists {
11287		log.Printf("category %s does not match any existing category\n", c.Params("cat"))
11288		return c.Redirect().To("/cat")
11289	}
11290	if catexists || (catexists && subcatexists) {
11291		return cathtmlfunc(c)
11292	}
11293	return c.SendStatus(fiber.StatusNotFound)
11294}
11295
11296func homepage(c fiber.Ctx) error {
11297	tmpl, err := mainTmpl()
11298	if err != nil {
11299		msg := fmt.Sprintf("Could not parsing html template: %v", err)
11300		log.Println(msg)
11301		return c.Status(fiber.StatusInternalServerError).SendString(msg)
11302	}
11303	tmpl0, err := tmpl.Clone()
11304	if err != nil {
11305		msg := fmt.Sprintf("Error cloning template: %v", err)
11306		log.Println(msg)
11307		return c.Status(fiber.StatusInternalServerError).SendString(msg)
11308	}
11309	_, err = tmpl0.New("main").Parse(h.FrontPage())
11310	if err != nil {
11311		msg := fmt.Sprintf("Error parsing Front Page template: %v", err)
11312		log.Println(msg)
11313		return c.Status(fiber.StatusInternalServerError).SendString(msg)
11314	}
11315	_, err = tmpl0.New("about").Parse(h.AboutPage())
11316	if err != nil {
11317		msg := fmt.Sprintf("Error parsing About Page template: %v", err)
11318		log.Println(msg)
11319		return c.Status(fiber.StatusInternalServerError).SendString(msg)
11320	}
11321	_, err = tmpl0.New("policy").Parse(h.PolicyPage())
11322	if err != nil {
11323		msg := fmt.Sprintf("Error parsing Policy Page template: %v", err)
11324		log.Println(msg)
11325		return c.Status(fiber.StatusInternalServerError).SendString(msg)
11326	}
11327	_, err = tmpl0.New("links").Parse(h.LinksPage())
11328	if err != nil {
11329		msg := fmt.Sprintf("Error parsing Links Page template: %v", err)
11330		log.Println(msg)
11331		return c.Status(fiber.StatusInternalServerError).SendString(msg)
11332	}
11333	tmpl = tmpl0
11334	log.Println(c.Get("User-Agent"))
11335	c.Set("Content-Type", "text/html;charset=utf-8")
11336	h1 := pageMeta(c, htmlPageTemplateData)
11337	// The front page has the canvas stl2 animates into, and deco, which draws
11338	// the borders round the product tables, the ASCII art behind the
11339	// photographs and the headings as FIGlet banners. Both are decorations: the
11340	// HTML is the same either way, and a browser that runs neither gets the
11341	// page it always got.
11342	// The small build of each: these ride on every page view, and which
11343	// build newest-wins picks is decided by whichever toolchain finished
11344	// last β€” a page was serving the 23MB stl2 rather than the 9MB one
11345	// because the Go build had just been rebuilt. Size on a page a
11346	// customer loads is not a thing to leave to build order.
11347	h1.WasmBinary = wasmSmallFor("cart", "stl2", "deco")
11348	tmplData := map[string]interface{}{
11349		"Page":  h1,
11350		"Prods": allproducts,
11351	}
11352	var result bytes.Buffer
11353	err = tmpl.Execute(&result, tmplData)
11354	if err != nil {
11355		msg := fmt.Sprintf("Error executing template: %v", err)
11356		log.Println(msg)
11357		return c.Status(fiber.StatusInternalServerError).SendString(msg)
11358	}
11359	_, err = c.Status(fiber.StatusOK).Write(collapseNewlines.ReplaceAll(result.Bytes(), []byte("\n")))
11360	return err
11361}
11362
11363func productpage(c fiber.Ctx) error {
11364	tmpl, err := mainTmpl()
11365	if err != nil {
11366		msg := fmt.Sprintf("Error parsing html template: %v", err)
11367		log.Println(msg)
11368		return c.Status(fiber.StatusInternalServerError).SendString(msg)
11369	}
11370	tmpl0, err := tmpl.Clone()
11371	if err != nil {
11372		msg := fmt.Sprintf("Error cloning template: %v", err)
11373		log.Println(msg)
11374		return c.Status(fiber.StatusInternalServerError).SendString(msg)
11375	}
11376	_, err = tmpl0.New("main").Parse(h.ProductPage())
11377	if err != nil {
11378		msg := fmt.Sprintf("Error parsing product page template: %v", err)
11379		log.Println(msg)
11380		return c.Status(fiber.StatusInternalServerError).SendString(msg)
11381	}
11382	tmpl = tmpl0
11383	c.Set("Content-Type", "text/html;charset=utf-8")
11384	for _, prod := range allproducts {
11385		if strings.EqualFold(prod.Partno, c.Params("partno")) {
11386			var result bytes.Buffer
11387			h1 := pageMeta(c, htmlPageTemplateData)
11388			h1.Page = "product"
11389			// A product page has the canvas and the package-type element the
11390			// STL viewer reads to pick its model β€” and an Add to cart,
11391			// which needs the cart that defines addToCart.
11392			h1.WasmBinary = wasmSmallFor("cart", "stl2")
11393			h1.Title = fmt.Sprintf("%s | %s", prod.Name, h1.Title)
11394			tmplData := map[string]interface{}{
11395				"Prod":  prod,
11396				"Page":  h1,
11397				"Prods": allproducts,
11398			}
11399			err := tmpl.Execute(&result, tmplData)
11400			if err != nil {
11401				log.Println("error: ", err)
11402				_, err = c.Status(fiber.StatusInternalServerError).Write(result.Bytes())
11403				return err
11404			}
11405			_, err = c.Status(fiber.StatusOK).Write(collapseNewlines.ReplaceAll(result.Bytes(), []byte("\n")))
11406			return err
11407		}
11408	}
11409	log.Printf("product %s does not match any existing product\n", c.Params("partno"))
11410	return c.Status(fiber.StatusNotFound).Redirect().To("/cat")
11411}
11412
11413// homeReadyJS restores the terminal's home directory before the wasm starts,
11414// as a desktop finds ~ where it left it: bottle's jsfs (the filesystem Go's os
11415// package uses in the page) is persisted to IndexedDB for /home only. The
11416// rest of the tree β€” /tmp, /dev, the templates a page seeds β€” is made fresh
11417// each load. The store keeps its cart there (pkg/tui/cartsave.go). Where jsfs
11418// is absent (?bare) or IndexedDB is refused, the wasm starts anyway, with a
11419// cart that lasts the page.
11420const homeReadyJS = `const homeReady = (globalThis.jsfs && jsfs.persist)
11421  ? jsfs.persist.enable("m2-home", { exclude: (p) => !(p === "/home" || p.startsWith("/home/")) }).catch(() => {})
11422  : Promise.resolve();`
11423
11424
11425// ===== pkg/web/shipping.go =====
11426package web
11427
11428// The shipping form, as the page draws it.
11429//
11430// These are methods on the page data rather than FuncMap entries for the
11431// reason wasmpick.go's WasmExecs records: templates here are parsed in
11432// several places and only some of them call .Funcs(funcs), so a template
11433// function is a parse error waiting for whichever page was built bare.
11434// A method travels with the data and every parse site can call it.
11435
11436import (
11437	"github.com/0magnet/m2/pkg/storepage"
11438)
11439
11440// ShippingFields is the form, in order β€” the same list pkg/tui builds its
11441// overlay from and the same field names wasm/cart reads back.
11442func (h htmlTemplateData) ShippingFields() []storepage.Field {
11443	return storepage.ShippingFields
11444}
11445
11446// ShippingStates is the State select's options.
11447func (h htmlTemplateData) ShippingStates() []storepage.Jurisdiction {
11448	return storepage.States
11449}
11450
11451// ShippingCountries is the Country select's options.
11452func (h htmlTemplateData) ShippingCountries() []string { return storepage.Countries }
11453
11454// StatePrompt is the unselected entry at the top of the State select.
11455func (h htmlTemplateData) StatePrompt() string { return storepage.StatePrompt }
11456
11457// MinShipping is the least the store ships for, as the number a min
11458// attribute wants ("7").
11459func (h htmlTemplateData) MinShipping() string {
11460	return itoa(storepage.MinShippingCents / 100)
11461}
11462
11463// MinShippingValue is that same figure as a price ("7.00"), for the
11464// amount field's default.
11465func (h htmlTemplateData) MinShippingValue() string {
11466	c := storepage.MinShippingCents
11467	return itoa(c/100) + "." + pad2(c%100)
11468}
11469
11470func itoa(n int) string {
11471	if n == 0 {
11472		return "0"
11473	}
11474	var b []byte
11475	for n > 0 {
11476		b = append([]byte{byte('0' + n%10)}, b...)
11477		n /= 10
11478	}
11479	return string(b)
11480}
11481
11482func pad2(n int) string {
11483	s := itoa(n)
11484	if len(s) < 2 {
11485		return "0" + s
11486	}
11487	return s
11488}
11489
11490// CoinCheckout reports whether the deployment takes crypto, so the cart
11491// offers the button only where it will work. A method rather than a
11492// FuncMap entry, for the reason at the top of this file.
11493func (h htmlTemplateData) CoinCheckout() bool { return cryptoPay != nil }
11494
11495// CoinName is the coin the store takes, for the button's label.
11496func (h htmlTemplateData) CoinName() string {
11497	if cryptoPay == nil {
11498		return ""
11499	}
11500	return string(cryptoPay.backend.Coin())
11501}
11502
11503
11504// ===== pkg/web/shipping_test.go =====
11505package web
11506
11507import (
11508	"bytes"
11509	htmpl "html/template"
11510	"os"
11511	"strings"
11512	"testing"
11513
11514	"github.com/0magnet/m2/pkg/storepage"
11515)
11516
11517// renderFooter runs the footer partial the way a page does. It is parsed
11518// BARE β€” no .Funcs β€” on purpose: that is the state a template set built
11519// somewhere that forgot the FuncMap is in, and the shipping form must
11520// survive it. See pkg/web/shipping.go.
11521func renderFooter(t *testing.T) string {
11522	t.Helper()
11523	src, err := os.ReadFile("../../htmpl/footer.html")
11524	if err != nil {
11525		t.Fatal(err)
11526	}
11527	tmpl, err := htmpl.New("footer").Parse(string(src))
11528	if err != nil {
11529		t.Fatalf("footer does not parse without a FuncMap: %v", err)
11530	}
11531	var b bytes.Buffer
11532	if err := tmpl.Execute(&b, struct{ Page htmlTemplateData }{}); err != nil {
11533		t.Fatal(err)
11534	}
11535	return b.String()
11536}
11537
11538// The cart wasm reads each control by [name='...']; a renamed or dropped
11539// control silently produces an empty field in the shipping line, so the
11540// generated form must still carry every one of them.
11541func TestShippingFormKeepsItsControlNames(t *testing.T) {
11542	got := renderFooter(t)
11543	for _, f := range storepage.ShippingFields {
11544		if !strings.Contains(got, "name='"+f.Name+"'") {
11545			t.Errorf("no control named %q in the rendered form", f.Name)
11546		}
11547		if !strings.Contains(got, "id='"+f.Name+"'") {
11548			t.Errorf("no id %q in the rendered form", f.Name)
11549		}
11550	}
11551}
11552
11553// Every jurisdiction reaches the page, by code and by name.
11554func TestShippingFormListsEveryState(t *testing.T) {
11555	got := renderFooter(t)
11556	if n := strings.Count(got, "<option value='"); n != len(storepage.States)+len(storepage.Countries)+1 {
11557		t.Errorf("option count = %d, want %d states + %d countries + 1 prompt",
11558			n, len(storepage.States), len(storepage.Countries))
11559	}
11560	for _, s := range storepage.States {
11561		if !strings.Contains(got, "<option value='"+s.Code+"'>"+s.Name+"</option>") {
11562			t.Errorf("missing state option %s / %s", s.Code, s.Name)
11563		}
11564	}
11565}
11566
11567// The minimum is one number now; the form must show it rather than its own.
11568func TestShippingFormUsesTheSharedMinimum(t *testing.T) {
11569	got := renderFooter(t)
11570	if !strings.Contains(got, "min='7'") || !strings.Contains(got, "value='7.00'") {
11571		t.Errorf("amount field does not carry the shared minimum:\n%s", got)
11572	}
11573}
11574
11575// A pipe in any field shifts every field after it in the cart line.
11576func TestShippingRejectsAndScrubsTheSeparator(t *testing.T) {
11577	s := storepage.Shipping{
11578		Cents: 700, Name: "A|B", City: "Dallas", State: "TX", Country: "United States",
11579	}
11580	if msg := s.Validate(); msg == "" {
11581		t.Error("a pipe in Name was accepted")
11582	}
11583	if strings.Count(s.ID(), "|") != 7 {
11584		t.Errorf("ID() did not scrub the separator: %q", s.ID())
11585	}
11586	back, ok := storepage.ParseShipping(s.ID())
11587	if !ok || back.City != "Dallas" || back.State != "TX" {
11588		t.Errorf("round trip lost fields: %+v ok=%v", back, ok)
11589	}
11590}
11591
11592// Below the minimum is refused rather than silently charged.
11593func TestShippingEnforcesTheMinimum(t *testing.T) {
11594	if msg := (storepage.Shipping{Cents: 0}).Validate(); msg == "" {
11595		t.Error("zero shipping was accepted")
11596	}
11597	if msg := (storepage.Shipping{Cents: storepage.MinShippingCents}).Validate(); msg != "" {
11598		t.Errorf("the minimum itself was refused: %s", msg)
11599	}
11600}
11601
11602
11603// ===== pkg/web/sourcecode.go =====
11604// Package web pkg/web/sourcecode.go β€” the store serves its own source.
11605package web
11606
11607import (
11608	"bytes"
11609	"embed"
11610	"fmt"
11611	"io/fs"
11612	"os"
11613	"strings"
11614
11615	"github.com/alecthomas/chroma/v2"
11616	"github.com/alecthomas/chroma/v2/formatters/html"
11617	"github.com/alecthomas/chroma/v2/lexers"
11618	"github.com/alecthomas/chroma/v2/styles"
11619	"github.com/gofiber/fiber/v3"
11620
11621	"github.com/0magnet/m2/pkg/config"
11622	"github.com/0magnet/m2/pkg/product"
11623	"github.com/0magnet/m2/pkg/tui"
11624)
11625
11626//go:embed *.go
11627var srcWeb embed.FS
11628
11629type srcEntry struct {
11630	Prefix string
11631	FS     fs.FS
11632}
11633
11634// goSources holds each package's embedded Go files for /sourcecode/go. A Go
11635// package cannot embed files outside its own directory, so since the code
11636// moved out of the repo root every package embeds its own *.go and is listed
11637// here. The web package's own embed picks up the other.go drop-in when a
11638// deployment builds with one.
11639var goSources = []srcEntry{
11640	{"pkg/config", config.Source},
11641	{"pkg/product", product.Source},
11642	{"pkg/tui", tui.Source},
11643	{"pkg/web", srcWeb},
11644}
11645
11646// RegisterSource adds a package's embedded sources to /sourcecode/go β€” used
11647// by the commands package, which web cannot import without a cycle.
11648func RegisterSource(prefix string, fsys fs.FS) {
11649	goSources = append(goSources, srcEntry{prefix, fsys})
11650}
11651
11652var sourcesWasm []fs.FS
11653var sourceCore = os.DirFS("ui")
11654var sourceHtml = os.DirFS("htmpl")
11655var sourceContent = os.DirFS("content")
11656
11657func serveSourceCode(r *fiber.App) {
11658	for _, wasmSRC := range f.WasmSRC {
11659		sourcesWasm = append(sourcesWasm, os.DirFS(wasmSRC))
11660	}
11661	r.Get("/sourcecode", func(c fiber.Ctx) error {
11662		ret := `<!doctype html>
11663<html lang='en'>
11664<head>
11665<link rel="stylesheet" href="/style.css" type="text/css">
11666</head>
11667<body class='grid-container' style='background-color:black;color:white;'>
11668<a href='/sourcecode/go'>GO</a><br><br>
11669
11670<a href='/sourcecode/html'>HTML</a><br><br>
11671
11672<a href='/sourcecode/content'>Content</a><br><br>
11673
11674<a href='/sourcecode/core'>C.O.R.E.</a><br><br>
11675
11676<a href='/sourcecode/wasm'>WASM</a><br><br>
11677
11678</body>
11679</html>
11680`
11681		c.Set("Content-Type", "text/html;charset=utf-8")
11682		_, err := c.Status(fiber.StatusOK).Write([]byte(ret))
11683		return err
11684	})
11685
11686	r.Get("/sourcecode/html", sourcecodehtml)
11687	r.Get("/sourcecode/content", sourcecodecontent)
11688	r.Get("/sourcecode/go", sourcecodego)
11689	r.Get("/sourcecode/core", sourcecodecore)
11690	r.Get("/sourcecode/wasm", func(c fiber.Ctx) error {
11691		ret := `<!doctype html>
11692<html lang='en'>
11693<head>
11694<link rel="stylesheet" href="/style.css" type="text/css">
11695</head>
11696<body class='grid-container' style='background-color:black;color:white;'>
11697`
11698		for _, wasmSRC := range f.WasmSRC {
11699			pathNameSlc := strings.Split(wasmSRC, "/")
11700			pathName := pathNameSlc[len(pathNameSlc)-1]
11701			ret += `<a href='/sourcecode/wasm/` + pathName + `'>` + pathName + `</a><br>
11702			`
11703		}
11704		ret += `</body></html>
11705		`
11706		c.Set("Content-Type", "text/html;charset=utf-8")
11707		_, err := c.Status(fiber.StatusOK).Write([]byte(ret))
11708		return err
11709	})
11710
11711	for i, wasmSRC := range f.WasmSRC {
11712		pathNameSlc := strings.Split(wasmSRC, "/")
11713		pathName := pathNameSlc[len(pathNameSlc)-1]
11714		r.Get("/sourcecode/wasm/"+pathName, func(c fiber.Ctx) error {
11715			return sourcecode(c, sourcesWasm[i], "dracula", "go")
11716		})
11717	}
11718}
11719
11720func sourcecodehtml(c fiber.Ctx) error {
11721	return sourcecode(c, sourceHtml, "monokai", "html")
11722}
11723func sourcecodecontent(c fiber.Ctx) error {
11724	return sourcecode(c, sourceContent, "monokai", "html")
11725}
11726
11727func sourcecodego(c fiber.Ctx) error {
11728	var builder strings.Builder
11729	for _, src := range goSources {
11730		if err := collectSource(&builder, src.FS, src.Prefix, "go"); err != nil {
11731			return err
11732		}
11733	}
11734	return renderSource(c, builder.String(), "monokai", "go")
11735}
11736
11737func sourcecodecore(c fiber.Ctx) error {
11738	return sourcecode(c, sourceCore, "solarized-dark256", "go")
11739}
11740
11741func sourcecode(c fiber.Ctx, fsys fs.FS, styleName string, lang string) error {
11742	var builder strings.Builder
11743	if err := collectSource(&builder, fsys, "", lang); err != nil {
11744		return err
11745	}
11746	return renderSource(c, builder.String(), styleName, lang)
11747}
11748
11749// collectSource concatenates every .<lang> file in fsys into builder, each
11750// under a banner naming it (with prefix, so files from different packages
11751// keep their repo paths).
11752func collectSource(builder *strings.Builder, fsys fs.FS, prefix string, lang string) error {
11753	return fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
11754		if err != nil {
11755			return err
11756		}
11757		if !d.IsDir() && strings.HasSuffix(path, "."+lang) {
11758			content, err := fs.ReadFile(fsys, path)
11759			if err != nil {
11760				return err
11761			}
11762			name := path
11763			if prefix != "" {
11764				name = prefix + "/" + path
11765			}
11766			builder.WriteString(fmt.Sprintf("// ===== %s =====\n", name))
11767			builder.Write(content)
11768			builder.WriteString("\n\n")
11769		}
11770		return nil
11771	})
11772}
11773
11774func renderSource(c fiber.Ctx, source string, styleName string, lang string) error {
11775	c.Set("Content-Type", "text/html;charset=utf-8")
11776	var buf bytes.Buffer
11777
11778	// Pick lexer & style
11779	lexer := lexers.Get(lang)
11780	if lexer == nil {
11781		lexer = lexers.Fallback
11782	}
11783	lexer = chroma.Coalesce(lexer)
11784
11785	style := styles.Get(styleName)
11786	if style == nil {
11787		style = styles.Fallback
11788	}
11789
11790	// Formatter with line numbers & CSS classes
11791	formatter := html.New(
11792		html.WithLineNumbers(true),
11793		html.WithClasses(true),
11794	)
11795
11796	iterator, err := lexer.Tokenise(nil, source)
11797	if err != nil {
11798		return err
11799	}
11800
11801	// Optional: include CSS in output
11802	var css bytes.Buffer
11803	_ = formatter.WriteCSS(&css, style) //nolint:errcheck // the stylesheet is decoration; the highlighted source is still readable without it
11804	buf.WriteString("<style>")
11805	buf.Write(css.Bytes())
11806	buf.WriteString("</style>")
11807
11808	if err := formatter.Format(&buf, style, iterator); err != nil {
11809		return err
11810	}
11811
11812	_, err = c.Status(fiber.StatusOK).Write(buf.Bytes())
11813	return err
11814}
11815
11816
11817// ===== pkg/web/tmpl.go =====
11818// Package web pkg/web/tmpl.go β€” template plumbing.
11819package web
11820
11821import (
11822	"bytes"
11823	"fmt"
11824	htmpl "html/template"
11825	"log"
11826	"os"
11827	"path/filepath"
11828	"sort"
11829	"strconv"
11830	"strings"
11831	ttmpl "text/template"
11832	"time"
11833
11834	"github.com/gofiber/fiber/v3"
11835
11836	p "github.com/0magnet/m2/pkg/product"
11837)
11838
11839/*
11840//go:embed htmpl/*
11841var templatesFS embed.FS
11842
11843//go:embed content/*
11844var contentFS embed.FS
11845*/
11846/*
11847var (
11848	templatesFS = os.DirFS("htmpl")
11849	contentFS   = os.DirFS("content")
11850)
11851*/
11852/*
11853func mustReadEmbeddedFileToString(path string, fs embed.FS) string {
11854	return string(mustReadEmbeddedFileToBytes(path, fs))
11855}
11856
11857func mustReadEmbeddedFileToBytes(path string, fs embed.FS) []byte {
11858	data, err := fs.ReadFile(path)
11859	if err != nil {
11860		panic(err)
11861	}
11862	return data
11863}
11864*/
11865
11866func mustReadFileToString(path string) string {
11867	return string(mustReadFileToBytes(path))
11868}
11869
11870// optionalReadFileToString returns "" when the file does not exist.
11871// Used for deployment-local assets (e.g. content/font.css) that are
11872// not part of the source repo.
11873func optionalReadFileToString(path string) string {
11874	data, err := os.ReadFile(path) //nolint
11875	if err != nil {
11876		return ""
11877	}
11878	return string(data)
11879}
11880
11881// contentFile returns the deployment-local file when present, falling
11882// back to the committed <path>.example. Lets site operators override
11883// stock pages (about, policy, links) without touching tracked files.
11884func contentFile(path string) string {
11885	if s := optionalReadFileToString(path); s != "" {
11886		return s
11887	}
11888	return mustReadFileToString(path + ".example")
11889}
11890
11891func mustReadFileToBytes(path string) []byte {
11892	data, err := os.ReadFile(path) //nolint
11893	if err != nil {
11894		panic(err)
11895	}
11896	return data
11897}
11898
11899type htmlTemplate struct {
11900	Empty          func() string
11901	Head           func() string
11902	Logo           func() string
11903	Header         func() string
11904	Categories     func() string
11905	CatSubcats     func() string
11906	Footer         func() string
11907	MainPage       func() string
11908	AuxPage        func() string
11909	FrontPage      func() string
11910	CategoryPage   func() string
11911	CategoryPageMD func() string
11912	ProductPage    func() string
11913	ProductPageMD  func() string
11914	Schema         func() string
11915	Cart           func() string
11916	XMLSitemap     func() string
11917	Wasm           func() string
11918	Keys           func() string
11919	AboutPage      func() string
11920	PolicyPage     func() string
11921	LinksPage      func() string
11922	CheckoutPage   func() string
11923	CompletePage   func() string
11924	CheckoutCSS    func() string
11925	StyleCSS       func() string
11926	FontCSS        func() string
11927}
11928
11929var h = htmlTemplate{
11930	Empty:          func() string { return mustReadFileToString("htmpl/empty.html") },
11931	Head:           func() string { return mustReadFileToString("htmpl/head.html") },
11932	Logo:           func() string { return mustReadFileToString("htmpl/logo.html") },
11933	Header:         func() string { return mustReadFileToString("htmpl/header.html") },
11934	Categories:     func() string { return mustReadFileToString("htmpl/categories.html") },
11935	CatSubcats:     func() string { return mustReadFileToString("htmpl/catsubcats.html") },
11936	Footer:         func() string { return mustReadFileToString("htmpl/footer.html") },
11937	MainPage:       func() string { return mustReadFileToString("htmpl/main.html") },
11938	AuxPage:        func() string { return mustReadFileToString("htmpl/auxpage.html") },
11939	FrontPage:      func() string { return mustReadFileToString("htmpl/front.html") },
11940	CategoryPage:   func() string { return mustReadFileToString("htmpl/category.html") },
11941	CategoryPageMD: func() string { return mustReadFileToString("htmpl/category.md") },
11942	ProductPage:    func() string { return mustReadFileToString("htmpl/product.html") },
11943	ProductPageMD:  func() string { return mustReadFileToString("htmpl/product.md") },
11944	Schema:         func() string { return mustReadFileToString("htmpl/schema.html") },
11945	Cart:           func() string { return mustReadFileToString("htmpl/cart.html") },
11946	XMLSitemap:     func() string { return mustReadFileToString("htmpl/sitemap.xml") },
11947	Wasm:           func() string { return mustReadFileToString("htmpl/wasm.html") },
11948	Keys:           func() string { return mustReadFileToString("htmpl/keys.html") },
11949	CompletePage:   func() string { return mustReadFileToString("htmpl/complete.html") },
11950	AboutPage:      func() string { return contentFile("content/about.html") },
11951	PolicyPage:     func() string { return contentFile("content/policy.html") },
11952	LinksPage:      func() string { return contentFile("content/links.html") },
11953	CheckoutPage:   func() string { return mustReadFileToString("content/checkout.html") },
11954	CheckoutCSS:    func() string { return mustReadFileToString("content/checkout.css") },
11955	StyleCSS: func() string {
11956		return fontCSS() + mustReadFileToString("content/style.css")
11957	},
11958	// The terminal page wants the face on its own, without the stylesheet.
11959	FontCSS: func() string { return fontCSS() },
11960}
11961
11962var htmlPageTemplateData htmlTemplateData
11963
11964var funcs = htmpl.FuncMap{
11965	"replace": replace, "mul": mul, "div": div, "safeHTML": safeHTML,
11966	"safeJS": safeJS, "stripProtocol": stripProtocol, "add": add, "sub": sub,
11967	"toFloat": toFloat, "equalsIgnoreCase": equalsIgnoreCase,
11968	"getsubcats": getsubcats, "escapesubcat": escapesubcat,
11969	"sortsubcats": sortsubcats, "repeat": repeat, "subcatlink": subcatlink,
11970	"navmenu": navmenu,
11971}
11972
11973func mainTmpl() (tmpl *htmpl.Template, err error) {
11974	tmpl = htmpl.New("index").Funcs(funcs)
11975	if _, err := tmpl.Parse(h.MainPage()); err != nil {
11976		log.Println("Error parsing index template:", err)
11977		return tmpl, err
11978	}
11979
11980	for _, p := range mainPartials() {
11981		if _, err := tmpl.New(p.Name).Parse(p.Content); err != nil {
11982			log.Printf("Error parsing %s template: %v", p.Name, err)
11983			return tmpl, err
11984		}
11985	}
11986	return tmpl, err
11987}
11988
11989// mainPartialSrc is every template main.html may invoke, besides the
11990// "main" its caller supplies.
11991//
11992// One list of name and loader rather than a list of names and a map to
11993// look them up in: the names have to be readable without loading the
11994// bodies, because a test has no working directory to read them from, and
11995// two collections would let a name arrive with no body behind it.
11996//
11997// The test matters because a missing partial is not a parse error.
11998// html/template notices only at execute time, and then on every page of
11999// the site at once, which is a poor place to find out.
12000var mainPartialSrc = []struct {
12001	Name string
12002	Body func() string
12003}{
12004	{"head", h.Head},
12005	{"schema", h.Schema},
12006	{"header", h.Header},
12007	{"catsubcats", h.CatSubcats},
12008	{"categories", h.Categories},
12009	{"footer", h.Footer},
12010	{"cart", h.Cart},
12011	{"wasm", h.Wasm},
12012	{"keys", h.Keys},
12013}
12014
12015// mainPartials loads them.
12016func mainPartials() []struct{ Name, Content string } {
12017	out := make([]struct{ Name, Content string }, 0, len(mainPartialSrc))
12018	for _, p := range mainPartialSrc {
12019		out = append(out, struct{ Name, Content string }{p.Name, p.Body()})
12020	}
12021	return out
12022}
12023
12024func auxTmpl() (tmpl *htmpl.Template, err error) {
12025	tmpl = htmpl.New("index").Funcs(funcs)
12026	if _, err := tmpl.Parse(h.AuxPage()); err != nil {
12027		log.Println("Error parsing index template:", err)
12028		return tmpl, err
12029	}
12030
12031	partials := []struct {
12032		Name    string
12033		Content string
12034	}{
12035		{"head", h.Head()},
12036		{"schema", h.Empty()},
12037		{"wasm", h.Empty()},
12038	}
12039
12040	for _, p := range partials {
12041		if _, err := tmpl.New(p.Name).Parse(p.Content); err != nil {
12042			log.Printf("Error parsing %s template: %v", p.Name, err)
12043			return tmpl, err
12044		}
12045	}
12046	return tmpl, err
12047}
12048
12049func pageMeta(c fiber.Ctx, base htmlTemplateData) htmlTemplateData {
12050	h := base
12051	host := string(c.Request().Host())
12052	/*
12053		proto := "http"
12054		if c.Secure() {
12055			proto += "s"
12056		}
12057	*/
12058	proto := "https"
12059	h.Canonical = proto + "://" + host + c.OriginalURL()
12060	h.BaseURL = proto + "://" + host
12061	h.RequestHost = host
12062	h.Protocol = proto
12063	h.CatsCounts, h.Cats, h.SubCatsCounts, h.SubCatsByCat = getcategories(allproducts)
12064	h.LenAllProducts = len(allproducts)
12065	h.Time = time.Now().Format(time.RFC3339Nano)
12066	h.Year = fmt.Sprintf("%v", time.Now().Year())
12067	h.MetaDesc = f.Sitemeta
12068	h.ImgBase = h.ImgSRC
12069	if strings.HasPrefix(h.ImgBase, "/") {
12070		h.ImgBase = h.BaseURL + h.ImgBase
12071	}
12072	h.KeyWords = strings.Replace(f.Sitelongname, " ", ", ", -1)
12073	return h
12074}
12075
12076func initTMPL() {
12077	htmlPageTemplateData = htmlTemplateData{
12078		TestMode:           f.Teststripekey,
12079		Title:              f.Sitelongname,
12080		StripePK:           f.StripePK,
12081		SiteName:           f.Sitedomain,
12082		SiteTagLine:        f.Sitetagline,
12083		SiteName1:          htmpl.HTML(checkerBoard(f.Sitedomain)), //nolint
12084		SiteLongName:       f.Sitelongname,
12085		SiteASCIILogo:      htmpl.HTML(f.SiteASCIILogo), //nolint
12086		SitePrettyName:     f.Siteprettyname,
12087		SitePrettyNameCap:  f.Siteprettynamecap,
12088		SitePrettyNameCaps: f.Siteprettynamecaps,
12089		TelegramContact:    f.Tgcontact,
12090		TelegramChannel:    f.Tgchannel,
12091		WasmExecPath:       f.WasmExecPath,
12092		WasmExecRel:        f.WasmExecPath,
12093		Cats:               getcats(),
12094		LenAllProducts:     len(allproducts),
12095		ImgSRC: func() (ret string) {
12096			ret = f.Siteimagesrc
12097			if ret == "" {
12098				ret = "/i"
12099			}
12100			return ret
12101		}(),
12102		Page:     "front",
12103		Time:     time.Now().Format(time.RFC3339Nano),
12104		Year:     fmt.Sprintf("%v", time.Now().Year()),
12105		NavLinks: extraNavLinks,
12106	}
12107	htmlPageTemplateData.CatsCounts, htmlPageTemplateData.Cats, htmlPageTemplateData.SubCatsCounts, htmlPageTemplateData.SubCatsByCat = getcategories(allproducts)
12108	// No wasm by default; a page opts in with wasmFor. Defaulting to all of
12109	// them is what had every page in the site fetching stl2 (7.9MB) for a
12110	// canvas it does not have, and asking for the tui and desk binaries,
12111	// which have no TinyGo build and answer 500. Those two pages build their
12112	// own HTML and loader anyway, so they never read this list.
12113	htmlPageTemplateData.WasmBinary = nil
12114
12115}
12116
12117// wasmFor returns only the configured binaries named, so a page carries the
12118// wasm it can actually use and nothing else.
12119//
12120// Every page used to carry all of them, and each binary opens by looking for
12121// the element it drives and returning when that element is not there β€” so an
12122// ordinary category page fetched several megabytes, instantiated it, and got
12123// a handful of early returns. stl2 is the expensive one at 7.9MB, and only
12124// the front page and a product page have the canvas it draws into.
12125//
12126// It also stops the binaries that have no TinyGo build yet from being asked
12127// for on every page in the site: those fetches answer 500, which is two
12128// failed requests per page view for something the page could not use anyway.
12129//
12130// Names match on the part before the suffix, so a caller says "stl2" and gets
12131// whichever of stl2.wasm or stl2-tiny.wasm this build is configured for.
12132func wasmFor(names ...string) []wasmPick { return wasmPicks(pickWasm, names) }
12133
12134// wasmSmallFor is wasmFor for a binary a page carries on every view
12135// rather than when the reader asks for it: it takes the tinygo build
12136// where there is one. See pickWasmSmall.
12137func wasmSmallFor(names ...string) []wasmPick { return wasmPicks(pickWasmSmall, names) }
12138
12139func wasmPicks(pick func(string) (wasmPick, bool), names []string) []wasmPick {
12140	// WasmSRC lists the sources THIS process builds, and a page must not
12141	// name a drop-in the deployment never compiles. The tab compiles
12142	// nothing and its list is empty, so the same gate there would drop
12143	// every binary before it was looked for; the origin, which pick asks
12144	// directly, is the authority instead.
12145	inTab := servingInTab()
12146	configured := map[string]bool{}
12147	if !inTab {
12148		for _, src := range f.WasmSRC {
12149			configured[strings.TrimSuffix(filepath.Base(src), filepath.Ext(src))] = true
12150		}
12151	}
12152	ret := make([]wasmPick, 0, len(names))
12153	for _, want := range names {
12154		if !inTab && !configured[want] {
12155			continue
12156		}
12157		if p, ok := pick(want); ok {
12158			ret = append(ret, p)
12159		}
12160	}
12161	return ret
12162}
12163
12164type xmlTemplateData struct {
12165	BaseURL      string
12166	Extra        []string
12167	Cats         []string
12168	SubCatsByCat map[string][]string
12169	Products     p.Products
12170	Update       string
12171}
12172
12173func generateSitemapXML() string {
12174	xmlSitemapTemplateData := xmlTemplateData{
12175		BaseURL:  "https://" + f.Sitedomain,
12176		Products: allproducts,
12177		Extra:    extraSitemapURLs,
12178		Update:   time.Now().Format("2006-01-02"),
12179	}
12180	_, xmlSitemapTemplateData.Cats, _, xmlSitemapTemplateData.SubCatsByCat = getcategories(allproducts)
12181	var err1 error
12182	xtmpl, err1 := ttmpl.New("index").Funcs(ttmpl.FuncMap{"getsubcats": getsubcats}).Parse(h.XMLSitemap())
12183	if err1 != nil {
12184		log.Println("Error parsing index template:", err1)
12185	}
12186	var result bytes.Buffer
12187	err1 = xtmpl.Execute(&result, xmlSitemapTemplateData)
12188	if err1 != nil {
12189		log.Println("error: ", err1)
12190	}
12191	return result.String()
12192}
12193
12194func toFloat(s string) float64 {
12195	if s == "" {
12196		return 0.0
12197	}
12198	f, err := strconv.ParseFloat(s, 64)
12199	if err != nil {
12200		return 0.0
12201	}
12202	return f
12203}
12204
12205func checkerBoard(input string) string {
12206	var result strings.Builder
12207	for i, char := range input {
12208		// Wrap every other letter with the specified HTML
12209		if i%2 == 0 {
12210			result.WriteString(fmt.Sprintf("<span class='nv'>%c</span>", char))
12211		} else {
12212			result.WriteRune(char)
12213		}
12214	}
12215	return result.String()
12216}
12217
12218type htmlTemplateData struct {
12219	Title     string
12220	MetaDesc  string
12221	Canonical string
12222	BaseURL   string
12223	ImgSRC    string // url where images are hosted
12224	// ImgBase is ImgSRC made absolute. og:image and schema.org image must be
12225	// absolute URLs: a link unfurler has no page to resolve a relative one
12226	// against and most simply drop it, which is why every share of this site
12227	// showed no picture. ImgSRC may already name another host, so this cannot
12228	// be done by pasting BaseURL in front of it in the template.
12229	ImgBase            string
12230	OrdersURL          string // url where checkout is served from
12231	SiteName           string
12232	SiteTagLine        string
12233	SiteName1          htmpl.HTML //checkerboard - alternate swap text & bg color
12234	SiteLongName       string
12235	SitePrettyName     string //π•„π•’π•˜π•Ÿπ•–π•₯𝕠𝕀𝕑𝕙𝕖𝕣𝕖.π•Ÿπ•–π•₯
12236	SitePrettyNameCap  string //π•„π•’π•˜π•Ÿπ•–π•₯𝕠𝕀𝕑𝕙𝕖𝕣𝕖.π•Ÿπ•–π•₯
12237	SitePrettyNameCaps string //π•„π”Έπ”Ύβ„•π”Όπ•‹π•†π•Šβ„™β„π”Όβ„π”Ό.ℕ𝔼𝕋
12238	SiteASCIILogo      htmpl.HTML
12239	TelegramContact    string
12240	TelegramChannel    string
12241	Protocol           string
12242	RequestHost        string
12243	KeyWords           string
12244	Style              htmpl.HTML
12245	Heading            htmpl.HTML
12246	StripePK           string
12247	Cats               []string
12248	CatsCounts         map[string]int
12249	SubCatsCounts      map[string]map[string]int
12250	SubCatsByCat       map[string][]string
12251	LenAllProducts     int
12252	Mobile             bool
12253	Gocanvas           htmpl.HTML
12254	WasmBinary         []wasmPick
12255	WasmExecPath       string
12256	WasmExecRel        string
12257	StyleFontFace      htmpl.CSS
12258	Message            htmpl.HTML
12259	Page               string
12260	Year               string
12261	Time               string
12262	AboutHTML          htmpl.HTML
12263	LinksHTML          htmpl.HTML
12264	PolicyHTML         htmpl.HTML
12265	TestMode           bool
12266
12267	// NavLinks are the entries drop-in routes added to the header; see
12268	// extraNavLinks.
12269	NavLinks []NavLink
12270}
12271
12272func equalsIgnoreCase(a, b string) bool {
12273	return strings.EqualFold(strings.Join(strings.Fields(a), ""), strings.Join(strings.Fields(b), ""))
12274}
12275
12276func replace(s, o, n string) string {
12277	return strings.ReplaceAll(s, o, n)
12278}
12279func mul(a, b float64) float64 {
12280	return a * b
12281}
12282func div(a, b float64) float64 {
12283	return a / b
12284}
12285func add(a, b int) int {
12286	return a + b
12287}
12288func sub(a, b int) int {
12289	return a - b
12290}
12291func safeHTML(s string) htmpl.HTML {
12292	return htmpl.HTML(s) //nolint
12293}
12294func safeJS(s string) htmpl.JS {
12295	return htmpl.JS(s) //nolint
12296}
12297func stripProtocol(s string) string {
12298	return strings.Replace(strings.Replace(s, "https://", "", -1), "http://", "", -1)
12299}
12300func repeat(s string, count int) string {
12301	var result string
12302	for i := 0; i < count; i++ {
12303		result += s
12304	}
12305	return result
12306}
12307func sortsubcats(subcats []string, counts map[string]map[string]int) []string {
12308	sort.Slice(subcats, func(i, j int) bool {
12309		catI, catJ := subcats[i], subcats[j]
12310		countI, countJ := counts[catI]["count"], counts[catJ]["count"]
12311		return countI > countJ
12312	})
12313	return subcats
12314}
12315
12316func subcatlink(subcategory string) string {
12317	s := subcategory
12318	s = strings.ReplaceAll(s, "ΒΌ", "quarter-")
12319	s = strings.ReplaceAll(s, "Β½", "half-")
12320	s = strings.ReplaceAll(s, "1/16", "sixteenth-")
12321	s = strings.ReplaceAll(s, "%", "-pct")
12322	s = strings.ReplaceAll(s, "  ", " ")
12323	s = strings.ReplaceAll(s, "watt1", "watt-1")
12324	s = strings.ReplaceAll(s, "watt5", "watt-5")
12325	s = strings.ReplaceAll(s, " ", "-")
12326	s = strings.ReplaceAll(s, "--", "-")
12327	return s
12328}
12329
12330// fontCSS is the deployment's @font-face block, read from disk. It is a
12331// plain function rather than a method on h because h's own initializer
12332// needs it, and a var cannot refer to itself.
12333func fontCSS() string { return optionalReadFileToString("content/font.css") }
12334
12335
12336// ===== pkg/web/tmpl_test.go =====
12337package web
12338
12339import (
12340	"os"
12341	"regexp"
12342	"testing"
12343)
12344
12345var tmplInvoke = regexp.MustCompile(`\{\{\s*template\s+"([^"]+)"`)
12346
12347// Every partial main.html asks for has to be registered with it.
12348//
12349// html/template does not notice a missing one when the page is parsed β€”
12350// only when it is executed, and then on every page of the site at once.
12351// That is a poor place to find out, and a new partial is exactly the
12352// change that forgets the registration.
12353func TestMainTemplateInvokesOnlyRegisteredPartials(t *testing.T) {
12354	src, err := os.ReadFile("../../htmpl/main.html")
12355	if err != nil {
12356		t.Fatalf("read main.html: %v", err)
12357	}
12358	known := map[string]bool{
12359		// Supplied by the handler for the page being rendered.
12360		"main": true,
12361	}
12362	for _, p := range mainPartialSrc {
12363		known[p.Name] = true
12364	}
12365	found := tmplInvoke.FindAllSubmatch(src, -1)
12366	if len(found) == 0 {
12367		t.Fatal("main.html invokes no templates at all, which cannot be right")
12368	}
12369	for _, m := range found {
12370		if name := string(m[1]); !known[name] {
12371			t.Errorf("main.html invokes %q, which mainTmpl does not register", name)
12372		}
12373	}
12374}
12375
12376// Every registration names a distinct template and carries a loader for
12377// it. The loaders read from the working directory, so this checks the
12378// shape rather than calling them.
12379func TestMainPartialsAreWellFormed(t *testing.T) {
12380	seen := map[string]bool{}
12381	for _, p := range mainPartialSrc {
12382		if p.Name == "" || p.Body == nil {
12383			t.Errorf("partial %+v has no name or no body", p.Name)
12384		}
12385		if seen[p.Name] {
12386			t.Errorf("%q is registered twice", p.Name)
12387		}
12388		seen[p.Name] = true
12389	}
12390}
12391
12392
12393// ===== pkg/web/toolchain_js.go =====
12394//go:build js && wasm
12395
12396package web
12397
12398// haveToolchain reports whether this build can shell out to a Go
12399// compiler. In a page it cannot: there is no Go, no tinygo, and nothing
12400// to exec them with.
12401//
12402// This exists because the whole m2 command tree now runs in the browser
12403// shell, so `m2 wasm` and `m2 run` are reachable there β€” and every
12404// failure path in wasmbuild.go is a log.Fatal, which is os.Exit, which
12405// in a page takes down the wasm instance and every terminal in it. A
12406// command that cannot do its job should say so and return non-zero, not
12407// kill the tab.
12408const haveToolchain = false
12409
12410
12411// ===== pkg/web/toolchain_native.go =====
12412//go:build !js || !wasm
12413
12414package web
12415
12416// haveToolchain reports whether this build can shell out to a Go
12417// compiler. Natively it can.
12418const haveToolchain = true
12419
12420
12421// ===== pkg/web/wasmbuild.go =====
12422// Package web pkg/web/wasmbuild.go β€” compiling the drop-in wasm apps.
12423package web
12424
12425import (
12426	"encoding/json"
12427	"fmt"
12428	"io"
12429	"io/fs"
12430	"log"
12431	"net/http"
12432	"os"
12433	"os/exec"
12434	"path/filepath"
12435	"regexp"
12436	"strconv"
12437
12438	"github.com/gofiber/fiber/v3"
12439	"strings"
12440	"time"
12441
12442	"github.com/bitfield/script"
12443	"github.com/briandowns/spinner"
12444
12445	"github.com/0magnet/m2/pkg/config"
12446)
12447
12448func CompileWASM() {
12449	if !haveToolchain {
12450		// Nothing here to build with; see toolchain_js.go.
12451		log.Println("wasm: no Go toolchain in this environment; nothing compiled")
12452		return
12453	}
12454	s := spinner.New(spinner.CharSets[14], 25*time.Millisecond)
12455	s.Suffix = " Compiling wasm..."
12456	// Each build runs on the system's default Go first; only if that fails does
12457	// it retry on a pinned Go toolchain. The pinned version is normally resolved
12458	// dynamically β€” tinygo trails the latest Go release, so on failure we read
12459	// the Go range tinygo supports from its own error and pin the newest matching
12460	// Go patch (see resolveCompatibleToolchain). That keeps the site on the newest
12461	// Go whenever tinygo supports it and self-heals across Go bumps with no
12462	// hardcoded version. GOTOOLCHAINFALLBACK in the MENV config is an OPTIONAL
12463	// offline backstop, used only if the dynamic lookup can't reach the release
12464	// list; leave it empty for fully dynamic behavior.
12465	offlineFallback := config.ScriptExecString("${GOTOOLCHAINFALLBACK}")
12466
12467	// Stage the drop-ins BEFORE building anything, from whatever the last run
12468	// left on disk, because a page binary embeds them (dropinfs.go) and
12469	// go:embed is read when it compiles.
12470	//
12471	// Staging after the builds instead would make the embed current within a
12472	// single run, and it is tempting for that reason. It also puts the tinygo
12473	// builds β€” stl2's is about twenty minutes β€” in front of every term.wasm
12474	// rebuild, so a one-line server edit would not reach the site for half an
12475	// hour. Staging first costs the opposite and much less: when a drop-in's
12476	// SOURCE changes, the copy inside the page binary is one restart behind.
12477	//
12478	// That is affordable because the embedded copy is a fallback and nothing
12479	// else. The host serves the drop-ins off disk, where they are current the
12480	// moment the build finishes, and a tab whose carried copy is stale is
12481	// still a tab serving a working cart. Nothing reads the embed unless the
12482	// disk has nothing.
12483	stageDropins(dropInSources())
12484
12485	for _, wasmSRC := range f.WasmSRC {
12486		ascend := strings.Repeat("../", len(strings.Split(wasmSRC, "/")))
12487		outputFile := baseName(wasmSRC) + ".wasm"
12488		mk := func(tc string) string {
12489			return fmt.Sprintf("cd %s || exit 1 ; time GOOS=js GOARCH=wasm %s%s -o %s %s -ldflags=\"-s -w\" %s && cd %s && du %s", wasmSRC, tc, f.Gobuild, ascend+outputFile, ldflags(wasmSRC), ".", ascend, outputFile)
12490		}
12491		buildWasmWithFallback(mk, offlineFallback, s, true)
12492	}
12493	if !f.UseTinygo {
12494		return
12495	}
12496	for _, wasmSRC := range f.WasmSRC {
12497		ascend := strings.Repeat("../", len(strings.Split(wasmSRC, "/")))
12498		outputFile := baseName(wasmSRC) + "-tiny.wasm"
12499		mk := func(tc string) string {
12500			return fmt.Sprintf("cd %s || exit 1 ; time GOOS=js GOARCH=wasm %s%s -o %s %s %s && cd %s && du %s", wasmSRC, tc, f.Tinygobuild, ascend+outputFile, ldflags(wasmSRC), ".", ascend, outputFile)
12501		}
12502		// The Go build above succeeded and serves; a drop-in that tinygo
12503		// cannot compile (e.g. one using net/http, which tinygo's shim
12504		// does not build) must not take the whole store down.
12505		buildWasmWithFallback(mk, offlineFallback, s, false)
12506	}
12507}
12508
12509// dropInSources is WasmSRC minus the page binaries β€” the sources whose builds
12510// a page loads alongside itself, and so the ones worth carrying.
12511func dropInSources() []string {
12512	var out []string
12513	for _, src := range f.WasmSRC {
12514		if !pageWasm[baseName(src)] {
12515			out = append(out, src)
12516		}
12517	}
12518	return out
12519}
12520
12521// pageWasm names the binaries that ARE a page rather than something a page
12522// loads alongside itself. They are not staged into the embed: the tab does not
12523// serve /tui or /desk, and term.wasm is the binary doing the embedding β€” a
12524// file cannot contain itself.
12525var pageWasm = map[string]bool{"term": true, "tui": true, "desk": true}
12526
12527func baseName(wasmSRC string) string {
12528	return strings.TrimSuffix(filepath.Base(wasmSRC), filepath.Ext(wasmSRC))
12529}
12530
12531// stageDropins copies the smallest build of each drop-in into the directory
12532// dropinfs.go embeds, so the page binaries compiled next carry them.
12533//
12534// Best effort throughout. A tree without the staging directory β€” a binary
12535// running somewhere other than its own source β€” simply stages nothing and the
12536// build carries nothing, which is the same as a fresh checkout.
12537func stageDropins(srcs []string) {
12538	dir := filepath.Join("pkg", "web", "dropins")
12539	// os.Root confines every name below to this directory. The names are
12540	// derived from WasmSRC, which is configuration rather than a request, but
12541	// "configuration cannot traverse" is a claim about the config file and not
12542	// about this code; the root makes it a property of this code.
12543	root, err := os.OpenRoot(dir)
12544	if err != nil {
12545		log.Printf("wasm: no %s to stage into; page binaries will carry no drop-ins", dir)
12546		return
12547	}
12548	defer root.Close() //nolint:errcheck // read-mostly; the writes below are closed individually
12549
12550	// Clear first, so a drop-in dropped from WasmSRC stops being carried.
12551	if ents, rerr := fs.ReadDir(root.FS(), "."); rerr == nil {
12552		for _, e := range ents {
12553			if !e.IsDir() && strings.HasSuffix(e.Name(), ".wasm") {
12554				_ = root.Remove(e.Name()) //nolint:errcheck // stale copy; the writes below are what matter
12555			}
12556		}
12557	}
12558
12559	for _, src := range srcs {
12560		name := baseName(src)
12561		// Smallest first, matching what a page is given to load.
12562		for _, cand := range []string{name + "-tiny.wasm", name + ".wasm"} {
12563			b, rerr := os.ReadFile(cand) //nolint:gosec // a path this process just built
12564			if rerr != nil {
12565				continue
12566			}
12567			if werr := writeInRoot(root, cand, b); werr != nil {
12568				log.Printf("wasm: staging %s: %v", cand, werr)
12569				continue
12570			}
12571			log.Printf("wasm: staged %s (%d bytes) for the in-tab server", cand, len(b))
12572			break
12573		}
12574	}
12575}
12576
12577// writeInRoot creates one file under root and writes it whole.
12578func writeInRoot(root *os.Root, name string, b []byte) error {
12579	fh, err := root.Create(name)
12580	if err != nil {
12581		return err
12582	}
12583	if _, err = fh.Write(b); err != nil {
12584		_ = fh.Close() //nolint:errcheck // the write error is the one to report
12585		return err
12586	}
12587	return fh.Close()
12588}
12589
12590// runBash runs an inner bash script and returns its combined stdout+stderr, so
12591// the caller can both log it and parse compiler errors out of it.
12592func runBash(inner string) (string, error) {
12593	// The command is built by this package, not taken from a request.
12594	out, err := exec.Command("bash", "-c", inner).CombinedOutput() //nolint:gosec
12595	return string(out), err
12596}
12597
12598// buildWasmWithFallback builds wasm on the system Go first (mkCmd("")). On
12599// failure it resolves a Go toolchain tinygo can use β€” dynamically from tinygo's
12600// own error output, or the offlineFallback if the release list is unreachable β€”
12601// and retries with GOTOOLCHAIN pinned to it. When required, a build that still
12602// fails is fatal; otherwise it is logged and skipped (the pages fall back to
12603// whichever binary exists). mkCmd receives the string to place before the
12604// build command: "" for the default, or "GOTOOLCHAIN=… ".
12605func buildWasmWithFallback(mkCmd func(toolchainPrefix string) string, offlineFallback string, s *spinner.Spinner, required bool) {
12606	log.Println("Compiling wasm with:")
12607	log.Println(mkCmd(""))
12608	s.Start()
12609	out, err := runBash(mkCmd(""))
12610	s.Stop()
12611	log.Print(out)
12612	if err == nil {
12613		log.Println("Compiled wasm!")
12614		return
12615	}
12616	tc := resolveCompatibleToolchain(out)
12617	if tc == "" {
12618		tc = offlineFallback
12619	}
12620	if tc == "" {
12621		if required {
12622			log.Fatalf("wasm build failed and no compatible Go toolchain could be resolved: %v", err)
12623		}
12624		log.Printf("optional wasm build failed (no compatible toolchain): %v", err)
12625		return
12626	}
12627	log.Printf("wasm build failed on the default Go; retrying with GOTOOLCHAIN=%s", tc)
12628	s.Start()
12629	out, err = runBash(mkCmd("GOTOOLCHAIN=" + tc + " "))
12630	s.Stop()
12631	log.Print(out)
12632	if err != nil {
12633		if required {
12634			log.Fatal(err)
12635		}
12636		log.Printf("optional wasm build failed: %v", err)
12637		return
12638	}
12639	log.Printf("Compiled wasm (Go toolchain %s)!", tc)
12640}
12641
12642// resolveCompatibleToolchain reads tinygo's supported Go range from a failed
12643// build's output (e.g. "requires go version 1.19 through 1.26, got go1.27") and
12644// returns the newest released Go patch of that ceiling minor (e.g. "go1.26.7"),
12645// or "" if the range can't be parsed or the release list can't be fetched.
12646func resolveCompatibleToolchain(buildOutput string) string {
12647	m := regexp.MustCompile(`through (\d+)\.(\d+)`).FindStringSubmatch(buildOutput)
12648	if m == nil {
12649		return ""
12650	}
12651	minor := m[1] + "." + m[2]
12652
12653	client := &http.Client{Timeout: 20 * time.Second}
12654	resp, err := client.Get("https://go.dev/dl/?mode=json&include=all")
12655	if err != nil {
12656		return ""
12657	}
12658	defer func() { _ = resp.Body.Close() }() //nolint:errcheck,gosec
12659	body, err := io.ReadAll(resp.Body)
12660	if err != nil {
12661		return ""
12662	}
12663	var rels []struct {
12664		Version string `json:"version"`
12665	}
12666	if err := json.Unmarshal(body, &rels); err != nil {
12667		return ""
12668	}
12669
12670	// Newest patch of go<minor> (require a patch number: GOTOOLCHAIN rejects a
12671	// bare "go1.26" β€” it must be a full toolchain version like go1.26.7).
12672	re := regexp.MustCompile(`^go` + regexp.QuoteMeta(minor) + `\.(\d+)$`)
12673	best, bestPatch := "", -1
12674	for _, r := range rels {
12675		mm := re.FindStringSubmatch(r.Version)
12676		if mm == nil {
12677			continue
12678		}
12679		// A version that does not parse yields 0, which loses the comparison
12680		// below, so an unparsable tag is skipped rather than erroring.
12681		if p, _ := strconv.Atoi(mm[1]); p > bestPatch { //nolint:errcheck
12682			bestPatch, best = p, r.Version
12683		}
12684	}
12685	return best
12686}
12687
12688func ldflags(s string) (ss string) {
12689	checkFiles, err := script.FindFiles(s).Slice()
12690	if err != nil {
12691		log.Fatal(err)
12692	}
12693	if f.LDFlagsX != "" {
12694		for _, s := range checkFiles {
12695			res, err := script.File(s).Match(strings.Split(f.LDFlagsX, "=")[0]).String()
12696			if err != nil {
12697				log.Fatal(err)
12698			}
12699			if res != "" {
12700				ss += fmt.Sprintf(` -X 'main.%s' `, f.LDFlagsX)
12701				break
12702			}
12703		}
12704	}
12705	for _, s := range checkFiles {
12706		res, err := script.File(s).Match("wasmName").String()
12707		if err != nil {
12708			log.Fatal(err)
12709		}
12710		if res != "" {
12711			ss += fmt.Sprintf(` -X 'main.wasmName=%s' `, strings.TrimSuffix(filepath.Base(s), filepath.Ext(s))+".wasm")
12712			break
12713		}
12714	}
12715	if ss != "" {
12716		ss = `-ldflags="` + ss + `"`
12717	}
12718	return ss
12719}
12720
12721// serveCarriedWASM registers a route for every drop-in the build staged into
12722// the binary, and for the runtime shims a page needs to start one.
12723//
12724// It is the tab's copy of serveWASM. serveWASM reads the working directory and
12725// is registered only in the host role, which is right there and wrong here: a
12726// tab has no working directory carrying binaries and never will. Everything
12727// unmatched falls through to the read-through proxy, so a drop-in the build did
12728// not stage still reaches the origin; this only means the common ones do not
12729// have to.
12730//
12731// Registered BEFORE the proxy catch-all, and only for files actually carried,
12732// so a route never shadows a path the origin could answer better.
12733func serveCarriedWASM(r *fiber.App) {
12734	for _, file := range embeddedDropinNames() {
12735		body, ok := embeddedDropin(file)
12736		if !ok {
12737			continue
12738		}
12739		r.Get("/"+file, func(c fiber.Ctx) error {
12740			c.Set(fiber.HeaderContentType, "application/wasm")
12741			// Immutable: this one is compiled into the binary serving it, so
12742			// it cannot change without the page that loaded it changing too.
12743			c.Set(fiber.HeaderCacheControl, "public, max-age=31536000, immutable")
12744			return c.Send(body)
12745		})
12746	}
12747}
12748
12749
12750// ===== pkg/web/wasmpick.go =====
12751package web
12752
12753// Which build of each wasm drop-in a page loads, and which runtime shim starts
12754// it.
12755//
12756// USETINYGO does not mean "tinygo only". wasmbuild.go builds the Go variant of
12757// every source FIRST and unconditionally (that build is `required`), and only
12758// then, if the flag is set, additionally builds the tinygo variants as
12759// best-effort. So the usual state on the server is that BOTH stl2.wasm and
12760// stl2-tiny.wasm exist.
12761//
12762// The serving side did not know that. The old wasmBinary() named "<x>-tiny.wasm" for
12763// every source whenever the flag was on, without looking at the disk, which had
12764// two consequences:
12765//
12766//   - A fresh Go build was ignored in favor of a stale tinygo one. The Go build
12767//     of stl2 takes about 4 seconds and the tinygo build about 22 minutes, so
12768//     during that window the page served a binary from before the edit β€” a
12769//     22-minute feedback loop for a 4-second change.
12770//   - A tinygo build that FAILED still had its name emitted. wasmbuild.go says
12771//     a drop-in tinygo cannot compile "must not take the whole store down" and
12772//     that "the pages fall back to whichever binary exists", but only tuipage
12773//     and deskpage ever did that stat; template pages asked for a file that was
12774//     not there.
12775//
12776// So the choice is made per binary, from the disk, newest wins.
12777
12778import (
12779	"net/http"
12780	"os"
12781	"strings"
12782	"sync"
12783)
12784
12785// wasmPick is one binary a page will load together with the shim that can start
12786// it. The two travel together because they must match: tinygo's wasm_exec.js
12787// cannot start a stdlib Go binary (it throws a LinkError on the gojs imports)
12788// and the stdlib shim cannot start a tinygo one.
12789type wasmPick struct {
12790	File     string // "stl2.wasm" or "stl2-tiny.wasm"
12791	ExecPath string // the wasm_exec.js matching its toolchain
12792	GoGlobal string // the JS global holding that shim's Go constructor
12793}
12794
12795// The two shims both define globalThis.Go, so a page needing both must load
12796// them one after another and stash each constructor before the next overwrites
12797// it. These are the names it stashes them under.
12798const (
12799	goGlobalStd  = "__GoStd"
12800	goGlobalTiny = "__GoTiny"
12801)
12802
12803// newerFile reports which of two paths has the later modification time.
12804// Missing files lose; if neither exists, ok is false.
12805func newerFile(a, b string) (path string, ok bool) {
12806	sa, ea := os.Stat(a)
12807	sb, eb := os.Stat(b)
12808	switch {
12809	case ea == nil && eb == nil:
12810		if sb.ModTime().After(sa.ModTime()) {
12811			return b, true
12812		}
12813		return a, true
12814	case ea == nil:
12815		return a, true
12816	case eb == nil:
12817		return b, true
12818	}
12819	return "", false
12820}
12821
12822// pickWasm chooses the build of one drop-in to serve.
12823//
12824// When the tinygo build is not wanted at all the Go build is the only candidate,
12825// so a site that never sets USETINYGO behaves exactly as before.
12826func pickWasm(name string) (wasmPick, bool) {
12827	if servingInTab() {
12828		if p, ok := pickCarried(name); ok {
12829			return p, true
12830		}
12831		return pickFromOrigin(name)
12832	}
12833	std := name + ".wasm"
12834	if !f.UseTinygo {
12835		if _, err := os.Stat(std); err != nil {
12836			return wasmPick{}, false
12837		}
12838		return wasmPick{File: std, ExecPath: f.WasmExecPathGo, GoGlobal: goGlobalStd}, true
12839	}
12840	which, ok := newerFile(std, name+"-tiny.wasm")
12841	if !ok {
12842		return wasmPick{}, false
12843	}
12844	if strings.HasSuffix(which, "-tiny.wasm") {
12845		return wasmPick{File: which, ExecPath: f.WasmExecPathTinyGo, GoGlobal: goGlobalTiny}, true
12846	}
12847	return wasmPick{File: which, ExecPath: f.WasmExecPathGo, GoGlobal: goGlobalStd}, true
12848}
12849
12850// pickWasmSmall chooses the SMALLER build of a drop-in: the tinygo one
12851// when it exists, whatever the modification times say.
12852//
12853// pickWasm's newest-wins rule is right for a binary a page loads because
12854// that binary is the point of the page β€” during the twenty-odd minutes a
12855// tinygo build takes, serving the four-second Go build is what keeps the
12856// edit visible. It is the wrong rule for one every page carries whether
12857// the reader uses it or not. The cart is that: 4.5MB as a Go build
12858// against 1MB as a tinygo one, fetched on every catalog page view to
12859// keep a list of part numbers in localStorage.
12860//
12861// Falls back to pickWasm, so a site with no tinygo β€” or one whose tinygo
12862// build of this source failed β€” gets the Go build rather than nothing.
12863func pickWasmSmall(name string) (wasmPick, bool) {
12864	if servingInTab() {
12865		if p, ok := pickCarried(name); ok {
12866			return p, true
12867		}
12868		return pickFromOrigin(name)
12869	}
12870	if f.UseTinygo {
12871		if tiny := name + "-tiny.wasm"; fileExists(tiny) {
12872			return wasmPick{File: tiny, ExecPath: f.WasmExecPathTinyGo, GoGlobal: goGlobalTiny}, true
12873		}
12874	}
12875	return pickWasm(name)
12876}
12877
12878func fileExists(path string) bool {
12879	_, err := os.Stat(path)
12880	return err == nil
12881}
12882
12883// pageBinary picks the binary a full-page app loads, taking the first of
12884// names the deployment has actually built, and the tinygo build of it
12885// where there is one.
12886//
12887// The names are given joint-first. /tui and /desk were two binaries of
12888// about fifty megabytes each that were all but the same fifty β€” the desk
12889// links pkg/storepane to put a store in a window, and the storefront page
12890// is pkg/storepane and nothing else β€” so wasm/term is the two together,
12891// eight kilobytes larger than the desk alone and serving both. A
12892// deployment that builds it gets it; one that still lists the separate
12893// drop-ins gets those, and nothing has to change at once.
12894//
12895// The shim travels with the pick because the two are not interchangeable:
12896// tinygo's wasm_exec.js throws a LinkError on a stdlib Go binary's gojs
12897// imports, and the stdlib one cannot start a tinygo binary.
12898func pageBinary(names ...string) (file, execPath string) {
12899	for _, n := range names {
12900		if f.UseTinygo && fileExists(n+"-tiny.wasm") {
12901			return n + "-tiny.wasm", f.WasmExecPathTinyGo
12902		}
12903		if fileExists(n + ".wasm") {
12904			return n + ".wasm", f.WasmExecPathGo
12905		}
12906	}
12907	// Nothing built yet. Name the app's own Go build, so a page served
12908	// during the first compile asks for the file that is about to exist
12909	// rather than for nothing at all.
12910	return names[len(names)-1] + ".wasm", f.WasmExecPathGo
12911}
12912
12913// wasmExec is one runtime shim a page must load.
12914type wasmExec struct {
12915	Path     string
12916	GoGlobal string
12917}
12918
12919// wasmExecsFor returns the shims a set of picks needs, deduplicated and in a
12920// stable order. A page with a tinygo binary and a Go binary on it gets both.
12921func wasmExecsFor(picks []wasmPick) []wasmExec {
12922	var out []wasmExec
12923	seen := map[string]bool{}
12924	for _, p := range picks {
12925		if p.ExecPath == "" || seen[p.GoGlobal] {
12926			continue
12927		}
12928		seen[p.GoGlobal] = true
12929		out = append(out, wasmExec{Path: p.ExecPath, GoGlobal: p.GoGlobal})
12930	}
12931	return out
12932}
12933
12934// WasmExecs is the shim list for this page's binaries, as a METHOD rather than
12935// a template function.
12936//
12937// It was a FuncMap entry, and that broke the whole site: templates are parsed
12938// in several places here and only some of them call .Funcs(funcs), so a page
12939// whose set was built bare β€” order.go's, for one β€” died at parse time with
12940// `function "wasmexecs" not defined` and served a 500. A method travels with
12941// the data instead of with the template set, so every set can call it and no
12942// new parse site has to remember anything.
12943func (h htmlTemplateData) WasmExecs() []wasmExec { return wasmExecsFor(h.WasmBinary) }
12944
12945// The in-tab server's filesystem is not the authority on which drop-ins a
12946// page may load.
12947//
12948// When the store runs inside the page (pkg/storepane's `serve`, behind the
12949// desk's netscrape window) its jsfs carries the templates and content the
12950// render code reads and nothing else β€” images, fonts and models already
12951// read through to the origin on a miss, and so do the wasm binaries. But
12952// the picking above asks the local disk, which in the tab holds none of
12953// them, so every page rendered there came out with no drop-in referenced
12954// at all: no cart, no decoration, and no attractor over the logo. The
12955// binaries were reachable the whole time; nothing ever named them.
12956//
12957// So in the tab the origin is asked instead, once per name.
12958
12959// originSrc is the origin an in-tab server's misses read through to, and
12960// what it has learned about which binaries that origin serves. base is
12961// empty in the host process, where the disk is the authority and none of
12962// this runs.
12963var originSrc struct {
12964	mu   sync.Mutex
12965	base string
12966	seen map[string]bool
12967}
12968
12969// serveFromOrigin marks this process the in-tab server. NewApp calls it
12970// with the same origin it gives the read-through proxy.
12971func serveFromOrigin(base string) {
12972	originSrc.mu.Lock()
12973	defer originSrc.mu.Unlock()
12974	originSrc.base, originSrc.seen = base, map[string]bool{}
12975}
12976
12977// servingInTab reports whether this is the in-page server.
12978func servingInTab() bool {
12979	originSrc.mu.Lock()
12980	defer originSrc.mu.Unlock()
12981	return originSrc.base != ""
12982}
12983
12984// originHas reports whether the origin serves a file, asking it at most
12985// once per name and remembering the answer. A binary the origin does not
12986// have goes unreferenced, which is the answer a missing file gives on a
12987// host.
12988func originHas(file string) bool {
12989	originSrc.mu.Lock()
12990	base, seen := originSrc.base, originSrc.seen
12991	if have, ok := seen[file]; ok {
12992		originSrc.mu.Unlock()
12993		return have
12994	}
12995	originSrc.mu.Unlock()
12996
12997	have := false
12998	if req, err := http.NewRequest(http.MethodHead, base+"/"+file, nil); err == nil {
12999		if resp, derr := http.DefaultClient.Do(req); derr == nil {
13000			_ = resp.Body.Close() //nolint:errcheck // nothing was read from it
13001			have = resp.StatusCode == http.StatusOK
13002		}
13003	}
13004
13005	originSrc.mu.Lock()
13006	seen[file] = have
13007	originSrc.mu.Unlock()
13008	return have
13009}
13010
13011// pickFromOrigin chooses a drop-in the way the tab must: smallest build
13012// the origin actually serves.
13013//
13014// It does not consult UseTinygo. That flag says whether THIS process
13015// should run tinygo over the sources, and the tab has no toolchain and
13016// built nothing; what matters is which builds exist at the other end. The
13017// tab also pays for every byte twice β€” once over the network into the
13018// page, once through the proxy into the frame β€” so the small build is
13019// wanted wherever there is one.
13020// pickCarried chooses a drop-in the binary itself carries. It runs before the
13021// origin is asked, so a tab with a staged build answers out of itself.
13022func pickCarried(name string) (wasmPick, bool) {
13023	if tiny := name + "-tiny.wasm"; carriesDropin(tiny) {
13024		return wasmPick{File: tiny, ExecPath: f.WasmExecPathTinyGo, GoGlobal: goGlobalTiny}, true
13025	}
13026	if std := name + ".wasm"; carriesDropin(std) {
13027		return wasmPick{File: std, ExecPath: f.WasmExecPathGo, GoGlobal: goGlobalStd}, true
13028	}
13029	return wasmPick{}, false
13030}
13031
13032// carriesDropin reports whether the build staged this file into the binary.
13033func carriesDropin(file string) bool {
13034	_, ok := lookupDropin(file)
13035	return ok
13036}
13037
13038func pickFromOrigin(name string) (wasmPick, bool) {
13039	if tiny := name + "-tiny.wasm"; originHas(tiny) {
13040		return wasmPick{File: tiny, ExecPath: f.WasmExecPathTinyGo, GoGlobal: goGlobalTiny}, true
13041	}
13042	if std := name + ".wasm"; originHas(std) {
13043		return wasmPick{File: std, ExecPath: f.WasmExecPathGo, GoGlobal: goGlobalStd}, true
13044	}
13045	return wasmPick{}, false
13046}
13047
13048
13049// ===== pkg/web/wasmpick_test.go =====
13050package web
13051
13052import (
13053	"bytes"
13054	htmpl "html/template"
13055	"net/http"
13056	"net/http/httptest"
13057	"os"
13058	"path/filepath"
13059	"strings"
13060	"testing"
13061	"time"
13062)
13063
13064// touch writes a file with an explicit mtime, so "newest wins" can be tested
13065// without sleeping between writes.
13066func touch(t *testing.T, path string, age time.Duration) {
13067	t.Helper()
13068	if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
13069		t.Fatal(err)
13070	}
13071	when := time.Now().Add(-age)
13072	if err := os.Chtimes(path, when, when); err != nil {
13073		t.Fatal(err)
13074	}
13075}
13076
13077func TestNewerFile(t *testing.T) {
13078	dir := t.TempDir()
13079	a := filepath.Join(dir, "a.wasm")
13080	b := filepath.Join(dir, "b.wasm")
13081
13082	if _, ok := newerFile(a, b); ok {
13083		t.Error("neither file exists, but newerFile reported a winner")
13084	}
13085
13086	touch(t, a, time.Hour)
13087	if got, ok := newerFile(a, b); !ok || got != a {
13088		t.Errorf("only a exists: got %q ok=%v, want %q true", got, ok, a)
13089	}
13090	if got, ok := newerFile(b, a); !ok || got != a {
13091		t.Errorf("only a exists (as second arg): got %q ok=%v, want %q true", got, ok, a)
13092	}
13093
13094	// b newer than a: b must win from either argument position, or the
13095	// selection would depend on the order the caller happened to pass them.
13096	touch(t, b, time.Minute)
13097	if got, _ := newerFile(a, b); got != b {
13098		t.Errorf("b is newer: got %q, want %q", got, b)
13099	}
13100	if got, _ := newerFile(b, a); got != b {
13101		t.Errorf("b is newer (arg order swapped): got %q, want %q", got, b)
13102	}
13103}
13104
13105// A tie must not flap between the two on successive page loads.
13106func TestNewerFileTieIsStable(t *testing.T) {
13107	dir := t.TempDir()
13108	a := filepath.Join(dir, "a.wasm")
13109	b := filepath.Join(dir, "b.wasm")
13110	touch(t, a, time.Hour)
13111	touch(t, b, time.Hour)
13112	first, _ := newerFile(a, b)
13113	for i := 0; i < 5; i++ {
13114		if got, _ := newerFile(a, b); got != first {
13115			t.Fatalf("tie is not stable: %q then %q", first, got)
13116		}
13117	}
13118}
13119
13120// The shim must match the build, and a page carrying both toolchains must get
13121// both shims under distinct globals β€” the whole point of the aliasing.
13122func TestWasmExecsForDedupesAndKeepsBoth(t *testing.T) {
13123	picks := []wasmPick{
13124		{File: "stl2-tiny.wasm", ExecPath: "/tiny/wasm_exec.js", GoGlobal: goGlobalTiny},
13125		{File: "deco-tiny.wasm", ExecPath: "/tiny/wasm_exec.js", GoGlobal: goGlobalTiny},
13126		{File: "cart.wasm", ExecPath: "/go/wasm_exec.js", GoGlobal: goGlobalStd},
13127	}
13128	got := wasmExecsFor(picks)
13129	if len(got) != 2 {
13130		t.Fatalf("got %d shims, want 2 (one per toolchain): %+v", len(got), got)
13131	}
13132	if got[0].GoGlobal != goGlobalTiny || got[1].GoGlobal != goGlobalStd {
13133		t.Errorf("shims out of order or wrong globals: %+v", got)
13134	}
13135	if goGlobalTiny == goGlobalStd {
13136		t.Error("the two globals must differ, or the second shim overwrites the first")
13137	}
13138}
13139
13140func TestWasmExecsForSkipsEmptyPath(t *testing.T) {
13141	if got := wasmExecsFor([]wasmPick{{File: "x.wasm"}}); len(got) != 0 {
13142		t.Errorf("a pick with no shim path yielded %+v, want none", got)
13143	}
13144}
13145
13146// The wasm partial must render from a template set built WITHOUT .Funcs().
13147//
13148// This is the bug that took the site down: the shim list was a FuncMap entry,
13149// but templates are parsed in several places here and only some call
13150// .Funcs(funcs). A page whose set was built bare died at parse time with
13151// `function "wasmexecs" not defined` and served a 500 for the whole front page.
13152// A method on the data has no such dependency, and this test is the proof.
13153func TestWasmPartialParsesWithoutFuncMap(t *testing.T) {
13154	src, err := os.ReadFile("../../htmpl/wasm.html")
13155	if err != nil {
13156		t.Skip("template not present:", err)
13157	}
13158	tmpl := htmpl.New("index")
13159	if _, err := tmpl.New("wasm").Parse(string(src)); err != nil {
13160		t.Fatalf("wasm.html needs a FuncMap it may not get: %v", err)
13161	}
13162	data := struct{ Page htmlTemplateData }{Page: htmlTemplateData{
13163		WasmBinary: []wasmPick{
13164			{File: "stl2-tiny.wasm", ExecPath: "/tiny/wasm_exec.js", GoGlobal: goGlobalTiny},
13165			{File: "cart.wasm", ExecPath: "/go/wasm_exec.js", GoGlobal: goGlobalStd},
13166		},
13167	}}
13168	var buf bytes.Buffer
13169	if err := tmpl.ExecuteTemplate(&buf, "wasm", data); err != nil {
13170		t.Fatalf("executing the wasm partial: %v", err)
13171	}
13172	out := buf.String()
13173	// Both shims, both stashed under distinct globals, and each binary using
13174	// the one that matches it.
13175	for _, want := range []string{
13176		"/tiny/wasm_exec.js", "/go/wasm_exec.js",
13177		`window["` + goGlobalTiny + `"] = Go`, `window["` + goGlobalStd + `"] = Go`,
13178		"stl2-tiny.wasm", "cart.wasm",
13179	} {
13180		if !strings.Contains(out, want) {
13181			t.Errorf("rendered wasm partial is missing %q", want)
13182		}
13183	}
13184}
13185
13186// A page with no wasm must render nothing at all β€” not an empty <script> that
13187// would still try to construct a Go runtime.
13188func TestWasmPartialEmptyWhenNoBinaries(t *testing.T) {
13189	src, err := os.ReadFile("../../htmpl/wasm.html")
13190	if err != nil {
13191		t.Skip("template not present:", err)
13192	}
13193	tmpl := htmpl.New("index")
13194	if _, err := tmpl.New("wasm").Parse(string(src)); err != nil {
13195		t.Fatal(err)
13196	}
13197	var buf bytes.Buffer
13198	data := struct{ Page htmlTemplateData }{}
13199	if err := tmpl.ExecuteTemplate(&buf, "wasm", data); err != nil {
13200		t.Fatal(err)
13201	}
13202	if strings.Contains(buf.String(), "<script") {
13203		t.Errorf("a page with no wasm still emitted a script tag:\n%s", buf.String())
13204	}
13205}
13206
13207// The cart rides on every catalog page, so it takes the tinygo build
13208// where there is one β€” 1MB against 4.5MB, fetched whether or not the
13209// reader ever opens the cart. pickWasm's newest-wins rule is right for a
13210// binary that IS the page and wrong for one that merely travels with it.
13211func TestPickWasmSmallPrefersTheTinyBuild(t *testing.T) {
13212	t.Chdir(t.TempDir())
13213	saved := *f
13214	t.Cleanup(func() { *f = saved })
13215	f.UseTinygo = true
13216	f.WasmExecPathGo = "/go.js"
13217	f.WasmExecPathTinyGo = "/tiny.js"
13218
13219	write := func(name string) {
13220		if err := os.WriteFile(name, []byte("x"), 0o600); err != nil {
13221			t.Fatal(err)
13222		}
13223	}
13224	// The Go build written LAST, so newest-wins would take it.
13225	write("cart-tiny.wasm")
13226	time.Sleep(10 * time.Millisecond)
13227	write("cart.wasm")
13228
13229	if got, ok := pickWasm("cart"); !ok || got.File != "cart.wasm" {
13230		t.Fatalf("pickWasm took %+v, want the newer cart.wasm β€” the premise of this test", got)
13231	}
13232	got, ok := pickWasmSmall("cart")
13233	if !ok {
13234		t.Fatal("pickWasmSmall found nothing")
13235	}
13236	if got.File != "cart-tiny.wasm" {
13237		t.Errorf("pickWasmSmall took %q, want cart-tiny.wasm", got.File)
13238	}
13239	if got.ExecPath != f.WasmExecPathTinyGo || got.GoGlobal != goGlobalTiny {
13240		t.Errorf("tiny build paired with %+v, want tinygo's shim", got)
13241	}
13242}
13243
13244// With no tinygo build β€” or no tinygo at all β€” the Go build is served
13245// rather than nothing.
13246func TestPickWasmSmallFallsBackToTheGoBuild(t *testing.T) {
13247	t.Chdir(t.TempDir())
13248	saved := *f
13249	t.Cleanup(func() { *f = saved })
13250	f.UseTinygo = true
13251	f.WasmExecPathGo = "/go.js"
13252	if err := os.WriteFile("cart.wasm", []byte("x"), 0o600); err != nil {
13253		t.Fatal(err)
13254	}
13255	got, ok := pickWasmSmall("cart")
13256	if !ok || got.File != "cart.wasm" || got.GoGlobal != goGlobalStd {
13257		t.Errorf("with no tinygo build got %+v, want the Go one", got)
13258	}
13259	f.UseTinygo = false
13260	if got, ok := pickWasmSmall("cart"); !ok || got.File != "cart.wasm" {
13261		t.Errorf("with tinygo off got %+v, want the Go one", got)
13262	}
13263}
13264
13265// The joint build wins when the deployment has one, and the page's own
13266// is the fallback β€” so a site that has not rebuilt yet keeps working.
13267func TestPageBinaryPrefersTheJointBuild(t *testing.T) {
13268	t.Chdir(t.TempDir())
13269	saved := *f
13270	t.Cleanup(func() { *f = saved })
13271	f.UseTinygo = false
13272	f.WasmExecPathGo = "/go.js"
13273	f.WasmExecPathTinyGo = "/tiny.js"
13274	write := func(name string) {
13275		if err := os.WriteFile(name, []byte("x"), 0o600); err != nil {
13276			t.Fatal(err)
13277		}
13278	}
13279
13280	// Only the separate builds so far: each page gets its own.
13281	write("tui.wasm")
13282	write("desk.wasm")
13283	if got, _ := pageBinary("term", "tui"); got != "tui.wasm" {
13284		t.Errorf("with no joint build /tui got %q, want tui.wasm", got)
13285	}
13286	if got, _ := pageBinary("term", "desk"); got != "desk.wasm" {
13287		t.Errorf("with no joint build /desk got %q, want desk.wasm", got)
13288	}
13289
13290	// The joint build arrives and both pages move to it.
13291	write("term.wasm")
13292	for _, page := range []string{"tui", "desk"} {
13293		got, exec := pageBinary("term", page)
13294		if got != "term.wasm" {
13295			t.Errorf("/%s got %q, want term.wasm", page, got)
13296		}
13297		if exec != f.WasmExecPathGo {
13298			t.Errorf("/%s paired term.wasm with %q, want the Go shim", page, exec)
13299		}
13300	}
13301}
13302
13303// The tinygo build of a name beats the Go build of that same name, and a
13304// name with neither is skipped rather than named.
13305func TestPageBinaryTinygoAndSkipping(t *testing.T) {
13306	t.Chdir(t.TempDir())
13307	saved := *f
13308	t.Cleanup(func() { *f = saved })
13309	f.UseTinygo = true
13310	f.WasmExecPathGo = "/go.js"
13311	f.WasmExecPathTinyGo = "/tiny.js"
13312
13313	if err := os.WriteFile("term-tiny.wasm", []byte("x"), 0o600); err != nil {
13314		t.Fatal(err)
13315	}
13316	got, exec := pageBinary("term", "tui")
13317	if got != "term-tiny.wasm" || exec != f.WasmExecPathTinyGo {
13318		t.Errorf("got %q with %q, want the tinygo joint build and its shim", got, exec)
13319	}
13320
13321	// Nothing built at all: name the page's own Go build rather than "",
13322	// so a page served during the first compile asks for the file that is
13323	// about to exist.
13324	t.Chdir(t.TempDir())
13325	if got, _ := pageBinary("term", "desk"); got != "desk.wasm" {
13326		t.Errorf("with nothing built got %q, want desk.wasm", got)
13327	}
13328}
13329
13330// The in-tab server has none of the binaries on its own filesystem. It must
13331// still name them: they read through to the origin like every other file it
13332// does not carry. Before this, every page it rendered referenced no drop-in
13333// at all β€” which is what left the attractor missing from the store inside
13334// the desk's browser window while the same page had it on the origin.
13335func TestPickFromOriginNamesWhatTheOriginServes(t *testing.T) {
13336	noDropins(t)         // this case is about the origin, not what the build carried
13337	t.Chdir(t.TempDir()) // deliberately empty: the tab's disk has no wasm
13338	saved := *f
13339	t.Cleanup(func() { *f = saved })
13340	f.UseTinygo = false // the tab has no toolchain and never built anything
13341	f.WasmExecPathGo = "/go.js"
13342	f.WasmExecPathTinyGo = "/tiny.js"
13343
13344	var asked []string
13345	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
13346		asked = append(asked, r.Method+" "+r.URL.Path)
13347		switch r.URL.Path {
13348		case "/stl2-tiny.wasm", "/cart.wasm":
13349			w.WriteHeader(http.StatusOK)
13350		default:
13351			w.WriteHeader(http.StatusNotFound)
13352		}
13353	}))
13354	t.Cleanup(srv.Close)
13355
13356	if servingInTab() {
13357		t.Fatal("a fresh process must not think it is in a tab")
13358	}
13359	serveFromOrigin(srv.URL)
13360	t.Cleanup(func() { serveFromOrigin("") })
13361	if !servingInTab() {
13362		t.Fatal("serveFromOrigin did not mark the in-tab role")
13363	}
13364
13365	// A small build at the origin wins even though UseTinygo is off here:
13366	// the flag describes this process's toolchain, not the other end.
13367	got, ok := pickWasmSmall("stl2")
13368	if !ok || got.File != "stl2-tiny.wasm" || got.ExecPath != "/tiny.js" || got.GoGlobal != goGlobalTiny {
13369		t.Errorf("stl2 = %+v, %v; want stl2-tiny.wasm on the tinygo shim", got, ok)
13370	}
13371	// Only a Go build at the origin: take it, with the matching shim.
13372	got, ok = pickWasmSmall("cart")
13373	if !ok || got.File != "cart.wasm" || got.ExecPath != "/go.js" || got.GoGlobal != goGlobalStd {
13374		t.Errorf("cart = %+v, %v; want cart.wasm on the Go shim", got, ok)
13375	}
13376	// Nothing at the origin: name nothing, as a missing file does on a host.
13377	if got, ok = pickWasm("deco"); ok {
13378		t.Errorf("deco = %+v, %v; want no pick", got, ok)
13379	}
13380
13381	// Each name is asked about once and the answer kept.
13382	before := len(asked)
13383	for range 3 {
13384		pickWasmSmall("stl2")
13385		pickWasm("deco")
13386	}
13387	if len(asked) != before {
13388		t.Errorf("origin re-probed: %v", asked[before:])
13389	}
13390	for _, a := range asked {
13391		if !strings.HasPrefix(a, http.MethodHead+" ") {
13392			t.Errorf("probe %q should be a HEAD: a drop-in is up to 22MB", a)
13393		}
13394	}
13395}
13396
13397// wasmPicks gates on WasmSRC, which names the sources this process builds.
13398// In the tab that list is empty β€” it builds nothing β€” and the gate dropped
13399// every binary before the pick was reached, which is why naming them from
13400// the origin alone was not enough.
13401func TestWasmPicksIgnoresWasmSRCInTheTab(t *testing.T) {
13402	noDropins(t)
13403	t.Chdir(t.TempDir())
13404	saved := *f
13405	t.Cleanup(func() { *f = saved })
13406	f.WasmSRC = nil // as in the tab: nothing configured to build
13407	f.WasmExecPathTinyGo = "/tiny.js"
13408
13409	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
13410		if r.URL.Path == "/cart-tiny.wasm" {
13411			w.WriteHeader(http.StatusOK)
13412			return
13413		}
13414		w.WriteHeader(http.StatusNotFound)
13415	}))
13416	t.Cleanup(srv.Close)
13417
13418	// On a host with nothing configured, nothing is named.
13419	if got := wasmSmallFor("cart"); len(got) != 0 {
13420		t.Errorf("host with empty WasmSRC named %+v, want nothing", got)
13421	}
13422
13423	serveFromOrigin(srv.URL)
13424	t.Cleanup(func() { serveFromOrigin("") })
13425	got := wasmSmallFor("cart", "stl2")
13426	if len(got) != 1 || got[0].File != "cart-tiny.wasm" {
13427		t.Errorf("in-tab picks = %+v, want just cart-tiny.wasm", got)
13428	}
13429}
13430
13431// noDropins makes the test behave as a binary built from a tree that staged
13432// nothing, whatever this one was built from.
13433func noDropins(t *testing.T) {
13434	t.Helper()
13435	saved := lookupDropin
13436	lookupDropin = func(string) ([]byte, bool) { return nil, false }
13437	t.Cleanup(func() { lookupDropin = saved })
13438}
13439
13440// A carried drop-in is answered from the binary; the origin is only asked for
13441// what the build did not stage. The tab pays a round trip per name otherwise,
13442// and pays it on the first render of every page.
13443func TestCarriedDropinBeatsTheOrigin(t *testing.T) {
13444	t.Chdir(t.TempDir())
13445	saved := *f
13446	t.Cleanup(func() { *f = saved })
13447	f.WasmSRC, f.UseTinygo = nil, false
13448	f.WasmExecPathGo, f.WasmExecPathTinyGo = "/go.js", "/tiny.js"
13449
13450	var asked []string
13451	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
13452		asked = append(asked, r.URL.Path)
13453		w.WriteHeader(http.StatusOK) // the origin has everything
13454	}))
13455	t.Cleanup(srv.Close)
13456
13457	carried := map[string][]byte{"stl2-tiny.wasm": []byte("staged")}
13458	savedLookup := lookupDropin
13459	lookupDropin = func(file string) ([]byte, bool) { b, ok := carried[file]; return b, ok }
13460	t.Cleanup(func() { lookupDropin = savedLookup })
13461
13462	serveFromOrigin(srv.URL)
13463	t.Cleanup(func() { serveFromOrigin("") })
13464
13465	if got, ok := pickWasmSmall("stl2"); !ok || got.File != "stl2-tiny.wasm" {
13466		t.Errorf("stl2 = %+v %v, want the carried stl2-tiny.wasm", got, ok)
13467	}
13468	if len(asked) != 0 {
13469		t.Errorf("origin asked about a carried drop-in: %v", asked)
13470	}
13471	// Not carried: the origin answers, as before.
13472	if got, ok := pickWasmSmall("cart"); !ok || got.File != "cart-tiny.wasm" {
13473		t.Errorf("cart = %+v %v, want cart-tiny.wasm from the origin", got, ok)
13474	}
13475	if len(asked) == 0 {
13476		t.Error("origin should have been asked about the uncarried drop-in")
13477	}
13478}
13479
13480
13481// ===== cmd/m2/commands/gen.go =====
13482// Package commands cmd/m2/commands/gen.go β€” the `m2 gen` config template.
13483package commands
13484
13485import (
13486	"fmt"
13487
13488	"github.com/spf13/cobra"
13489)
13490
13491var genCmd = &cobra.Command{
13492	Use:   "gen",
13493	Short: "generate conf template",
13494	Long:  "generate conf template",
13495	// To the command's own writer, not os.Stdout. They are the same
13496	// thing at a shell prompt and not in a page, where the command runs
13497	// inside a wasm instance whose stdout goes to the JS console β€” so
13498	// `m2 gen > site.conf` wrote an empty file and `m2 gen | head`
13499	// printed nothing at all.
13500	RunE: func(cmd *cobra.Command, _ []string) error {
13501		_, err := fmt.Fprint(cmd.OutOrStdout(), envfiletemplate)
13502		return err
13503	},
13504}
13505
13506const envfiletemplate = `#########################################################################
13507#	M2 CONFIG
13508#
13509# Copy to <yoursite>.conf and run with:  MENV=<yoursite>.conf m2 run
13510# This file is sourced by bash; use shell syntax.
13511# Comment a value with # to use the built-in default.
13512#########################################################################
13513
13514### Stripe Configuration ################################################
13515
13516#-- Live and test API keys - REQUIRED for checkout
13517# https://dashboard.stripe.com/apikeys
13518STRIPELIVEPK='pk_live_...'
13519STRIPELIVESK='sk_live_...'
13520STRIPETESTPK='pk_test_...'
13521STRIPETESTSK='sk_test_...'
13522
13523#-- Use the test keys instead of the live keys
13524TESTSTRIPEKEY=true
13525
13526### Product Data ########################################################
13527
13528#-- Products CSV path (see products.example.csv for the schema)
13529PRODUCTSCSV='products.csv'
13530
13531### Site Identity #######################################################
13532
13533#-- Image subdomain, no trailing slash (ex. 'https://img.example.com')
13534# empty = serve images from ./img
13535SITEIMAGESRC=''
13536
13537#-- Orders subdomain, no trailing slash (ex. 'https://pay.example.com')
13538SITEORDERSURL=''
13539
13540#-- Website (Host) Name - domain minus extension (ex. 'example')
13541SITENAME='example'
13542
13543#-- Website Domain Extension (ex. '.com' '.net')
13544SITEEXT='.com'
13545
13546#-- Site Long Name (ex. 'example electronic surplus')
13547SITELONGNAME='example web store'
13548
13549#-- Site Tag Line
13550SITETAGLINE='an example web store'
13551
13552#-- Site Meta Description (SEO)
13553SITEMETA='an example web store selling example things'
13554
13555#-- Telegram contact + channel; username only, no 'https://t.me/'
13556TGCONTACT=''
13557TGCHANNEL=''
13558
13559### Web Server ##########################################################
13560
13561#-- Port to serve http on
13562WEBPORT='9883'
13563
13564
13565### WebAssembly #########################################################
13566
13567#-- Compile wasm with tinygo (smaller output) in addition to go
13568USETINYGO=true
13569
13570#-- wasm source directories, relative paths
13571# 'wasm/cart' powers checkout β€” keep it. 'wasm/term' is the terminal
13572# storefront at /tui and the desktop at /desk in one binary: the desk
13573# links the storefront anyway, so the two separately were fifty
13574# megabytes of very nearly the same fifty. 'wasm/tui' and 'wasm/desk'
13575# still build one page each if you would rather have them apart.
13576# Additional entries are drop-in wasm apps: any dir under wasm/ with a
13577# main package (a self-contained module with its own vendor/ also
13578# works). Empty () disables all wasm.
13579WASMSRC=('wasm/cart' 'wasm/term')
13580
13581### Receipt Printing (CUPS) #############################################
13582
13583#-- CUPS printer name (default: system default)
13584PRINTERNAME=''
13585
13586#-- CUPS options, comma separated (ex. 'media=Custom.80x200mm,fit-to-page')
13587CUPSOPTIONS=''
13588
13589#-- timeout for lp command
13590LPTIMEOUT='10s'
13591
13592### Terminal UI ##########################################################
13593
13594#-- m2 tui client mode: browse this store over http instead of the
13595# local products csv / img / content files (ex. 'https://example.com')
13596STOREURL=''
13597`
13598
13599
13600// ===== cmd/m2/commands/root.go =====
13601// Package commands cmd/m2/commands/root.go β€” the m2 CLI.
13602package commands
13603
13604import (
13605	"embed"
13606	"log"
13607
13608	"github.com/spf13/cobra"
13609	"github.com/stripe/stripe-go/v81"
13610
13611	"github.com/0magnet/calvin/clihelp"
13612
13613	"github.com/0magnet/m2/pkg/config"
13614	"github.com/0magnet/m2/pkg/web"
13615)
13616
13617//go:embed *.go
13618var source embed.FS
13619
13620// f aliases the shared configuration, as in pkg/web.
13621var f = &config.F
13622
13623func init() {
13624	stripe.EnableTelemetry = false
13625	RootCmd.CompletionOptions.DisableDefaultCmd = true
13626	RootCmd.AddCommand(
13627		runCmd,
13628		genCmd,
13629		wasmCmd,
13630		tuiCmd,
13631	)
13632	RootCmd.SetHelpCommand(&cobra.Command{Hidden: true})
13633	// The banner keeps the domain, which is what this serves and what the
13634	// binary is short for.
13635	clihelp.Init(RootCmd, "magnetosphere.net", true)
13636
13637	web.RegisterSource("cmd/m2/commands", source)
13638}
13639
13640func init() {
13641	runCmd.Flags().SortFlags = false
13642	config.AddStringFlag([]*cobra.Command{runCmd}, &f.ProductsCSV, "products csv file")
13643	config.AddBoolFlag([]*cobra.Command{runCmd, wasmCmd}, &f.Teststripekey, "use stripe test api keys instead of live key")
13644	config.AddStringFlag([]*cobra.Command{runCmd, wasmCmd}, &f.StripeliveSK, "stripe live api sk")
13645	config.AddStringFlag([]*cobra.Command{runCmd, wasmCmd}, &f.StripelivePK, "stripe live api pk")
13646	config.AddStringFlag([]*cobra.Command{runCmd, wasmCmd}, &f.StripetestSK, "stripe test api sk")
13647	config.AddStringFlag([]*cobra.Command{runCmd, wasmCmd}, &f.StripetestPK, "stripe test api pk")
13648	config.AddIntFlag([]*cobra.Command{runCmd}, &f.WebPort, "port to serve on")
13649	config.AddStringFlag([]*cobra.Command{runCmd}, &f.Siteimagesrc, "domain for images - leave blank to serve images")
13650	config.AddStringFlag([]*cobra.Command{runCmd}, &f.Siteordersurl, "domain for orders - leave blank for same domain")
13651	config.AddStringFlag([]*cobra.Command{runCmd}, &f.Sitename, "site name")
13652	config.AddStringFlag([]*cobra.Command{runCmd}, &f.Siteext, "site domain extension")
13653	config.AddStringFlag([]*cobra.Command{runCmd}, &f.Sitelongname, "site long name")
13654	config.AddStringFlag([]*cobra.Command{runCmd}, &f.Sitetagline, "site tag line")
13655	config.AddStringFlag([]*cobra.Command{runCmd}, &f.Sitemeta, "site meta")
13656	config.AddStringFlag([]*cobra.Command{runCmd}, &f.Tgcontact, "telegram contact")
13657	config.AddStringFlag([]*cobra.Command{runCmd}, &f.Tgchannel, "telegram channel")
13658	config.AddBoolFlag([]*cobra.Command{runCmd}, &f.UseTinygo, "use tinygo instead of go to compile wasm")
13659	config.AddStringSliceFlag([]*cobra.Command{runCmd, wasmCmd}, &f.WasmSRC, "wasm source code files RELATIVE PATHS without '..'")
13660	config.AddStringFlag([]*cobra.Command{runCmd}, &f.PrinterName, "CUPS printer name (default: system default)")
13661	config.AddStringFlag([]*cobra.Command{runCmd}, &f.CupsOptions, "e.g. 'media=Custom.80x200mm,fit-to-page'")
13662	config.AddStringFlag([]*cobra.Command{runCmd}, &f.BrowseSuffix, "browse-origin domain for the desk browser - a DIFFERENT registrable domain from this site")
13663	config.AddStringFlag([]*cobra.Command{runCmd}, &f.BrowseAddr, "listen address for the browse-origin bootstrap")
13664	config.AddStringFlag([]*cobra.Command{runCmd}, &f.SkyXpub, "account-level skycoin xpub for crypto checkout - NOT the reward account")
13665	config.AddStringFlag([]*cobra.Command{runCmd}, &f.SkyNodeURL, "skycoin node API root for settlement")
13666	config.AddStringFlag([]*cobra.Command{runCmd}, &f.SkyRatePair, "exchange pair to price skycoin from, e.g. skycoin_usdt")
13667	config.AddStringFlag([]*cobra.Command{runCmd}, &f.SkyRateFix, "a fixed USD rate per SKY, instead of an exchange")
13668	config.AddStringFlag([]*cobra.Command{runCmd}, &f.RateMin, "refuse a crypto quote below this USD rate")
13669	config.AddStringFlag([]*cobra.Command{runCmd}, &f.RateMax, "refuse a crypto quote above this USD rate")
13670	config.AddIntFlag([]*cobra.Command{runCmd}, &f.InvoiceMins, "minutes a crypto quote is held before expiring")
13671	config.AddDurationFlag([]*cobra.Command{runCmd}, &f.LpTimeout, "timeout for lp command")
13672	config.AddStringFlag([]*cobra.Command{tuiCmd}, &f.Storeurl, "browse this store over http (ex. 'https://magnetosphere.net') instead of local files")
13673}
13674
13675// RootCmd is the top-level m2 command.
13676var RootCmd = &cobra.Command{
13677	Use:   "m2",
13678	Short: "web store server",
13679	Long:  "web store server",
13680}
13681
13682// Execute executes the root cli command
13683func Execute() {
13684	if err := RootCmd.Execute(); err != nil {
13685		log.Fatal("Failed to execute command: ", err)
13686	}
13687}
13688
13689
13690// ===== cmd/m2/commands/run.go =====
13691// Package commands cmd/m2/commands/run.go β€” the `m2 run` web server command.
13692package commands
13693
13694import (
13695	"fmt"
13696	"log"
13697	"os"
13698	"strings"
13699
13700	"github.com/spf13/cobra"
13701	"golang.org/x/text/cases"
13702	"golang.org/x/text/language"
13703
13704	"github.com/0magnet/calvin"
13705	"github.com/0magnet/calvin/clihelp"
13706
13707	"github.com/0magnet/m2/pkg/config"
13708	"github.com/0magnet/m2/pkg/web"
13709)
13710
13711var runCmd = &cobra.Command{
13712	Use:   "run",
13713	Short: "run the web application",
13714	// The subcommand carries the same banner as the root, so `m2 run --help`
13715	// is not a plainer screen than `m2 --help`.
13716	Long: clihelp.Banner("magnetosphere.net") + "\n\n" + func() string {
13717		helptext := `Run the web application
13718Generate a config file first
13719
13720Config defaults file may also be specified with:
13721MENV=m2.conf m2 run
13722OR
13723MENV=/path/to/m2.conf m2 run
13724print the MENV file template with:
13725m2 gen`
13726		if config.MENV == "" {
13727			return helptext
13728		}
13729		if _, err := os.Stat(config.MENV); err == nil {
13730			return `Run the web application
13731
13732menv file detected: ` + config.MENV
13733		}
13734		return helptext
13735	}(),
13736	RunE: func(cmd *cobra.Command, _ []string) error {
13737		f.Sitedomain = f.Sitename + f.Siteext
13738		log.Println(" Initializing " + f.Sitedomain)
13739		out := cmd.OutOrStdout()
13740		// A banner that will not print is not a reason to refuse to serve.
13741		fmt.Fprintln(out, calvin.BlackboardBold(f.Sitedomain)) //nolint:errcheck
13742		fmt.Fprintln(out, calvin.AsciiFont(f.Sitedomain))      //nolint:errcheck
13743		config.InitStripe()
13744		f.Siteprettyname = calvin.BlackboardBold(f.Sitedomain) //"π•„π•’π•˜π•Ÿπ•–π•₯𝕠𝕀𝕑𝕙𝕖𝕣𝕖.π•Ÿπ•–π•₯"
13745		c := cases.Title(language.English)
13746		f.Siteprettynamecap = calvin.BlackboardBold(c.String(f.Sitedomain))         //"π•„π•’π•˜π•Ÿπ•–π•₯𝕠𝕀𝕑𝕙𝕖𝕣𝕖.π•Ÿπ•–π•₯"
13747		f.Siteprettynamecaps = calvin.BlackboardBold(strings.ToUpper(f.Sitedomain)) //"π•„π”Έπ”Ύβ„•π”Όπ•‹π•†π•Šβ„™β„π”Όβ„π”Ό.ℕ𝔼𝕋"
13748		f.SiteASCIILogo = strings.Replace(strings.Replace(calvin.AsciiFont(f.Sitedomain), " ", "&nbsp;", -1), "\n", "<br>\n", -1)
13749
13750		if f.UseTinygo {
13751			f.WasmExecPath = f.WasmExecPathTinyGo
13752			f.Buildwasmwith = f.Tinygobuild
13753		}
13754		if len(f.WasmSRC) == 0 {
13755			f.WasmExecPath = ""
13756			f.Buildwasmwith = ""
13757		}
13758		log.Println("Checking for products CSV")
13759		log.Println("Reading products CSV")
13760		if err := web.LoadCatalog(); err != nil {
13761			return fmt.Errorf("loading the catalog: %w", err)
13762		}
13763		go web.WatchCatalog()
13764		web.Serve()
13765		return nil
13766	},
13767}
13768
13769
13770// ===== cmd/m2/commands/tui.go =====
13771// Package commands cmd/m2/commands/tui.go β€” the `m2 tui` terminal storefront.
13772package commands
13773
13774import (
13775	"errors"
13776	"fmt"
13777
13778	"github.com/spf13/cobra"
13779
13780	"github.com/0magnet/m2/pkg/product"
13781	"github.com/0magnet/m2/pkg/tui"
13782)
13783
13784var tuiCmd = &cobra.Command{
13785	Use:   "tui",
13786	Short: "browse the store in the terminal",
13787	Long: `Browse the store in a terminal UI.
13788
13789Reads the same config as 'run':
13790MENV=m2.conf m2 tui
13791
13792As a client of a running store (catalog, images, and checkout over
13793http; no local files needed):
13794m2 tui --storeurl https://magnetosphere.net`,
13795	// RunE with returned errors rather than log.Fatal: this command also
13796	// runs inside a page, and log.Fatal is os.Exit β€” which there takes down
13797	// the whole wasm instance, the shell it was typed into and every other
13798	// window on the desk with it.
13799	RunE: func(cmd *cobra.Command, _ []string) error {
13800		var prods product.Products
13801		if f.Storeurl != "" {
13802			var err error
13803			prods, err = tui.FetchCatalog(f.Storeurl)
13804			if err != nil {
13805				return fmt.Errorf("could not fetch the catalog from %s: %w", f.Storeurl, err)
13806			}
13807		} else {
13808			if f.ProductsCSV == "" {
13809				f.ProductsCSV = "products.csv"
13810			}
13811			prods = product.ReadCSV(f.ProductsCSV)
13812		}
13813		if len(prods) == 0 {
13814			return errors.New("no products in the catalog")
13815		}
13816		// The context carries how to get a screen where tcell.NewScreen is
13817		// the wrong answer β€” a browser, where it has to be bound to the
13818		// terminal this was typed into. See tui.WithScreen.
13819		return tui.Run(cmd.Context(), prods)
13820	},
13821}
13822
13823
13824// ===== cmd/m2/commands/wasm.go =====
13825// Package commands cmd/m2/commands/wasm.go β€” the `m2 wasm` compile command.
13826package commands
13827
13828import (
13829	"errors"
13830
13831	"github.com/spf13/cobra"
13832
13833	"github.com/0magnet/m2/pkg/config"
13834	"github.com/0magnet/m2/pkg/web"
13835)
13836
13837var wasmCmd = &cobra.Command{
13838	Use:   "wasm",
13839	Short: "compile wasm",
13840	Long:  "compile wasm",
13841	RunE: func(_ *cobra.Command, _ []string) error {
13842		if len(f.WasmSRC) == 0 {
13843			return errors.New("no wasm source code specified")
13844		}
13845		config.InitStripe()
13846		web.CompileWASM()
13847		return nil
13848	},
13849}
13850
13851