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	// PprofAddr serves Go's profiles on a loopback listener of their own;
   79	// empty serves none.
   80	PprofAddr string
   81
   82	// Crypto checkout. Empty SkyXpub leaves the whole thing off and the
   83	// store card-only, which is what a deployment that has not configured
   84	// a wallet should get rather than a button that errors.
   85	SkyXpub     string // BIP44 ACCOUNT-level xpub, watch-only; must not be the reward account
   86	SkyNodeURL  string // skycoin node API root; empty means the local node
   87	SkyRatePair string // exchange pair for pricing, e.g. "skycoin_usdt"; never a bare ticker
   88	SkyRateFix  string // a rate the operator sets instead of asking an exchange
   89	RateMin     string // refuse a quote below this, as a decimal string
   90	RateMax     string // refuse a quote above this
   91	InvoiceMins int    // how long a quote is held before it expires
   92
   93	// Order confirmation email (pkg/web/ordermail.go). Empty MailFrom or
   94	// Sendmail sends none.
   95	MailFrom string // the From address, e.g. orders@magnetosphere.net
   96	MailBcc  string // the store's copy of every confirmation
   97	Sendmail string // the command each message is piped to, recipient appended, e.g. "/usr/local/bin/mox sendmail"
   98}
   99
  100// F is the live configuration, shared by every package.
  101var F = Values{
  102	//	WasmSRC: []string{"wasm/stl2.go","wasm/checkout_wasm.go"},
  103	WasmExecPath:       runtime.GOROOT() + "/lib/wasm/wasm_exec.js",                                     //nolint
  104	WasmExecPathGo:     runtime.GOROOT() + "/lib/wasm/wasm_exec.js",                                     //nolint
  105	WasmExecPathTinyGo: strings.TrimSuffix(runtime.GOROOT(), "go") + "tinygo" + "/targets/wasm_exec.js", //nolint
  106	Gobuild:            "go build",
  107	Tinygobuild:        "tinygo build -target=wasm --no-debug",
  108	Buildwasmwith:      "go build",
  109	LDFlagsX:           "stripePK",
  110}
  111
  112// InitStripe selects the live or test key pair, hands the secret key to the
  113// stripe library, and bakes the publishable key into the wasm ldflags.
  114func InitStripe() {
  115	F.StripeSK = F.StripeliveSK
  116	F.StripePK = F.StripelivePK
  117	if F.Teststripekey {
  118		F.StripeSK = F.StripetestSK
  119		F.StripePK = F.StripetestPK
  120	}
  121	stripe.Key = F.StripeSK
  122	// awkward way to do this
  123	F.LDFlagsX += "=" + F.StripePK
  124}
  125
  126var (
  127	// Hardcoded array of valid shorthand characters, excluding "h"
  128	shorthandChars = []rune("abcdefgijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
  129	nextShortIndex = 0 // Index for the next shorthand flag
  130)
  131
  132func getNextShortFlag() string {
  133	if nextShortIndex >= len(shorthandChars) {
  134		return ""
  135	}
  136	short := shorthandChars[nextShortIndex]
  137	nextShortIndex++
  138	return string(short)
  139}
  140
  141var a = true
  142var b = false
  143
  144// AddStringFlag registers a string flag bound to a field of F on each command,
  145// defaulting to the MENV file's value for the field's uppercase name.
  146func AddStringFlag(cmds []*cobra.Command, fieldPtr *string, description string) {
  147	for i := range cmds {
  148		cmds[i].Flags().StringVarP(fieldPtr, ccc(fieldPtr, &F, b), getNextShortFlag(), ScriptExecString(fmt.Sprintf("${%s%s}", ccc(fieldPtr, &F, a), func(s string) string {
  149			if s != "" {
  150				s = "-" + s
  151			}
  152			return s
  153		}(*fieldPtr))), fmt.Sprintf("%s env: %s\033[0m\n\r", description, ccc(fieldPtr, &F, a)))
  154	}
  155}
  156
  157// AddStringSliceFlag is AddStringFlag for []string fields (bash arrays).
  158func AddStringSliceFlag(cmds []*cobra.Command, fieldPtr *[]string, description string) {
  159	for i := range cmds {
  160		cmds[i].Flags().StringSliceVarP(
  161			fieldPtr,
  162			ccc(fieldPtr, &F, b),
  163			getNextShortFlag(),
  164			ScriptExecStringSlice(fmt.Sprintf("${%s[@]}", ccc(fieldPtr, &F, a))),
  165			fmt.Sprintf("%s env: %s\033[0m\n\r", description, ccc(fieldPtr, &F, a)),
  166		)
  167	}
  168}
  169
  170// AddBoolFlag is AddStringFlag for bool fields.
  171func AddBoolFlag(cmds []*cobra.Command, fieldPtr *bool, description string) {
  172	for i := range cmds {
  173		cmds[i].Flags().BoolVarP(fieldPtr, ccc(fieldPtr, &F, b), getNextShortFlag(), ScriptExecBool(fmt.Sprintf("${%s%s}", ccc(fieldPtr, &F, a), func(b bool) string {
  174			return "-" + strconv.FormatBool(b)
  175		}(*fieldPtr))), fmt.Sprintf("%s env: %s\033[0m\n\r", description, ccc(fieldPtr, &F, a)))
  176	}
  177}
  178
  179// AddIntFlag is AddStringFlag for int fields.
  180func AddIntFlag(cmds []*cobra.Command, fieldPtr *int, description string) {
  181	for i := range cmds {
  182		cmds[i].Flags().IntVarP(fieldPtr, ccc(fieldPtr, &F, b), getNextShortFlag(), ScriptExecInt(fmt.Sprintf("${%s%s}", ccc(fieldPtr, &F, a), func(i int) string {
  183			return fmt.Sprintf("-%d", i)
  184		}(*fieldPtr))), fmt.Sprintf("%s env: %s\033[0m\n\r", description, ccc(fieldPtr, &F, a)))
  185	}
  186}
  187
  188// AddDurationFlag is AddStringFlag for time.Duration fields.
  189func AddDurationFlag(cmds []*cobra.Command, fieldPtr *time.Duration, description string) {
  190	for i := range cmds {
  191		// Keep parity with the pattern of embedding a "-" when a non-zero default is present.
  192		def := ScriptExecDuration(fmt.Sprintf("${%s%s}",
  193			ccc(fieldPtr, &F, a),
  194			func(d time.Duration) string {
  195				if d != 0 {
  196					return "-" + d.String() // e.g. "-5s"
  197				}
  198				return ""
  199			}(*fieldPtr),
  200		))
  201		cmds[i].Flags().DurationVarP(
  202			fieldPtr,
  203			ccc(fieldPtr, &F, b),
  204			getNextShortFlag(),
  205			def,
  206			fmt.Sprintf("%s env: %s\033[0m\n\r", description, ccc(fieldPtr, &F, a)),
  207		)
  208	}
  209}
  210
  211// ccc finds the name of the struct field val points into, upper or lower case.
  212func ccc(val interface{}, strct interface{}, upper bool) string {
  213	v := reflect.ValueOf(strct)
  214	if v.Kind() == reflect.Ptr {
  215		v = v.Elem()
  216	}
  217	if v.Kind() != reflect.Struct {
  218		panic("uc: second argument must be a pointer to a struct")
  219	}
  220	for i := 0; i < v.NumField(); i++ {
  221		field := v.Field(i)
  222		if field.CanAddr() && field.Addr().Interface() == val {
  223			if upper {
  224				return strings.ToUpper(v.Type().Field(i).Name)
  225			}
  226			return strings.ToLower(v.Type().Field(i).Name)
  227		}
  228	}
  229	return ""
  230}
  231
  232// ScriptExecString evaluates a bash expression with the MENV file sourced.
  233func ScriptExecString(s string) string {
  234	z, err := script.Exec(fmt.Sprintf(`bash -c 'MENV=%s ; if [[ $MENV != "" ]] && [[ -f $MENV ]] ; then source $MENV ; fi ; printf "%s"'`, MENV, s)).String()
  235	if err == nil {
  236		return strings.TrimSpace(z)
  237	}
  238	return ""
  239}
  240
  241// ScriptExecStringSlice evaluates a bash array expression with the MENV file
  242// sourced. Empty entries are dropped: an empty or unset array still prints
  243// one empty line, which would otherwise come back as [""] and defeat every
  244// `len == 0` check downstream (e.g. WASMSRC=() disabling wasm).
  245func ScriptExecStringSlice(s string) []string {
  246	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()
  247	if err != nil {
  248		return nil
  249	}
  250	out := z[:0]
  251	for _, e := range z {
  252		if strings.TrimSpace(e) != "" {
  253			out = append(out, e)
  254		}
  255	}
  256	return out
  257}
  258
  259// ScriptExecBool evaluates a bash expression as a bool with the MENV file sourced.
  260func ScriptExecBool(s string) bool {
  261	z, err := script.Exec(fmt.Sprintf(`bash -c 'MENV=%s ; if [[ $MENV != "" ]] && [[ -f $MENV ]] ; then source $MENV ; fi ; printf "%s"'`, MENV, s)).String()
  262	if err == nil {
  263		b, err := strconv.ParseBool(z)
  264		if err == nil {
  265			return b
  266		}
  267	}
  268	return false
  269}
  270
  271// ScriptExecInt evaluates a bash expression as an int with the MENV file sourced.
  272func ScriptExecInt(s string) int {
  273	z, err := script.Exec(fmt.Sprintf(`bash -c 'MENV=%s ; if [[ $MENV != "" ]] && [[ -f $MENV ]] ; then source $MENV ; fi ; printf "%s"'`, MENV, s)).String()
  274	if err == nil {
  275		if z == "" {
  276			return 0
  277		}
  278		i, err := strconv.Atoi(z)
  279		if err == nil {
  280			return i
  281		}
  282	}
  283	return 0
  284}
  285
  286// ScriptExecDuration evaluates a bash expression as a duration with the MENV
  287// file sourced. Accepts Go duration strings ("750ms", "2s", "5m", "1h") and
  288// bare integers (treated as seconds).
  289func ScriptExecDuration(s string) time.Duration {
  290	z, err := script.Exec(fmt.Sprintf(`bash -c 'MENV=%s ; if [[ $MENV != "" ]] && [[ -f $MENV ]] ; then source $MENV ; fi ; printf "%s"'`, MENV, s)).String()
  291	if err != nil {
  292		return 0
  293	}
  294	z = strings.TrimSpace(z)
  295	if z == "" {
  296		return 0
  297	}
  298	z = strings.TrimPrefix(z, "-") // keep parity with how defaults are built
  299
  300	// Try full Go duration syntax first.
  301	if d, err := time.ParseDuration(z); err == nil {
  302		return d
  303	}
  304	// Fallback: plain integer means seconds.
  305	if n, err := strconv.ParseInt(z, 10, 64); err == nil {
  306		return time.Duration(n) * time.Second
  307	}
  308	return 0
  309}
  310
  311
  312// ===== pkg/product/csv.go =====
  313// Package product pkg/product/csv.go β€” catalog CSV loading.
  314package product
  315
  316import (
  317	"bufio"
  318	"bytes"
  319	"embed"
  320	"fmt"
  321	"log"
  322	"strings"
  323
  324	"github.com/bitfield/script"
  325)
  326
  327//go:embed *.go
  328var Source embed.FS
  329
  330func readproductscsv(csvFile string) (data []byte) {
  331	data, err := script.File(csvFile).Bytes() //nolint
  332	if err != nil {
  333		log.Printf(`Error reading %s file %v`, csvFile, err)
  334	}
  335	return data
  336}
  337
  338const csvMinFields = 51 // f[0] through f[50]
  339
  340// ReadCSV reads the catalog from a file. The parsing is in ParseCSV so that
  341// it can be tested without one.
  342func ReadCSV(csvFile string) Products {
  343	return ParseCSV(readproductscsv(csvFile))
  344}
  345
  346// ParseCSV turns the catalog bytes into products, skipping rows that are not
  347// enabled and rows too short to fill one.
  348func ParseCSV(data []byte) (prods Products) {
  349	scanner := bufio.NewScanner(bytes.NewReader(data))
  350	lineNum := 0
  351	for scanner.Scan() {
  352		lineNum++
  353		line := scanner.Text()
  354		f := strings.Split(line, ",")
  355		if len(f) < 4 {
  356			continue
  357		}
  358		if f[3] != "TRUE" {
  359			continue
  360		}
  361		if len(f) < csvMinFields {
  362			log.Printf("csv line %d: expected %d fields, got %d β€” skipping", lineNum, csvMinFields, len(f))
  363			continue
  364		}
  365		q := Product{
  366			Image1:            f[0],
  367			Partno:            f[1],
  368			Name:              f[2],
  369			Enable:            f[3],
  370			Price:             f[4],
  371			Quantity:          f[5],
  372			Shippable:         f[6],
  373			Minorder:          f[7],
  374			Maxorder:          f[8],
  375			Defaultquantity:   f[9],
  376			Stepquantity:      f[10],
  377			Mfgpartno:         f[11],
  378			Mfgname:           f[12],
  379			Category:          f[13],
  380			Subcategory:       f[14],
  381			Location:          f[15],
  382			Msrp:              f[16],
  383			Cost:              f[17],
  384			Typ:               f[18],
  385			Packagetype:       f[19],
  386			Technology:        f[20],
  387			Materials:         f[21],
  388			Value:             f[22],
  389			ValUnit:           f[23],
  390			Resistance:        f[24],
  391			ResUnit:           f[25],
  392			Tolerance:         f[26],
  393			VoltsRating:       f[27],
  394			AmpsRating:        f[28],
  395			WattsRating:       f[29],
  396			TempRating:        f[30],
  397			TempUnit:          f[31],
  398			Description1:      f[32],
  399			Description2:      f[33],
  400			Color1:            f[34],
  401			Color2:            f[35],
  402			Sourceinfo:        f[36],
  403			Datasheet:         f[37],
  404			Docs:              f[38],
  405			Reference:         f[39],
  406			Attributes:        f[40],
  407			Year:              f[41],
  408			Condition:         f[42],
  409			Note:              f[43],
  410			Warning:           f[44],
  411			CableLengthInches: f[45],
  412			LengthInches:      f[46],
  413			WidthInches:       f[47],
  414			HeightInches:      f[48],
  415			WeightLb:          f[49],
  416			WeightOz:          f[50],
  417		}
  418		prods = append(prods, q)
  419	}
  420	return prods
  421}
  422
  423// ValidateCSV scans products for patterns that could cause issues in HTML/JS rendering.
  424// Returns a list of warnings. Call after ReadCSV to check data integrity.
  425func ValidateCSV(prods Products) []string {
  426	var warnings []string
  427	for i, pr := range prods {
  428		check := func(field, value string) {
  429			if strings.ContainsAny(value, "<>\"'&") {
  430				warnings = append(warnings, fmt.Sprintf("product %d (%s): %s contains HTML-unsafe characters: %q", i, pr.Partno, field, value))
  431			}
  432			if strings.Contains(value, "|") {
  433				warnings = append(warnings, fmt.Sprintf("product %d (%s): %s contains pipe character: %q", i, pr.Partno, field, value))
  434			}
  435		}
  436		check("Partno", pr.Partno)
  437		check("Name", pr.Name)
  438		check("Description1", pr.Description1)
  439		check("Description2", pr.Description2)
  440		check("Note", pr.Note)
  441		check("Warning", pr.Warning)
  442		check("Category", pr.Category)
  443		check("Subcategory", pr.Subcategory)
  444		check("Mfgname", pr.Mfgname)
  445		check("Mfgpartno", pr.Mfgpartno)
  446	}
  447	return warnings
  448}
  449
  450
  451// ===== pkg/product/csv_test.go =====
  452package product
  453
  454import (
  455	"strings"
  456	"testing"
  457)
  458
  459// row builds a catalog line with the fields the parser needs, so a test can
  460// set the few it cares about without writing fifty-one commas.
  461func row(set map[int]string) string {
  462	f := make([]string, csvMinFields)
  463	f[0] = "img.jpg"
  464	f[1] = "PN-1"
  465	f[2] = "A Part"
  466	f[3] = "TRUE"
  467	f[4] = "1.50"
  468	f[5] = "10"
  469	for i, v := range set {
  470		f[i] = v
  471	}
  472	return strings.Join(f, ",")
  473}
  474
  475func TestParseCSVReadsAProduct(t *testing.T) {
  476	prods := ParseCSV([]byte(row(nil)))
  477	if len(prods) != 1 {
  478		t.Fatalf("got %d products, want 1", len(prods))
  479	}
  480	got := prods[0]
  481	if got.Partno != "PN-1" || got.Name != "A Part" || got.Price != "1.50" || got.Quantity != "10" {
  482		t.Errorf("fields did not land where they should: %+v", got)
  483	}
  484}
  485
  486// The last field is the fiftieth, and off-by-one at the end of a fifty-one
  487// column row is invisible in a spreadsheet.
  488func TestParseCSVReadsTheLastField(t *testing.T) {
  489	prods := ParseCSV([]byte(row(map[int]string{50: "3.25", 49: "1"})))
  490	if len(prods) != 1 {
  491		t.Fatalf("got %d products, want 1", len(prods))
  492	}
  493	if prods[0].WeightOz != "3.25" {
  494		t.Errorf("WeightOz = %q, want the last column", prods[0].WeightOz)
  495	}
  496	if prods[0].WeightLb != "1" {
  497		t.Errorf("WeightLb = %q, want the second to last column", prods[0].WeightLb)
  498	}
  499}
  500
  501// Only enabled rows are sold. A row that says anything but TRUE is a product
  502// deliberately withdrawn, and showing it anyway sells something not in stock.
  503func TestParseCSVSkipsRowsThatAreNotEnabled(t *testing.T) {
  504	for _, enable := range []string{"FALSE", "false", "true", "", "1", "yes"} {
  505		prods := ParseCSV([]byte(row(map[int]string{3: enable})))
  506		if len(prods) != 0 {
  507			t.Errorf("Enable=%q produced a product; only TRUE should", enable)
  508		}
  509	}
  510}
  511
  512// A short row cannot fill a product, and reading one anyway would panic on a
  513// live catalog rather than skip a line.
  514func TestParseCSVSkipsShortRows(t *testing.T) {
  515	short := strings.Join([]string{"img.jpg", "PN-1", "A Part", "TRUE", "1.50"}, ",")
  516	if prods := ParseCSV([]byte(short)); len(prods) != 0 {
  517		t.Errorf("a row with 5 fields produced %d products", len(prods))
  518	}
  519	// Exactly one short of the minimum is the boundary worth pinning.
  520	f := make([]string, csvMinFields-1)
  521	for i := range f {
  522		f[i] = "x"
  523	}
  524	f[3] = "TRUE"
  525	if prods := ParseCSV([]byte(strings.Join(f, ","))); len(prods) != 0 {
  526		t.Errorf("a row one field short produced %d products", len(prods))
  527	}
  528}
  529
  530func TestParseCSVSkipsVeryShortAndBlankRows(t *testing.T) {
  531	for _, line := range []string{"", ",", "a,b,c", "\n\n"} {
  532		if prods := ParseCSV([]byte(line)); len(prods) != 0 {
  533			t.Errorf("%q produced %d products", line, len(prods))
  534		}
  535	}
  536}
  537
  538func TestParseCSVReadsSeveralRows(t *testing.T) {
  539	data := strings.Join([]string{
  540		row(map[int]string{1: "AAA"}),
  541		row(map[int]string{3: "FALSE", 1: "SKIPPED"}),
  542		row(map[int]string{1: "BBB"}),
  543	}, "\n")
  544	prods := ParseCSV([]byte(data))
  545	if len(prods) != 2 {
  546		t.Fatalf("got %d products, want 2", len(prods))
  547	}
  548	if prods[0].Partno != "AAA" || prods[1].Partno != "BBB" {
  549		t.Errorf("got %q and %q, want AAA and BBB", prods[0].Partno, prods[1].Partno)
  550	}
  551}
  552
  553// The catalog is split on commas rather than parsed as CSV, so a quoted
  554// field containing one shifts every column after it β€” including Enable, which
  555// is column 3. The row then does not say TRUE where the parser looks, and the
  556// product is dropped from the store without a word.
  557//
  558// A spreadsheet writes that quoting itself the moment a name contains a comma,
  559// so this is reachable from ordinary editing rather than from bad data.
  560//
  561// This test records what the parser does today. If it is ever changed to use
  562// encoding/csv, this test is the one that should fail.
  563func TestParseCSVSilentlyDropsRowsWithAQuotedComma(t *testing.T) {
  564	f := make([]string, csvMinFields)
  565	for i := range f {
  566		f[i] = "x"
  567	}
  568	f[1] = "PN-1"
  569	f[2] = `"Resistor, 10k"` // one field in a spreadsheet, two after a split
  570	f[3] = "TRUE"
  571	f[4] = "1.50"
  572	line := strings.Join(f, ",")
  573
  574	// The row is long enough β€” it is the shift that loses it, not the length.
  575	if n := len(strings.Split(line, ",")); n <= csvMinFields {
  576		t.Fatalf("the fixture has %d fields; it needs more than %d to isolate the shift", n, csvMinFields)
  577	}
  578
  579	prods := ParseCSV([]byte(line))
  580	if len(prods) != 0 {
  581		t.Fatalf("the parser now keeps rows with a quoted comma (%d products); "+
  582			"replace this test with one asserting the fields are correct", len(prods))
  583	}
  584
  585	// Without the comma the same row is read, which is what shows the comma is
  586	// the cause rather than anything else in the fixture.
  587	f[2] = "Resistor 10k"
  588	if prods := ParseCSV([]byte(strings.Join(f, ","))); len(prods) != 1 {
  589		t.Errorf("the same row without the comma gave %d products, want 1", len(prods))
  590	}
  591}
  592
  593func TestValidateCSVAcceptsCleanData(t *testing.T) {
  594	prods := ParseCSV([]byte(row(nil)))
  595	if w := ValidateCSV(prods); len(w) != 0 {
  596		t.Errorf("clean data produced warnings: %v", w)
  597	}
  598}
  599
  600// These characters are what turn a product name into markup on the page.
  601func TestValidateCSVFlagsCharactersThatBreakThePage(t *testing.T) {
  602	for _, bad := range []string{`<script>`, `a"b`, "a'b", "a&b", "a>b"} {
  603		prods := ParseCSV([]byte(row(map[int]string{2: bad})))
  604		if len(prods) != 1 {
  605			t.Fatalf("%q: got %d products", bad, len(prods))
  606		}
  607		w := ValidateCSV(prods)
  608		if len(w) == 0 {
  609			t.Errorf("%q in a product name was not flagged", bad)
  610			continue
  611		}
  612		if !strings.Contains(w[0], "Name") {
  613			t.Errorf("%q: the warning does not name the field: %s", bad, w[0])
  614		}
  615	}
  616}
  617
  618// The pipe is the field separator in the cart's stored format, so a pipe in a
  619// product name corrupts the cart rather than the page.
  620func TestValidateCSVFlagsPipes(t *testing.T) {
  621	prods := ParseCSV([]byte(row(map[int]string{2: "A|B"})))
  622	w := ValidateCSV(prods)
  623	if len(w) == 0 {
  624		t.Fatal("a pipe in a product name was not flagged")
  625	}
  626	if !strings.Contains(w[0], "pipe") {
  627		t.Errorf("the warning does not mention the pipe: %s", w[0])
  628	}
  629}
  630
  631// Every field that reaches the page is checked, not just the name.
  632func TestValidateCSVChecksEveryRenderedField(t *testing.T) {
  633	fields := map[int]string{
  634		1:  "PN<",  // Partno
  635		2:  "N<",   // Name
  636		12: "MFG<", // Mfgname
  637		11: "MP<",  // Mfgpartno
  638		13: "CAT<", // Category
  639		14: "SUB<", // Subcategory
  640		32: "D1<",  // Description1
  641		33: "D2<",  // Description2
  642		43: "NT<",  // Note
  643		44: "WN<",  // Warning
  644	}
  645	for col, val := range fields {
  646		prods := ParseCSV([]byte(row(map[int]string{3: "TRUE", col: val})))
  647		if len(prods) != 1 {
  648			t.Fatalf("column %d: got %d products", col, len(prods))
  649		}
  650		if w := ValidateCSV(prods); len(w) == 0 {
  651			t.Errorf("an unsafe character in column %d was not flagged", col)
  652		}
  653	}
  654}
  655
  656func TestValidateCSVOnNoProducts(t *testing.T) {
  657	if w := ValidateCSV(nil); len(w) != 0 {
  658		t.Errorf("an empty catalog produced warnings: %v", w)
  659	}
  660}
  661
  662// The warning names the row so it can be found in a spreadsheet of thousands.
  663func TestValidateCSVNamesTheProduct(t *testing.T) {
  664	prods := ParseCSV([]byte(row(map[int]string{1: "PN-SPECIAL", 2: "bad<"})))
  665	w := ValidateCSV(prods)
  666	if len(w) == 0 {
  667		t.Fatal("no warning")
  668	}
  669	if !strings.Contains(w[0], "PN-SPECIAL") {
  670		t.Errorf("the warning does not name the part: %s", w[0])
  671	}
  672}
  673
  674
  675// ===== pkg/product/product.go =====
  676// Package product pkg/product/product.go
  677package product
  678
  679type Product struct {
  680	Enable            string
  681	Partno            string
  682	Name              string
  683	Image1            string
  684	Price             string
  685	Quantity          string
  686	Shippable         string
  687	Minorder          string
  688	Maxorder          string
  689	Defaultquantity   string
  690	Stepquantity      string
  691	Mfgpartno         string
  692	Mfgname           string
  693	Category          string
  694	Subcategory       string
  695	Location          string
  696	Msrp              string
  697	Cost              string
  698	Typ               string
  699	Packagetype       string
  700	Technology        string
  701	Materials         string
  702	Value             string
  703	ValUnit           string
  704	Resistance        string
  705	ResUnit           string
  706	Tolerance         string
  707	VoltsRating       string
  708	AmpsRating        string
  709	WattsRating       string
  710	TempRating        string
  711	TempUnit          string
  712	Description1      string
  713	Description2      string
  714	Color1            string
  715	Color2            string
  716	Sourceinfo        string
  717	Datasheet         string
  718	Docs              string
  719	Reference         string
  720	Attributes        string
  721	Year              string
  722	Condition         string
  723	Note              string
  724	Warning           string
  725	CableLengthInches string
  726	LengthInches      string
  727	WidthInches       string
  728	HeightInches      string
  729	WeightLb          string
  730	WeightOz          string
  731}
  732
  733// Products is an array of Product
  734type Products []Product
  735
  736
  737// ===== pkg/tui/address.go =====
  738package tui
  739
  740import (
  741	"strings"
  742
  743	"github.com/0magnet/websh/progressive"
  744)
  745
  746// The page's address (websh's PROTOCOL.md, The page): where the host lets
  747// it, the store tells it which page is showing, as the site's own path β€”
  748// /p/<part number>, /cat/<category> β€” so the place can be linked to, and a
  749// link opens the store there again. Elsewhere nothing is sent.
  750
  751// StartPath is the site path to open at, instead of the home page: the
  752// path a link handed back (progressive.Caps.LinkPath).
  753var StartPath string
  754
  755// announcePage tells the host the page now showing, when it changed: its
  756// path for the page's address, and its mirror (mirror.go).
  757func (u *ui) announcePage() {
  758	c := progressive.Current()
  759	if !c.Has("page") && !c.Has("mirror") {
  760		return
  761	}
  762	p := u.pagePath()
  763	if p == u.announced {
  764		return
  765	}
  766	u.announced = p
  767	if c.Has("mirror") {
  768		for _, seq := range progressive.Mirror([]byte(u.mirrorHTML())) {
  769			u.writeTerm(seq)
  770		}
  771	}
  772	if !c.Has("page") {
  773		return
  774	}
  775	title := u.sitedomain
  776	if part, ok := strings.CutPrefix(p, "/p/"); ok {
  777		if prod := u.cat.find(part); prod != nil && prod.Name != "" {
  778			title = prod.Name + " β€” " + u.sitedomain
  779		}
  780	}
  781	u.writeTerm(progressive.Page(p, title))
  782}
  783
  784// pageFromPath is pagePath backwards: the page a site path names, or "" for
  785// none this store has.
  786func (u *ui) pageFromPath(p string) string {
  787	switch {
  788	case p == "":
  789		return ""
  790	case p == "/":
  791		return "home"
  792	case strings.HasPrefix(p, "/#"):
  793		return "content:" + strings.TrimPrefix(p, "/#")
  794	case strings.HasPrefix(p, "/p/"):
  795		part := strings.TrimPrefix(p, "/p/")
  796		if u.cat.find(part) == nil {
  797			return ""
  798		}
  799		return "prod:" + part
  800	case p == "/cat" || strings.HasPrefix(p, "/cat/"):
  801		// As the site reads it: case aside, and a subcategory by either
  802		// spelling.
  803		if t, ok := u.cat.resolve(strings.TrimPrefix(p, "/cat")); ok {
  804			return listPageID(t)
  805		}
  806	}
  807	return ""
  808}
  809
  810
  811// ===== pkg/tui/address_test.go =====
  812package tui
  813
  814import (
  815	"strings"
  816	"testing"
  817
  818	"github.com/0magnet/m2/pkg/product"
  819)
  820
  821// TestAddressAndMirror: a site path names the page the store would show at
  822// it, as the site reads it, and the mirror of each page is its structure,
  823// escaped, with links to the site.
  824func TestAddressAndMirror(t *testing.T) {
  825	old := f.Storeurl
  826	f.Storeurl = "https://shop.example"
  827	defer func() { f.Storeurl = old }()
  828	u := &ui{cat: newCatalogInfo(product.Products{
  829		{Partno: "qjack", Name: "mono <jack>", Price: "1.25", Quantity: "50", Category: "audio", Subcategory: "jack"},
  830		{Partno: "r10k", Name: "10k resistor", Price: "0.05", Category: "resistor"},
  831	}), sitedomain: "shop.example"}
  832
  833	for path, want := range map[string]string{
  834		"/":               "home",
  835		"/p/qjack":        "prod:qjack",
  836		"/p/nosuch":       "",
  837		"/cat":            "list:",
  838		"/cat/Audio":      "list:audio",
  839		"/cat/audio/jack": "list:audio|jack",
  840		"/cat/nosuch":     "",
  841		"/elsewhere":      "",
  842	} {
  843		if got := u.pageFromPath(path); got != want {
  844			t.Errorf("%s: %q, want %q", path, got, want)
  845		}
  846	}
  847
  848	u.current = "prod:qjack"
  849	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"`) {
  850		t.Errorf("product mirror: %s", m)
  851	}
  852	u.current = "list:audio"
  853	if m := u.mirrorHTML(); !strings.Contains(m, "<h1>audio</h1><p>1 products.</p>") || !strings.Contains(m, "mono &lt;jack&gt;</a> β€” $1.25") {
  854		t.Errorf("list mirror: %s", m)
  855	}
  856	u.current = "home"
  857	if m := u.mirrorHTML(); !strings.Contains(m, `<a href="https://shop.example/cat/resistor">resistor</a> (1)`) {
  858		t.Errorf("home mirror: %s", m)
  859	}
  860}
  861
  862
  863// ===== pkg/tui/border.go =====
  864// Package tui pkg/tui/border.go β€” the pisano frame around the product
  865// table.
  866//
  867// The same drawing the website puts around its category tables, from the
  868// same library: an integer sequence reduced modulo m, walked as a turtle
  869// and rendered in box-drawing characters. On the page that is the wasm/deco
  870// drop-in decorating the DOM after the fact; here there is no DOM to
  871// decorate, so the frame is composed into the cells the table is drawn in.
  872//
  873// The figures are wasm/deco's, deliberately β€” one frame seen in two places
  874// is the point, and a terminal that invented its own would just be a second
  875// drawing to keep in step. What differs is the fit. The page knows the table
  876// and grows a frame around it (FitAround); a terminal knows the area and has
  877// to put both inside it (FitBox), because there is nowhere for a border to
  878// overflow to.
  879package tui
  880
  881import (
  882	"strconv"
  883	"sync"
  884
  885	"github.com/gdamore/tcell/v3"
  886
  887	"github.com/0magnet/pisano/pkg/border"
  888	"github.com/0magnet/pisano/pkg/pisano"
  889)
  890
  891// borderArt is a composed frame: the glyphs, the color of each, and the
  892// clear middle the table goes in.
  893type borderArt struct {
  894	g    border.Grid
  895	tint border.Tint
  896	in   border.Rect
  897}
  898
  899// recipe is one frame's three figures: the run along the top and bottom,
  900// the run down the sides, and the closed figure at the corners.
  901type recipe struct{ across, down, corner border.Figure }
  902
  903var (
  904	recipesOnce sync.Once
  905	borderSet   []recipe
  906)
  907
  908// recipes are wasm/deco's, and the reasoning is its: modulus 13 is both
  909// runs β€” it travels +0,-4, which makes a side, and a quarter turn makes a
  910// top square to it by construction rather than by search. Modulus 31 at
  911// four passes is the corner, closed and with the full symmetry of the
  912// square; an open figure would have loose ends of its own just where a run
  913// is trying to stop. Modulus 8 is the second rung, for an area that cannot
  914// spare the first's reach β€” a smaller figure beats no figure.
  915//
  916// The order is the preference, fullest corner first, and fitBorder keeps
  917// it as long as the content can afford it.
  918func recipes() []recipe {
  919	recipesOnce.Do(func() {
  920		side, ok := border.OfPasses(13, 1)
  921		if !ok {
  922			return
  923		}
  924		top := side.RotateCW()
  925		for _, c := range [][2]int{{31, 4}, {8, 2}} {
  926			corner, ok := border.OfPasses(c[0], c[1])
  927			if !ok || !corner.Closed {
  928				continue
  929			}
  930			borderSet = append(borderSet, recipe{across: top, down: side, corner: corner})
  931		}
  932	})
  933	return borderSet
  934}
  935
  936// borderPalette is pisano's six pass colors, which is what its own turtle
  937// tints with β€” so the frame here and the frame on the page are the same
  938// drawing in the same colors.
  939var borderPalette = func() []tcell.Style {
  940	hex := pisano.PassHex()
  941	out := make([]tcell.Style, len(hex))
  942	for i, h := range hex {
  943		out[i] = styleText.Foreground(tcell.GetColor(h))
  944	}
  945	return out
  946}()
  947
  948// frameCache keeps one frame per area size.
  949//
  950// Composing one is not free β€” it lays every copy of both runs and all four
  951// corners onto a grid, cuts the runs where their own wave crosses, then
  952// walks the result to check it is a single closed line. A draw happens on
  953// every event the UI handles, pointer motion included, so composing per
  954// draw would be the most expensive thing the program does. A size that
  955// fits nothing is cached as a miss, so it is not retried either.
  956var (
  957	frameMu    sync.Mutex
  958	frameCache = map[string]*borderArt{}
  959)
  960
  961// minFramed is the smallest clear middle worth taking an area down to. A
  962// frame that fits but leaves room for a header and one row has spent the
  963// table to draw a picture of one.
  964const minFramed = 6
  965
  966// roomyRows and roomyCols are what a middle has to keep for the fuller
  967// corner to be worth its longer reach: a table still worth reading β€” its
  968// header and a dozen-odd products, at a width that does not start cutting
  969// names β€” rather than merely a table.
  970//
  971// Below them the corner is competing with the content for the same cells
  972// and the content wins, which is the only reason to draw a smaller figure
  973// at all.
  974const (
  975	roomyRows = 16
  976	roomyCols = 40
  977)
  978
  979// fitBorder composes the largest frame that fits in a cols by rows area,
  980// or reports none. The area is what the caller has; the frame is drawn
  981// inside it and the table goes in what is left.
  982func fitBorder(cols, rows int) *borderArt {
  983	if cols < 8 || rows < 8 {
  984		return nil
  985	}
  986	key := strconv.Itoa(cols) + "x" + strconv.Itoa(rows)
  987	frameMu.Lock()
  988	defer frameMu.Unlock()
  989	if hit, seen := frameCache[key]; seen {
  990		return hit
  991	}
  992	// The fullest corner that still leaves a table worth reading, and the
  993	// roomiest otherwise.
  994	//
  995	// wasm/deco takes the first that fits, largest corner first, and can:
  996	// it grows a frame AROUND its table, so a bigger corner only makes a
  997	// bigger frame and costs the table nothing. Here the frame is cut OUT
  998	// of a fixed area and every cell of corner is a row of products not
  999	// shown, so the order alone is not an answer β€” but neither is simply
 1000	// taking the roomiest, which spends the good corner to buy four rows
 1001	// on a screen that had forty to spare.
 1002	//
 1003	// So: the same order deco uses, and the same preference, held only as
 1004	// long as the content is not the thing running out. Under that, the
 1005	// figure gives way rather than the table.
 1006	var got, roomiest *borderArt
 1007	var bestRoom int
 1008	for _, r := range recipes() {
 1009		l, g, ok := border.FitBox(r.across, r.down, r.corner, cols, rows)
 1010		if !ok {
 1011			continue
 1012		}
 1013		in := g.Inside()
 1014		w, h := in.X1-in.X0, in.Y1-in.Y0
 1015		if w < minFramed || h < minFramed {
 1016			continue
 1017		}
 1018		art := &borderArt{g: g, tint: l.TintByCopy(g, len(borderPalette)), in: in}
 1019		if h >= roomyRows && w >= roomyCols {
 1020			// Room to spare, and this is the fullest corner left to try.
 1021			// Nothing after it is better, and composing it would be work
 1022			// for a frame this one has already beaten.
 1023			got = art
 1024			break
 1025		}
 1026		// Rows before columns: a terminal runs out of height first, and a
 1027		// row lost is a product off the screen where a column lost is a
 1028		// name a little shorter.
 1029		if score := h<<16 | w; score > bestRoom {
 1030			bestRoom, roomiest = score, art
 1031		}
 1032	}
 1033	if got == nil {
 1034		got = roomiest
 1035	}
 1036	frameCache[key] = got
 1037	return got
 1038}
 1039
 1040// place centers the frame in the area it was fitted to and reports the
 1041// rectangle its clear middle occupies on screen β€” where the table goes.
 1042//
 1043// Centered because a frame is quantised by its figures and is never
 1044// exactly the size asked for; hanging it from the top left would leave the
 1045// slack in one corner.
 1046func (b *borderArt) place(area rect) (origin, inner rect) {
 1047	ox := area.x + (area.w-b.g.W())/2
 1048	oy := area.y + (area.h-b.g.H())/2
 1049	origin = rect{ox, oy, b.g.W(), b.g.H()}
 1050	inner = rect{ox + b.in.X0, oy + b.in.Y0, b.in.X1 - b.in.X0, b.in.Y1 - b.in.Y0}
 1051	return origin, inner
 1052}
 1053
 1054// draw paints the frame. Blanks are skipped rather than drawn as spaces,
 1055// so the frame never erases anything it overlaps β€” it is a decoration, and
 1056// the page's is drawn under its table for the same reason.
 1057func (b *borderArt) draw(sc tcell.Screen, origin rect) {
 1058	for y, row := range b.g {
 1059		for x, ch := range row {
 1060			if ch == border.Blank {
 1061				continue
 1062			}
 1063			st := styleText
 1064			if y < len(b.tint) && x < len(b.tint[y]) {
 1065				if i := b.tint[y][x]; i >= 0 {
 1066					st = borderPalette[i%len(borderPalette)]
 1067				}
 1068			}
 1069			sc.SetContent(origin.x+x, origin.y+y, ch, nil, st)
 1070		}
 1071	}
 1072}
 1073
 1074// framed splits an area into a frame and the cells inside it. An area that
 1075// fits no frame is handed back whole, which is what the page does too: a
 1076// box with no border is the answer, not a box with a broken one.
 1077func framed(area rect) (inner rect, b *borderArt, origin rect) {
 1078	b = fitBorder(area.w, area.h)
 1079	if b == nil {
 1080		return area, nil, rect{}
 1081	}
 1082	origin, inner = b.place(area)
 1083	return inner, b, origin
 1084}
 1085
 1086
 1087// ===== pkg/tui/border_test.go =====
 1088package tui
 1089
 1090import (
 1091	"testing"
 1092
 1093	"github.com/0magnet/pisano/pkg/border"
 1094)
 1095
 1096// A frame has to fit in the area it was asked for. It is cut out of the
 1097// table's own space, so one cell over means a border drawn across the
 1098// heading or the footer.
 1099func TestBorderFitsTheAreaItIsGiven(t *testing.T) {
 1100	for _, sz := range [][2]int{{160, 60}, {120, 40}, {80, 30}, {60, 24}} {
 1101		b := fitBorder(sz[0], sz[1])
 1102		if b == nil {
 1103			continue // nothing fits here, which is an answer
 1104		}
 1105		if b.g.W() > sz[0] || b.g.H() > sz[1] {
 1106			t.Errorf("%dx%d: frame is %dx%d, larger than its area", sz[0], sz[1], b.g.W(), b.g.H())
 1107		}
 1108		w, h := b.in.X1-b.in.X0, b.in.Y1-b.in.Y0
 1109		if w < minFramed || h < minFramed {
 1110			t.Errorf("%dx%d: middle is %dx%d, under the minimum", sz[0], sz[1], w, h)
 1111		}
 1112	}
 1113}
 1114
 1115// The clear middle is where the table is drawn, so nothing may be inked
 1116// inside it β€” a glyph there would be a box character among the products.
 1117func TestBorderMiddleIsClear(t *testing.T) {
 1118	b := fitBorder(160, 60)
 1119	if b == nil {
 1120		t.Fatal("no frame at 160x60, which is the size this page runs at")
 1121	}
 1122	for y := b.in.Y0; y < b.in.Y1; y++ {
 1123		for x := b.in.X0; x < b.in.X1; x++ {
 1124			if ch := b.g[y][x]; ch != border.Blank {
 1125				t.Fatalf("%q at %d,%d is inside the clear middle", ch, x, y)
 1126			}
 1127		}
 1128	}
 1129}
 1130
 1131// place puts the middle on screen where the table will be drawn, and the
 1132// frame around it β€” both inside the area.
 1133func TestBorderPlacesItsMiddleInsideTheArea(t *testing.T) {
 1134	area := rect{x: 3, y: 9, w: 160, h: 60}
 1135	b := fitBorder(area.w, area.h)
 1136	if b == nil {
 1137		t.Fatal("no frame")
 1138	}
 1139	origin, inner := b.place(area)
 1140	if origin.x < area.x || origin.y < area.y ||
 1141		origin.x+origin.w > area.x+area.w || origin.y+origin.h > area.y+area.h {
 1142		t.Errorf("frame at %+v escapes the area %+v", origin, area)
 1143	}
 1144	if inner.x < origin.x || inner.y < origin.y ||
 1145		inner.x+inner.w > origin.x+origin.w || inner.y+inner.h > origin.y+origin.h {
 1146		t.Errorf("middle at %+v escapes the frame %+v", inner, origin)
 1147	}
 1148	if inner.w >= area.w || inner.h >= area.h {
 1149		t.Errorf("middle %dx%d is not smaller than the area %dx%d", inner.w, inner.h, area.w, area.h)
 1150	}
 1151}
 1152
 1153// cornerReach is how far a frame's corner comes in from its own edge,
 1154// which is what tells the two figures apart: the fuller corner reaches
 1155// further, and that reach is its whole cost.
 1156func cornerReach(b *borderArt) int { return b.in.Y0 }
 1157
 1158// With room to spare the frame uses the fuller corner, as the page does.
 1159func TestBorderKeepsTheFullCornerWhenThereIsRoom(t *testing.T) {
 1160	roomy := fitBorder(160, 60)
 1161	tight := fitBorder(160, 30)
 1162	if roomy == nil || tight == nil {
 1163		t.Fatal("expected a frame at both sizes")
 1164	}
 1165	if cornerReach(roomy) <= cornerReach(tight) {
 1166		t.Errorf("the roomy area's corner reaches %d, no further than the tight one's %d",
 1167			cornerReach(roomy), cornerReach(tight))
 1168	}
 1169	if h := roomy.in.Y1 - roomy.in.Y0; h < roomyRows {
 1170		t.Errorf("the full corner was kept on a middle of only %d rows", h)
 1171	}
 1172}
 1173
 1174// When it is the content running out, the figure gives way rather than
 1175// the table: the frame chosen leaves more rows than the fuller corner
 1176// would have.
 1177func TestBorderGivesUpTheCornerRatherThanTheTable(t *testing.T) {
 1178	const cols, rows = 160, 30
 1179	got := fitBorder(cols, rows)
 1180	if got == nil {
 1181		t.Fatal("no frame at 160x30")
 1182	}
 1183	full := recipes()[0]
 1184	_, g, ok := border.FitBox(full.across, full.down, full.corner, cols, rows)
 1185	if !ok {
 1186		t.Skip("the fuller corner does not fit here at all, which is a different path")
 1187	}
 1188	in := g.Inside()
 1189	if want := in.Y1 - in.Y0; got.in.Y1-got.in.Y0 <= want {
 1190		t.Errorf("gave up the corner for %d rows, no better than the %d it would have left",
 1191			got.in.Y1-got.in.Y0, want)
 1192	}
 1193}
 1194
 1195// An area too small for any figure gets no frame, rather than a broken
 1196// one or a table squeezed to nothing.
 1197func TestBorderDeclinesASmallArea(t *testing.T) {
 1198	for _, sz := range [][2]int{{4, 4}, {20, 6}, {7, 30}} {
 1199		if b := fitBorder(sz[0], sz[1]); b != nil {
 1200			t.Errorf("%dx%d got a frame with a %dx%d middle, want none",
 1201				sz[0], sz[1], b.in.X1-b.in.X0, b.in.Y1-b.in.Y0)
 1202		}
 1203	}
 1204}
 1205
 1206// Composing is the expensive thing here and a draw happens on every
 1207// event, so the same size must come back from the cache rather than be
 1208// composed again.
 1209func TestBorderCachesBySize(t *testing.T) {
 1210	a := fitBorder(132, 44)
 1211	b := fitBorder(132, 44)
 1212	if a != b {
 1213		t.Error("the same size composed twice instead of coming from the cache")
 1214	}
 1215}
 1216
 1217
 1218// ===== pkg/tui/cartsave.go =====
 1219package tui
 1220
 1221import (
 1222	"encoding/json"
 1223	"os"
 1224	"path/filepath"
 1225	"runtime"
 1226	"strings"
 1227
 1228	"github.com/0magnet/m2/pkg/storepage"
 1229)
 1230
 1231// The cart is a file, as a desktop program keeps its state:
 1232// ~/.config/m2/cart-<store>.json, read at start and written on every change.
 1233// The same code runs in the browser, where the filesystem is bottle's jsfs β€”
 1234// the page persists its /home to IndexedDB β€” so a reload finds the cart where
 1235// a restart does on a desktop. One file per store, so carts at two stores do
 1236// not mix.
 1237//
 1238// It holds what the buyer chose and nothing the store decides: part numbers
 1239// and quantities, and where to ship. Prices come from the catalog when the
 1240// cart is shown, and the server checks them again at checkout.
 1241
 1242// savedCart is the file.
 1243type savedCart struct {
 1244	Items    []savedItem         `json:"items"`
 1245	Shipping *storepage.Shipping `json:"shipping,omitempty"`
 1246}
 1247
 1248type savedItem struct {
 1249	Partno string `json:"partno"`
 1250	Qty    int    `json:"qty"`
 1251}
 1252
 1253// configDir is where a user's settings live: XDG's, or the shell's home in
 1254// a page, whose process has no HOME of its own.
 1255func configDir() string {
 1256	if d, err := os.UserConfigDir(); err == nil {
 1257		return d
 1258	}
 1259	if runtime.GOOS == "js" {
 1260		return "/home/user/.config"
 1261	}
 1262	return ""
 1263}
 1264
 1265// cartPath is this store's cart file, or "" where there is nowhere to keep one.
 1266func (u *ui) cartPath() string {
 1267	dir := configDir()
 1268	if dir == "" {
 1269		return ""
 1270	}
 1271	name := strings.Map(func(r rune) rune {
 1272		switch {
 1273		case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '-':
 1274			return r
 1275		}
 1276		return '_'
 1277	}, u.sitedomain)
 1278	return filepath.Join(dir, "m2", "cart-"+name+".json")
 1279}
 1280
 1281// loadCart reads the cart file, if there is one.
 1282func (u *ui) loadCart() {
 1283	p := u.cartPath()
 1284	if p == "" {
 1285		return
 1286	}
 1287	b, err := os.ReadFile(p) //nolint:gosec // the user's own config file
 1288	if err != nil {
 1289		return
 1290	}
 1291	var c savedCart
 1292	if json.Unmarshal(b, &c) != nil {
 1293		return
 1294	}
 1295	u.cartQty = map[string]int{}
 1296	u.cartOrder = nil
 1297	for _, it := range c.Items {
 1298		if it.Partno == "" || it.Qty <= 0 {
 1299			continue
 1300		}
 1301		if _, ok := u.cartQty[it.Partno]; !ok {
 1302			u.cartOrder = append(u.cartOrder, it.Partno)
 1303		}
 1304		u.cartQty[it.Partno] += it.Qty
 1305	}
 1306	u.shipping = c.Shipping
 1307}
 1308
 1309// cartChanged writes the cart after any change to it: to a file beside it
 1310// first, then over it, so a cart is never half written.
 1311func (u *ui) cartChanged() {
 1312	p := u.cartPath()
 1313	if p == "" {
 1314		return
 1315	}
 1316	c := savedCart{Items: []savedItem{}, Shipping: u.shipping}
 1317	for _, partno := range u.cartOrder {
 1318		if q := u.cartQty[partno]; q > 0 {
 1319			c.Items = append(c.Items, savedItem{partno, q})
 1320		}
 1321	}
 1322	b, err := json.MarshalIndent(c, "", "  ")
 1323	if err != nil {
 1324		return
 1325	}
 1326	if os.MkdirAll(filepath.Dir(p), 0o700) != nil {
 1327		return
 1328	}
 1329	tmp := p + ".tmp"
 1330	if os.WriteFile(tmp, append(b, '\n'), 0o600) != nil {
 1331		return
 1332	}
 1333	_ = os.Rename(tmp, p) //nolint:errcheck // the cart stays as it was; the next change tries again
 1334}
 1335
 1336// cartLine is one line of the cart as the website's cart wasm writes it and
 1337// /submit-order reads it: a product's id is its part number and its amount the line's cents
 1338// (price Γ— quantity); the shipping line's id is storepage's shipping-to line.
 1339type cartLine struct {
 1340	ID     string `json:"id"`
 1341	Amount int    `json:"amount"`
 1342	Qty    int    `json:"quantity"`
 1343}
 1344
 1345// cartLines is the cart in that form, for the order sent at checkout.
 1346func (u *ui) cartLines() []cartLine {
 1347	var ls []cartLine
 1348	for _, partno := range u.cartOrder {
 1349		qty := u.cartQty[partno]
 1350		if qty <= 0 {
 1351			continue
 1352		}
 1353		cents := 0
 1354		if p := u.cat.find(partno); p != nil {
 1355			cents = priceCents(p.Price)
 1356		}
 1357		ls = append(ls, cartLine{ID: partno, Amount: cents * qty, Qty: qty})
 1358	}
 1359	if u.shipping != nil {
 1360		ls = append(ls, cartLine{ID: u.shipping.ID(), Amount: u.shipping.Cents, Qty: 1})
 1361	}
 1362	return ls
 1363}
 1364
 1365// setQty sets one product's quantity in the cart; zero takes it out.
 1366func (u *ui) setQty(partno string, qty int) {
 1367	if qty <= 0 {
 1368		delete(u.cartQty, partno)
 1369		for i, p := range u.cartOrder {
 1370			if p == partno {
 1371				u.cartOrder = append(u.cartOrder[:i], u.cartOrder[i+1:]...)
 1372				break
 1373			}
 1374		}
 1375	} else {
 1376		if _, ok := u.cartQty[partno]; !ok {
 1377			u.cartOrder = append(u.cartOrder, partno)
 1378		}
 1379		u.cartQty[partno] = qty
 1380	}
 1381	u.cartChanged()
 1382}
 1383
 1384// emptyCart takes everything out, the shipping line too, as the page's
 1385// Empty Cart does.
 1386func (u *ui) emptyCart() {
 1387	u.cartQty = map[string]int{}
 1388	u.cartOrder = nil
 1389	u.shipping = nil
 1390	u.cartChanged()
 1391}
 1392
 1393
 1394// ===== pkg/tui/cartsave_test.go =====
 1395package tui
 1396
 1397import (
 1398	"os"
 1399	"path/filepath"
 1400	"reflect"
 1401	"strings"
 1402	"testing"
 1403
 1404	"github.com/gdamore/tcell/v3"
 1405
 1406	"github.com/0magnet/m2/pkg/product"
 1407	"github.com/0magnet/m2/pkg/storepage"
 1408)
 1409
 1410// TestTheCartIsAFile: the cart is written to the store's file in the user's
 1411// config directory on every change and read back from it at start β€” what
 1412// the buyer chose, without prices.
 1413func TestTheCartIsAFile(t *testing.T) {
 1414	dir := t.TempDir()
 1415	t.Setenv("XDG_CONFIG_HOME", dir)
 1416	cat := newCatalogInfo(product.Products{
 1417		{Partno: "A1", Price: "1.50", Enable: "TRUE", Category: "x"},
 1418		{Partno: "B2", Price: "10", Enable: "TRUE", Category: "x"},
 1419	})
 1420	u := &ui{cartQty: map[string]int{}, cat: cat, sitedomain: "shop.example"}
 1421	u.setQty("A1", 3)
 1422	u.setQty("B2", 1)
 1423	u.shipping = &storepage.Shipping{Cents: 795, Name: "N", Address: "1 St", City: "C", State: "TX", Zip: "77001", Country: "USA", Phone: "5"}
 1424	u.cartChanged()
 1425
 1426	b, err := os.ReadFile(filepath.Join(dir, "m2", "cart-shop.example.json")) //nolint:gosec // the test's own temp dir
 1427	if err != nil {
 1428		t.Fatal(err)
 1429	}
 1430	if strings.Contains(string(b), "1.50") || !strings.Contains(string(b), `"partno": "A1"`) {
 1431		t.Errorf("the file:\n%s", b)
 1432	}
 1433
 1434	v := &ui{cat: cat, sitedomain: "shop.example"}
 1435	v.loadCart()
 1436	if !reflect.DeepEqual(v.cartLines(), u.cartLines()) {
 1437		t.Fatalf("read back as\n%+v\nwant\n%+v", v.cartLines(), u.cartLines())
 1438	}
 1439	if got := v.cartLines()[0]; got != (cartLine{ID: "A1", Amount: 450, Qty: 3}) {
 1440		t.Errorf("first line %+v", got)
 1441	}
 1442
 1443	v.setQty("A1", 0)
 1444	if _, ok := v.cartQty["A1"]; ok || len(v.cartOrder) != 1 {
 1445		t.Fatalf("A1 not removed: %v %v", v.cartQty, v.cartOrder)
 1446	}
 1447	w := &ui{cat: cat, sitedomain: "other.example"}
 1448	w.loadCart()
 1449	if len(w.cartOrder) != 0 {
 1450		t.Error("another store's cart was read")
 1451	}
 1452}
 1453
 1454// TestPlacementOnItsOverlay: what an overlay places lies on that overlay,
 1455// and only what is drawn after it takes it away.
 1456func TestPlacementOnItsOverlay(t *testing.T) {
 1457	u := &ui{screen: &rawScreen{}}
 1458	dialog := rect{10, 5, 40, 20}
 1459	u.overlaysDrawn = 1
 1460	u.want("card", placed{r: rect{11, 6, 38, 17}, widget: "stripe"})
 1461	u.overlaysDrawn = 0
 1462	u.want("photo", placed{r: rect{0, 0, 30, 10}, url: "https://x/y.jpg"})
 1463	u.placeSync([]rect{dialog})
 1464	if _, ok := u.placeSent["card"]; !ok {
 1465		t.Error("the card form was taken off its own dialog")
 1466	}
 1467	if _, ok := u.placeSent["photo"]; ok {
 1468		t.Error("the photo under the dialog was left over it")
 1469	}
 1470
 1471	u.overlaysDrawn = 1
 1472	u.want("card", placed{r: rect{11, 6, 38, 17}, widget: "stripe"})
 1473	u.placeSync([]rect{dialog, {20, 10, 10, 5}}) // a notice over the form
 1474	if _, ok := u.placeSent["card"]; ok {
 1475		t.Error("the card form was left over the notice drawn after it")
 1476	}
 1477}
 1478
 1479// rawScreen takes what is written to the terminal itself, as the page's
 1480// terminal does; nothing else of it is used.
 1481type rawScreen struct {
 1482	tcell.Screen
 1483	out []byte
 1484}
 1485
 1486func (r *rawScreen) WriteRaw(b []byte) { r.out = append(r.out, b...) }
 1487
 1488
 1489// ===== pkg/tui/catalog.go =====
 1490// Package tui pkg/tui/catalog.go β€” the site's structures, derived the same
 1491// way the templates derive them: category ordering from getcategories, the
 1492// navigation tree from htmpl/catsubcats.html, the product page from
 1493// htmpl/product.html. Where the website renders HTML, this renders the same
 1494// data to terminal text.
 1495package tui
 1496
 1497import (
 1498	"fmt"
 1499	"sort"
 1500	"strconv"
 1501	"strings"
 1502
 1503	"github.com/0magnet/tallytree"
 1504
 1505	"github.com/0magnet/m2/pkg/product"
 1506	"github.com/0magnet/m2/pkg/storepage"
 1507)
 1508
 1509// catalogInfo mirrors what pageMeta hands the templates: categories sorted
 1510// by product count, subcategories likewise, with counts.
 1511type catalogInfo struct {
 1512	prods        product.Products
 1513	cats         []string
 1514	catCounts    map[string]int
 1515	subcatCounts map[string]map[string]int
 1516	subcatsByCat map[string][]string
 1517}
 1518
 1519func newCatalogInfo(prods product.Products) *catalogInfo {
 1520	c := &catalogInfo{
 1521		prods:        prods,
 1522		catCounts:    make(map[string]int),
 1523		subcatCounts: make(map[string]map[string]int),
 1524		subcatsByCat: make(map[string][]string),
 1525	}
 1526	for _, prod := range prods {
 1527		if prod.Category == "" {
 1528			continue
 1529		}
 1530		c.catCounts[prod.Category]++
 1531		if prod.Subcategory != "" {
 1532			if c.subcatCounts[prod.Category] == nil {
 1533				c.subcatCounts[prod.Category] = make(map[string]int)
 1534			}
 1535			c.subcatCounts[prod.Category][prod.Subcategory]++
 1536		}
 1537	}
 1538	for cat := range c.catCounts {
 1539		c.cats = append(c.cats, cat)
 1540	}
 1541	sort.Slice(c.cats, func(i, j int) bool {
 1542		if c.catCounts[c.cats[i]] != c.catCounts[c.cats[j]] {
 1543			return c.catCounts[c.cats[i]] > c.catCounts[c.cats[j]]
 1544		}
 1545		return c.cats[i] < c.cats[j]
 1546	})
 1547	for cat, subs := range c.subcatCounts {
 1548		var names []string
 1549		for sub := range subs {
 1550			names = append(names, sub)
 1551		}
 1552		sort.Slice(names, func(i, j int) bool {
 1553			if subs[names[i]] != subs[names[j]] {
 1554				return subs[names[i]] > subs[names[j]]
 1555			}
 1556			return names[i] < names[j]
 1557		})
 1558		c.subcatsByCat[cat] = names
 1559	}
 1560	return c
 1561}
 1562
 1563// products returns the rows the matching category/subcategory table
 1564// lists, as /cat/<cat>[/<subcat>] does: every product in the category,
 1565// subcategorized or not, and a subcategory page narrowing that to one.
 1566//
 1567// It used to hold back the products that have a subcategory, which is
 1568// front.html's rule for its "Other Products in X" table and not this
 1569// page's. Where every product in a category is subcategorized β€” which
 1570// is most of them β€” that left the category page with nothing on it. The
 1571// site had the same bug in its homepage sections and fixed it there
 1572// (catTargetCSS in pkg/web); this is that fix, for that reason.
 1573func (c *catalogInfo) products(cat, subcat string) product.Products {
 1574	var out product.Products
 1575	for _, p := range c.prods {
 1576		switch {
 1577		case cat == "":
 1578			out = append(out, p)
 1579		case subcat == "":
 1580			if p.Category == cat {
 1581				out = append(out, p)
 1582			}
 1583		default:
 1584			if p.Category == cat && p.Subcategory == subcat {
 1585				out = append(out, p)
 1586			}
 1587		}
 1588	}
 1589	return out
 1590}
 1591
 1592// resolve reads a "<cat>" or "<cat>/<subcat>" path as a place in this
 1593// catalog, matching the way a URL does: case-insensitively, and only
 1594// where the catalog holds that name. It reports whether it found one.
 1595func (c *catalogInfo) resolve(path string) (navTarget, bool) {
 1596	parts := strings.SplitN(strings.Trim(path, "/"), "/", 2)
 1597	if parts[0] == "" {
 1598		return navTarget{}, true // /cat/ is all products
 1599	}
 1600	cat := ""
 1601	for name := range c.catCounts {
 1602		if strings.EqualFold(name, parts[0]) {
 1603			cat = name
 1604			break
 1605		}
 1606	}
 1607	if cat == "" {
 1608		return navTarget{}, false
 1609	}
 1610	if len(parts) == 1 {
 1611		return navTarget{cat: cat}, true
 1612	}
 1613	// Either spelling: the name as the catalog holds it, or the name as
 1614	// a URL spells it. The site writes "ΒΌ watt 5%" into a path as
 1615	// quarter-watt-5-pct, and while the escaping lived only in pkg/web
 1616	// this could not match those at all β€” every subcategory link with a
 1617	// fraction or a percent in it left the store rather than opening the
 1618	// listing right here.
 1619	for _, sub := range c.subcatsByCat[cat] {
 1620		if strings.EqualFold(sub, parts[1]) || strings.EqualFold(storepage.EscapeSubcat(sub), parts[1]) {
 1621			return navTarget{cat: cat, subcat: sub}, true
 1622		}
 1623	}
 1624	return navTarget{}, false
 1625}
 1626
 1627func (c *catalogInfo) find(partno string) *product.Product {
 1628	for i := range c.prods {
 1629		if strings.EqualFold(c.prods[i].Partno, partno) {
 1630			return &c.prods[i]
 1631		}
 1632	}
 1633	return nil
 1634}
 1635
 1636// navTarget is a place the categories tree can go: all products, a
 1637// category, or a subcategory.
 1638type navTarget struct{ cat, subcat string }
 1639
 1640type navItem struct {
 1641	label   string
 1642	target  navTarget
 1643	hasSubs bool
 1644}
 1645
 1646// buildTree lays the category menu out: counts right-aligned by ─ padding,
 1647// β”œ/β”” branches, ┬ where a category opens into subcategories.
 1648//
 1649// The drawing is tallytree's, not this file's. It used to be written out here
 1650// and again in htmpl/catsubcats.html, two implementations of one picture kept
 1651// in agreement by hand; both now call the library, so the alignment arithmetic
 1652// exists once and has tests.
 1653//
 1654// Depth is pinned to two because the menu always COULD have subcategories, so
 1655// it reserves room for them even in a catalog where nothing does β€” otherwise
 1656// a flat catalog would draw a narrower tree than the same shop with one
 1657// subcategory added.
 1658func (c *catalogInfo) buildTree(expanded map[string]bool) []navItem {
 1659	forest := []tallytree.Node{{Label: "All Products", Count: len(c.prods)}}
 1660	for _, cat := range c.cats {
 1661		n := tallytree.Node{Label: cat, Count: c.catCounts[cat]}
 1662		for _, sub := range c.subcatsByCat[cat] {
 1663			n.Children = append(n.Children, tallytree.Node{Label: sub, Count: c.subcatCounts[cat][sub]})
 1664		}
 1665		forest = append(forest, n)
 1666	}
 1667	rows := tallytree.Rows(forest, tallytree.Options{
 1668		Depth: 2,
 1669		Open:  func(n tallytree.Node, d int) bool { return d != 0 || expanded == nil || expanded[n.Label] },
 1670	})
 1671	items := make([]navItem, 0, len(rows))
 1672	cat := ""
 1673	for _, r := range rows {
 1674		t := navTarget{}
 1675		switch {
 1676		case r.Depth == 0 && r.Label != "All Products":
 1677			cat = r.Label
 1678			t = navTarget{cat: r.Label}
 1679		case r.Depth > 0:
 1680			t = navTarget{cat: cat, subcat: r.Label}
 1681		}
 1682		items = append(items, navItem{r.Line(tallytree.Options{}), t, r.HasChildren})
 1683	}
 1684	return items
 1685}
 1686
 1687// checkerboard styles alternate letters inverse-video, as the site's
 1688// checkerBoard wraps every other letter of the domain in an .nv span.
 1689func checkerboard(s string) string {
 1690	var out strings.Builder
 1691	for i, ch := range s {
 1692		if i%2 == 0 {
 1693			fmt.Fprintf(&out, "[black:white]%c[white:black]", ch)
 1694		} else {
 1695			out.WriteRune(ch)
 1696		}
 1697	}
 1698	out.WriteString("[-:-]")
 1699	return out.String()
 1700}
 1701
 1702// set reports a field the way product.html's conditions do: present unless
 1703// empty or a bare zero.
 1704func set(v string) bool {
 1705	return v != "" && v != "0" && v != "0.0"
 1706}
 1707
 1708// productLines renders htmpl/product.html: the same fields, labels, and
 1709// order, as terminal text.
 1710//
 1711// anchor wraps the template's clickable things β€” its Add to cart button
 1712// and its category, subcategory and datasheet links β€” in whatever makes
 1713// them clickable on the page doing the drawing. A nil anchor draws them
 1714// as plain text, which is what a page with nowhere to send a click gets.
 1715func productLines(u *ui, p *product.Product, anchor func(text string, act func()) string) string {
 1716	if anchor == nil {
 1717		anchor = func(text string, _ func()) string { return text }
 1718	}
 1719	var b strings.Builder
 1720	line := func(format string, args ...interface{}) {
 1721		fmt.Fprintf(&b, format+"\n", args...)
 1722	}
 1723	line("[::b]%s[-:-:-]", esc(p.Name))
 1724	line("Price: [white]$%s[-]", esc(p.Price))
 1725	line("In stock: %s", esc(p.Quantity))
 1726	if p.Quantity != "0" {
 1727		// The template's <button onclick='addToCart(...)'>, in the place
 1728		// it sits on the page: under the stock line, above the part no.
 1729		line("%s", anchor("[black:#005f87] "+storepage.AddToCart+" [-:-]", func() { u.addToCart(p) }))
 1730		line("partno: %s", esc(p.Partno))
 1731	}
 1732	if !strings.EqualFold(strings.Join(strings.Fields(p.Description1), ""), strings.Join(strings.Fields(p.Name), "")) && p.Description1 != "" {
 1733		line("\n%s\n", esc(p.Description1))
 1734	}
 1735	if p.Mfgname != "" {
 1736		line("Brand: %s", esc(p.Mfgname))
 1737	}
 1738	if p.Mfgpartno != "" {
 1739		line("MPN: %s", esc(p.Mfgpartno))
 1740	}
 1741	cat, sub := p.Category, p.Subcategory
 1742	line("Category: %s", anchor("[aqua]"+esc(cat)+"[-]",
 1743		func() { u.show(listPageID(navTarget{cat: cat})) }))
 1744	if sub != "" {
 1745		line("Subcategory: %s", anchor("[aqua]"+esc(sub)+"[-]",
 1746			func() { u.show(listPageID(navTarget{cat: cat, subcat: sub})) }))
 1747	}
 1748	if set(p.VoltsRating) {
 1749		line("Voltage: %s", esc(p.VoltsRating))
 1750	}
 1751	if set(p.Value) {
 1752		line("Value: %s%s", esc(p.Value), esc(p.ValUnit))
 1753	}
 1754	if set(p.AmpsRating) {
 1755		line("Amperage: %s", esc(p.AmpsRating))
 1756	}
 1757	if p.Tolerance != "0" && p.Tolerance != "" {
 1758		if f, err := strconv.ParseFloat(p.Tolerance, 64); err == nil {
 1759			line("Tolerance: %.2f%%", 100*f)
 1760		}
 1761	}
 1762	if p.Typ != "" {
 1763		line("Typ: %s", esc(p.Typ))
 1764	}
 1765	if p.Packagetype != "" {
 1766		line("Package Type: %s", esc(p.Packagetype))
 1767	}
 1768	if p.Technology != "" {
 1769		line("Technology: %s", esc(p.Technology))
 1770	}
 1771	if p.Materials != "" {
 1772		line("Materials: %s", esc(p.Materials))
 1773	}
 1774	if set(p.WattsRating) {
 1775		line("Watts Rating: %s", esc(p.WattsRating))
 1776	}
 1777	if p.Year != "0" && p.Year != "" {
 1778		line("Year: %s", esc(p.Year))
 1779	}
 1780	if set(p.CableLengthInches) {
 1781		line("Cable Length: %s inches", esc(p.CableLengthInches))
 1782	}
 1783	if p.WeightOz != "0" && p.WeightOz != "0.0" && p.WeightOz != "" {
 1784		line("Weight: %s oz", esc(p.WeightOz))
 1785	}
 1786	if p.TempRating != "0" && p.TempRating != "0.0" && p.TempRating != "" {
 1787		line("Temp rating: %s%s", esc(p.TempRating), esc(p.TempUnit))
 1788	}
 1789	if p.Condition != "" {
 1790		line("Condition: %s", esc(p.Condition))
 1791	}
 1792	if p.Datasheet != "" {
 1793		// The template links this at <imgsrc>/pdf/<name>; the terminal
 1794		// cannot show a PDF, so following it hands over the URL.
 1795		ds := p.Datasheet
 1796		line("Datasheet: %s", anchor("[aqua]"+esc(ds)+"[-]",
 1797			func() { u.openURL(datasheetURL(ds)) }))
 1798	}
 1799	if p.Docs != "" {
 1800		line("Documentation: %s", esc(p.Docs))
 1801	}
 1802	if p.Note != "" {
 1803		line("Note: [yellow]%s[-]", esc(p.Note))
 1804	}
 1805	if p.Warning != "" {
 1806		line("Warning: [red]%s[-]", esc(p.Warning))
 1807	}
 1808	if p.Description2 != "" {
 1809		line("Additional Description: %s", esc(p.Description2))
 1810	}
 1811	return b.String()
 1812}
 1813
 1814
 1815// ===== pkg/tui/catalog_test.go =====
 1816package tui
 1817
 1818import (
 1819	"strings"
 1820	"testing"
 1821
 1822	"github.com/0magnet/m2/pkg/product"
 1823)
 1824
 1825// sampleCatalog is a catalog small enough to write the expected drawing out
 1826// by hand, and shaped to exercise the alignment: counts of one, two, three and
 1827// four digits, a category with subcategories and one without, and a last
 1828// sibling on both levels.
 1829func sampleCatalog() *catalogInfo {
 1830	return &catalogInfo{
 1831		prods:        make(product.Products, 1247),
 1832		cats:         []string{"resistor", "inductor"},
 1833		catCounts:    map[string]int{"resistor": 111, "inductor": 34},
 1834		subcatsByCat: map[string][]string{"resistor": {"quarter watt 5%", "ceramic"}},
 1835		subcatCounts: map[string]map[string]int{"resistor": {"quarter watt 5%": 67, "ceramic": 1}},
 1836	}
 1837}
 1838
 1839// The menu's counts are right-aligned by padding the branch rule itself, so
 1840// every count ends in the same column and the tree still reads as one line.
 1841// These are the exact rows the site drew before the layout moved to tallytree.
 1842func TestBuildTreeDrawing(t *testing.T) {
 1843	want := []string{
 1844		"β”œβ”€β”€1247 All Products",
 1845		"β”œβ”€β”¬β”€111 resistor",
 1846		"β”‚ β”œβ”€β”€67 quarter watt 5%",
 1847		"β”‚ └───1 ceramic",
 1848		"└────34 inductor",
 1849	}
 1850	got := sampleCatalog().buildTree(map[string]bool{"resistor": true})
 1851	if len(got) != len(want) {
 1852		t.Fatalf("%d rows, want %d", len(got), len(want))
 1853	}
 1854	for i := range want {
 1855		if got[i].label != want[i] {
 1856			t.Errorf("row %d:\n got %q\nwant %q", i, got[i].label, want[i])
 1857		}
 1858	}
 1859}
 1860
 1861// A collapsed category keeps the ┬ that says it has subcategories, and the
 1862// columns do not move when it closes.
 1863func TestBuildTreeCollapsed(t *testing.T) {
 1864	got := sampleCatalog().buildTree(map[string]bool{})
 1865	if len(got) != 3 {
 1866		t.Fatalf("%d rows with everything collapsed, want 3", len(got))
 1867	}
 1868	if !strings.Contains(got[1].label, "┬") {
 1869		t.Errorf("collapsed category lost its ┬: %q", got[1].label)
 1870	}
 1871	open := sampleCatalog().buildTree(map[string]bool{"resistor": true})
 1872	if a, b := strings.Index(got[1].label, " "), strings.Index(open[1].label, " "); a != b {
 1873		t.Errorf("collapsing moved the count column: %d vs %d", a, b)
 1874	}
 1875}
 1876
 1877// A category page lists everything in the category, as /cat/<cat> does
 1878// β€” not only the products with no subcategory, which is front.html's
 1879// rule for its "Other Products in X" table. Getting that wrong left
 1880// every category page in this catalog empty.
 1881func TestCategoryPageListsTheWholeCategory(t *testing.T) {
 1882	c := newCatalogInfo(product.Products{
 1883		{Partno: "a", Category: "diode", Subcategory: "1N47XX"},
 1884		{Partno: "b", Category: "diode", Subcategory: "1N47XX"},
 1885		{Partno: "c", Category: "diode", Subcategory: "bridge"},
 1886		{Partno: "d", Category: "diode"},
 1887		{Partno: "e", Category: "resistor"},
 1888	})
 1889	names := func(ps product.Products) string {
 1890		var out []string
 1891		for _, p := range ps {
 1892			out = append(out, p.Partno)
 1893		}
 1894		return strings.Join(out, ",")
 1895	}
 1896	if got := names(c.products("diode", "")); got != "a,b,c,d" {
 1897		t.Errorf("/cat/diode lists %q, want %q", got, "a,b,c,d")
 1898	}
 1899	if got := names(c.products("diode", "1N47XX")); got != "a,b" {
 1900		t.Errorf("/cat/diode/1N47XX lists %q, want %q", got, "a,b")
 1901	}
 1902	if got := names(c.products("", "")); got != "a,b,c,d,e" {
 1903		t.Errorf("all products lists %q, want %q", got, "a,b,c,d,e")
 1904	}
 1905}
 1906
 1907// Targets have to survive the layout: the rows carry where each entry goes.
 1908func TestBuildTreeTargets(t *testing.T) {
 1909	got := sampleCatalog().buildTree(map[string]bool{"resistor": true})
 1910	want := []navTarget{{}, {cat: "resistor"}, {cat: "resistor", subcat: "quarter watt 5%"},
 1911		{cat: "resistor", subcat: "ceramic"}, {cat: "inductor"}}
 1912	for i := range want {
 1913		if got[i].target != want[i] {
 1914			t.Errorf("row %d target %+v, want %+v", i, got[i].target, want[i])
 1915		}
 1916	}
 1917	if !got[1].hasSubs || got[4].hasSubs {
 1918		t.Error("hasSubs is wrong: resistor has subcategories, inductor does not")
 1919	}
 1920}
 1921
 1922
 1923// ===== pkg/tui/checkout.go =====
 1924// Package tui pkg/tui/checkout.go β€” the cart, the shipping form, and
 1925// checkout, mirroring the website's footer commerce (footer.html + the
 1926// cart wasm). On the website the cart and the shipping form are
 1927// <details> dropdowns rising from the fixed footer and checkout is a
 1928// <dialog>; here they are overlays above the footer bar, outside the
 1929// navigation history β€” v toggles the cart, Esc closes.
 1930//
 1931// The TUI is a client of the same store server the browser cart talks
 1932// to: it builds the identical items payload ("partno X qty" lines plus
 1933// a "shipping-to|..." line) and POSTs the same /create-payment-intent.
 1934// The card-entry step lives behind payWithElements: in the browser
 1935// (pay_js.go) it lays the Stripe Payment Element over the checkout
 1936// dialog, as the cart wasm opens it in the page's; a native terminal
 1937// (pay_native.go) cannot take card details, and says so.
 1938package tui
 1939
 1940import (
 1941	"bytes"
 1942	"encoding/json"
 1943	"fmt"
 1944	"io"
 1945	"net/http"
 1946	"strings"
 1947	"time"
 1948
 1949	"github.com/gdamore/tcell/v3"
 1950
 1951	"github.com/0magnet/m2/pkg/storepage"
 1952)
 1953
 1954// The shipping line, the form that fills it and the state list all live in
 1955// pkg/storepage now, so the page, this and the cart wasm cannot drift apart.
 1956// See pkg/storepage/shipping.go.
 1957
 1958// commerceRect anchors an overlay just above the footer summary it
 1959// expands from β€” the cart (and checkout) above View Cart, the form
 1960// above Add Shipping Info.
 1961func (u *ui) commerceRect() rect {
 1962	w, h := u.commerce.size()
 1963	sw, sh := u.screen.Size()
 1964	if w > sw-2 {
 1965		w = sw - 2
 1966	}
 1967	x := 1
 1968	region := 0
 1969	if _, ok := u.commerce.(*shippingOverlay); ok {
 1970		region = 1
 1971	}
 1972	if region < len(u.footRegions) {
 1973		x = u.footRegions[region].x1 - 1
 1974	}
 1975	if x+w > sw-1 {
 1976		x = sw - 1 - w
 1977	}
 1978	if x < 0 {
 1979		x = 0
 1980	}
 1981	y := sh - 2 - h
 1982	if y < 1 {
 1983		y = 1
 1984		h = sh - 3
 1985	}
 1986	return rect{x, y, w, h}
 1987}
 1988
 1989func (u *ui) closeCommerce() {
 1990	u.commerce = nil
 1991}
 1992
 1993func (u *ui) toggleCart() {
 1994	if u.commerce != nil {
 1995		u.closeCommerce()
 1996		return
 1997	}
 1998	u.openCart()
 1999}
 2000
 2001// ---- the cart overlay ----
 2002
 2003// The page's cart is a table β€” item, price, a quantity box and Remove on
 2004// each line β€” so this one has a line the arrows are on, whose quantity +
 2005// and - change and r takes out.
 2006type cartOverlay struct {
 2007	ta    *textArea
 2008	lines int
 2009	sel   int      // the line the arrows are on
 2010	items []string // the part numbers on its lines, in order
 2011}
 2012
 2013func (u *ui) openCart() {
 2014	sel := 0
 2015	if c, ok := u.commerce.(*cartOverlay); ok {
 2016		sel = c.sel
 2017	}
 2018	var items []string
 2019	for _, partno := range u.cartOrder {
 2020		if u.cartQty[partno] > 0 && u.cat.find(partno) != nil {
 2021			items = append(items, partno)
 2022		}
 2023	}
 2024	if sel >= len(items) {
 2025		sel = len(items) - 1
 2026	}
 2027	if sel < 0 {
 2028		sel = 0
 2029	}
 2030	var b strings.Builder
 2031	total := 0
 2032	for i, partno := range items {
 2033		qty := u.cartQty[partno]
 2034		p := u.cat.find(partno)
 2035		cents := priceCents(p.Price) * qty
 2036		total += cents
 2037		mark, name := "  ", "[aqua]"+esc(p.Name)+"[-]"
 2038		if i == sel {
 2039			mark, name = "[#5fd7ff]β–Έ[-] ", "[black:aqua]"+esc(p.Name)+"[-:-]"
 2040		}
 2041		fmt.Fprintf(&b, "%s%3d Γ— %s  $%d.%02d\n", mark, qty, name, cents/100, cents%100)
 2042	}
 2043	if u.shipping != nil {
 2044		total += u.shipping.Cents
 2045		to := strings.TrimSpace(u.shipping.Name + " " + u.shipping.City + " " + u.shipping.State)
 2046		fmt.Fprintf(&b, "      shipping[gray] to %s[-]  $%d.%02d\n",
 2047			esc(to), u.shipping.Cents/100, u.shipping.Cents%100)
 2048	}
 2049	if total == 0 {
 2050		b.WriteString("[gray]your cart is empty[-]\n")
 2051	}
 2052	fmt.Fprintf(&b, "\nTotal: $%d.%02d\n", total/100, total%100)
 2053	if len(items) > 0 {
 2054		b.WriteString("\n[#5fd7ff]↑↓[-] line Β· [#5fd7ff]+ -[-] quantity Β· [#5fd7ff]r[-] remove")
 2055	}
 2056	b.WriteString("\n[#5fd7ff]s[-] shipping Β· [#5fd7ff]p[-] card")
 2057	if coinOffered {
 2058		b.WriteString(" Β· [#5fd7ff]k[-] coin")
 2059	}
 2060	b.WriteString(" Β· [#5fd7ff]x[-] empty Β· [#5fd7ff]v[-] close\n")
 2061	fmt.Fprintf(&b, "[gray]checkout talks to the store server at %s[-]", esc(ordersURL()))
 2062	content := b.String()
 2063	u.commerce = &cartOverlay{ta: newTextArea(content, false), lines: strings.Count(content, "\n") + 1, sel: sel, items: items}
 2064}
 2065
 2066func (c *cartOverlay) title() string { return "View Cart" }
 2067func (c *cartOverlay) size() (int, int) {
 2068	return 70, c.lines + 2
 2069}
 2070
 2071func (c *cartOverlay) draw(u *ui, sc tcell.Screen, r rect) {
 2072	c.ta.draw(sc, r)
 2073}
 2074
 2075func (c *cartOverlay) key(u *ui, ev *tcell.EventKey) bool {
 2076	line := ""
 2077	if c.sel < len(c.items) {
 2078		line = c.items[c.sel]
 2079	}
 2080	switch ev.Key() {
 2081	case tcell.KeyUp:
 2082		if c.sel > 0 {
 2083			c.sel--
 2084			u.openCart()
 2085		}
 2086		return true
 2087	case tcell.KeyDown:
 2088		if c.sel < len(c.items)-1 {
 2089			c.sel++
 2090			u.openCart()
 2091		}
 2092		return true
 2093	case tcell.KeyDelete:
 2094		if line != "" {
 2095			u.setQty(line, 0)
 2096			u.openCart()
 2097		}
 2098		return true
 2099	}
 2100	switch keyRune(ev) {
 2101	case '+', '=':
 2102		if line != "" {
 2103			u.setQty(line, u.cartQty[line]+1)
 2104			u.openCart()
 2105		}
 2106		return true
 2107	case '-':
 2108		if line != "" {
 2109			u.setQty(line, u.cartQty[line]-1)
 2110			u.openCart()
 2111		}
 2112		return true
 2113	case 'r':
 2114		if line != "" {
 2115			u.setQty(line, 0)
 2116			u.openCart()
 2117		}
 2118		return true
 2119	case 'x':
 2120		u.emptyCart()
 2121		u.openCart()
 2122		return true
 2123	case 's':
 2124		u.openShipping()
 2125		return true
 2126	case 'p':
 2127		u.startCheckout()
 2128		return true
 2129	case 'k':
 2130		if !coinOffered {
 2131			return false
 2132		}
 2133		// The checkout a terminal can finish by itself; see crypto.go.
 2134		u.startCoinCheckout()
 2135		return true
 2136	}
 2137	return false
 2138}
 2139
 2140// ---- the shipping form, from footer.html's Add Shipping Info ----
 2141
 2142type shippingOverlay struct {
 2143	frm *form
 2144	h   int
 2145}
 2146
 2147func (s *shippingOverlay) title() string                       { return "Add Shipping Info" }
 2148func (s *shippingOverlay) size() (int, int)                    { return 62, s.h }
 2149func (s *shippingOverlay) draw(u *ui, sc tcell.Screen, r rect) { s.frm.draw(sc, r) }
 2150func (s *shippingOverlay) key(u *ui, ev *tcell.EventKey) bool  { return s.frm.key(ev) }
 2151
 2152func (u *ui) openShipping() {
 2153	prev := u.shipping
 2154	if prev == nil {
 2155		prev = &storepage.Shipping{Cents: storepage.MinShippingCents, Country: storepage.DefaultCountry}
 2156	}
 2157	// Built by walking the shared field list rather than naming each input,
 2158	// so a field added to the form appears here and on the page together.
 2159	// read[name] returns what that control holds, in the spelling the cart
 2160	// line wants β€” a state picker shows "Texas" and yields "TX".
 2161	read := map[string]func() string{}
 2162	items := make([]formItem, 0, len(storepage.ShippingFields))
 2163	for _, f := range storepage.ShippingFields {
 2164		switch f.Kind {
 2165		case storepage.FieldState:
 2166			opts := storepage.StateNames()
 2167			dd := &fdropdown{lbl: f.TUILabel, opts: opts}
 2168			for i, n := range opts {
 2169				if n == storepage.StateName(prev.State) {
 2170					dd.sel = i
 2171				}
 2172			}
 2173			items = append(items, dd)
 2174			read[f.Name] = func() string { return storepage.StateCode(dd.value()) }
 2175		case storepage.FieldCountry:
 2176			dd := &fdropdown{lbl: f.TUILabel, opts: storepage.Countries}
 2177			items = append(items, dd)
 2178			read[f.Name] = dd.value
 2179		default:
 2180			val := prev.Get(f.Name)
 2181			if f.Kind == storepage.FieldMoney {
 2182				val = fmt.Sprintf("%d.%02d", prev.Cents/100, prev.Cents%100)
 2183			}
 2184			in := &finput{lbl: f.TUILabel, text: []rune(val), width: f.Width}
 2185			in.cur = len(in.text)
 2186			items = append(items, in)
 2187			read[f.Name] = in.value
 2188		}
 2189	}
 2190
 2191	// Declared separately on purpose: the buttons below close over frm, so it
 2192	// has to exist before newForm is called.
 2193	var frm *form //nolint:staticcheck
 2194	frm = newForm(items, []fbutton{
 2195		{"Add Shipping to Cart", func() {
 2196			sh := storepage.Shipping{Cents: priceCents(read["shipping-price"]())}
 2197			for _, f := range storepage.ShippingFields {
 2198				if f.Kind != storepage.FieldMoney {
 2199					sh.Set(f.Name, read[f.Name]())
 2200				}
 2201			}
 2202			if msg := sh.Validate(); msg != "" {
 2203				u.notice = msg
 2204				return
 2205			}
 2206			u.shipping = &sh
 2207			u.cartChanged()
 2208			u.openCart()
 2209		}},
 2210		{"Cancel", func() { u.openCart() }},
 2211	}, func() { u.openCart() })
 2212	u.commerce = &shippingOverlay{frm: frm, h: len(items)*2 + 3}
 2213}
 2214
 2215// ---- checkout: the cart wasm's flow, against the same server ----
 2216
 2217type checkoutOverlay struct {
 2218	ta *textArea
 2219	// form is set while the card form is laid over the dialog (pay_js.go):
 2220	// the dialog grows to hold it, keeps a line above it for what is being
 2221	// paid and one below for the keys, and Enter pays.
 2222	form *cardForm
 2223}
 2224
 2225// cardForm is the card form's part of the dialog.
 2226type cardForm struct {
 2227	head string // what is being paid, above the form
 2228	pay  func() // confirms the payment, as the form's own Pay button does
 2229}
 2230
 2231func (c *checkoutOverlay) title() string { return "Checkout" }
 2232func (c *checkoutOverlay) size() (int, int) {
 2233	if c.form != nil {
 2234		return 72, 32
 2235	}
 2236	return 78, 14
 2237}
 2238
 2239func (c *checkoutOverlay) draw(u *ui, sc tcell.Screen, r rect) {
 2240	if c.form == nil || r.h < 6 {
 2241		c.ta.draw(sc, r)
 2242		return
 2243	}
 2244	printMarkup(sc, r.x+1, r.y, r.w-2, c.form.head, styleText)
 2245	formR := rect{r.x, r.y + 1, r.w, r.h - 3}
 2246	// The cells under the form, for a terminal that cannot place it: the
 2247	// dialog's own message says what is missing there.
 2248	c.ta.draw(sc, formR)
 2249	u.want("card", placed{r: formR, widget: "stripe", input: true})
 2250	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)
 2251}
 2252
 2253func (c *checkoutOverlay) key(u *ui, ev *tcell.EventKey) bool {
 2254	if c.form != nil && ev.Key() == tcell.KeyEnter {
 2255		c.form.pay()
 2256		return true
 2257	}
 2258	return c.ta.key(ev)
 2259}
 2260
 2261// ordersURL is where checkout is served from: the store being browsed
 2262// in client mode, else SITEORDERSURL when the config names one (as the
 2263// website's cart uses it), else the local store server.
 2264func ordersURL() string {
 2265	if s := storeURL(); s != "" {
 2266		return s
 2267	}
 2268	if f.Siteordersurl != "" {
 2269		return f.Siteordersurl
 2270	}
 2271	port := f.WebPort
 2272	if port == 0 {
 2273		port = 9883
 2274	}
 2275	return fmt.Sprintf("http://127.0.0.1:%d", port)
 2276}
 2277
 2278// cartItem matches the JSON the cart wasm POSTs to /create-payment-intent.
 2279type cartItem struct {
 2280	ID     string `json:"ID"`
 2281	Amount int64  `json:"Amount"`
 2282}
 2283
 2284func (u *ui) startCheckout() {
 2285	if len(u.cartOrder) == 0 {
 2286		u.notice = "the cart is empty"
 2287		return
 2288	}
 2289	if u.shipping == nil {
 2290		u.notice = "add shipping info first (s in the cart)"
 2291		return
 2292	}
 2293	if !storepage.ValidEmail(u.shipping.Email) {
 2294		u.notice = "add an email to the shipping info, for the order confirmation (s in the cart)"
 2295		return
 2296	}
 2297
 2298	// The same payload the cart wasm sends, and the same one the coin
 2299	// checkout sends: see cartItems in crypto.go.
 2300	items := u.cartItems()
 2301
 2302	co := &checkoutOverlay{ta: newTextArea("\n contacting "+esc(ordersURL())+" …", true)}
 2303	u.commerce = co
 2304
 2305	go func() {
 2306		clientSecret, err := createPaymentIntent(items)
 2307		u.post(func() {
 2308			if u.commerce != co {
 2309				return // the overlay was closed meanwhile
 2310			}
 2311			if err != nil {
 2312				co.ta.setContent(fmt.Sprintf(
 2313					"\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",
 2314					esc(err.Error()), esc(ordersURL())))
 2315				return
 2316			}
 2317			u.payWithElements(co, clientSecret)
 2318		})
 2319	}()
 2320}
 2321
 2322// createPaymentIntent POSTs the cart to the store server, which
 2323// validates every line against the catalog and answers with the payment
 2324// intent's client secret β€” identical to the cart wasm's fetch.
 2325func createPaymentIntent(items []cartItem) (string, error) {
 2326	body, err := json.Marshal(map[string]interface{}{"items": items})
 2327	if err != nil {
 2328		return "", err
 2329	}
 2330	client := &http.Client{Timeout: 15 * time.Second}
 2331	resp, err := client.Post(ordersURL()+"/create-payment-intent", "application/json", bytes.NewReader(body))
 2332	if err != nil {
 2333		return "", err
 2334	}
 2335	defer resp.Body.Close() //nolint:errcheck // read-side close
 2336	data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
 2337	if err != nil {
 2338		return "", err
 2339	}
 2340	var out struct {
 2341		ClientSecret string `json:"clientSecret"`
 2342		Error        string `json:"error"`
 2343	}
 2344	if err := json.Unmarshal(data, &out); err != nil {
 2345		return "", fmt.Errorf("bad response (%d): %s", resp.StatusCode, strings.TrimSpace(string(data)))
 2346	}
 2347	if out.Error != "" {
 2348		return "", fmt.Errorf("%s", out.Error)
 2349	}
 2350	if out.ClientSecret == "" {
 2351		return "", fmt.Errorf("no client secret in response (%d)", resp.StatusCode)
 2352	}
 2353	return out.ClientSecret, nil
 2354}
 2355
 2356
 2357// ===== pkg/tui/crypto.go =====
 2358// Package tui pkg/tui/crypto.go β€” paying in coin from the terminal.
 2359//
 2360// This is the checkout a terminal can actually complete. Stripe's card
 2361// entry is a browser widget, which is why payWithElements tells a native
 2362// terminal it cannot take card details; a payment address is text, and text
 2363// is what a terminal has. The same /create-invoice endpoint the page uses
 2364// answers here, so the terminal is not a second implementation of anything
 2365// β€” it renders what the server already decided.
 2366package tui
 2367
 2368import (
 2369	"bytes"
 2370	"encoding/json"
 2371	"fmt"
 2372	"io"
 2373	"net/http"
 2374	"net/url"
 2375	"strings"
 2376	"time"
 2377
 2378	"github.com/gdamore/tcell/v3"
 2379
 2380	"github.com/0magnet/m2/pkg/storepage"
 2381)
 2382
 2383// invoiceView is the subset of an invoice the server hands a client.
 2384type invoiceView struct {
 2385	ID          string `json:"id"`
 2386	Coin        string `json:"coin"`
 2387	Address     string `json:"address"`
 2388	Amount      string `json:"amount"`
 2389	URI         string `json:"uri"`
 2390	FiatCents   int64  `json:"fiat_cents"`
 2391	Status      string `json:"status"`
 2392	Paid        string `json:"paid"`
 2393	Outstanding string `json:"outstanding"`
 2394	Expires     string `json:"expires"`
 2395	TxID        string `json:"txid"`
 2396	Error       string `json:"error"`
 2397}
 2398
 2399type invoiceOverlay struct {
 2400	ta  *textArea
 2401	inv *invoiceView
 2402}
 2403
 2404func (o *invoiceOverlay) title() string                       { return "Pay in coin" }
 2405func (o *invoiceOverlay) size() (int, int)                    { return 74, 16 }
 2406func (o *invoiceOverlay) draw(u *ui, sc tcell.Screen, r rect) { o.ta.draw(sc, r) }
 2407
 2408func (o *invoiceOverlay) key(u *ui, ev *tcell.EventKey) bool {
 2409	if keyRune(ev) == 'r' && o.inv != nil {
 2410		u.refreshInvoice(o)
 2411		return true
 2412	}
 2413	return o.ta.key(ev)
 2414}
 2415
 2416// startCoinCheckout asks the store for an invoice and shows it.
 2417func (u *ui) startCoinCheckout() {
 2418	if len(u.cartOrder) == 0 {
 2419		u.notice = "the cart is empty"
 2420		return
 2421	}
 2422	if u.shipping == nil {
 2423		u.notice = "add shipping info first (s in the cart)"
 2424		return
 2425	}
 2426	if !storepage.ValidEmail(u.shipping.Email) {
 2427		u.notice = "add an email to the shipping info, for the order confirmation (s in the cart)"
 2428		return
 2429	}
 2430	items := u.cartItems()
 2431
 2432	ov := &invoiceOverlay{ta: newTextArea("\n asking "+esc(ordersURL())+" for an address …", true)}
 2433	u.commerce = ov
 2434
 2435	go func() {
 2436		inv, err := createInvoice(items)
 2437		u.post(func() {
 2438			if u.commerce != ov {
 2439				return // closed meanwhile
 2440			}
 2441			if err != nil {
 2442				ov.ta.setContent(fmt.Sprintf(
 2443					"\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",
 2444					esc(err.Error())))
 2445				return
 2446			}
 2447			ov.inv = inv
 2448			ov.ta.setContent(renderInvoice(inv))
 2449		})
 2450	}()
 2451}
 2452
 2453// refreshInvoice re-reads one invoice; the server polls the chain when
 2454// asked, so this is what turns "pending" into "paid" on screen.
 2455func (u *ui) refreshInvoice(ov *invoiceOverlay) {
 2456	id := ov.inv.ID
 2457	go func() {
 2458		inv, err := getInvoice(id)
 2459		u.post(func() {
 2460			if u.commerce != ov {
 2461				return
 2462			}
 2463			if err != nil {
 2464				u.notice = "could not refresh: " + esc(err.Error())
 2465				return
 2466			}
 2467			ov.inv = inv
 2468			ov.ta.setContent(renderInvoice(inv))
 2469		})
 2470	}()
 2471}
 2472
 2473// cartItems builds the payload both checkouts send.
 2474func (u *ui) cartItems() []cartItem {
 2475	var items []cartItem
 2476	for _, partno := range u.cartOrder {
 2477		qty := u.cartQty[partno]
 2478		p := u.cat.find(partno)
 2479		if qty == 0 || p == nil {
 2480			continue
 2481		}
 2482		items = append(items, cartItem{
 2483			ID:     fmt.Sprintf("%s X %d", partno, qty),
 2484			Amount: int64(priceCents(p.Price) * qty),
 2485		})
 2486	}
 2487	if u.shipping != nil {
 2488		items = append(items, cartItem{ID: u.shipping.ID(), Amount: int64(u.shipping.Cents)})
 2489	}
 2490	return items
 2491}
 2492
 2493func renderInvoice(inv *invoiceView) string {
 2494	var b strings.Builder
 2495	fmt.Fprintf(&b, "\n send [::b]%s %s[-:-:-]\n", esc(inv.Amount), esc(inv.Coin))
 2496	fmt.Fprintf(&b, " to   [aqua]%s[-]\n\n", esc(inv.Address))
 2497	fmt.Fprintf(&b, " [gray]uri[-]    %s\n", esc(inv.URI))
 2498	fmt.Fprintf(&b, " [gray]order[-]  $%d.%02d\n", inv.FiatCents/100, inv.FiatCents%100)
 2499	if t, err := time.Parse(time.RFC3339, inv.Expires); err == nil {
 2500		fmt.Fprintf(&b, " [gray]quote holds until[-] %s\n", esc(t.Local().Format("15:04:05")))
 2501	}
 2502	b.WriteString("\n")
 2503	switch inv.Status {
 2504	case "paid":
 2505		b.WriteString(" [green]paid β€” thank you. the order is printing.[-]\n")
 2506		if inv.TxID != "" {
 2507			fmt.Fprintf(&b, " [gray]txid %s[-]\n", esc(inv.TxID))
 2508		}
 2509	case "underpaid":
 2510		fmt.Fprintf(&b, " [yellow]part paid:[-] %s received, [::b]%s[-:-:-] still owed\n",
 2511			esc(inv.Paid), esc(inv.Outstanding))
 2512	case "expired":
 2513		b.WriteString(" [red]the quote expired[-] β€” close and start again for a fresh price\n")
 2514	default:
 2515		b.WriteString(" [gray]waiting for the payment to confirm…[-]\n")
 2516	}
 2517	b.WriteString("\n [#5fd7ff]r[-] check again Β· [#5fd7ff]esc[-] close\n")
 2518	return b.String()
 2519}
 2520
 2521func createInvoice(items []cartItem) (*invoiceView, error) {
 2522	body, err := json.Marshal(map[string]any{"coin": "SKY", "items": items})
 2523	if err != nil {
 2524		return nil, err
 2525	}
 2526	client := &http.Client{Timeout: 30 * time.Second}
 2527	resp, err := client.Post(ordersURL()+"/create-invoice", "application/json", bytes.NewReader(body))
 2528	if err != nil {
 2529		return nil, err
 2530	}
 2531	return readInvoice(resp)
 2532}
 2533
 2534func getInvoice(id string) (*invoiceView, error) {
 2535	client := &http.Client{Timeout: 30 * time.Second}
 2536	resp, err := client.Get(ordersURL() + "/invoice/" + url.PathEscape(id))
 2537	if err != nil {
 2538		return nil, err
 2539	}
 2540	return readInvoice(resp)
 2541}
 2542
 2543func readInvoice(resp *http.Response) (*invoiceView, error) {
 2544	defer resp.Body.Close() //nolint:errcheck // read-side close
 2545	data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
 2546	if err != nil {
 2547		return nil, err
 2548	}
 2549	var inv invoiceView
 2550	if err := json.Unmarshal(data, &inv); err != nil {
 2551		return nil, fmt.Errorf("%s: %s", resp.Status, strings.TrimSpace(string(data)))
 2552	}
 2553	if inv.Error != "" {
 2554		return nil, fmt.Errorf("%s", inv.Error)
 2555	}
 2556	if resp.StatusCode != http.StatusOK {
 2557		return nil, fmt.Errorf("%s", resp.Status)
 2558	}
 2559	return &inv, nil
 2560}
 2561
 2562
 2563// ===== pkg/tui/frame_dump_test.go =====
 2564package tui
 2565
 2566import (
 2567	"image/png"
 2568	"os"
 2569	"testing"
 2570)
 2571
 2572func TestDumpFrame(t *testing.T) {
 2573	out := os.Getenv("FRAMEOUT")
 2574	if out == "" {
 2575		t.Skip("set FRAMEOUT to dump a frame")
 2576	}
 2577	logo, err := loadImage(os.Getenv("FRAMELOGO"))
 2578	if err != nil {
 2579		t.Fatal(err)
 2580	}
 2581	bd := makeBackdrop(logo, 200, 100)
 2582	fr := globeFrame(bd, 0.9, 2.3, -0.6)
 2583	f, err := os.Create(out) //nolint:gosec
 2584	if err != nil {
 2585		t.Fatalf("create %s: %v", out, err)
 2586	}
 2587	defer f.Close() //nolint:errcheck,gosec
 2588	if err := png.Encode(f, fr); err != nil {
 2589		t.Fatal(err)
 2590	}
 2591}
 2592
 2593
 2594// ===== pkg/tui/htmltable.go =====
 2595// Package tui pkg/tui/htmltable.go β€” tables in a content page.
 2596//
 2597// htmlToText used to flatten a table: </td> became two spaces and </tr> a
 2598// newline, so the Links page β€” which is a three-column table of supplier,
 2599// location and notes β€” arrived as ragged prose with the columns running into
 2600// each other. The site draws it as a table and so should this.
 2601//
 2602// The work is laid out here rather than in render.go because it needs its own
 2603// wrapper: a cell's text carries the [aqua] and [link=N] tags the anchor pass
 2604// already wrote into it, and those are zero width. Wrapping on len() would
 2605// count them and wrapping without them would drop a link's styling β€” and its
 2606// clickable region β€” at the break.
 2607package tui
 2608
 2609import (
 2610	"regexp"
 2611	"strings"
 2612
 2613	xhtml "html"
 2614)
 2615
 2616var (
 2617	tagTable    = regexp.MustCompile(`(?is)<table[^>]*>(.*?)</table>`)
 2618	tagRow      = regexp.MustCompile(`(?is)<tr[^>]*>(.*?)</tr>`)
 2619	tagCellFull = regexp.MustCompile(`(?is)<(t[dh])[^>]*>(.*?)</t[dh]>`)
 2620	runOfSpaces = regexp.MustCompile(`[ \t\n]+`)
 2621)
 2622
 2623// colGap is the space between columns, in cells.
 2624const colGap = 2
 2625
 2626// minCol is how narrow shrinking may make a column before it gives up and
 2627// lets the table overflow instead. A column squeezed below this is unreadable,
 2628// and an honestly too-wide table is easier to deal with than a shredded one.
 2629const minCol = 6
 2630
 2631// cellText reduces one cell's HTML to its printed text, keeping the markup
 2632// tags the anchor pass wrote and dropping everything else.
 2633func cellText(s string) string {
 2634	s = tagBreak.ReplaceAllString(s, " ")
 2635	s = tagAny.ReplaceAllString(s, "")
 2636	s = xhtml.UnescapeString(s)
 2637	return strings.TrimSpace(runOfSpaces.ReplaceAllString(s, " "))
 2638}
 2639
 2640// isResetTag reports whether a color tag turns styling off rather than on:
 2641// "[-]", "[-:-:-]" and the like are built only from dashes and colons.
 2642func isResetTag(body string) bool {
 2643	if body == "" {
 2644		return false
 2645	}
 2646	return strings.Trim(body, "-:") == ""
 2647}
 2648
 2649// mtok is one piece of a tagged string: a zero-width tag, or one visible rune.
 2650type mtok struct {
 2651	tag string
 2652	txt string
 2653}
 2654
 2655// markupTokens splits a tagged string the way parseMarkup reads it, so the
 2656// two agree on what is a tag and what is text. "[[" is a literal bracket.
 2657func markupTokens(s string) []mtok {
 2658	var out []mtok
 2659	r := []rune(s)
 2660	for i := 0; i < len(r); i++ {
 2661		if r[i] != '[' {
 2662			out = append(out, mtok{txt: string(r[i])})
 2663			continue
 2664		}
 2665		if i+1 < len(r) && r[i+1] == '[' {
 2666			out = append(out, mtok{txt: "["})
 2667			i++
 2668			continue
 2669		}
 2670		end := -1
 2671		for j := i + 1; j < len(r) && j < i+24; j++ {
 2672			if r[j] == ']' {
 2673				end = j
 2674				break
 2675			}
 2676		}
 2677		if end < 0 {
 2678			out = append(out, mtok{txt: "["})
 2679			continue
 2680		}
 2681		body := string(r[i+1 : end])
 2682		if _, ok := linkBody(body); ok || tagBody(body) {
 2683			out = append(out, mtok{tag: "[" + body + "]"})
 2684			i = end
 2685			continue
 2686		}
 2687		out = append(out, mtok{txt: "["})
 2688	}
 2689	return out
 2690}
 2691
 2692// wrapMarkup breaks a tagged string into lines of at most w printed columns.
 2693//
 2694// The color and link tags still open at a break are repeated at the start of
 2695// the next line: each line is drawn on its own, so a wrapped link that did not
 2696// carry its [link=N] across would stop being clickable half way through.
 2697func wrapMarkup(s string, w int) []string {
 2698	if w < 1 {
 2699		w = 1
 2700	}
 2701	var (
 2702		lines       []string
 2703		line        strings.Builder
 2704		lineW       int
 2705		color, link string
 2706		word        []mtok
 2707		wordW       int
 2708	)
 2709	startLine := func() {
 2710		line.Reset()
 2711		lineW = 0
 2712		line.WriteString(color)
 2713		line.WriteString(link)
 2714	}
 2715	pushLine := func() {
 2716		lines = append(lines, line.String())
 2717		startLine()
 2718	}
 2719	emitWord := func() {
 2720		if len(word) == 0 {
 2721			return
 2722		}
 2723		switch {
 2724		case lineW > 0 && lineW+1+wordW > w:
 2725			pushLine()
 2726		case lineW > 0:
 2727			line.WriteString(" ")
 2728			lineW++
 2729		}
 2730		for _, t := range word {
 2731			if t.tag != "" {
 2732				body := t.tag[1 : len(t.tag)-1]
 2733				if _, ok := linkBody(body); ok {
 2734					if body == "/link" {
 2735						link = ""
 2736					} else {
 2737						link = t.tag
 2738					}
 2739				} else if isResetTag(body) {
 2740					color = ""
 2741				} else {
 2742					color = t.tag
 2743				}
 2744				line.WriteString(t.tag)
 2745				continue
 2746			}
 2747			if lineW >= w { // a single word longer than the column
 2748				pushLine()
 2749			}
 2750			if t.txt == "[" {
 2751				line.WriteString("[[")
 2752			} else {
 2753				line.WriteString(t.txt)
 2754			}
 2755			lineW++
 2756		}
 2757		word = word[:0]
 2758		wordW = 0
 2759	}
 2760
 2761	for _, t := range markupTokens(s) {
 2762		if t.tag == "" && t.txt == " " {
 2763			emitWord()
 2764			continue
 2765		}
 2766		word = append(word, t)
 2767		if t.tag == "" {
 2768			wordW++
 2769		}
 2770	}
 2771	emitWord()
 2772	if lineW > 0 || len(lines) == 0 {
 2773		lines = append(lines, line.String())
 2774	}
 2775	return lines
 2776}
 2777
 2778// fitCols shrinks the natural column widths until the row fits, always taking
 2779// from the widest column so the notes column gives way before the two narrow
 2780// ones do.
 2781func fitCols(nat []int, width int) []int {
 2782	cols := append([]int(nil), nat...)
 2783	n := len(cols)
 2784	if n == 0 {
 2785		return cols
 2786	}
 2787	avail := width - colGap*(n-1)
 2788	if avail < n {
 2789		avail = n
 2790	}
 2791	total := 0
 2792	for _, c := range cols {
 2793		total += c
 2794	}
 2795	for total > avail {
 2796		wi, wv := 0, cols[0]
 2797		for i, c := range cols {
 2798			if c > wv {
 2799				wi, wv = i, c
 2800			}
 2801		}
 2802		if wv <= minCol {
 2803			break
 2804		}
 2805		cols[wi]--
 2806		total--
 2807	}
 2808	return cols
 2809}
 2810
 2811// renderTable draws one table's inner HTML as aligned columns.
 2812func renderTable(inner string, width int) string {
 2813	var (
 2814		rows  [][]string
 2815		heads []bool
 2816	)
 2817	for _, r := range tagRow.FindAllStringSubmatch(inner, -1) {
 2818		cells := tagCellFull.FindAllStringSubmatch(r[1], -1)
 2819		if len(cells) == 0 {
 2820			continue
 2821		}
 2822		row := make([]string, 0, len(cells))
 2823		head := true
 2824		for _, c := range cells {
 2825			if !strings.EqualFold(c[1], "th") {
 2826				head = false
 2827			}
 2828			row = append(row, cellText(c[2]))
 2829		}
 2830		rows = append(rows, row)
 2831		heads = append(heads, head)
 2832	}
 2833	if len(rows) == 0 {
 2834		return ""
 2835	}
 2836	n := 0
 2837	for _, r := range rows {
 2838		if len(r) > n {
 2839			n = len(r)
 2840		}
 2841	}
 2842	nat := make([]int, n)
 2843	for _, r := range rows {
 2844		for i, c := range r {
 2845			if w := markupWidth(c); w > nat[i] {
 2846				nat[i] = w
 2847			}
 2848		}
 2849	}
 2850	cols := fitCols(nat, width)
 2851
 2852	var b strings.Builder
 2853	for ri, row := range rows {
 2854		cellLines := make([][]string, n)
 2855		h := 1
 2856		for i := 0; i < n; i++ {
 2857			txt := ""
 2858			if i < len(row) {
 2859				txt = row[i]
 2860			}
 2861			if heads[ri] && txt != "" {
 2862				txt = "[::b]" + txt + "[-:-:-]"
 2863			}
 2864			cellLines[i] = wrapMarkup(txt, cols[i])
 2865			if len(cellLines[i]) > h {
 2866				h = len(cellLines[i])
 2867			}
 2868		}
 2869		for li := 0; li < h; li++ {
 2870			var line strings.Builder
 2871			for i := 0; i < n; i++ {
 2872				cell := ""
 2873				if li < len(cellLines[i]) {
 2874					cell = cellLines[i][li]
 2875				}
 2876				line.WriteString(cell)
 2877				if i < n-1 {
 2878					pad := cols[i] - markupWidth(cell) + colGap
 2879					if pad < 1 {
 2880						pad = 1
 2881					}
 2882					line.WriteString(strings.Repeat(" ", pad))
 2883				}
 2884			}
 2885			b.WriteString(strings.TrimRight(line.String(), " "))
 2886			b.WriteByte('\n')
 2887		}
 2888		if heads[ri] {
 2889			var rule strings.Builder
 2890			for i := 0; i < n; i++ {
 2891				rule.WriteString(strings.Repeat("─", cols[i]))
 2892				if i < n-1 {
 2893					rule.WriteString(strings.Repeat(" ", colGap))
 2894				}
 2895			}
 2896			b.WriteString("[gray]" + rule.String() + "[-]\n")
 2897		}
 2898	}
 2899	return b.String()
 2900}
 2901
 2902
 2903// ===== pkg/tui/htmltable_test.go =====
 2904package tui
 2905
 2906import (
 2907	"strings"
 2908	"testing"
 2909)
 2910
 2911// A table is the Links page's whole content. Flattened to runs of spaces it
 2912// stopped being a table at all, so what matters is that the columns line up
 2913// and that a cell too wide for its column wraps inside it rather than
 2914// pushing everything after it out of true.
 2915func TestTableAlignsColumnsAndWraps(t *testing.T) {
 2916	const in = `<table>
 2917<thead><tr><th>Supplier</th><th>Where</th><th>Notes</th></tr></thead>
 2918<tbody>
 2919<tr><td>Alpha</td><td>Dallas, TX</td><td>one two three four five six seven</td></tr>
 2920<tr><td>Beta</td><td>Lima, OH</td><td>short</td></tr>
 2921</tbody></table>`
 2922	text, _ := htmlToText(in, "2026", 46)
 2923	lines := strings.Split(strings.TrimRight(text, "\n"), "\n")
 2924	for _, l := range lines {
 2925		if markupWidth(l) > 46 {
 2926			t.Errorf("line wider than the pane (%d): %q", markupWidth(l), l)
 2927		}
 2928	}
 2929	// Every row starts its second column at the same place.
 2930	if a, b := columnOf(lines, "Dallas, TX"), columnOf(lines, "Lima, OH"); a != b || a <= 0 {
 2931		t.Errorf("second column not aligned: %d vs %d\n%s", a, b, text)
 2932	}
 2933	// The long notes cell wrapped instead of running off.
 2934	if !strings.Contains(text, "seven") {
 2935		t.Errorf("wrapped text lost:\n%s", text)
 2936	}
 2937	if columnOf(lines, "one") != columnOf(lines, "six") {
 2938		t.Errorf("continuation not in the notes column:\n%s", text)
 2939	}
 2940}
 2941
 2942// A link that wraps has to stay a link on the second line: each line is
 2943// drawn on its own, so the [link=N] tag must be repeated at the break.
 2944func TestWrappedLinkKeepsItsTag(t *testing.T) {
 2945	const in = `<table><tr><td><a href='https://example.com/'>a very long supplier name here</a></td></tr></table>`
 2946	text, hrefs := htmlToText(in, "2026", 12)
 2947	if len(hrefs) != 1 || hrefs[0] != "https://example.com/" {
 2948		t.Fatalf("hrefs = %v", hrefs)
 2949	}
 2950	for i, l := range strings.Split(strings.TrimSpace(text), "\n") {
 2951		if strings.TrimSpace(stripTagsLine(l)) == "" {
 2952			continue
 2953		}
 2954		if !strings.Contains(l, "[link=1]") {
 2955			t.Errorf("line %d dropped the link tag: %q", i, l)
 2956		}
 2957	}
 2958}
 2959
 2960// Anchors inside a table are numbered with the rest of the page, in
 2961// document order: the table is set aside and drawn last, which must not
 2962// renumber anything.
 2963func TestTableAnchorsKeepDocumentOrder(t *testing.T) {
 2964	const in = `<p><a href='/one'>one</a></p>
 2965<table><tr><td><a href='/two'>two</a></td><td><a href='/three'>three</a></td></tr></table>
 2966<p><a href='/four'>four</a></p>`
 2967	_, hrefs := htmlToText(in, "2026", 60)
 2968	want := []string{"/one", "/two", "/three", "/four"}
 2969	if len(hrefs) != len(want) {
 2970		t.Fatalf("hrefs = %v", hrefs)
 2971	}
 2972	for i := range want {
 2973		if hrefs[i] != want[i] {
 2974			t.Errorf("hrefs[%d] = %q, want %q", i, hrefs[i], want[i])
 2975		}
 2976	}
 2977}
 2978
 2979func stripTagsLine(s string) string {
 2980	out := ""
 2981	for _, sg := range parseMarkup(s, styleText) {
 2982		out += string(sg.txt)
 2983	}
 2984	return out
 2985}
 2986
 2987// columnOf reports the printed column a word starts at, on the first line
 2988// that holds it.
 2989func columnOf(lines []string, word string) int {
 2990	for _, l := range lines {
 2991		plain := stripTagsLine(l)
 2992		if i := strings.Index(plain, word); i >= 0 {
 2993			return i
 2994		}
 2995	}
 2996	return -1
 2997}
 2998
 2999
 3000// ===== pkg/tui/kit.go =====
 3001// Package tui pkg/tui/kit.go β€” drawing primitives on tcell: a small
 3002// color-tag markup ([fg:bg:flags], as tview popularized), line printing
 3003// with alignment and wrapping, and bordered boxes. The TUI is written
 3004// straight on tcell v3 so it runs wherever the 0magnet ecosystem's
 3005// terminal stack does β€” natively today, in the browser via
 3006// tuiwasm/xtcell tomorrow.
 3007package tui
 3008
 3009import (
 3010	"fmt"
 3011	"strconv"
 3012	"strings"
 3013
 3014	"github.com/gdamore/tcell/v3"
 3015)
 3016
 3017type rect struct{ x, y, w, h int }
 3018
 3019// esc quotes user data so it never parses as a color tag: "[" doubles,
 3020// and the parser reads "[[" back as one literal bracket.
 3021func esc(s string) string {
 3022	return strings.ReplaceAll(s, "[", "[[")
 3023}
 3024
 3025// seg is a run of text in one style, optionally inside a link.
 3026type seg struct {
 3027	txt []rune
 3028	st  tcell.Style
 3029	// link is the 1-based index of the anchor this run belongs to, or 0
 3030	// for ordinary text. What it indexes is the drawing code's business:
 3031	// each page keeps its own table of what its links do. See linkTag.
 3032	link int
 3033}
 3034
 3035// namedColors are the palette names the markup uses; anything else goes
 3036// to tcell.GetColor (which handles #rrggbb and the W3C names).
 3037var namedColors = map[string]tcell.Color{
 3038	"aqua":   tcell.ColorAqua.TrueColor(),
 3039	"white":  tcell.ColorWhite.TrueColor(),
 3040	"black":  tcell.ColorBlack.TrueColor(),
 3041	"gray":   tcell.GetColor("#808080"),
 3042	"grey":   tcell.GetColor("#808080"),
 3043	"yellow": tcell.ColorYellow.TrueColor(),
 3044	"red":    tcell.ColorRed.TrueColor(),
 3045	"green":  tcell.ColorGreen.TrueColor(),
 3046	"orange": tcell.GetColor("#ffa500"),
 3047}
 3048
 3049func markupColor(name string) (tcell.Color, bool) {
 3050	if c, ok := namedColors[name]; ok {
 3051		return c, true
 3052	}
 3053	c := tcell.GetColor(name)
 3054	if c == tcell.ColorDefault && name != "default" {
 3055		return c, false
 3056	}
 3057	return c, true
 3058}
 3059
 3060// linkTag wraps text in an anchor: everything between [link=N] and
 3061// [/link] carries link index N, which the drawing code turns into a
 3062// clickable region. N is 1-based so that a zero index means "no link".
 3063//
 3064// The page's own markup is the only thing that may produce these β€” esc
 3065// doubles the bracket in anything that came from the catalog or from a
 3066// content file, so user data cannot open an anchor.
 3067func linkTag(n int, s string) string {
 3068	return fmt.Sprintf("[link=%d]%s[/link]", n, s)
 3069}
 3070
 3071// linkBody reads a [link=N] / [/link] tag, returning the index it opens
 3072// (0 for the closing tag) and whether the body was one of them at all.
 3073func linkBody(s string) (int, bool) {
 3074	if s == "/link" {
 3075		return 0, true
 3076	}
 3077	rest, ok := strings.CutPrefix(s, "link=")
 3078	if !ok {
 3079		return 0, false
 3080	}
 3081	n, err := strconv.Atoi(rest)
 3082	if err != nil || n < 1 {
 3083		return 0, false
 3084	}
 3085	return n, true
 3086}
 3087
 3088// tagBody reports whether the text between brackets looks like a color
 3089// tag: colors and flags only, at most two colons.
 3090func tagBody(s string) bool {
 3091	if s == "" {
 3092		return false
 3093	}
 3094	colons := 0
 3095	for _, r := range s {
 3096		switch {
 3097		case r == ':':
 3098			colons++
 3099		case r == '#' || r == '-':
 3100		case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
 3101		default:
 3102			return false
 3103		}
 3104	}
 3105	return colons <= 2
 3106}
 3107
 3108// applyTag folds one [fg:bg:flags] spec into a style. Empty parts keep,
 3109// "-" resets to the base.
 3110func applyTag(cur, base tcell.Style, body string) tcell.Style {
 3111	parts := strings.SplitN(body, ":", 3)
 3112	if len(parts) > 0 && parts[0] != "" {
 3113		if parts[0] == "-" {
 3114			cur = cur.Foreground(base.GetForeground())
 3115		} else if c, ok := markupColor(parts[0]); ok {
 3116			cur = cur.Foreground(c)
 3117		}
 3118	}
 3119	if len(parts) > 1 && parts[1] != "" {
 3120		if parts[1] == "-" {
 3121			cur = cur.Background(base.GetBackground())
 3122		} else if c, ok := markupColor(parts[1]); ok {
 3123			cur = cur.Background(c)
 3124		}
 3125	}
 3126	if len(parts) > 2 {
 3127		if parts[2] == "-" || parts[2] == "" {
 3128			cur = cur.Attributes(base.GetAttributes())
 3129		} else {
 3130			for _, f := range parts[2] {
 3131				switch f {
 3132				case 'b':
 3133					cur = cur.Bold(true)
 3134				case 'u':
 3135					cur = cur.Underline(true)
 3136				}
 3137			}
 3138		}
 3139	}
 3140	return cur
 3141}
 3142
 3143// parseMarkup splits a tagged string into styled segments.
 3144func parseMarkup(s string, base tcell.Style) []seg {
 3145	var out []seg
 3146	cur := base
 3147	link := 0
 3148	var run []rune
 3149	flush := func() {
 3150		if len(run) > 0 {
 3151			out = append(out, seg{run, cur, link})
 3152			run = nil
 3153		}
 3154	}
 3155	r := []rune(s)
 3156	for i := 0; i < len(r); i++ {
 3157		if r[i] != '[' {
 3158			run = append(run, r[i])
 3159			continue
 3160		}
 3161		if i+1 < len(r) && r[i+1] == '[' { // escaped literal bracket
 3162			run = append(run, '[')
 3163			i++
 3164			continue
 3165		}
 3166		end := -1
 3167		for j := i + 1; j < len(r) && j < i+24; j++ {
 3168			if r[j] == ']' {
 3169				end = j
 3170				break
 3171			}
 3172		}
 3173		if end < 0 {
 3174			run = append(run, r[i])
 3175			continue
 3176		}
 3177		body := string(r[i+1 : end])
 3178		if n, ok := linkBody(body); ok {
 3179			flush()
 3180			link = n
 3181			i = end
 3182			continue
 3183		}
 3184		if !tagBody(body) {
 3185			run = append(run, r[i])
 3186			continue
 3187		}
 3188		flush()
 3189		cur = applyTag(cur, base, body)
 3190		i = end
 3191	}
 3192	flush()
 3193	return out
 3194}
 3195
 3196// markupWidth is the printed width of a tagged string.
 3197func markupWidth(s string) int {
 3198	n := 0
 3199	for _, sg := range parseMarkup(s, tcell.StyleDefault) {
 3200		n += len(sg.txt)
 3201	}
 3202	return n
 3203}
 3204
 3205// printSegs draws segments on one row, clipped to maxW; returns cells drawn.
 3206func printSegs(sc tcell.Screen, x, y, maxW int, segs []seg) int {
 3207	return printSegsLinks(sc, x, y, maxW, segs, nil)
 3208}
 3209
 3210// printSegsLinks is printSegs, reporting each anchor it drew: onLink is
 3211// called once per run of link cells with the run's position, width and
 3212// link index, which is what turns markup into something clickable.
 3213//
 3214// Per run of cells and not per segment: an anchor split by a color
 3215// change inside it is still one link, and a link clipped away at maxW
 3216// must not leave a region covering cells that were never drawn.
 3217func printSegsLinks(sc tcell.Screen, x, y, maxW int, segs []seg, onLink func(x, y, w, link int)) int {
 3218	n := 0
 3219	run, runStart := 0, 0
 3220	flush := func() {
 3221		if run != 0 && onLink != nil {
 3222			onLink(x+runStart, y, n-runStart, run)
 3223		}
 3224		run = 0
 3225	}
 3226	for _, sg := range segs {
 3227		if sg.link != run {
 3228			flush()
 3229			run, runStart = sg.link, n
 3230		}
 3231		for _, r := range sg.txt {
 3232			if n >= maxW {
 3233				flush()
 3234				return n
 3235			}
 3236			sc.SetContent(x+n, y, r, nil, sg.st)
 3237			n++
 3238		}
 3239	}
 3240	flush()
 3241	return n
 3242}
 3243
 3244// printMarkup draws one tagged line, clipped to maxW.
 3245// The width it returns mirrors printSegs, which callers of that do use.
 3246func printMarkup(sc tcell.Screen, x, y, maxW int, s string, base tcell.Style) int { //nolint:unparam
 3247	return printSegs(sc, x, y, maxW, parseMarkup(s, base))
 3248}
 3249
 3250// printMarkupLinks draws one tagged line and reports its anchors.
 3251func printMarkupLinks(sc tcell.Screen, x, y, maxW int, s string, base tcell.Style, onLink func(x, y, w, link int)) {
 3252	printSegsLinks(sc, x, y, maxW, parseMarkup(s, base), onLink)
 3253}
 3254
 3255// printMarkupCenter draws one tagged line centered in w columns.
 3256func printMarkupCenter(sc tcell.Screen, x, y, w int, s string, base tcell.Style) {
 3257	pad := (w - markupWidth(s)) / 2
 3258	if pad < 0 {
 3259		pad = 0
 3260	}
 3261	printMarkup(sc, x+pad, y, w-pad, s, base)
 3262}
 3263
 3264// wrapSegs word-wraps parsed lines to width w.
 3265func wrapSegs(s string, w int, base tcell.Style, wrap bool) [][]seg {
 3266	if w < 1 {
 3267		w = 1
 3268	}
 3269	var out [][]seg
 3270	for _, line := range strings.Split(s, "\n") {
 3271		segs := parseMarkup(line, base)
 3272		if !wrap {
 3273			out = append(out, segs)
 3274			continue
 3275		}
 3276		// Flatten to styled runes, then break into rows at spaces.
 3277		var flat []styledRune
 3278		for _, sg := range segs {
 3279			for _, r := range sg.txt {
 3280				flat = append(flat, styledRune{r, sg.st, sg.link})
 3281			}
 3282		}
 3283		for len(flat) > w {
 3284			cut := -1
 3285			for i := w; i > 0; i-- {
 3286				if flat[i].r == ' ' {
 3287					cut = i
 3288					break
 3289				}
 3290			}
 3291			if cut <= 0 {
 3292				cut = w
 3293			}
 3294			out = append(out, packSegs(flat[:cut]))
 3295			for cut < len(flat) && flat[cut].r == ' ' {
 3296				cut++
 3297			}
 3298			flat = flat[cut:]
 3299		}
 3300		out = append(out, packSegs(flat))
 3301	}
 3302	return out
 3303}
 3304
 3305type styledRune struct {
 3306	r    rune
 3307	st   tcell.Style
 3308	link int
 3309}
 3310
 3311// keyRune reads the rune of a KeyRune event (v3 carries it as Str).
 3312func keyRune(ev *tcell.EventKey) rune {
 3313	if ev.Key() != tcell.KeyRune {
 3314		return 0
 3315	}
 3316	rs := []rune(ev.Str())
 3317	if len(rs) == 0 {
 3318		return 0
 3319	}
 3320	return rs[0]
 3321}
 3322
 3323func packSegs(flat []styledRune) []seg {
 3324	var segs []seg
 3325	for _, c := range flat {
 3326		if n := len(segs); n > 0 && segs[n-1].st == c.st && segs[n-1].link == c.link {
 3327			segs[n-1].txt = append(segs[n-1].txt, c.r)
 3328		} else {
 3329			segs = append(segs, seg{[]rune{c.r}, c.st, c.link})
 3330		}
 3331	}
 3332	return segs
 3333}
 3334
 3335// fillRect paints a rectangle with spaces in the given style.
 3336func fillRect(sc tcell.Screen, r rect, st tcell.Style) {
 3337	for y := r.y; y < r.y+r.h; y++ {
 3338		for x := r.x; x < r.x+r.w; x++ {
 3339			sc.SetContent(x, y, ' ', nil, st)
 3340		}
 3341	}
 3342}
 3343
 3344// drawRule draws a horizontal rule, the terminal stand-in for the 1px
 3345// table borders that close the page's header and footer.
 3346func drawRule(sc tcell.Screen, x, y, w int, st tcell.Style) {
 3347	for i := 0; i < w; i++ {
 3348		sc.SetContent(x+i, y, '─', nil, st)
 3349	}
 3350}
 3351
 3352// drawBox fills and frames a rectangle, with an optional title in the
 3353// top border.
 3354func drawBox(sc tcell.Screen, r rect, title string, border, fill tcell.Style) {
 3355	if r.w < 2 || r.h < 2 {
 3356		return
 3357	}
 3358	fillRect(sc, r, fill)
 3359	for x := r.x + 1; x < r.x+r.w-1; x++ {
 3360		sc.SetContent(x, r.y, '─', nil, border)
 3361		sc.SetContent(x, r.y+r.h-1, '─', nil, border)
 3362	}
 3363	for y := r.y + 1; y < r.y+r.h-1; y++ {
 3364		sc.SetContent(r.x, y, 'β”‚', nil, border)
 3365		sc.SetContent(r.x+r.w-1, y, 'β”‚', nil, border)
 3366	}
 3367	sc.SetContent(r.x, r.y, 'β”Œ', nil, border)
 3368	sc.SetContent(r.x+r.w-1, r.y, '┐', nil, border)
 3369	sc.SetContent(r.x, r.y+r.h-1, 'β””', nil, border)
 3370	sc.SetContent(r.x+r.w-1, r.y+r.h-1, 'β”˜', nil, border)
 3371	if title != "" {
 3372		printMarkup(sc, r.x+2, r.y, r.w-4, title, fill)
 3373	}
 3374}
 3375
 3376// inner is the area inside a box's border.
 3377func (r rect) inner() rect {
 3378	return rect{r.x + 1, r.y + 1, r.w - 2, r.h - 2}
 3379}
 3380
 3381func (r rect) contains(x, y int) bool {
 3382	return x >= r.x && y >= r.y && x < r.x+r.w && y < r.y+r.h
 3383}
 3384
 3385
 3386// ===== pkg/tui/kittyimg.go =====
 3387package tui
 3388
 3389import (
 3390	"bytes"
 3391	"encoding/base64"
 3392	"fmt"
 3393	"image"
 3394	"image/draw"
 3395	"image/png"
 3396	"os"
 3397	"strconv"
 3398
 3399	"github.com/0magnet/websh/progressive"
 3400)
 3401
 3402// Pictures in a terminal that is not websh: where the terminal speaks
 3403// kitty's graphics protocol (kitty, WezTerm, Ghostty, Konsole β€” the probe
 3404// asks), a product's photo is the photo itself, laid over the cells that
 3405// draw it in half blocks, as websh lays it with a placement. Widgets need a
 3406// page; in a terminal they stay cells.
 3407//
 3408// M2_IMAGES chooses: "place" (websh's placements), "kitty", or "cells".
 3409
 3410// imageMode is how pictures go beyond cells here: "place", "kitty" or "".
 3411func imageMode() string {
 3412	switch os.Getenv("M2_IMAGES") {
 3413	case "cells":
 3414		return ""
 3415	case "kitty":
 3416		return "kitty"
 3417	case "place":
 3418		return "place"
 3419	}
 3420	c := progressive.Current()
 3421	if !c.Has("place") && c.Has("kitty-graphics") {
 3422		return "kitty"
 3423	}
 3424	// A terminal that does not know OSC 7337 ignores it, so placements are
 3425	// the default even where nothing answered.
 3426	return "place"
 3427}
 3428
 3429// kittyID is the image id a placement's pictures go by.
 3430func (u *ui) kittyID(id string) uint32 {
 3431	if u.kittyIDs == nil {
 3432		u.kittyIDs = map[string]uint32{}
 3433	}
 3434	n, ok := u.kittyIDs[id]
 3435	if !ok {
 3436		n = uint32(len(u.kittyIDs) + 7337) //nolint:gosec // a few placements at most
 3437		u.kittyIDs[id] = n
 3438	}
 3439	return n
 3440}
 3441
 3442// kittyPlace lays p's photo over its cells, or does nothing for what is not
 3443// a photo.
 3444func (u *ui) kittyPlace(id string, p placed) {
 3445	if p.url == "" {
 3446		return
 3447	}
 3448	data, err := u.kittyPNG(p)
 3449	if err != nil {
 3450		return
 3451	}
 3452	enc := base64.StdEncoding.EncodeToString(data)
 3453	var b bytes.Buffer
 3454	fmt.Fprintf(&b, "\x1b7\x1b[%d;%dH", p.r.y+1, p.r.x+1) // save the cursor, go to the cells
 3455	first := true
 3456	for len(enc) > 0 || first {
 3457		n := min(len(enc), 4096)
 3458		more := "0"
 3459		if n < len(enc) {
 3460			more = "1"
 3461		}
 3462		if first {
 3463			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])
 3464			first = false
 3465		} else {
 3466			fmt.Fprintf(&b, "\x1b_Gm=%s;%s\x1b\\", more, enc[:n])
 3467		}
 3468		enc = enc[n:]
 3469	}
 3470	b.WriteString("\x1b8") // and back
 3471	u.writeTerm(b.String())
 3472}
 3473
 3474// kittyRemove takes p's picture away.
 3475func (u *ui) kittyRemove(id string) {
 3476	u.writeTerm("\x1b_Ga=d,d=I,i=" + strconv.FormatUint(uint64(u.kittyID(id)), 10) + ",q=2\x1b\\")
 3477}
 3478
 3479// kittyPNG is p's photo as a PNG the shape of its cells β€” kitty stretches a
 3480// picture to the cells it is given β€” contained or, for "cover", cropped.
 3481func (u *ui) kittyPNG(p placed) ([]byte, error) {
 3482	key := fmt.Sprintf("%s|%dx%d|%s", p.url, p.r.w, p.r.h, p.fit)
 3483	if b, ok := u.kittyCache[key]; ok {
 3484		return b, nil
 3485	}
 3486	raw, err := getImageData(p.url)
 3487	if err != nil {
 3488		return nil, err
 3489	}
 3490	src, _, err := image.Decode(bytes.NewReader(raw))
 3491	if err != nil {
 3492		return nil, err
 3493	}
 3494	cw, ch := 1.0, 2.0 // a cell's shape where the terminal does not say
 3495	if c := progressive.Current(); c != nil && c.Cell.W > 0 && c.Cell.H > 0 {
 3496		cw, ch = c.Cell.W, c.Cell.H
 3497	}
 3498	aspect := float64(p.r.w) * cw / (float64(p.r.h) * ch)
 3499	sb := src.Bounds()
 3500	sw, sh := float64(sb.Dx()), float64(sb.Dy())
 3501	var dst *image.RGBA
 3502	if p.fit == "cover" { // crop the middle to the cells' shape
 3503		w, h := sw, sh
 3504		if sw/sh > aspect {
 3505			w = sh * aspect
 3506		} else {
 3507			h = sw / aspect
 3508		}
 3509		r := image.Rect(0, 0, int(w), int(h))
 3510		dst = image.NewRGBA(r)
 3511		off := image.Pt(sb.Min.X+int((sw-w)/2), sb.Min.Y+int((sh-h)/2))
 3512		draw.Draw(dst, r, src, off, draw.Src)
 3513	} else { // the whole of it, transparent around it
 3514		w, h := sw, sh
 3515		if sw/sh > aspect {
 3516			h = sw / aspect
 3517		} else {
 3518			w = sh * aspect
 3519		}
 3520		dst = image.NewRGBA(image.Rect(0, 0, int(w), int(h)))
 3521		at := image.Pt(int((w-sw)/2), int((h-sh)/2))
 3522		draw.Draw(dst, sb.Sub(sb.Min).Add(at), src, sb.Min, draw.Src)
 3523	}
 3524	var out bytes.Buffer
 3525	if err := png.Encode(&out, dst); err != nil {
 3526		return nil, err
 3527	}
 3528	if u.kittyCache == nil {
 3529		u.kittyCache = map[string][]byte{}
 3530	}
 3531	u.kittyCache[key] = out.Bytes()
 3532	return out.Bytes(), nil
 3533}
 3534
 3535
 3536// ===== pkg/tui/links_test.go =====
 3537package tui
 3538
 3539import (
 3540	"strings"
 3541	"testing"
 3542
 3543	"github.com/gdamore/tcell/v3"
 3544
 3545	"github.com/0magnet/m2/pkg/product"
 3546	"github.com/0magnet/m2/pkg/storepage"
 3547)
 3548
 3549// nullScreen is somewhere for the printers to put cells. Only SetContent
 3550// is implemented: tcell v3 ships no simulation screen, and the anchor
 3551// bookkeeping under test needs nothing else. The embedded nil interface
 3552// supplies the rest of the method set, so a test that reached for one
 3553// would panic rather than quietly pass.
 3554type nullScreen struct{ tcell.Screen }
 3555
 3556func (nullScreen) SetContent(int, int, rune, []rune, tcell.Style) {}
 3557
 3558// span is a drawn anchor, as printSegsLinks reports one.
 3559type span struct{ x, y, w, link int }
 3560
 3561// recorder collects the anchors a print reports, which is the whole of
 3562// what makes a link clickable.
 3563type recorder struct{ got []span }
 3564
 3565func (r *recorder) sink(x, y, w, link int) {
 3566	r.got = append(r.got, span{x, y, w, link})
 3567}
 3568
 3569// A link tag marks its text and nothing else, and the tag itself takes
 3570// up no room on the line.
 3571func TestLinkTagMarksOnlyItsText(t *testing.T) {
 3572	s := "Category: " + linkTag(1, "diode") + " end"
 3573	if w := markupWidth(s); w != len("Category: diode end") {
 3574		t.Errorf("width %d, want %d", w, len("Category: diode end"))
 3575	}
 3576	var linked string
 3577	for _, sg := range parseMarkup(s, tcell.StyleDefault) {
 3578		if sg.link == 1 {
 3579			linked += string(sg.txt)
 3580		}
 3581	}
 3582	if linked != "diode" {
 3583		t.Errorf("link 1 covers %q, want %q", linked, "diode")
 3584	}
 3585}
 3586
 3587// Color changes inside an anchor do not split it: the region has to
 3588// cover the whole of the link or half of it stops being clickable.
 3589func TestLinkSurvivesAColorChangeInside(t *testing.T) {
 3590	var r recorder
 3591	printMarkupLinks(nullScreen{}, 3, 7, 80, linkTag(1, "[aqua]dio[white]de[-]"), styleText, r.sink)
 3592	if len(r.got) != 1 {
 3593		t.Fatalf("%d regions, want 1: %+v", len(r.got), r.got)
 3594	}
 3595	if want := (span{x: 3, y: 7, w: 5, link: 1}); r.got[0] != want {
 3596		t.Errorf("region %+v, want %+v", r.got[0], want)
 3597	}
 3598}
 3599
 3600// A link clipped off the end of the line must not leave a region over
 3601// cells that were never drawn.
 3602func TestLinkRegionStopsAtTheClip(t *testing.T) {
 3603	var r recorder
 3604	printMarkupLinks(nullScreen{}, 0, 0, 6, "abc"+linkTag(1, "defghij"), styleText, r.sink)
 3605	if len(r.got) != 1 {
 3606		t.Fatalf("%d regions, want 1: %+v", len(r.got), r.got)
 3607	}
 3608	if got := r.got[0].x + r.got[0].w; got != 6 {
 3609		t.Errorf("region ends at %d, want 6 (the clip)", got)
 3610	}
 3611}
 3612
 3613// Two anchors on one line are two regions, in the order they were
 3614// numbered β€” which is what binds each to the right action.
 3615func TestTwoLinksOnOneLine(t *testing.T) {
 3616	var idx []int
 3617	printMarkupLinks(nullScreen{}, 0, 0, 80, linkTag(1, "one")+", "+linkTag(2, "two"), styleText,
 3618		func(_, _, _, link int) { idx = append(idx, link) })
 3619	if len(idx) != 2 || idx[0] != 1 || idx[1] != 2 {
 3620		t.Errorf("link indices %v, want [1 2]", idx)
 3621	}
 3622}
 3623
 3624// Catalog text is escaped before it reaches the parser, so a product
 3625// named with a bracket cannot open an anchor of its own.
 3626func TestEscapedTextCannotForgeALink(t *testing.T) {
 3627	for _, sg := range parseMarkup(esc("[link=1]nope[/link]"), tcell.StyleDefault) {
 3628		if sg.link != 0 {
 3629			t.Fatalf("escaped text produced link %d", sg.link)
 3630		}
 3631	}
 3632}
 3633
 3634// The stock pages are mostly anchors; htmlToText has to hand back where
 3635// each one goes, in the order its tags are numbered.
 3636func TestHTMLToTextReturnsHrefs(t *testing.T) {
 3637	in := `<p>see <a title='x' href="https://example.com/a">A</a> and ` +
 3638		`<a href='/cat/diode'>diodes</a> and <a>nowhere</a></p>`
 3639	text, hrefs := htmlToText(in, "2026", 80)
 3640	want := []string{"https://example.com/a", "/cat/diode", ""}
 3641	if len(hrefs) != len(want) {
 3642		t.Fatalf("%d hrefs, want %d: %q", len(hrefs), len(want), hrefs)
 3643	}
 3644	for i := range want {
 3645		if hrefs[i] != want[i] {
 3646			t.Errorf("href %d = %q, want %q", i, hrefs[i], want[i])
 3647		}
 3648	}
 3649	if !strings.Contains(text, "[link=2]") {
 3650		t.Errorf("text carries no anchor tags: %q", text)
 3651	}
 3652	if markupWidth(text) != len("see A and diodes and nowhere\n") {
 3653		t.Errorf("anchor tags left width %d in %q", markupWidth(text), text)
 3654	}
 3655}
 3656
 3657// An href is routed the way the site routes it: a path this store serves
 3658// is a page the TUI has, and anything else is a link out.
 3659func TestFollowRoutesSitePaths(t *testing.T) {
 3660	u := &ui{panels: map[string]panel{}, cat: newCatalogInfo(product.Products{
 3661		{Partno: "1N4001", Category: "diode", Subcategory: "1N40XX"},
 3662		{Partno: "1N4729", Category: "diode", Subcategory: "1N47XX"},
 3663	})}
 3664	if u.follow("") != nil {
 3665		t.Error("an empty href is not a link")
 3666	}
 3667	u.current = "content:about"
 3668	u.follow("/")()
 3669	if u.current != "home" {
 3670		t.Errorf("/ went to %q, want home", u.current)
 3671	}
 3672	u.follow("/cat/diode/1N47XX")()
 3673	if want := "list:diode|1N47XX"; u.current != want {
 3674		t.Errorf("subcategory path went to %q, want %q", u.current, want)
 3675	}
 3676	u.follow("/p/1N4001")()
 3677	if want := "prod:1N4001"; u.current != want {
 3678		t.Errorf("product path went to %q, want %q", u.current, want)
 3679	}
 3680	// A link out does not navigate; it hands over the address.
 3681	before := u.current
 3682	u.follow("https://example.com")()
 3683	if u.current != before {
 3684		t.Errorf("an external link navigated to %q", u.current)
 3685	}
 3686	if !strings.Contains(u.notice, "example.com") {
 3687		t.Errorf("external link gave no address: %q", u.notice)
 3688	}
 3689}
 3690
 3691// The pointer lights up whichever of the page's links it is over: the
 3692// header nav on the top row, the footer's cells on the bottom one, and
 3693// the panel's own anchors in between.
 3694func TestHoveredFindsEachKindOfLink(t *testing.T) {
 3695	const h = 20
 3696	u := &ui{hoverX: -1, hoverY: -1}
 3697	u.navRegions = append(u.navRegions, struct {
 3698		x1, x2 int
 3699		act    string
 3700	}{4, 14, "cats"})
 3701	u.footRegions = append(u.footRegions, struct {
 3702		x1, x2 int
 3703		act    string
 3704	}{2, 9, "cart"})
 3705	u.links = []linkRegion{{x: 6, y: 7, w: 5, act: func() {}}}
 3706
 3707	for _, c := range []struct {
 3708		name           string
 3709		x, y           int
 3710		wantX1, wantX2 int
 3711		want           bool
 3712	}{
 3713		{"nothing seen yet", -1, -1, 0, 0, false},
 3714		{"header entry", 5, 0, 4, 14, true},
 3715		{"header, past the entry", 30, 0, 0, 0, false},
 3716		{"footer cell", 3, h - 1, 2, 9, true},
 3717		{"panel anchor", 8, 7, 6, 11, true},
 3718		{"one cell past the anchor", 11, 7, 0, 0, false},
 3719		{"the row below it", 8, 8, 0, 0, false},
 3720	} {
 3721		u.hoverX, u.hoverY = c.x, c.y
 3722		x1, x2, _, ok := u.hovered(h)
 3723		if ok != c.want {
 3724			t.Errorf("%s: hovered=%v, want %v", c.name, ok, c.want)
 3725			continue
 3726		}
 3727		if ok && (x1 != c.wantX1 || x2 != c.wantX2) {
 3728			t.Errorf("%s: span %d..%d, want %d..%d", c.name, x1, x2, c.wantX1, c.wantX2)
 3729		}
 3730	}
 3731}
 3732
 3733// sampleTable is a two-column table whose first column is the row's link.
 3734func sampleTable() *table {
 3735	tbl := &table{cols: []tcol{
 3736		{title: "Name", link: true},
 3737		{title: "Price", width: 9, alignRight: true},
 3738	}}
 3739	tbl.setRows([]trow{
 3740		{cells: []tcell_{{text: "one"}, {text: "$1"}}},
 3741		{cells: []tcell_{{text: "two"}, {text: "$2"}}},
 3742	})
 3743	return tbl
 3744}
 3745
 3746// Every drawn row offers its name as an anchor, covering the name and
 3747// not the blank column beside it β€” that span is both what one click
 3748// follows and what the pointer lights up.
 3749func TestTableReportsEachRowsLinkCell(t *testing.T) {
 3750	tbl := sampleTable()
 3751	var got []span
 3752	tbl.onLink = func(x, y, w, row, _ int) { got = append(got, span{x, y, w, row}) }
 3753	tbl.draw(nullScreen{}, rect{x: 2, y: 4, w: 40, h: 10})
 3754
 3755	want := []span{{x: 2, y: 5, w: 3, link: 0}, {x: 2, y: 6, w: 3, link: 1}}
 3756	if len(got) != len(want) {
 3757		t.Fatalf("%d anchors, want %d: %+v", len(got), len(want), got)
 3758	}
 3759	for i := range want {
 3760		if got[i] != want[i] {
 3761			t.Errorf("anchor %d = %+v, want %+v", i, got[i], want[i])
 3762		}
 3763	}
 3764}
 3765
 3766// A column that is not the link column offers nothing, and neither does
 3767// the header row.
 3768func TestTableReportsOnlyTheLinkColumn(t *testing.T) {
 3769	tbl := sampleTable()
 3770	tbl.cols[0].link = false
 3771	n := 0
 3772	tbl.onLink = func(int, int, int, int, int) { n++ }
 3773	tbl.draw(nullScreen{}, rect{x: 0, y: 0, w: 40, h: 10})
 3774	if n != 0 {
 3775		t.Errorf("%d anchors from a table with no link column, want 0", n)
 3776	}
 3777}
 3778
 3779// A row has an anchor per link cell, and an empty one offers none β€”
 3780// which is how a product with no stock has nothing to add to a cart,
 3781// the page omitting its button for the same reason.
 3782func TestTableSkipsAnEmptyLinkCell(t *testing.T) {
 3783	tbl := &table{cols: []tcol{
 3784		{title: "Name", link: true},
 3785		{title: "Buy", width: 11, link: true},
 3786	}}
 3787	tbl.setRows([]trow{
 3788		{cells: []tcell_{{text: "in stock"}, {text: storepage.AddToCart}}},
 3789		{cells: []tcell_{{text: "sold out"}, {text: ""}}},
 3790	})
 3791	byCol := map[int]int{}
 3792	tbl.onLink = func(_, _, _, _, col int) { byCol[col]++ }
 3793	tbl.draw(nullScreen{}, rect{x: 0, y: 0, w: 40, h: 10})
 3794
 3795	if byCol[0] != 2 {
 3796		t.Errorf("%d name anchors, want one per row", byCol[0])
 3797	}
 3798	if byCol[1] != 1 {
 3799		t.Errorf("%d buy anchors, want one β€” the sold-out row has no button", byCol[1])
 3800	}
 3801}
 3802
 3803// The link cell never reaches the table's own mouse handling β€” the page
 3804// follows the anchor first. What is left is the rest of the row: it
 3805// selects, and a second click on the selected row opens it.
 3806func TestTableClickSelectsThenOpens(t *testing.T) {
 3807	tbl := sampleTable()
 3808	opened := -1
 3809	tbl.onActivate = func(i int) { opened = i }
 3810	click := tcell.NewEventMouse(0, 0, tcell.Button1, 0)
 3811
 3812	tbl.mouse(2, click) // row 1, which is not selected
 3813	if opened != -1 {
 3814		t.Errorf("the first click opened %d, want nothing", opened)
 3815	}
 3816	if tbl.sel != 1 {
 3817		t.Errorf("the first click selected row %d, want 1", tbl.sel)
 3818	}
 3819	tbl.mouse(2, click)
 3820	if opened != 1 {
 3821		t.Errorf("the second click opened %d, want 1", opened)
 3822	}
 3823}
 3824
 3825
 3826// ===== pkg/tui/mirror.go =====
 3827package tui
 3828
 3829import (
 3830	"fmt"
 3831	"html"
 3832	"net/url"
 3833	"sort"
 3834	"strings"
 3835
 3836	"github.com/0magnet/m2/pkg/storepage"
 3837)
 3838
 3839// The mirror (websh's PROTOCOL.md, Accessibility): where the host takes it,
 3840// the store publishes the page it shows as structure β€” the product, the list,
 3841// the categories β€” for a screen reader, which reads the cells as a grid of
 3842// characters. Its links go to the site's own pages.
 3843
 3844// mirrorLimit is how many products a list's mirror names.
 3845const mirrorLimit = 500
 3846
 3847// mirrorHTML is the page now showing, as an HTML fragment.
 3848func (u *ui) mirrorHTML() string {
 3849	esc := html.EscapeString
 3850	site := storeURL()
 3851	link := func(path, text string) string {
 3852		return `<a href="` + esc(site+path) + `">` + esc(text) + `</a>`
 3853	}
 3854	catPath := func(cat, sub string) string {
 3855		p := "/cat/" + url.PathEscape(cat)
 3856		if sub != "" {
 3857			p += "/" + url.PathEscape(storepage.EscapeSubcat(sub))
 3858		}
 3859		return p
 3860	}
 3861	var b strings.Builder
 3862	u.mu.Lock()
 3863	cur := u.current
 3864	u.mu.Unlock()
 3865	switch {
 3866	case strings.HasPrefix(cur, "prod:"):
 3867		p := u.cat.find(strings.TrimPrefix(cur, "prod:"))
 3868		if p == nil {
 3869			return ""
 3870		}
 3871		fmt.Fprintf(&b, "<h1>%s</h1><ul>", esc(p.Name))
 3872		for _, kv := range [][2]string{{"Price", "$" + strings.TrimPrefix(p.Price, "$")}, {"In stock", p.Quantity}, {"Part number", p.Partno}, {"Package", p.Packagetype}} {
 3873			if kv[1] != "" && kv[1] != "$" {
 3874				fmt.Fprintf(&b, "<li>%s: %s</li>", kv[0], esc(kv[1]))
 3875			}
 3876		}
 3877		if p.Category != "" {
 3878			b.WriteString("<li>Category: " + link(catPath(p.Category, ""), p.Category))
 3879			if p.Subcategory != "" {
 3880				b.WriteString(", " + link(catPath(p.Category, p.Subcategory), p.Subcategory))
 3881			}
 3882			b.WriteString("</li>")
 3883		}
 3884		b.WriteString("</ul>")
 3885		for _, d := range []string{p.Description1, p.Description2} {
 3886			if d != "" {
 3887				fmt.Fprintf(&b, "<p>%s</p>", esc(d))
 3888			}
 3889		}
 3890		b.WriteString("<p>" + link("/p/"+url.PathEscape(p.Partno), "This product on the website") + "</p>")
 3891	case strings.HasPrefix(cur, "list:"):
 3892		cat, sub, _ := strings.Cut(strings.TrimPrefix(cur, "list:"), "|")
 3893		title := "All products"
 3894		if cat != "" {
 3895			title = cat
 3896			if sub != "" {
 3897				title += ": " + sub
 3898			}
 3899		}
 3900		prods := u.cat.products(cat, sub)
 3901		fmt.Fprintf(&b, "<h1>%s</h1><p>%d products.</p><ul>", esc(title), len(prods))
 3902		for i, p := range prods {
 3903			if i == mirrorLimit {
 3904				fmt.Fprintf(&b, "<li>and %d more</li>", len(prods)-i)
 3905				break
 3906			}
 3907			fmt.Fprintf(&b, "<li>%s β€” $%s</li>", link("/p/"+url.PathEscape(p.Partno), p.Name), esc(strings.TrimPrefix(p.Price, "$")))
 3908		}
 3909		b.WriteString("</ul>")
 3910	case strings.HasPrefix(cur, "content:"):
 3911		name := strings.TrimPrefix(cur, "content:")
 3912		fmt.Fprintf(&b, "<h1>%s</h1>%s", esc(name), fetchContent(name)) // the site's own page; the host keeps only structure
 3913	default: // home
 3914		long := f.Sitelongname
 3915		if long == "" {
 3916			long = u.sitedomain
 3917		}
 3918		fmt.Fprintf(&b, "<h1>%s</h1>", esc(long))
 3919		if f.Sitetagline != "" {
 3920			fmt.Fprintf(&b, "<p>%s</p>", esc(f.Sitetagline))
 3921		}
 3922		cats := make([]string, 0, len(u.cat.catCounts))
 3923		for c := range u.cat.catCounts {
 3924			cats = append(cats, c)
 3925		}
 3926		sort.Strings(cats)
 3927		b.WriteString("<h2>Categories</h2><ul>")
 3928		for _, c := range cats {
 3929			fmt.Fprintf(&b, "<li>%s (%d)</li>", link(catPath(c, ""), c), u.cat.catCounts[c])
 3930		}
 3931		b.WriteString("</ul>")
 3932	}
 3933	return b.String()
 3934}
 3935
 3936
 3937// ===== pkg/tui/open_js.go =====
 3938//go:build js && wasm
 3939
 3940// Package tui pkg/tui/open_js.go β€” following a link in the browser
 3941// build. The TUI is a page, so a link that leaves the store can be
 3942// opened the way the page's own would be: in another tab.
 3943package tui
 3944
 3945import "syscall/js"
 3946
 3947// openURL opens a link the store cannot render itself β€” an external
 3948// site, or the PDF behind a datasheet.
 3949func (u *ui) openURL(href string) {
 3950	if href == "" {
 3951		return
 3952	}
 3953	w := js.Global().Get("window")
 3954	if !w.Truthy() {
 3955		u.notice = "[aqua]" + esc(href) + "[-]"
 3956		return
 3957	}
 3958	// noopener, or the opened page gets a handle on this one.
 3959	if opened := w.Call("open", href, "_blank", "noopener"); !opened.Truthy() {
 3960		// A blocked popup is silent otherwise; show the address instead
 3961		// so the link is still followable by hand.
 3962		u.notice = "[aqua]" + esc(href) + "[-]"
 3963	}
 3964}
 3965
 3966
 3967// ===== pkg/tui/open_native.go =====
 3968//go:build !js || !wasm
 3969
 3970// Package tui pkg/tui/open_native.go β€” following a link in a native
 3971// terminal, where there is no browser to hand it to.
 3972package tui
 3973
 3974// openURL shows a link the store cannot render itself β€” an external
 3975// site, or the PDF behind a datasheet. A terminal has nowhere to open
 3976// one, so it displays the address, which is what a terminal can do with
 3977// a link that a person can then act on.
 3978func (u *ui) openURL(href string) {
 3979	if href == "" {
 3980		return
 3981	}
 3982	u.notice = "[aqua]" + esc(href) + "[-]"
 3983}
 3984
 3985
 3986// ===== pkg/tui/pay_js.go =====
 3987//go:build js && wasm
 3988
 3989// Package tui pkg/tui/pay_js.go β€” the browser half of the payment seam.
 3990// The card form is Stripe's Payment Element: Stripe's fields in Stripe's
 3991// frame, so the card never passes through this program, which is why a
 3992// terminal cannot have one. Here the terminal is in a page, so the form is
 3993// laid over the checkout dialog's cells as a websh placement β€” a widget this
 3994// package offers as "stripe" β€” as the website's cart wasm opens the same
 3995// form in the page's dialog.
 3996//
 3997// A card is paid without leaving the terminal (redirect "if_required"):
 3998// the order goes to /submit-order, as /complete would send it, and the cart
 3999// is emptied. A payment method that must leave the page goes to /complete
 4000// as the website's does, and /complete submits the order it finds in
 4001// localStorage: so for as long as a payment is being confirmed, and only
 4002// then, the order is put there too (handOff). The cart itself is a file
 4003// (cartsave.go).
 4004package tui
 4005
 4006import (
 4007	"bytes"
 4008	"encoding/json"
 4009	"fmt"
 4010	"io"
 4011	"net/http"
 4012	"strings"
 4013	"sync"
 4014	"syscall/js"
 4015	"time"
 4016
 4017	"github.com/gdamore/tcell/v3"
 4018
 4019	"github.com/0magnet/websh/widget"
 4020)
 4021
 4022func init() {
 4023	widget.Register("stripe", mountCardForm)
 4024}
 4025
 4026// cardPay is the checkout the card form is for.
 4027type cardPay struct {
 4028	u      *ui
 4029	co     *checkoutOverlay
 4030	secret string
 4031	lines  []cartLine // the cart the payment intent was made for
 4032
 4033	mu       sync.Mutex
 4034	elements js.Value                 // the mounted form's Elements, undefined while none is
 4035	stripe   js.Value                 // Stripe.js, for that key
 4036	say      func(s string, bad bool) // the message under the form
 4037	busy     bool
 4038}
 4039
 4040var (
 4041	payMu  sync.Mutex
 4042	paying *cardPay
 4043)
 4044
 4045func (u *ui) payWithElements(co *checkoutOverlay, clientSecret string) {
 4046	if f.StripePK == "" {
 4047		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")
 4048		return
 4049	}
 4050	if _, inPage := u.screen.(interface{ WriteRaw([]byte) }); !inPage {
 4051		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")
 4052		return
 4053	}
 4054	st := &cardPay{u: u, co: co, secret: clientSecret, lines: u.cartLines()}
 4055	payMu.Lock()
 4056	paying = st
 4057	payMu.Unlock()
 4058	total, _ := u.cartTotal()
 4059	head := fmt.Sprintf("Pay [::b]$%d.%02d[-:-:-] by card", total/100, total%100)
 4060	if f.Teststripekey {
 4061		head += "  [yellow]test mode: 4242 4242 4242 4242, any future date[-]"
 4062	}
 4063	co.form = &cardForm{head: head, pay: func() { go st.confirm() }}
 4064	// Under the form, seen only where it is not laid over these cells.
 4065	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")
 4066}
 4067
 4068// mountCardForm fills a placement with the card form for the checkout in
 4069// progress.
 4070func mountCardForm(el js.Value) func() {
 4071	payMu.Lock()
 4072	st := paying
 4073	payMu.Unlock()
 4074	if st == nil {
 4075		return nil
 4076	}
 4077	doc := js.Global().Get("document")
 4078	mk := func(tag, css string) js.Value {
 4079		e := doc.Call("createElement", tag)
 4080		e.Get("style").Set("cssText", css)
 4081		return e
 4082	}
 4083	box := mk("div", "position:absolute;inset:0;overflow:auto;background:#000;padding:10px 14px;box-sizing:border-box")
 4084	form := mk("div", "min-height:120px")
 4085	btn := mk("button", "margin-top:12px;padding:6px 18px;font:inherit;color:#000;background:#00ffff;border:0;cursor:pointer")
 4086	btn.Set("textContent", "Pay now")
 4087	msg := mk("div", "margin-top:10px;color:#bbb;white-space:pre-wrap")
 4088	box.Call("append", form, btn, msg)
 4089	el.Call("append", box)
 4090
 4091	click := js.FuncOf(func(_ js.Value, args []js.Value) any {
 4092		args[0].Call("preventDefault")
 4093		go st.confirm()
 4094		return nil
 4095	})
 4096	btn.Call("addEventListener", "click", click)
 4097
 4098	var (
 4099		gone bool
 4100		pe   js.Value
 4101	)
 4102	say := func(s string, bad bool) {
 4103		color := "#bbb"
 4104		if bad {
 4105			color = "#ff6b6b"
 4106		}
 4107		msg.Get("style").Set("color", color)
 4108		msg.Set("textContent", s)
 4109	}
 4110	say("loading the card form …", false)
 4111	withStripe(f.StripePK, func(stripe js.Value, err string) {
 4112		if gone {
 4113			return
 4114		}
 4115		if err != "" {
 4116			say(err, true)
 4117			return
 4118		}
 4119		elements := stripe.Call("elements", map[string]any{
 4120			"clientSecret": st.secret,
 4121			"appearance": map[string]any{
 4122				"theme": "night",
 4123				"variables": map[string]any{
 4124					"colorPrimary":    "#00ffff",
 4125					"colorBackground": "#000000",
 4126					"fontFamily":      "ui-monospace, Menlo, Consolas, monospace",
 4127				},
 4128			},
 4129		})
 4130		pe = elements.Call("create", "payment", map[string]any{"layout": "tabs"})
 4131		pe.Call("mount", form)
 4132		st.mu.Lock()
 4133		st.stripe, st.elements, st.say = stripe, elements, say
 4134		st.mu.Unlock()
 4135		say("", false)
 4136	})
 4137	return func() {
 4138		gone = true
 4139		st.mu.Lock()
 4140		st.elements, st.say = js.Undefined(), nil
 4141		st.mu.Unlock()
 4142		if pe.Truthy() {
 4143			pe.Call("destroy")
 4144		}
 4145		btn.Call("removeEventListener", "click", click)
 4146		click.Release()
 4147		el.Set("textContent", "")
 4148	}
 4149}
 4150
 4151// confirm pays with what is in the form. It waits on Stripe, so it runs on
 4152// its own goroutine, never the event loop's.
 4153func (st *cardPay) confirm() {
 4154	st.mu.Lock()
 4155	if st.busy || !st.elements.Truthy() {
 4156		st.mu.Unlock()
 4157		return
 4158	}
 4159	st.busy = true
 4160	stripe, elements := st.stripe, st.elements
 4161	st.mu.Unlock()
 4162	defer func() {
 4163		st.mu.Lock()
 4164		st.busy = false
 4165		st.mu.Unlock()
 4166	}()
 4167	// What is said goes under whichever form is mounted when it is said.
 4168	tell := func(s string, bad bool) {
 4169		st.mu.Lock()
 4170		say := st.say
 4171		st.mu.Unlock()
 4172		if say != nil {
 4173			say(s, bad)
 4174		}
 4175	}
 4176	tell("paying …", false)
 4177	// If Stripe takes the page to /complete, that page submits this.
 4178	restore := handOff(st.lines)
 4179	origin := js.Global().Get("location").Get("origin").String()
 4180	res, err := await(stripe.Call("confirmPayment", map[string]any{
 4181		"elements":      elements,
 4182		"confirmParams": map[string]any{"return_url": origin + "/complete"},
 4183		"redirect":      "if_required",
 4184	}))
 4185	// Still here: the page did not leave, so /complete will not need it.
 4186	restore()
 4187	if err != "" {
 4188		tell("could not reach Stripe: "+err, true)
 4189		return
 4190	}
 4191	if e := res.Get("error"); e.Truthy() {
 4192		tell(e.Get("message").String(), true)
 4193		return
 4194	}
 4195	pi := res.Get("paymentIntent")
 4196	id, status := pi.Get("id").String(), pi.Get("status").String()
 4197	if status != "succeeded" {
 4198		tell("the payment is "+status+" β€” it has not gone through yet ("+id+")", true)
 4199		return
 4200	}
 4201	tell("paid β€” sending the order …", false)
 4202	serr := submitOrder(st.lines, id)
 4203	st.u.postWait(func() {
 4204		payMu.Lock()
 4205		if paying == st {
 4206			paying = nil
 4207		}
 4208		payMu.Unlock()
 4209		st.co.form = nil
 4210		if serr != nil {
 4211			// Paid, and the order not recorded: the cart stays, as the
 4212			// record of what was bought, and the payment's id is the way
 4213			// to find it.
 4214			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())))
 4215			return
 4216		}
 4217		st.u.emptyCart()
 4218		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)))
 4219	})
 4220}
 4221
 4222// submitOrder sends the order as the website's /complete does: the cart as
 4223// it was saved, beside the payment's id, which the server checks with
 4224// Stripe before it records anything.
 4225func submitOrder(lines []cartLine, paymentIntentID string) error {
 4226	body, err := json.Marshal(map[string]any{
 4227		"localStorageData": map[string]any{completeKey: lines},
 4228		"paymentIntentId":  paymentIntentID,
 4229	})
 4230	if err != nil {
 4231		return err
 4232	}
 4233	client := &http.Client{Timeout: 30 * time.Second}
 4234	resp, err := client.Post(ordersURL()+"/submit-order", "application/json", bytes.NewReader(body))
 4235	if err != nil {
 4236		return err
 4237	}
 4238	defer resp.Body.Close() //nolint:errcheck // read-side close
 4239	if resp.StatusCode != http.StatusOK {
 4240		data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) //nolint:errcheck // the status says enough without it
 4241		return fmt.Errorf("%s: %s", resp.Status, strings.TrimSpace(string(data)))
 4242	}
 4243	return nil
 4244}
 4245
 4246// await waits for a promise, from a goroutine that is not the event loop's.
 4247func await(p js.Value) (js.Value, string) {
 4248	type result struct {
 4249		v   js.Value
 4250		err string
 4251	}
 4252	ch := make(chan result, 1)
 4253	var then, catch js.Func
 4254	then = js.FuncOf(func(_ js.Value, args []js.Value) any {
 4255		ch <- result{v: args[0]}
 4256		return nil
 4257	})
 4258	catch = js.FuncOf(func(_ js.Value, args []js.Value) any {
 4259		ch <- result{err: js.Global().Get("String").Invoke(args[0]).String()}
 4260		return nil
 4261	})
 4262	p.Call("then", then, catch)
 4263	r := <-ch
 4264	then.Release()
 4265	catch.Release()
 4266	return r.v, r.err
 4267}
 4268
 4269// Stripe.js is loaded once, from Stripe (it may not be bundled or served
 4270// from elsewhere), the first time a card form is shown.
 4271var (
 4272	stripeMu      sync.Mutex
 4273	stripeByKey   = map[string]js.Value{}
 4274	stripeLoading bool
 4275	stripeWaiting []func(string)
 4276)
 4277
 4278// withStripe calls fn with Stripe.js for key, once it has loaded, on the
 4279// event loop.
 4280func withStripe(key string, fn func(stripe js.Value, err string)) {
 4281	ready := func(err string) {
 4282		if err != "" {
 4283			fn(js.Value{}, err)
 4284			return
 4285		}
 4286		stripeMu.Lock()
 4287		s, ok := stripeByKey[key]
 4288		if !ok {
 4289			s = js.Global().Get("Stripe").Invoke(key)
 4290			stripeByKey[key] = s
 4291		}
 4292		stripeMu.Unlock()
 4293		fn(s, "")
 4294	}
 4295	if js.Global().Get("Stripe").Type() == js.TypeFunction {
 4296		ready("")
 4297		return
 4298	}
 4299	stripeMu.Lock()
 4300	stripeWaiting = append(stripeWaiting, ready)
 4301	if stripeLoading {
 4302		stripeMu.Unlock()
 4303		return
 4304	}
 4305	stripeLoading = true
 4306	stripeMu.Unlock()
 4307
 4308	doc := js.Global().Get("document")
 4309	script := doc.Call("createElement", "script")
 4310	script.Set("src", "https://js.stripe.com/v3/")
 4311	var onload, onerror js.Func
 4312	done := func(err string) {
 4313		onload.Release()
 4314		onerror.Release()
 4315		stripeMu.Lock()
 4316		waiting := stripeWaiting
 4317		stripeWaiting, stripeLoading = nil, false
 4318		stripeMu.Unlock()
 4319		for _, w := range waiting {
 4320			w(err)
 4321		}
 4322	}
 4323	onload = js.FuncOf(func(js.Value, []js.Value) any { done(""); return nil })
 4324	onerror = js.FuncOf(func(js.Value, []js.Value) any {
 4325		done("could not load Stripe.js β€” is js.stripe.com blocked?")
 4326		return nil
 4327	})
 4328	script.Call("addEventListener", "load", onload)
 4329	script.Call("addEventListener", "error", onerror)
 4330	doc.Get("head").Call("append", script)
 4331}
 4332
 4333// postWait is post for what must not be dropped β€” a payment's outcome:
 4334// it waits for room in the queue rather than giving up, until shutdown.
 4335func (u *ui) postWait(fn func()) {
 4336	for {
 4337		u.postMu.Lock()
 4338		if u.finished {
 4339			u.postMu.Unlock()
 4340			return
 4341		}
 4342		select {
 4343		case u.screen.EventQ() <- tcell.NewEventInterrupt(fn):
 4344			u.postMu.Unlock()
 4345			return
 4346		default:
 4347		}
 4348		u.postMu.Unlock()
 4349		time.Sleep(20 * time.Millisecond)
 4350	}
 4351}
 4352
 4353// completeKey is where the website's /complete page finds the order it
 4354// submits after a payment that left the page.
 4355const completeKey = "cartItems"
 4356
 4357// handOff puts the order where /complete looks for it, and returns what puts
 4358// back what was there β€” the website's own cart uses the same key.
 4359func handOff(lines []cartLine) (restore func()) {
 4360	restore = func() {}
 4361	defer func() { _ = recover() }() //nolint:errcheck // storage the browser refuses: /complete then finds nothing, as before
 4362	st := js.Global().Get("localStorage")
 4363	b, err := json.Marshal(lines)
 4364	if !st.Truthy() || err != nil {
 4365		return restore
 4366	}
 4367	was := st.Call("getItem", completeKey)
 4368	st.Call("setItem", completeKey, string(b))
 4369	return func() {
 4370		defer func() { _ = recover() }() //nolint:errcheck // as above
 4371		if was.Type() == js.TypeString {
 4372			st.Call("setItem", completeKey, was)
 4373		} else {
 4374			st.Call("removeItem", completeKey)
 4375		}
 4376	}
 4377}
 4378
 4379
 4380// ===== pkg/tui/pay_native.go =====
 4381//go:build !js || !wasm
 4382
 4383// Package tui pkg/tui/pay_native.go β€” the native half of the payment
 4384// seam. Card details can never be typed into a terminal (they would
 4385// pass through this program, which PCI forbids); the browser build of
 4386// this TUI lays the Stripe Payment Element over the checkout dialog
 4387// (pay_js.go). Here the native flow proves the whole pipeline β€” cart,
 4388// shipping, server validation, payment intent β€” and says where the last
 4389// step lives.
 4390package tui
 4391
 4392// payWithElements takes over the checkout dialog once the store server
 4393// has created the payment intent for this cart.
 4394func (u *ui) payWithElements(co *checkoutOverlay, clientSecret string) {
 4395	preview := clientSecret
 4396	if len(preview) > 12 {
 4397		preview = preview[:12] + "…"
 4398	}
 4399	co.ta.setContent("\n [green]the store server accepted the cart[-] and created the payment\n intent (" +
 4400		esc(preview) + ").\n\n" +
 4401		" [yellow]card entry needs the Stripe Payment Element[-], which a terminal\n" +
 4402		" cannot host. In the browser build of this TUI it opens right here,\n" +
 4403		" over the terminal β€” in a native terminal, complete this order at\n" +
 4404		" [aqua]https://" + esc(u.sitedomain) + "[-]\n\n" +
 4405		" [gray]Esc closes β€” the cart is kept[-]\n")
 4406}
 4407
 4408
 4409// ===== pkg/tui/place.go =====
 4410package tui
 4411
 4412import (
 4413	"encoding/base64"
 4414	"encoding/json"
 4415	"strings"
 4416)
 4417
 4418// The real thing over the cells, where the terminal can show it: websh's
 4419// placements (OSC 7337). The home panel's logo and turning globe have the
 4420// website's own β€” its logo.svg and its globe, a widget this program offers
 4421// as "globe" (widgets_js.go) β€” laid over the cells that draw them, and a product's photo has
 4422// the photo itself. A terminal without placements ignores the sequences, so
 4423// they are written wherever this runs, and the cells are drawn as ever; an
 4424// overlay (a menu, the cart, a notice) keeps the cells it crosses.
 4425
 4426// placementsShown is set where websh told this program, run as a process of
 4427// its own, that its terminal shows placements (widgets_js.go).
 4428var placementsShown = func() bool { return false }
 4429
 4430// A placed is what one placement shows over r.
 4431type placed struct {
 4432	r      rect
 4433	url    string // an image, or
 4434	widget string // a widget the page registered
 4435	fit    string
 4436	input  bool // the mouse over it is its own: the globe is turned by hand
 4437	// above is how many of the frame's overlays were drawn when it was
 4438	// asked for: one an overlay asks for lies on that overlay, and only
 4439	// what is drawn after it covers it.
 4440	above int
 4441}
 4442
 4443// want asks for p over its cells as id this frame.
 4444func (u *ui) want(id string, p placed) {
 4445	if p.r.w <= 0 || p.r.h <= 0 {
 4446		return
 4447	}
 4448	if p.url != "" && !strings.HasPrefix(p.url, "https://") && !strings.HasPrefix(p.url, "http://") {
 4449		return // a file here, not an image the terminal can fetch
 4450	}
 4451	p.above = u.overlaysDrawn
 4452	if u.placeWant == nil {
 4453		u.placeWant = map[string]placed{}
 4454	}
 4455	u.placeWant[id] = p
 4456}
 4457
 4458// placeSync, after a frame, tells the terminal what changed: what was wanted
 4459// and is not covered by an overlay is laid (or moved), and the rest taken
 4460// away β€” all of it when the person keeps to the cells (w).
 4461func (u *ui) placeSync(overlays []rect) {
 4462	if u.cellsOnly {
 4463		clear(u.placeWant)
 4464	}
 4465	for id, p := range u.placeWant {
 4466		for _, o := range overlays[min(p.above, len(overlays)):] {
 4467			if p.r.overlaps(o) {
 4468				delete(u.placeWant, id)
 4469				break
 4470			}
 4471		}
 4472	}
 4473	mode := imageMode()
 4474	for id := range u.placeSent {
 4475		if _, ok := u.placeWant[id]; !ok {
 4476			if mode == "kitty" {
 4477				u.kittyRemove(id)
 4478			} else {
 4479				u.writeTerm("\x1b]7337;remove;" + id + "\x1b\\")
 4480			}
 4481			delete(u.placeSent, id)
 4482		}
 4483	}
 4484	for id, p := range u.placeWant {
 4485		if s, ok := u.placeSent[id]; ok && s == p {
 4486			continue
 4487		}
 4488		if mode == "" {
 4489			continue
 4490		}
 4491		if mode == "kitty" {
 4492			u.kittyPlace(id, p)
 4493			if u.placeSent == nil {
 4494				u.placeSent = map[string]placed{}
 4495			}
 4496			u.placeSent[id] = p
 4497			continue
 4498		}
 4499		b, err := json.Marshal(struct {
 4500			Row    int    `json:"row"`
 4501			Col    int    `json:"col"`
 4502			W      int    `json:"w"`
 4503			H      int    `json:"h"`
 4504			URL    string `json:"url,omitempty"`
 4505			Widget string `json:"widget,omitempty"`
 4506			Fit    string `json:"fit,omitempty"`
 4507			Input  bool   `json:"input,omitempty"`
 4508		}{p.r.y, p.r.x, p.r.w, p.r.h, p.url, p.widget, p.fit, p.input})
 4509		if err != nil {
 4510			continue
 4511		}
 4512		u.writeTerm("\x1b]7337;place;" + id + ";" + base64.StdEncoding.EncodeToString(b) + "\x1b\\")
 4513		if u.placeSent == nil {
 4514			u.placeSent = map[string]placed{}
 4515		}
 4516		u.placeSent[id] = p
 4517	}
 4518	// The page's own terminal (a screen written straight into its emulator)
 4519	// surely shows placements, and so does a websh that says so to a program
 4520	// it runs; elsewhere the cells are the picture.
 4521	_, inPage := u.screen.(interface{ WriteRaw([]byte) })
 4522	u.homeCovered.Store((inPage || placementsShown()) && u.placeSent["home"].widget != "")
 4523	clear(u.placeWant)
 4524}
 4525
 4526// placeClear takes every placement away, as the program ends.
 4527func (u *ui) placeClear() {
 4528	if imageMode() == "kitty" {
 4529		for id := range u.placeSent {
 4530			u.kittyRemove(id)
 4531		}
 4532		return
 4533	}
 4534	if len(u.placeSent) > 0 {
 4535		u.writeTerm("\x1b]7337;clear\x1b\\")
 4536	}
 4537}
 4538
 4539// writeTerm writes s to the terminal itself, outside the screen's frames:
 4540// to the tty on a real terminal, or straight to the emulator in a page.
 4541func (u *ui) writeTerm(s string) {
 4542	if w, ok := u.screen.(interface{ WriteRaw([]byte) }); ok {
 4543		w.WriteRaw([]byte(s))
 4544		return
 4545	}
 4546	if tty, ok := u.screen.Tty(); ok {
 4547		_, _ = tty.Write([]byte(s)) //nolint:errcheck // a terminal that is gone shows nothing either
 4548	}
 4549}
 4550
 4551// overlaps reports whether r and o share a cell.
 4552func (r rect) overlaps(o rect) bool {
 4553	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
 4554}
 4555
 4556
 4557// ===== pkg/tui/probe_js.go =====
 4558//go:build js
 4559
 4560package tui
 4561
 4562// probeTerminal: in a browser the host is asked otherwise β€” the page hands a
 4563// program in it the answer (storepane), and a program run from websh's
 4564// filesystem asks through its terminal (websh's childtty).
 4565func probeTerminal() {}
 4566
 4567
 4568// ===== pkg/tui/probe_other.go =====
 4569//go:build !js
 4570
 4571package tui
 4572
 4573import (
 4574	"os"
 4575	"time"
 4576
 4577	"golang.org/x/term"
 4578
 4579	"github.com/0magnet/websh/progressive"
 4580)
 4581
 4582// probeTerminal asks the terminal what it can do beyond cells β€” kitty
 4583// graphics, say β€” before tcell takes it, so the store can show real
 4584// pictures where the terminal can. It asks on /dev/tty, in raw mode, and
 4585// closes it after, which ends the read the probe may still have waiting:
 4586// no key is taken from tcell.
 4587func probeTerminal() {
 4588	tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
 4589	if err != nil {
 4590		return
 4591	}
 4592	defer tty.Close()   //nolint:errcheck // done with it either way
 4593	fd := int(tty.Fd()) //nolint:gosec // a file descriptor fits an int
 4594	old, err := term.MakeRaw(fd)
 4595	if err != nil {
 4596		return
 4597	}
 4598	defer term.Restore(fd, old)                        //nolint:errcheck // back as it was, as far as it goes
 4599	progressive.Probe(tty, tty, 1500*time.Millisecond) //nolint:errcheck,gosec // no answer is cells
 4600}
 4601
 4602
 4603// ===== pkg/tui/remote.go =====
 4604// Package tui pkg/tui/remote.go β€” the TUI as a store client. With
 4605// STOREURL set (m2 tui --storeurl https://magnetosphere.net) nothing is
 4606// read from disk: the catalog comes from /api/products, images from /i,
 4607// the logo from /logo.jpg, and checkout from the same origin β€” exactly
 4608// what the browser build of this TUI will do from the page the store
 4609// serves it on. Without it, the TUI reads the deployment's own files as
 4610// before.
 4611package tui
 4612
 4613import (
 4614	"encoding/json"
 4615	"fmt"
 4616	"io"
 4617	"net/http"
 4618	"net/url"
 4619	"os"
 4620	"strings"
 4621	"sync"
 4622	"time"
 4623
 4624	"github.com/0magnet/m2/pkg/product"
 4625)
 4626
 4627// storeURL is the remote store, normalized; empty means local files.
 4628func storeURL() string {
 4629	return strings.TrimRight(f.Storeurl, "/")
 4630}
 4631
 4632// FetchCatalog loads the product catalog from a store's /api/products.
 4633func FetchCatalog(store string) (product.Products, error) {
 4634	client := &http.Client{Timeout: 30 * time.Second}
 4635	resp, err := client.Get(strings.TrimRight(store, "/") + "/api/products")
 4636	if err != nil {
 4637		return nil, err
 4638	}
 4639	defer resp.Body.Close() //nolint:errcheck // read-side close
 4640	if resp.StatusCode != http.StatusOK {
 4641		return nil, fmt.Errorf("%s from %s/api/products", resp.Status, store)
 4642	}
 4643	var prods product.Products
 4644	if err := json.NewDecoder(io.LimitReader(resp.Body, 32<<20)).Decode(&prods); err != nil {
 4645		return nil, err
 4646	}
 4647	return prods, nil
 4648}
 4649
 4650// FetchSite fills the site identity (name, tagline, telegram links) from
 4651// a store's /api/site β€” the browser build has no MENV file to source.
 4652func FetchSite(store string) error {
 4653	client := &http.Client{Timeout: 15 * time.Second}
 4654	resp, err := client.Get(strings.TrimRight(store, "/") + "/api/site")
 4655	if err != nil {
 4656		return err
 4657	}
 4658	defer resp.Body.Close() //nolint:errcheck // read-side close
 4659	if resp.StatusCode != http.StatusOK {
 4660		return fmt.Errorf("%s from %s/api/site", resp.Status, store)
 4661	}
 4662	var site struct {
 4663		Sitename, Siteext, Sitelongname, Sitetagline string
 4664		Tgcontact, Tgchannel                         string
 4665		Teststripekey                                bool
 4666		Sitemeta, Sitedomain                         string
 4667		Siteprettyname, Siteprettynamecap            string
 4668		Siteprettynamecaps, SiteASCIILogo            string
 4669		Stripepk                                     string
 4670		NavLinks                                     []navLink
 4671		Coin                                         string
 4672	}
 4673	if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&site); err != nil {
 4674		return err
 4675	}
 4676	f.Sitename, f.Siteext = site.Sitename, site.Siteext
 4677	f.Sitelongname, f.Sitetagline = site.Sitelongname, site.Sitetagline
 4678	f.Tgcontact, f.Tgchannel = site.Tgcontact, site.Tgchannel
 4679	f.Teststripekey = site.Teststripekey
 4680	f.Sitemeta, f.Sitedomain = site.Sitemeta, site.Sitedomain
 4681	f.Siteprettyname, f.Siteprettynamecap = site.Siteprettyname, site.Siteprettynamecap
 4682	f.Siteprettynamecaps, f.SiteASCIILogo = site.Siteprettynamecaps, site.SiteASCIILogo
 4683	if site.Stripepk != "" {
 4684		f.StripePK = site.Stripepk
 4685	}
 4686	navLinks = site.NavLinks
 4687	coinOffered = site.Coin != ""
 4688	return nil
 4689}
 4690
 4691// navLink is an entry a server-side drop-in added to the site navigation β€”
 4692// pkg/web's NavLink, as /api/site serves it.
 4693//
 4694// The Etc menu was written out twice, once in htmpl/header.html and once in
 4695// openEtc, and only the template knew about these: the store's Software page
 4696// was in the page's menu and missing from the terminal's. A store client
 4697// cannot see pkg/web's registry, so the server hands it over instead.
 4698type navLink struct {
 4699	Title, Href, Desc string
 4700}
 4701
 4702// coinOffered is whether the store takes coin, which the cart offers as k.
 4703// A store reports it in /api/site; the local TUI, beside its own server,
 4704// offers it and lets the server answer.
 4705var coinOffered = true
 4706
 4707// navLinks is what /api/site last reported. Empty in a deployment with no
 4708// drop-ins, and empty in a local TUI with no store to ask.
 4709var navLinks []navLink
 4710
 4711// fetchContent reads a stock page (about/policy/links): from the store's
 4712// /api/content in client mode, from the deployment's content/ otherwise.
 4713func fetchContent(name string) string {
 4714	s := storeURL()
 4715	if s == "" {
 4716		return contentFile("content/" + name + ".html")
 4717	}
 4718	client := &http.Client{Timeout: 15 * time.Second}
 4719	resp, err := client.Get(s + "/api/content/" + url.PathEscape(name))
 4720	if err != nil {
 4721		return ""
 4722	}
 4723	defer resp.Body.Close() //nolint:errcheck // read-side close
 4724	if resp.StatusCode != http.StatusOK {
 4725		return ""
 4726	}
 4727	data, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
 4728	if err != nil {
 4729		return ""
 4730	}
 4731	return string(data)
 4732}
 4733
 4734// imageCache holds fetched remote images; the render worker re-reads an
 4735// image at every pane size, and the network should pay only once.
 4736var (
 4737	imageCacheMu sync.Mutex
 4738	imageCache   = map[string][]byte{}
 4739)
 4740
 4741// getImageData reads an image by path or URL.
 4742func getImageData(path string) ([]byte, error) {
 4743	if !strings.HasPrefix(path, "http://") && !strings.HasPrefix(path, "https://") {
 4744		return os.ReadFile(path) //nolint:gosec // paths come from the catalog, not the user
 4745	}
 4746	imageCacheMu.Lock()
 4747	data, ok := imageCache[path]
 4748	imageCacheMu.Unlock()
 4749	if ok {
 4750		return data, nil
 4751	}
 4752	client := &http.Client{Timeout: 20 * time.Second}
 4753	// Fetching the URL it was asked for is the point of this function.
 4754	resp, err := client.Get(path) //nolint:gosec
 4755	if err != nil {
 4756		return nil, err
 4757	}
 4758	defer resp.Body.Close() //nolint:errcheck // read-side close
 4759	if resp.StatusCode != http.StatusOK {
 4760		return nil, fmt.Errorf("%s", resp.Status)
 4761	}
 4762	data, err = io.ReadAll(io.LimitReader(resp.Body, 16<<20))
 4763	if err != nil {
 4764		return nil, err
 4765	}
 4766	imageCacheMu.Lock()
 4767	if len(imageCache) > 64 {
 4768		imageCache = map[string][]byte{}
 4769	}
 4770	imageCache[path] = data
 4771	imageCacheMu.Unlock()
 4772	return data, nil
 4773}
 4774
 4775
 4776// ===== pkg/tui/render.go =====
 4777// Package tui pkg/tui/render.go β€” images, the globe, and text conversion.
 4778// Everything renders to cells: product photos become pixel buffers
 4779// painted as β–€ half-blocks (or libcaca canvases blitted glyph by glyph),
 4780// and tcell diffs frames so only changed cells reach the terminal.
 4781package tui
 4782
 4783import (
 4784	"bytes"
 4785	"fmt"
 4786	"image"
 4787	"image/color"
 4788	_ "image/gif"
 4789	_ "image/jpeg"
 4790	_ "image/png"
 4791	"net/url"
 4792	"os"
 4793	"path/filepath"
 4794	"regexp"
 4795	"strconv"
 4796	"strings"
 4797
 4798	xhtml "html"
 4799
 4800	"github.com/gdamore/tcell/v3"
 4801	"golang.org/x/image/draw"
 4802
 4803	"github.com/0magnet/chaosrack/pkg/geom"
 4804	"github.com/0magnet/chaosrack/pkg/rasterview"
 4805	"github.com/0magnet/img2txt-go/caca"
 4806
 4807	"github.com/0magnet/m2/pkg/product"
 4808)
 4809
 4810// imgMode selects how product photos are painted.
 4811type imgMode int
 4812
 4813const (
 4814	modeHalfBlock imgMode = iota // truecolor, two pixels per cell via β–€
 4815	modeCaca                     // libcaca ANSI art, as /logo renders it
 4816)
 4817
 4818// artwork is a rendered product photo: a pixel buffer (half-block mode),
 4819// a cell canvas (caca mode), or a message when there is nothing to show.
 4820type artwork struct {
 4821	rgba  *image.RGBA
 4822	cells *cellArt
 4823	msg   string
 4824}
 4825
 4826// cellArt is a libcaca canvas as terminal cells.
 4827type cellArt struct {
 4828	w, h int
 4829	ch   []rune
 4830	st   []tcell.Style
 4831}
 4832
 4833// imagePath resolves a product photo the way the templates do:
 4834// img/<category>/<image1> (from the store's /i in client mode), falling
 4835// back locally to img/<image1>. A nil product (nothing selected yet β€”
 4836// an empty table's preview) has no image.
 4837func imagePath(p *product.Product) string {
 4838	if p == nil || p.Image1 == "" {
 4839		return ""
 4840	}
 4841	if s := storeURL(); s != "" {
 4842		return s + "/i/" + url.PathEscape(p.Category) + "/" + url.PathEscape(p.Image1)
 4843	}
 4844	for _, path := range []string{
 4845		filepath.Join("img", p.Category, p.Image1),
 4846		filepath.Join("img", p.Image1),
 4847	} {
 4848		if _, err := os.Stat(path); err == nil {
 4849			return path
 4850		}
 4851	}
 4852	return ""
 4853}
 4854
 4855// datasheetURL resolves a product datasheet the way product.html does:
 4856// <ImgSRC>/pdf/<name>, which is /i/pdf/<name> on the store's own origin.
 4857// Without a store there is no origin to build on, so the local path the
 4858// file would be served from is what a click has to offer instead.
 4859func datasheetURL(name string) string {
 4860	if name == "" {
 4861		return ""
 4862	}
 4863	if s := storeURL(); s != "" {
 4864		return s + "/i/pdf/" + url.PathEscape(name)
 4865	}
 4866	return filepath.Join("img", "pdf", name)
 4867}
 4868
 4869func loadImage(path string) (image.Image, error) {
 4870	data, err := getImageData(path)
 4871	if err != nil {
 4872		return nil, err
 4873	}
 4874	img, _, err := image.Decode(bytes.NewReader(data))
 4875	return img, err
 4876}
 4877
 4878// scaleToFit scales img into a pixel buffer no larger than maxW x maxH,
 4879// preserving aspect. Half-block pixels are roughly square.
 4880func scaleToFit(img image.Image, maxW, maxH int) *image.RGBA {
 4881	b := img.Bounds()
 4882	pw, ph := b.Dx(), b.Dy()
 4883	if pw*maxH > ph*maxW {
 4884		ph = ph * maxW / pw
 4885		pw = maxW
 4886	} else {
 4887		pw = pw * maxH / ph
 4888		ph = maxH
 4889	}
 4890	if pw < 1 {
 4891		pw = 1
 4892	}
 4893	if ph < 1 {
 4894		ph = 1
 4895	}
 4896	dst := image.NewRGBA(image.Rect(0, 0, pw, ph))
 4897	draw.ApproxBiLinear.Scale(dst, dst.Bounds(), img, b, draw.Src, nil)
 4898	return dst
 4899}
 4900
 4901// renderArt produces a product photo sized to fit cols x rows cells.
 4902func renderArt(path string, cols, rows int, mode imgMode) artwork {
 4903	if path == "" {
 4904		return artwork{msg: "no image"}
 4905	}
 4906	if cols < 2 || rows < 2 {
 4907		return artwork{msg: "pane too small"}
 4908	}
 4909	if mode == modeCaca {
 4910		cells, err := renderCaca(path, cols, rows)
 4911		if err != nil {
 4912			return artwork{msg: err.Error()}
 4913		}
 4914		return artwork{cells: cells}
 4915	}
 4916	img, err := loadImage(path)
 4917	if err != nil {
 4918		return artwork{msg: err.Error()}
 4919	}
 4920	if img.Bounds().Dx() == 0 || img.Bounds().Dy() == 0 {
 4921		return artwork{msg: "empty image"}
 4922	}
 4923	return artwork{rgba: scaleToFit(img, cols, rows*2)}
 4924}
 4925
 4926// renderCaca draws an image as libcaca ANSI art β€” the same renderer
 4927// behind the /logo endpoint, via the pure-Go img2txt port β€” and reads
 4928// the canvas cells straight into terminal cells.
 4929func renderCaca(path string, cols, rows int) (*cellArt, error) {
 4930	data, err := getImageData(path)
 4931	if err != nil {
 4932		return nil, err
 4933	}
 4934	im, err := caca.DecodeImage(bytes.NewReader(data))
 4935	if err != nil {
 4936		return nil, err
 4937	}
 4938	if im.W == 0 || im.H == 0 {
 4939		return nil, fmt.Errorf("bad dimensions")
 4940	}
 4941	// img2txt's 6x10 font-cell aspect, constrained by both axes.
 4942	lines := cols * im.H * 6 / im.W / 10
 4943	if lines > rows {
 4944		lines = rows
 4945		cols = lines * im.W * 10 / im.H / 6
 4946	}
 4947	if lines < 1 || cols < 1 {
 4948		return nil, fmt.Errorf("pane too small")
 4949	}
 4950	cv := caca.NewCanvas(cols, lines)
 4951	cv.SetColorANSI(caca.Default, caca.Transparent)
 4952	cv.Clear()
 4953	im.Dither.SetAlgorithm("fstein")
 4954	cv.DitherBitmap(0, 0, cols, lines, im.Dither, im.Pixels)
 4955
 4956	art := &cellArt{w: cv.Width, h: cv.Height,
 4957		ch: make([]rune, cv.Width*cv.Height), st: make([]tcell.Style, cv.Width*cv.Height)}
 4958	for i, ch := range cv.Chars {
 4959		art.ch[i] = ch
 4960		art.st[i] = tcell.StyleDefault.
 4961			Foreground(rgb12(caca.AttrToRGB12Fg(cv.Attrs[i]))).
 4962			Background(rgb12(caca.AttrToRGB12Bg(cv.Attrs[i])))
 4963	}
 4964	return art, nil
 4965}
 4966
 4967// rgb12 expands libcaca's 12-bit 0xRGB into a tcell color.
 4968func rgb12(v uint16) tcell.Color {
 4969	return tcell.NewRGBColor(
 4970		int32((v>>8&0xF)*17), int32((v>>4&0xF)*17), int32((v&0xF)*17))
 4971}
 4972
 4973// drawArtwork paints a rendered photo centered in r, and says which cells
 4974// the photo took (false when there was no photo to draw).
 4975func drawArtwork(sc tcell.Screen, r rect, art *artwork) (rect, bool) {
 4976	switch {
 4977	case art == nil:
 4978	case art.rgba != nil:
 4979		rows := (art.rgba.Bounds().Dy() + 1) / 2
 4980		yoff := (r.h - rows) / 2
 4981		if yoff < 0 {
 4982			yoff = 0
 4983		}
 4984		drawRGBA(sc, r.x, r.y+yoff, r.w, r.h-yoff, art.rgba)
 4985		pw := art.rgba.Bounds().Dx()
 4986		xoff := 0
 4987		if r.w > pw {
 4988			xoff = (r.w - pw) / 2
 4989		}
 4990		return rect{r.x + xoff, r.y + yoff, min(pw, r.w), min(rows, r.h-yoff)}, true
 4991	case art.cells != nil:
 4992		xoff := (r.w - art.cells.w) / 2
 4993		yoff := (r.h - art.cells.h) / 2
 4994		if xoff < 0 {
 4995			xoff = 0
 4996		}
 4997		if yoff < 0 {
 4998			yoff = 0
 4999		}
 5000		for y := 0; y < art.cells.h && y < r.h; y++ {
 5001			for x := 0; x < art.cells.w && x < r.w; x++ {
 5002				i := y*art.cells.w + x
 5003				sc.SetContent(r.x+xoff+x, r.y+yoff+y, art.cells.ch[i], nil, art.cells.st[i])
 5004			}
 5005		}
 5006		return rect{r.x + xoff, r.y + yoff, min(art.cells.w, r.w), min(art.cells.h, r.h)}, true
 5007	case art.msg != "":
 5008		printMarkupCenter(sc, r.x, r.y+r.h/2, r.w, "[gray]"+esc(art.msg)+"[-]", styleText)
 5009	}
 5010	return rect{}, false
 5011}
 5012
 5013// The home panel: a wireframe globe turning over the site logo, as the
 5014// website's landing view renders its wasm globe over the svg logo.
 5015
 5016// makeBackdrop scales the logo to one panel size.
 5017func makeBackdrop(logo image.Image, cols, rows int) *image.RGBA {
 5018	if logo == nil || cols <= 0 || rows <= 0 {
 5019		if cols < 1 {
 5020			cols = 1
 5021		}
 5022		if rows < 1 {
 5023			rows = 1
 5024		}
 5025		return image.NewRGBA(image.Rect(0, 0, cols, rows*2))
 5026	}
 5027	// Full brightness, as the website shows it: the svg behind the canvas
 5028	// is not dimmed there, so its white stays white here too. The globe
 5029	// reads on top by its own saturation, the way it does on the page.
 5030	return scaleToFit(logo, cols, rows*2)
 5031}
 5032
 5033// globeLines is the globe the website's attractor engine uploads to
 5034// WebGL β€” same geometry, from the same package.
 5035var globeLines = geom.Globe(18, 36, 60)
 5036
 5037// globeFrame composites one animation frame: the dimmed logo with the
 5038// chaosrack globe drawn over it by chaosrack's own software renderer, in
 5039// its default look — random pose, red→blue gradient along model z (see
 5040// rasterview.DefaultGradient). BackDim is the one departure from the
 5041// WebGL look: an unantialiased raster needs the depth cue.
 5042func globeFrame(backdrop *image.RGBA, ax, ay, az float64) *image.RGBA {
 5043	frame := image.NewRGBA(backdrop.Bounds())
 5044	copy(frame.Pix, backdrop.Pix)
 5045	view := rasterview.View{AngleX: ax, AngleY: ay, AngleZ: az, Dist: 3.2, BackDim: 0.5}
 5046	view.Render(frame, globeLines.Vertices, globeLines.Indices, rasterview.DefaultGradient())
 5047	return frame
 5048}
 5049
 5050// drawRGBA paints a pixel buffer into terminal cells at x0,y0, two pixels
 5051// per cell via β–€, centered in cols columns. Cells unchanged since the
 5052// last frame cost nothing: tcell diffs them away.
 5053func drawRGBA(sc tcell.Screen, x0, y0, cols, maxRows int, img *image.RGBA) {
 5054	pw, ph := img.Bounds().Dx(), img.Bounds().Dy()
 5055	xoff := 0
 5056	if cols > pw {
 5057		xoff = (cols - pw) / 2
 5058	}
 5059	for y := 0; y < ph; y += 2 {
 5060		if y/2 >= maxRows {
 5061			break
 5062		}
 5063		for x := 0; x < pw; x++ {
 5064			if xoff+x >= cols {
 5065				break
 5066			}
 5067			top := img.RGBAAt(x, y)
 5068			var bot color.RGBA
 5069			if y+1 < ph {
 5070				bot = img.RGBAAt(x, y+1)
 5071			}
 5072			st := tcell.StyleDefault.
 5073				Foreground(tcell.NewRGBColor(step16(top.R), step16(top.G), step16(top.B))).
 5074				Background(tcell.NewRGBColor(step16(bot.R), step16(bot.G), step16(bot.B)))
 5075			sc.SetContent(x0+xoff+x, y0+y/2, 'β–€', nil, st)
 5076		}
 5077	}
 5078}
 5079
 5080var (
 5081	tmplAction = regexp.MustCompile(`\{\{[^}]*\}\}`)
 5082	tagAnchor  = regexp.MustCompile(`(?is)<a\b([^>]*)>(.*?)</a>`)
 5083	attrHref   = regexp.MustCompile(`(?is)\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))`)
 5084	tagBreak   = regexp.MustCompile(`(?i)<br\s*/?>`)
 5085	tagBlock   = regexp.MustCompile(`(?i)</(p|h1|h2|h3|div|tr|li|ul|table|pre)>`)
 5086	tagHeading = regexp.MustCompile(`(?is)<h[1-3][^>]*>(.*?)</h[1-3]>`)
 5087	tagLi      = regexp.MustCompile(`(?i)<li[^>]*>`)
 5088	tagCell    = regexp.MustCompile(`(?i)</t[dh]>`)
 5089	tagAny     = regexp.MustCompile(`(?s)<[^>]*>`)
 5090	manyBlank  = regexp.MustCompile(`\n{3,}`)
 5091)
 5092
 5093// htmlToText renders a stock-page fragment (about/policy/links) as
 5094// terminal text: links cyan like the site, headings bold, tags dropped.
 5095//
 5096// It also returns each anchor's href, in the order the [link=N] tags it
 5097// wrote number them, so the page can make them followable. The Links
 5098// page is a page of nothing but links; cyan text that does nothing is
 5099// not a rendering of it.
 5100//
 5101// width is the column count the caller will draw into; tables are laid out
 5102// to fit it. See htmltable.go.
 5103func htmlToText(s, year string, width int) (string, []string) {
 5104	s = strings.ReplaceAll(s, "{{.Page.Year}}", year)
 5105	s = tmplAction.ReplaceAllString(s, "")
 5106	s = strings.ReplaceAll(s, "[", "[[") // user HTML must not become tags
 5107	s = tagHeading.ReplaceAllString(s, "\n[::b]$1[-:-:-]\n")
 5108	var hrefs []string
 5109	s = tagAnchor.ReplaceAllStringFunc(s, func(m string) string {
 5110		g := tagAnchor.FindStringSubmatch(m)
 5111		href := ""
 5112		if a := attrHref.FindStringSubmatch(g[1]); a != nil {
 5113			href = xhtml.UnescapeString(a[1] + a[2] + a[3]) // only one group matches
 5114		}
 5115		hrefs = append(hrefs, href)
 5116		return linkTag(len(hrefs), "[aqua]"+g[2]+"[-]")
 5117	})
 5118	// Tables come out whole and are drawn last. The flattening below would
 5119	// otherwise reduce their cells to runs of spaces, and drawing them first
 5120	// would feed the finished columns straight back into it.
 5121	var tables []string
 5122	s = tagTable.ReplaceAllStringFunc(s, func(m string) string {
 5123		tables = append(tables, tagTable.FindStringSubmatch(m)[1])
 5124		return "\x00T" + strconv.Itoa(len(tables)-1) + "\x00"
 5125	})
 5126	s = tagBreak.ReplaceAllString(s, "\n")
 5127	s = tagLi.ReplaceAllString(s, "  * ")
 5128	s = tagCell.ReplaceAllString(s, "  ")
 5129	s = tagBlock.ReplaceAllString(s, "\n")
 5130	s = tagAny.ReplaceAllString(s, "")
 5131	s = xhtml.UnescapeString(s)
 5132	s = manyBlank.ReplaceAllString(s, "\n\n")
 5133	for i, inner := range tables {
 5134		s = strings.Replace(s, "\x00T"+strconv.Itoa(i)+"\x00", "\n"+renderTable(inner, width), 1)
 5135	}
 5136	return strings.TrimSpace(s) + "\n", hrefs
 5137}
 5138
 5139// step16 snaps a channel to 16 levels, spanning the full range: 0x00 stays
 5140// 0x00 and 0xFF stays 0xFF, so the logo's black and white are untouched and
 5141// only the interpolated greys move, by at most 1/32 of the range.
 5142//
 5143// This is a rendering cost, not an aesthetic choice. The WebGL renderer
 5144// caches rasterised glyphs under a key that includes both colors, so a
 5145// truecolor half-block is a distinct glyph per color pair. A full-screen
 5146// gradient at ~11k cells hands it ~11k keys a frame against an atlas of
 5147// ~18k slots; it fills, clears, and re-rasterises everything, every frame.
 5148// Quantising collapses the key space enough for the cache to hold, which
 5149// measured out at roughly half the CPU and less than half the worst-case
 5150// frame time.
 5151func step16(v uint8) int32 { return int32(v&0xF0 | v>>4) }
 5152
 5153
 5154// ===== pkg/tui/tui.go =====
 5155// Package tui pkg/tui/tui.go β€” a terminal view of the web store, derived
 5156// from the website itself: the same masthead, the catsubcats navigation
 5157// tree, one panel at a time like the front page's :target panels, the
 5158// category tables, product pages in template order, the stock content
 5159// pages, and the footer bar. The landing view is the site's landing
 5160// view: a wireframe globe turning over the logo.
 5161//
 5162// Written directly on tcell v3 with the small widget kit in widgets.go β€”
 5163// the same terminal stack as the rest of the 0magnet ecosystem, so the
 5164// browser build can ride tuiwasm/xtcell.
 5165package tui
 5166
 5167import (
 5168	"context"
 5169	"embed"
 5170	"fmt"
 5171	"image"
 5172	"math"
 5173	"math/rand"
 5174	"os"
 5175	"strconv"
 5176	"strings"
 5177	"sync"
 5178	"sync/atomic"
 5179	"time"
 5180
 5181	"github.com/gdamore/tcell/v3"
 5182
 5183	"github.com/0magnet/calvin"
 5184
 5185	"github.com/0magnet/m2/pkg/config"
 5186	"github.com/0magnet/m2/pkg/product"
 5187	"github.com/0magnet/m2/pkg/storepage"
 5188)
 5189
 5190//go:embed *.go
 5191var Source embed.FS
 5192
 5193// f aliases the shared configuration, as in pkg/web.
 5194var f = &config.F
 5195
 5196// site styles, from content/style.css
 5197var (
 5198	styleText  = tcell.StyleDefault.Foreground(tcell.ColorWhite.TrueColor()).Background(tcell.ColorBlack.TrueColor())
 5199	styleLink  = styleText.Foreground(tcell.ColorAqua.TrueColor())                                                 // a{color:cyan}
 5200	colorHover = tcell.ColorGreen.TrueColor()                                                                      // a:hover{color:green}
 5201	styleSel   = tcell.StyleDefault.Foreground(tcell.GetColor("#ffa500")).Background(tcell.ColorWhite.TrueColor()) // a.cur
 5202	// .✞ thead{background-color:#6a1b9a;color:white} β€” a filled band, not
 5203	// underlined text. Underlined purple read as a link, which those
 5204	// column titles are not.
 5205	styleThead  = tcell.StyleDefault.Foreground(tcell.ColorWhite.TrueColor()).Background(tcell.GetColor("#6a1b9a")).Bold(true)
 5206	styleBorder = styleText.Foreground(tcell.ColorBlue.TrueColor())
 5207	styleField  = tcell.StyleDefault.Foreground(tcell.ColorWhite.TrueColor()).Background(tcell.GetColor("#000087"))
 5208	styleButton = tcell.StyleDefault.Foreground(tcell.ColorWhite.TrueColor()).Background(tcell.GetColor("#005f87"))
 5209
 5210	// The header and footer tables are closed by a 1px blue border on the
 5211	// page; their cell edges are white. Terminal rules stand in for both.
 5212	ruleBlue  = styleText.Foreground(tcell.GetColor("#0000ff"))
 5213	ruleWhite = styleText.Foreground(tcell.ColorWhite.TrueColor())
 5214)
 5215
 5216// panel is one of the site's :target panels.
 5217type panel interface {
 5218	draw(u *ui, sc tcell.Screen, r rect)
 5219	key(u *ui, ev *tcell.EventKey) bool
 5220	mouse(u *ui, r rect, ev *tcell.EventMouse) bool
 5221}
 5222
 5223// overlayW is a commerce overlay (cart / shipping / checkout).
 5224type overlayW interface {
 5225	draw(u *ui, sc tcell.Screen, r rect)
 5226	key(u *ui, ev *tcell.EventKey) bool
 5227	size() (w, h int)
 5228	title() string
 5229}
 5230
 5231// linkRegion is one anchor on the drawn page: the cells it occupies and
 5232// what following it does.
 5233type linkRegion struct {
 5234	x, y, w int
 5235	act     func()
 5236}
 5237
 5238// dropState is an open header dropdown.
 5239type dropState struct {
 5240	kind    string // "cats" | "etc"
 5241	lst     *list
 5242	targets []navTarget // cats only
 5243	width   int
 5244}
 5245
 5246// artHolder carries one asynchronously rendered product photo.
 5247type artHolder struct {
 5248	key  string // what art holds
 5249	want string // what was last requested
 5250	art  *artwork
 5251}
 5252
 5253type artReq struct {
 5254	key    string
 5255	path   string
 5256	w, h   int
 5257	mode   imgMode
 5258	holder *artHolder
 5259}
 5260
 5261type ui struct {
 5262	screen tcell.Screen
 5263	cat    *catalogInfo
 5264	quit   bool
 5265
 5266	panels  map[string]panel
 5267	history []string
 5268	fwd     []string
 5269
 5270	drop     *dropState
 5271	commerce overlayW
 5272	notice   string
 5273
 5274	// postMu gates post against RunOn's shutdown closing the queue.
 5275	postMu   sync.Mutex
 5276	finished bool
 5277
 5278	// header nav and footer summary click regions, rebuilt each draw
 5279	navRegions []struct {
 5280		x1, x2 int
 5281		act    string
 5282	}
 5283	footRegions []struct {
 5284		x1, x2 int
 5285		act    string
 5286	}
 5287
 5288	// links are the anchors the visible page drew, rebuilt every draw and
 5289	// consulted before the panel sees a click. On the website every one of
 5290	// these is an <a>; here it is a run of cells with something to do.
 5291	links []linkRegion
 5292
 5293	// hoverX, hoverY is the cell the pointer was last seen over, which is
 5294	// what a:hover needs. -1 means the pointer has not been anywhere yet.
 5295	hoverX, hoverY int
 5296
 5297	// bare drops the pisano frame around the product table, giving its
 5298	// rows back to the table. Toggled with d.
 5299	bare bool
 5300
 5301	// image rendering
 5302	mode  imgMode
 5303	reqCh chan artReq
 5304
 5305	// expanded holds which categories' branches are open in the
 5306	// Categories dropdown, like the template's <details open>.
 5307	expanded map[string]bool
 5308
 5309	// home animation. The ticker goroutine computes frame (a pixel
 5310	// buffer) and the home panel paints it; both sides go through mu.
 5311	// current names the visible panel; the goroutine reads it too.
 5312	mu       sync.Mutex
 5313	current  string
 5314	logo     image.Image
 5315	homeW    int
 5316	homeH    int
 5317	frame    *image.RGBA
 5318	backdrop *image.RGBA
 5319	bdW, bdH int
 5320	// pose randomized at startup like chaosrack's randomizeOrientation,
 5321	// then tumbled by a random rate on every axis.
 5322	poseX, poseY, poseZ float64
 5323	rateX, rateY, rateZ float64
 5324	done                chan struct{}
 5325	// Placements (place.go): what this frame wants over its cells, and
 5326	// what the terminal has.
 5327	placeWant, placeSent map[string]placed
 5328	// cellsOnly keeps to the cells: no placements, as in a terminal without
 5329	// them. Toggled with w.
 5330	cellsOnly bool
 5331	// overlaysDrawn counts the overlays drawn so far this frame; see placed.above.
 5332	overlaysDrawn int
 5333	// homeCovered: the page's globe is over the home panel's cells (set by
 5334	// placeSync, read by animateHome), so they need not turn.
 5335	homeCovered atomic.Bool
 5336
 5337	asciiLines []string
 5338	caps       string
 5339
 5340	// cart, as the footer's View Cart
 5341	cartOrder []string
 5342	cartQty   map[string]int
 5343	shipping  *storepage.Shipping
 5344
 5345	sitedomain string
 5346	// announced is the page path last told to the host (address.go).
 5347	announced string
 5348	// kittyIDs and kittyCache: pictures for kitty's graphics protocol
 5349	// (kittyimg.go).
 5350	kittyIDs   map[string]uint32
 5351	kittyCache map[string][]byte
 5352	masthead   [3]string
 5353}
 5354
 5355// screenKey carries an alternative way of getting a screen.
 5356type screenKey struct{}
 5357
 5358// WithScreen tells Run how to obtain its screen, for a host where
 5359// tcell's own answer is the wrong one.
 5360//
 5361// This is what lets `m2 tui` be the same command in both places. In a
 5362// terminal it opens a tcell screen on the tty and there is nothing to
 5363// say; in a browser the screen has to be an xtcell one bound to the
 5364// particular terminal and element the command was typed into, which
 5365// only the page knows. Rather than a second command for the browser β€”
 5366// which is what there used to be, and which promptly drifted β€” the page
 5367// puts the answer here and runs the real one.
 5368//
 5369// A function and not a screen: most commands never need one, and
 5370// building it takes the terminal over.
 5371func WithScreen(ctx context.Context, newScreen func() (tcell.Screen, error)) context.Context {
 5372	return context.WithValue(ctx, screenKey{}, newScreen)
 5373}
 5374
 5375// screenFrom returns the screen ctx asks for, or nil for tcell's own.
 5376func screenFrom(ctx context.Context) func() (tcell.Screen, error) {
 5377	if ctx == nil {
 5378		return nil
 5379	}
 5380	fn, _ := ctx.Value(screenKey{}).(func() (tcell.Screen, error))
 5381	return fn
 5382}
 5383
 5384// Run browses the store in a full-screen terminal UI until the user
 5385// quits, on the screen ctx names or on a new tcell one.
 5386func Run(ctx context.Context, prods product.Products) error {
 5387	if newScreen := screenFrom(ctx); newScreen != nil {
 5388		sc, err := newScreen()
 5389		if err != nil {
 5390			return err
 5391		}
 5392		return RunOn(sc, prods)
 5393	}
 5394	probeTerminal() // what this terminal can do beyond cells (probe_other.go)
 5395	sc, err := tcell.NewScreen()
 5396	if err != nil {
 5397		return err
 5398	}
 5399	if err := sc.Init(); err != nil {
 5400		return err
 5401	}
 5402	return RunOn(sc, prods)
 5403}
 5404
 5405// RunOn drives the store UI on an initialized screen until the user
 5406// quits, then finalizes it.
 5407func RunOn(sc tcell.Screen, prods product.Products) error {
 5408	u := &ui{
 5409		cat:      newCatalogInfo(prods),
 5410		panels:   map[string]panel{},
 5411		mode:     modeHalfBlock,
 5412		reqCh:    make(chan artReq, 1),
 5413		expanded: map[string]bool{},
 5414		done:     make(chan struct{}),
 5415		cartQty:  map[string]int{},
 5416		hoverX:   -1,
 5417		hoverY:   -1,
 5418	}
 5419	u.sitedomain = f.Sitename + f.Siteext
 5420	if u.sitedomain == "" {
 5421		u.sitedomain = "m2"
 5422	}
 5423	u.loadCart()
 5424	u.offerWidgets()
 5425	u.asciiLines = strings.Split(strings.TrimRight(calvin.AsciiFont(u.sitedomain), "\n"), "\n")
 5426	// The page sets this line in blackboard bold, and the terminal cannot:
 5427	// the double-struck capitals live outside the BMP and no monospace font
 5428	// here carries them, so every one of them falls back to a face whose
 5429	// glyph is nearly twice the cell it is given and collides with its
 5430	// neighbor. Plain capitals are the same words, drawn on the grid.
 5431	u.caps = strings.ToUpper(u.sitedomain)
 5432	long := f.Sitelongname
 5433	if long == "" {
 5434		long = u.sitedomain
 5435	}
 5436	tag := ""
 5437	if f.Sitetagline != "" {
 5438		tag = "- " + esc(f.Sitetagline) + " -"
 5439	}
 5440	u.masthead = [3]string{"[::b]" + esc(long) + "[-:-:-]", "[gray]" + tag + "[-]", checkerboard(u.sitedomain)}
 5441
 5442	// A fresh random starting pose each run, as the website gives each
 5443	// page load β€” and a random spin rate per axis, each with a floor so
 5444	// all three visibly contribute, the vector normalized so the overall
 5445	// speed matches auto-rotate's.
 5446	u.poseX = rand.Float64()*2*math.Pi - math.Pi //nolint:gosec
 5447	u.poseY = rand.Float64()*2*math.Pi - math.Pi //nolint:gosec
 5448	u.poseZ = rand.Float64()*2*math.Pi - math.Pi //nolint:gosec
 5449	randRate := func() float64 {
 5450		r := 0.35 + 0.65*rand.Float64() //nolint:gosec
 5451		if rand.Intn(2) == 0 {          //nolint:gosec
 5452			return -r
 5453		}
 5454		return r
 5455	}
 5456	u.rateX, u.rateY, u.rateZ = randRate(), randRate(), randRate()
 5457	norm := math.Sqrt(u.rateX*u.rateX + u.rateY*u.rateY + u.rateZ*u.rateZ)
 5458	const pace = 0.042 // auto-rotate's 0.3 rad/s at the 140ms ticker
 5459	u.rateX, u.rateY, u.rateZ = u.rateX/norm*pace, u.rateY/norm*pace, u.rateZ/norm*pace
 5460
 5461	logos := []string{"logo.jpg", "logo.png"}
 5462	if s := storeURL(); s != "" {
 5463		logos = []string{s + "/logo.jpg", s + "/logo.png"}
 5464	}
 5465	for _, p := range logos {
 5466		if img, err := loadImage(p); err == nil {
 5467			u.logo = img
 5468			break
 5469		}
 5470	}
 5471
 5472	u.screen = sc
 5473	sc.SetStyle(styleText)
 5474	sc.EnableMouse()
 5475
 5476	u.panels["home"] = &homePanel{}
 5477	u.current = "home"
 5478
 5479	go u.renderWorker()
 5480	go u.animateHome()
 5481	if p := u.pageFromPath(StartPath); p != "" && p != "home" {
 5482		u.show(p) // a link opened the store at this page
 5483	}
 5484	StartPath = "" // once: the store run again starts at home
 5485
 5486	defer func() {
 5487		// Stop accepting posts before the screen closes the event queue
 5488		// under them β€” a ticker mid-post must not race the close.
 5489		u.postMu.Lock()
 5490		u.finished = true
 5491		u.postMu.Unlock()
 5492		u.placeClear() // the placements go with the program
 5493		sc.Fini()
 5494		close(u.done)
 5495	}()
 5496	events := sc.EventQ()
 5497	u.draw()
 5498	for !u.quit {
 5499		ev, ok := <-events
 5500		if !ok {
 5501			break
 5502		}
 5503		switch e := ev.(type) {
 5504		case *tcell.EventInterrupt:
 5505			if fn, ok := e.Data().(func()); ok && fn != nil {
 5506				fn()
 5507			}
 5508		case *tcell.EventResize:
 5509			sc.Sync()
 5510			clear(u.placeSent) // the full redraw erased what was laid over the cells; lay it again
 5511		case *tcell.EventKey:
 5512			// v3 reports key releases too where the terminal can; a
 5513			// shortcut must fire once, on the press.
 5514			if e.Pressed() {
 5515				u.handleKey(e)
 5516			}
 5517		case *tcell.EventMouse:
 5518			u.handleMouse(e)
 5519		}
 5520		u.draw()
 5521	}
 5522	return nil
 5523}
 5524
 5525// animation ticker use it. Non-blocking and refused after shutdown, so
 5526// a producer can never race the queue's close nor wedge against a loop
 5527// that has already exited.
 5528func (u *ui) post(fn func()) {
 5529	u.postMu.Lock()
 5530	defer u.postMu.Unlock()
 5531	if u.finished {
 5532		return
 5533	}
 5534	select {
 5535	case u.screen.EventQ() <- tcell.NewEventInterrupt(fn):
 5536	default:
 5537	}
 5538}
 5539
 5540// ---- drawing ----
 5541
 5542// linkSink returns the callback the markup printers report anchors to,
 5543// binding each link index to the page's own table of what its links do.
 5544// A page builds that table as it builds its markup; see linkTag.
 5545func (u *ui) linkSink(acts []func()) func(x, y, w, link int) {
 5546	return func(x, y, w, link int) {
 5547		if w <= 0 || link < 1 || link > len(acts) || acts[link-1] == nil {
 5548			return
 5549		}
 5550		u.links = append(u.links, linkRegion{x, y, w, acts[link-1]})
 5551	}
 5552}
 5553
 5554// hovered is the run of cells the pointer is over, if it is over one of
 5555// the page's links: the header nav, the footer's two cells, or an anchor
 5556// the visible panel drew.
 5557func (u *ui) hovered(h int) (x1, x2, y int, ok bool) {
 5558	switch {
 5559	case u.hoverY < 0:
 5560	case u.hoverY == 0:
 5561		for _, reg := range u.navRegions {
 5562			if u.hoverX >= reg.x1 && u.hoverX < reg.x2 {
 5563				return reg.x1, reg.x2, 0, true
 5564			}
 5565		}
 5566	case u.hoverY == h-1:
 5567		for _, reg := range u.footRegions {
 5568			if u.hoverX >= reg.x1 && u.hoverX < reg.x2 {
 5569				return reg.x1, reg.x2, h - 1, true
 5570			}
 5571		}
 5572	default:
 5573		for _, l := range u.links {
 5574			if u.hoverY == l.y && u.hoverX >= l.x && u.hoverX < l.x+l.w {
 5575				return l.x, l.x + l.w, l.y, true
 5576			}
 5577		}
 5578	}
 5579	return 0, 0, 0, false
 5580}
 5581
 5582// paintHover recolors the link under the pointer, as a:hover{color:green}
 5583// does on the page.
 5584//
 5585// It repaints cells the page has already drawn rather than styling them
 5586// as they go, because which cells belong to a link is only known once it
 5587// is drawn β€” and this way no drawing code has to know where the pointer
 5588// is. It runs before the overlays, so an open dropdown covers it rather
 5589// than being recolored by it.
 5590func (u *ui) paintHover(sc tcell.Screen, h int) {
 5591	x1, x2, y, ok := u.hovered(h)
 5592	if !ok {
 5593		return
 5594	}
 5595	for x := x1; x < x2; x++ {
 5596		str, st, _ := sc.Get(x, y)
 5597		sc.Put(x, y, str, st.Foreground(colorHover))
 5598	}
 5599}
 5600
 5601func (u *ui) draw() {
 5602	sc := u.screen
 5603	w, h := sc.Size()
 5604	if w < 4 || h < 8 {
 5605		sc.Show()
 5606		return
 5607	}
 5608	sc.HideCursor()
 5609	u.links = u.links[:0]
 5610	u.overlaysDrawn = 0
 5611	fillRect(sc, rect{0, 0, w, h}, styleText)
 5612	u.drawHeader(sc, w)
 5613	// Row 1 is the header table's bottom border, so the masthead starts
 5614	// below it, as the page's title block sits under the nav rule.
 5615	for i, line := range u.masthead {
 5616		printMarkupCenter(sc, 0, 2+i, w, line, styleText)
 5617	}
 5618	content := rect{0, 5, w, h - 8}
 5619	u.mu.Lock()
 5620	cur := u.current
 5621	u.mu.Unlock()
 5622	if p := u.panels[cur]; p != nil {
 5623		p.draw(u, sc, content)
 5624	}
 5625	u.drawFooter(sc, w, h)
 5626	u.paintHover(sc, h)
 5627	var overlays []rect // what is drawn over the panel, which no placement may cover
 5628	if u.commerce != nil {
 5629		r := u.commerceRect()
 5630		overlays = append(overlays, r)
 5631		drawBox(sc, r, "[white] "+u.commerce.title()+" [-]", styleText.Foreground(tcell.ColorWhite.TrueColor()), styleText)
 5632		u.overlaysDrawn = len(overlays)
 5633		u.commerce.draw(u, sc, r.inner())
 5634	}
 5635	if u.drop != nil {
 5636		r := u.dropRect()
 5637		overlays = append(overlays, r)
 5638		drawBox(sc, r, "[white] "+map[string]string{"cats": "Categories", "etc": "Etc..."}[u.drop.kind]+" [-]", styleBorder, styleText)
 5639		u.drop.lst.draw(sc, r.inner())
 5640	}
 5641	if u.notice != "" {
 5642		nw := markupWidth(u.notice) + 6
 5643		if nw > w-4 {
 5644			nw = w - 4
 5645		}
 5646		r := rect{(w - nw) / 2, h/2 - 2, nw, 5}
 5647		overlays = append(overlays, r)
 5648		drawBox(sc, r, "", styleText.Foreground(tcell.ColorWhite.TrueColor()), styleText)
 5649		printMarkupCenter(sc, r.x+1, r.y+2, r.w-2, u.notice, styleText)
 5650	}
 5651	sc.Show()
 5652	u.placeSync(overlays)
 5653	u.announcePage()
 5654}
 5655
 5656// drawHeader lays the nav out as the website's header table does: three
 5657// equal columns β€” Categories, Home, Etc... β€” each label centered in its
 5658// third, with its dropdown opening beneath it. The current section is
 5659// cyan and underlined and the rest are white, as the page renders its
 5660// links, and the row is closed by the header table's blue bottom border.
 5661func (u *ui) drawHeader(sc tcell.Screen, w int) {
 5662	entries := []struct{ label, act string }{
 5663		{"Categories", "cats"}, {"Home", "home"}, {"Etc...", "etc"},
 5664	}
 5665	active := u.navActive()
 5666	u.navRegions = u.navRegions[:0]
 5667	third := w / 3
 5668	for i, e := range entries {
 5669		x := i*third + (third-len(e.label))/2
 5670		if x < 0 {
 5671			x = 0
 5672		}
 5673		tag := "[white]"
 5674		if e.act == active {
 5675			tag = "[aqua::u]"
 5676		}
 5677		printMarkup(sc, x, 0, w-x, tag+e.label+"[-:-:-]", styleText)
 5678		u.navRegions = append(u.navRegions, struct {
 5679			x1, x2 int
 5680			act    string
 5681		}{x, x + len(e.label), e.act})
 5682	}
 5683	drawRule(sc, 0, 1, w, ruleBlue)
 5684}
 5685
 5686// navActive names the header entry for the page on screen, the way the
 5687// site marks the section you are in: the catalog pages belong to
 5688// Categories, the stock pages to Etc..., everything else to Home.
 5689func (u *ui) navActive() string {
 5690	u.mu.Lock()
 5691	cur := u.current
 5692	u.mu.Unlock()
 5693	switch {
 5694	case strings.HasPrefix(cur, "list:"), strings.HasPrefix(cur, "prod:"):
 5695		return "cats"
 5696	case strings.HasPrefix(cur, "content:"):
 5697		return "etc"
 5698	}
 5699	return "home"
 5700}
 5701
 5702// drawFooter is the website's fixed footer: the status flash, address
 5703// and key hints on one row, then the footer table itself β€” View Cart on
 5704// the left half and Add Shipping Info on the right, each centered in its
 5705// cell, ruled above and divided down the middle as the page's is.
 5706func (u *ui) drawFooter(sc tcell.Screen, w, h int) {
 5707	status := "[#FF10F0:#009EFF] Open For Business [-:-]"
 5708	if f.Teststripekey {
 5709		status = "[yellow:red] Test Mode Active - No Orders Processed [-:-]"
 5710	}
 5711	arrows := ""
 5712	if len(u.history) > 0 {
 5713		arrows += "←"
 5714	}
 5715	if len(u.fwd) > 0 {
 5716		arrows += "β†’"
 5717	}
 5718	if arrows != "" {
 5719		arrows = "[gray]" + arrows + "[-] "
 5720	}
 5721	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[-]",
 5722		status, arrows, u.pagePath())
 5723	printMarkup(sc, 0, h-3, w, line, styleText)
 5724
 5725	// The footer table: a white rule for its top edge, then two equal
 5726	// cells split by the divider the page draws between them.
 5727	drawRule(sc, 0, h-2, w, ruleWhite)
 5728	total, count := u.cartTotal()
 5729	cart := fmt.Sprintf("β–΄ View Cart Total: $%d.%02d", total/100, total%100)
 5730	if count > 0 {
 5731		cart = fmt.Sprintf("β–΄ View Cart (%d) Total: $%d.%02d", count, total/100, total%100)
 5732	}
 5733	ship := "β–΄ Add Shipping Info"
 5734	u.footRegions = u.footRegions[:0]
 5735	half := w / 2
 5736	for i, e := range []struct{ label, act string }{{cart, "cart"}, {ship, "shipping"}} {
 5737		x := i*half + (half-len([]rune(e.label)))/2
 5738		if x < 0 {
 5739			x = 0
 5740		}
 5741		printMarkup(sc, x, h-1, w-x, "[aqua]"+esc(e.label)+"[-]", styleText)
 5742		u.footRegions = append(u.footRegions, struct {
 5743			x1, x2 int
 5744			act    string
 5745		}{x, x + len([]rune(e.label)), e.act})
 5746	}
 5747	sc.SetContent(half, h-1, 'β”‚', nil, ruleWhite)
 5748}
 5749
 5750func (u *ui) cartTotal() (cents, count int) {
 5751	for partno, qty := range u.cartQty {
 5752		if p := u.cat.find(partno); p != nil {
 5753			cents += priceCents(p.Price) * qty
 5754			count += qty
 5755		}
 5756	}
 5757	if u.shipping != nil {
 5758		cents += u.shipping.Cents
 5759	}
 5760	return cents, count
 5761}
 5762
 5763func priceCents(s string) int {
 5764	s = strings.TrimPrefix(s, "$")
 5765	fl, err := strconv.ParseFloat(s, 64)
 5766	if err != nil {
 5767		return 0
 5768	}
 5769	return int(fl*100 + 0.5)
 5770}
 5771
 5772// ---- input routing ----
 5773
 5774func (u *ui) handleKey(ev *tcell.EventKey) {
 5775	if ev.Key() == tcell.KeyCtrlC {
 5776		u.quit = true
 5777		return
 5778	}
 5779	if u.notice != "" {
 5780		u.notice = ""
 5781		return
 5782	}
 5783	if u.drop != nil {
 5784		if u.drop.lst.key(ev) {
 5785			return
 5786		}
 5787		r := keyRune(ev)
 5788		if ev.Key() == tcell.KeyEscape ||
 5789			(r == 'c' && u.drop.kind == "cats") || (r == 'e' && u.drop.kind == "etc") {
 5790			u.drop = nil
 5791			return
 5792		}
 5793	}
 5794	if u.commerce != nil {
 5795		if u.commerce.key(u, ev) {
 5796			return
 5797		}
 5798		if ev.Key() == tcell.KeyEscape {
 5799			u.closeCommerce()
 5800			return
 5801		}
 5802	}
 5803	switch ev.Key() {
 5804	case tcell.KeyEscape:
 5805		u.back()
 5806		return
 5807	case tcell.KeyBackspace, tcell.KeyBackspace2:
 5808		u.back()
 5809		return
 5810	}
 5811	if ev.Key() == tcell.KeyRune {
 5812		switch keyRune(ev) {
 5813		case 'q':
 5814			u.quit = true
 5815			return
 5816		case 'c':
 5817			u.openCategories()
 5818			return
 5819		case 'h':
 5820			u.show("home")
 5821			return
 5822		case 'e':
 5823			u.openEtc()
 5824			return
 5825		case 'f':
 5826			u.forward()
 5827			return
 5828		case 'v':
 5829			u.toggleCart()
 5830			return
 5831		case 'i':
 5832			if u.mode == modeHalfBlock {
 5833				u.mode = modeCaca
 5834			} else {
 5835				u.mode = modeHalfBlock
 5836			}
 5837			return // the changed mode re-keys art requests at next draw
 5838		case 'w':
 5839			u.cellsOnly = !u.cellsOnly
 5840			return
 5841		case 'd':
 5842			// The frame is drawn out of the table's own area, so it
 5843			// costs rows of products. Worth having and worth being able
 5844			// to put away, which the page never needs to offer.
 5845			u.bare = !u.bare
 5846			return
 5847		}
 5848	}
 5849	if u.drop != nil || u.commerce != nil {
 5850		return
 5851	}
 5852	u.mu.Lock()
 5853	cur := u.current
 5854	u.mu.Unlock()
 5855	if p := u.panels[cur]; p != nil {
 5856		p.key(u, ev)
 5857	}
 5858}
 5859
 5860func (u *ui) handleMouse(ev *tcell.EventMouse) {
 5861	x, y := ev.Position()
 5862	w, h := u.screen.Size()
 5863	click := ev.Buttons()&tcell.Button1 != 0
 5864	// Where the pointer is, for a:hover. Every event carries it, so bare
 5865	// motion is not the only thing that keeps it current.
 5866	u.hoverX, u.hoverY = x, y
 5867	if u.notice != "" {
 5868		if click {
 5869			u.notice = ""
 5870		}
 5871		return
 5872	}
 5873	wasOpen := ""
 5874	if u.drop != nil {
 5875		r := u.dropRect().inner()
 5876		if r.contains(x, y) {
 5877			u.drop.lst.mouse(y-r.y, ev)
 5878			return
 5879		}
 5880		if !click {
 5881			return
 5882		}
 5883		// Close, and let the click land: clicking the other header label
 5884		// switches menus in one click, as details dropdowns do β€” but a
 5885		// click on this menu's own label must collapse it, not reopen it.
 5886		wasOpen = u.drop.kind
 5887		u.drop = nil
 5888	}
 5889	if click && y == 0 {
 5890		for _, reg := range u.navRegions {
 5891			if x >= reg.x1 && x < reg.x2 {
 5892				switch reg.act {
 5893				case "cats":
 5894					if wasOpen != "cats" {
 5895						u.openCategories()
 5896					}
 5897				case "home":
 5898					u.show("home")
 5899				case "etc":
 5900					if wasOpen != "etc" {
 5901						u.openEtc()
 5902					}
 5903				}
 5904				return
 5905			}
 5906		}
 5907		return
 5908	}
 5909	if click && y == h-1 {
 5910		for _, reg := range u.footRegions {
 5911			if x >= reg.x1 && x < reg.x2 {
 5912				switch reg.act {
 5913				case "cart":
 5914					u.toggleCart()
 5915				case "shipping":
 5916					if _, open := u.commerce.(*shippingOverlay); open {
 5917						u.closeCommerce()
 5918					} else {
 5919						u.openShipping()
 5920					}
 5921				}
 5922				return
 5923			}
 5924		}
 5925		return
 5926	}
 5927	if u.commerce != nil {
 5928		r := u.commerceRect()
 5929		if !r.contains(x, y) && click {
 5930			u.closeCommerce()
 5931		}
 5932		return
 5933	}
 5934	// An anchor first: on the page these are <a>, and a click on one
 5935	// follows it rather than reaching the widget it was drawn over.
 5936	if click {
 5937		for _, l := range u.links {
 5938			if y == l.y && x >= l.x && x < l.x+l.w {
 5939				l.act()
 5940				return
 5941			}
 5942		}
 5943	}
 5944	content := rect{0, 5, w, h - 8}
 5945	if content.contains(x, y) {
 5946		u.mu.Lock()
 5947		cur := u.current
 5948		u.mu.Unlock()
 5949		if p := u.panels[cur]; p != nil {
 5950			p.mouse(u, content, ev)
 5951		}
 5952	}
 5953}
 5954
 5955// ---- navigation ----
 5956
 5957func (u *ui) show(page string) {
 5958	u.drop = nil
 5959	u.closeCommerce()
 5960	u.mu.Lock()
 5961	if page == u.current {
 5962		u.mu.Unlock()
 5963		return
 5964	}
 5965	u.history = append(u.history, u.current)
 5966	u.current = page
 5967	u.mu.Unlock()
 5968	u.fwd = nil
 5969	u.ensurePanel(page)
 5970}
 5971
 5972func (u *ui) back() {
 5973	u.mu.Lock()
 5974	if len(u.history) == 0 {
 5975		u.mu.Unlock()
 5976		return
 5977	}
 5978	page := u.history[len(u.history)-1]
 5979	u.history = u.history[:len(u.history)-1]
 5980	u.fwd = append(u.fwd, u.current)
 5981	u.current = page
 5982	u.mu.Unlock()
 5983}
 5984
 5985func (u *ui) forward() {
 5986	u.mu.Lock()
 5987	if len(u.fwd) == 0 {
 5988		u.mu.Unlock()
 5989		return
 5990	}
 5991	page := u.fwd[len(u.fwd)-1]
 5992	u.fwd = u.fwd[:len(u.fwd)-1]
 5993	u.history = append(u.history, u.current)
 5994	u.current = page
 5995	u.mu.Unlock()
 5996	u.ensurePanel(page)
 5997}
 5998
 5999func (u *ui) ensurePanel(page string) {
 6000	if _, ok := u.panels[page]; ok {
 6001		return
 6002	}
 6003	switch {
 6004	case strings.HasPrefix(page, "list:"):
 6005		spec := strings.TrimPrefix(page, "list:")
 6006		parts := strings.SplitN(spec, "|", 2)
 6007		cat, sub := parts[0], ""
 6008		if len(parts) == 2 {
 6009			sub = parts[1]
 6010		}
 6011		u.panels[page] = newListPanel(u, page, cat, sub)
 6012	case strings.HasPrefix(page, "prod:"):
 6013		u.panels[page] = newProductPanel(u, strings.TrimPrefix(page, "prod:"))
 6014	case strings.HasPrefix(page, "content:"):
 6015		u.panels[page] = newContentPanel(u, strings.TrimPrefix(page, "content:"))
 6016	}
 6017}
 6018
 6019func listPageID(t navTarget) string {
 6020	if t.cat == "" {
 6021		return "list:"
 6022	}
 6023	if t.subcat == "" {
 6024		return "list:" + t.cat
 6025	}
 6026	return "list:" + t.cat + "|" + t.subcat
 6027}
 6028
 6029// pagePath renders the current panel as the URL path the website would
 6030// give it β€” the footer shows it like an address bar.
 6031func (u *ui) pagePath() string {
 6032	u.mu.Lock()
 6033	cur := u.current
 6034	u.mu.Unlock()
 6035	switch {
 6036	case cur == "home":
 6037		return "/"
 6038	case strings.HasPrefix(cur, "content:"):
 6039		return "/#" + strings.TrimPrefix(cur, "content:")
 6040	case strings.HasPrefix(cur, "prod:"):
 6041		return "/p/" + strings.TrimPrefix(cur, "prod:")
 6042	case strings.HasPrefix(cur, "list:"):
 6043		spec := strings.TrimPrefix(cur, "list:")
 6044		if spec == "" {
 6045			return "/cat"
 6046		}
 6047		return "/cat/" + strings.ReplaceAll(spec, "|", "/")
 6048	}
 6049	return "/"
 6050}
 6051
 6052// ---- header dropdowns ----
 6053
 6054// dropRect puts an open header menu against the edge its entry is
 6055// nearest: Categories at the left, Etc at the right.
 6056//
 6057// The page does this as a consequence of how its header is built β€” each
 6058// menu is absolutely positioned inside its own cell of a full-width
 6059// table, so the first lands at the far left and the last at the far
 6060// right β€” and the result reads as deliberate: a pair of menus bracketing
 6061// the header rather than two labels with boxes hanging under them. The
 6062// terminal now does it on purpose.
 6063func (u *ui) dropRect() rect {
 6064	sw, sh := u.screen.Size()
 6065	x := 0
 6066	if u.drop.kind == "etc" {
 6067		x = sw - u.drop.width
 6068	}
 6069	if x < 0 {
 6070		x = 0
 6071	}
 6072	h := len(u.drop.lst.items) + 2
 6073	if max := sh - 4; h > max {
 6074		h = max
 6075	}
 6076	return rect{x, 2, u.drop.width, h}
 6077}
 6078
 6079// openCategories drops down the catsubcats tree. Like the template's
 6080// nested <details>, categories open and close: the current category
 6081// starts open, Enter on a closed category opens it (Enter again
 6082// navigates), β†’/space toggle, ← closes.
 6083func (u *ui) openCategories() {
 6084	if u.drop != nil && u.drop.kind == "cats" {
 6085		u.drop = nil
 6086		return
 6087	}
 6088	if cat, _ := u.currentTarget(); cat != "" {
 6089		u.expanded[cat] = true
 6090	}
 6091	// Width from the fully expanded tree, so the box doesn't resize as
 6092	// branches open and close.
 6093	width := 20
 6094	for _, it := range u.cat.buildTree(nil) {
 6095		if w := len([]rune(it.label)) + 4; w > width {
 6096			width = w
 6097		}
 6098	}
 6099	d := &dropState{kind: "cats", lst: &list{}, width: width}
 6100	u.drop = d
 6101	rebuild := func(selCat string) {
 6102		items := u.cat.buildTree(u.expanded)
 6103		d.lst.items = d.lst.items[:0]
 6104		d.targets = d.targets[:0]
 6105		sel := 0
 6106		for i, it := range items {
 6107			d.lst.items = append(d.lst.items, esc(it.label))
 6108			d.targets = append(d.targets, it.target)
 6109			if it.target.cat == selCat && it.target.subcat == "" {
 6110				sel = i
 6111			}
 6112		}
 6113		d.lst.sel = sel
 6114	}
 6115	rebuild("")
 6116	itemAt := func(i int) (navTarget, bool) {
 6117		if i < 0 || i >= len(d.targets) {
 6118			return navTarget{}, false
 6119		}
 6120		return d.targets[i], true
 6121	}
 6122	hasSubs := func(t navTarget) bool {
 6123		return t.cat != "" && t.subcat == "" && len(u.cat.subcatsByCat[t.cat]) > 0
 6124	}
 6125	d.lst.onPick = func(i int) {
 6126		t, ok := itemAt(i)
 6127		if !ok {
 6128			return
 6129		}
 6130		if hasSubs(t) && !u.expanded[t.cat] {
 6131			u.expanded[t.cat] = true
 6132			rebuild(t.cat)
 6133			return
 6134		}
 6135		u.show(listPageID(t))
 6136	}
 6137	d.lst.onKey = func(ev *tcell.EventKey, sel int) bool {
 6138		t, ok := itemAt(sel)
 6139		if !ok {
 6140			return false
 6141		}
 6142		switch {
 6143		case ev.Key() == tcell.KeyRight:
 6144			if hasSubs(t) && !u.expanded[t.cat] {
 6145				u.expanded[t.cat] = true
 6146				rebuild(t.cat)
 6147			}
 6148			return true
 6149		case ev.Key() == tcell.KeyLeft:
 6150			if t.cat != "" && (u.expanded[t.cat] || t.subcat != "") {
 6151				u.expanded[t.cat] = false
 6152				rebuild(t.cat)
 6153			}
 6154			return true
 6155		case keyRune(ev) == ' ':
 6156			if t.cat == "" {
 6157				return true
 6158			}
 6159			if t.subcat != "" {
 6160				u.expanded[t.cat] = false
 6161			} else if hasSubs(t) {
 6162				u.expanded[t.cat] = !u.expanded[t.cat]
 6163			}
 6164			rebuild(t.cat)
 6165			return true
 6166		}
 6167		return false
 6168	}
 6169}
 6170
 6171// currentTarget reports which category/subcategory panel is showing.
 6172func (u *ui) currentTarget() (string, string) {
 6173	u.mu.Lock()
 6174	cur := u.current
 6175	u.mu.Unlock()
 6176	if !strings.HasPrefix(cur, "list:") {
 6177		return "", ""
 6178	}
 6179	parts := strings.SplitN(strings.TrimPrefix(cur, "list:"), "|", 2)
 6180	if len(parts) == 2 {
 6181		return parts[0], parts[1]
 6182	}
 6183	return parts[0], ""
 6184}
 6185
 6186// openEtc drops down the Etc... menu: About, Policy, Telegram, Contact, Links.
 6187func (u *ui) openEtc() {
 6188	if u.drop != nil && u.drop.kind == "etc" {
 6189		u.drop = nil
 6190		return
 6191	}
 6192	type entry struct {
 6193		label string
 6194		fn    func()
 6195	}
 6196	var entries []entry
 6197	add := func(label string, fn func()) { entries = append(entries, entry{label, fn}) }
 6198	add("About", func() { u.show("content:about") })
 6199	add("Policy", func() { u.show("content:policy") })
 6200	// The Telegram entries are links out, and now follow like any other:
 6201	// the browser build opens the chat, and in a terminal openURL shows
 6202	// the address, which is what this did in both places before.
 6203	if f.Tgchannel != "" {
 6204		href := "https://t.me/" + f.Tgchannel
 6205		add("Telegram", func() { u.drop = nil; u.openURL(href) })
 6206	}
 6207	if f.Tgcontact != "" {
 6208		href := "https://t.me/" + f.Tgcontact
 6209		add("Contact", func() { u.drop = nil; u.openURL(href) })
 6210	}
 6211	add("Links", func() { u.show("content:links") })
 6212	// Whatever the deployment's drop-ins added to the page's menu, in the
 6213	// same place and the same order the header puts them: after Links.
 6214	// follow decides what each one means here β€” a page the TUI has of its
 6215	// own, or a link out.
 6216	for _, nl := range navLinks {
 6217		act := u.follow(nl.Href)
 6218		if act == nil {
 6219			continue
 6220		}
 6221		add(nl.Title, func() { u.drop = nil; act() })
 6222	}
 6223	lst := &list{}
 6224	for _, e := range entries {
 6225		lst.items = append(lst.items, esc(e.label))
 6226	}
 6227	lst.onPick = func(i int) { entries[i].fn() }
 6228	u.drop = &dropState{kind: "etc", lst: lst, width: 30}
 6229}
 6230
 6231// ---- the render worker ----
 6232
 6233func (u *ui) requestArt(h *artHolder, path string, w, ht int) {
 6234	key := fmt.Sprintf("%s|%d|%d|%d", path, w, ht, u.mode)
 6235	if h.key == key || h.want == key {
 6236		return
 6237	}
 6238	h.want = key
 6239	req := artReq{key: key, path: path, w: w, h: ht, mode: u.mode, holder: h}
 6240	select {
 6241	case u.reqCh <- req:
 6242	default:
 6243		select { // replace the stale pending request
 6244		case <-u.reqCh:
 6245		default:
 6246		}
 6247		u.reqCh <- req
 6248	}
 6249}
 6250
 6251func (u *ui) renderWorker() {
 6252	cache := map[string]artwork{}
 6253	for req := range u.reqCh {
 6254		art, ok := cache[req.key]
 6255		if !ok {
 6256			art = renderArt(req.path, req.w, req.h, req.mode)
 6257			if len(cache) > 128 {
 6258				cache = map[string]artwork{}
 6259			}
 6260			cache[req.key] = art
 6261		}
 6262		r := req
 6263		a := art
 6264		u.post(func() {
 6265			r.holder.key = r.key
 6266			r.holder.art = &a
 6267		})
 6268	}
 6269}
 6270
 6271// ---- the home panel ----
 6272
 6273type homePanel struct{}
 6274
 6275func (hp *homePanel) draw(u *ui, sc tcell.Screen, r rect) {
 6276	u.mu.Lock()
 6277	u.homeW, u.homeH = r.w, r.h
 6278	frame := u.frame
 6279	u.mu.Unlock()
 6280	if frame == nil {
 6281		return
 6282	}
 6283	rows := (frame.Bounds().Dy() + 1) / 2
 6284	drawRGBA(sc, r.x, r.y, r.w, r.h, frame)
 6285	// The website's own logo and globe over these cells, where the page
 6286	// offers its globe (place.go).
 6287	// Across the panel's whole width, the black either side of the logo
 6288	// included: the picture there is the widget's, so a click on it is too
 6289	// (it stops the globe). The logo is letterboxed to the same place.
 6290	u.want("home", placed{r: rect{r.x, r.y, r.w, min(rows, r.h)}, widget: "globe", input: true})
 6291	if rows < r.h {
 6292		ty := r.y + rows
 6293		for i, l := range u.asciiLines {
 6294			if ty+i >= r.y+r.h {
 6295				break
 6296			}
 6297			printMarkupCenter(sc, r.x, ty+i, r.w, esc(l), styleText)
 6298		}
 6299		if ty+len(u.asciiLines)+1 < r.y+r.h {
 6300			printMarkupCenter(sc, r.x, ty+len(u.asciiLines)+1, r.w, esc(u.caps), styleText)
 6301			// The page's double-struck capitals over them, where a page can
 6302			// set them (the "caps" widget, widgets_js.go); a terminal keeps these.
 6303			u.want("caps", placed{r: rect{r.x, ty + len(u.asciiLines) + 1, r.w, 1}, widget: "caps"})
 6304		}
 6305	}
 6306}
 6307
 6308func (hp *homePanel) key(*ui, *tcell.EventKey) bool           { return false }
 6309func (hp *homePanel) mouse(*ui, rect, *tcell.EventMouse) bool { return false }
 6310
 6311// animateHome turns the globe over the logo while the home panel is
 6312// shown, like the website's landing animation over the svg.
 6313func (u *ui) animateHome() {
 6314	ticker := time.NewTicker(140 * time.Millisecond)
 6315	defer ticker.Stop()
 6316	for {
 6317		select {
 6318		case <-u.done:
 6319			return
 6320		case <-ticker.C:
 6321		}
 6322		u.mu.Lock()
 6323		cur, w, h := u.current, u.homeW, u.homeH
 6324		u.mu.Unlock()
 6325		if cur != "home" || w <= 0 || h <= 0 {
 6326			continue
 6327		}
 6328		globeRows := h - len(u.asciiLines) - 2
 6329		if globeRows < 4 {
 6330			globeRows = h
 6331		}
 6332		if u.backdrop == nil || u.bdW != w || u.bdH != globeRows {
 6333			u.backdrop = makeBackdrop(u.logo, w, globeRows)
 6334			u.bdW, u.bdH = w, globeRows
 6335		}
 6336		if u.homeCovered.Load() {
 6337			// The page's globe turns over these cells: drawing ours under
 6338			// it would cost the tab a core for nothing seen. The logo alone,
 6339			// once, which then costs nothing.
 6340			u.mu.Lock()
 6341			if u.frame != u.backdrop {
 6342				u.frame = u.backdrop
 6343				u.mu.Unlock()
 6344				u.post(nil)
 6345			} else {
 6346				u.mu.Unlock()
 6347			}
 6348			continue
 6349		}
 6350		u.poseX += u.rateX
 6351		u.poseY += u.rateY
 6352		u.poseZ += u.rateZ
 6353		frame := globeFrame(u.backdrop, u.poseX, u.poseY, u.poseZ)
 6354		u.mu.Lock()
 6355		u.frame = frame
 6356		u.mu.Unlock()
 6357		u.post(nil) // just redraw
 6358	}
 6359}
 6360
 6361// ---- category / subcategory listings ----
 6362
 6363// listPanel renders a category page: category.html's h1, the sidebar's
 6364// list of subcategories, and the product table, with the selected row's
 6365// image beside it (the terminal's stand-in for the table's image column).
 6366type listPanel struct {
 6367	page, cat, sub string
 6368	heading        []string
 6369	links          []func()           // what this page's anchors do; see linkTag
 6370	cols           []storepage.Col    // the shared column set, image included
 6371	shown          []storepage.Col    // the ones this view draws, in table order
 6372	prods          []*product.Product // panel's full set
 6373	rows           []*product.Product // filtered
 6374	tbl            *table
 6375	art            artHolder
 6376	filterOn       bool
 6377	filter         finput
 6378}
 6379
 6380// link registers an action for this page and returns the markup that
 6381// wraps s in it.
 6382func (lp *listPanel) link(s string, act func()) string {
 6383	lp.links = append(lp.links, act)
 6384	return linkTag(len(lp.links), s)
 6385}
 6386
 6387func newListPanel(u *ui, page, cat, sub string) *listPanel {
 6388	lp := &listPanel{page: page, cat: cat, sub: sub}
 6389	all := u.cat.products(cat, sub)
 6390	for i := range all {
 6391		lp.prods = append(lp.prods, &all[i])
 6392	}
 6393	// category.html's h1, carrying the same links: the category name goes
 6394	// to the category β€” which is how a subcategory page climbs back β€” and
 6395	// each name links to its own page as the template's do.
 6396	catLink := func(t navTarget, label string) string {
 6397		return lp.link("[aqua]"+esc(label)+"[-]", func() { u.show(listPageID(t)) })
 6398	}
 6399	title := "All Products"
 6400	switch {
 6401	case sub != "":
 6402		title = "Category: " + catLink(navTarget{cat: cat}, cat) +
 6403			" | Subcategory: " + catLink(navTarget{cat: cat, subcat: sub}, sub)
 6404	case cat != "":
 6405		title = "Category: " + catLink(navTarget{cat: cat}, cat)
 6406	}
 6407	lp.heading = []string{"[::b]" + title + "[-:-:-]"}
 6408	// The page's aside lists the category's subcategories under a
 6409	// "Subcategories:" heading; the Categories dropdown is the terminal's
 6410	// aside, so the list goes on the page where it can be reached.
 6411	if cat != "" && sub == "" {
 6412		if subs := u.cat.subcatsByCat[cat]; len(subs) > 0 {
 6413			var parts []string
 6414			for _, s := range subs {
 6415				target := navTarget{cat: cat, subcat: s}
 6416				parts = append(parts, fmt.Sprintf("%s (%d)",
 6417					lp.link("[aqua]"+esc(s)+"[-]", func() { u.show(listPageID(target)) }),
 6418					u.cat.subcatCounts[cat][s]))
 6419			}
 6420			lp.heading = append(lp.heading, "Subcategories: "+strings.Join(parts, ", "))
 6421		}
 6422	}
 6423	// The columns are storepage's, so the page and the terminal list the
 6424	// same things in the same order. Image is the one column a terminal
 6425	// answers differently β€” it has one picture to spend, beside the
 6426	// table, not a thumbnail per row β€” so it is dropped here and the art
 6427	// panel stands in for it. Subcategory only where the rows can differ
 6428	// in it, which is what storepage is being asked.
 6429	lp.cols = storepage.Columns(sub == "")
 6430	var cols []tcol
 6431	for _, c := range lp.cols {
 6432		if c.Kind == storepage.ColImage {
 6433			continue
 6434		}
 6435		cols = append(cols, tcol{
 6436			title:      c.Title,
 6437			width:      c.Width,
 6438			alignRight: c.Align == storepage.AlignRight,
 6439			link:       c.Kind == storepage.ColName || c.Kind == storepage.ColSubcategory || c.Kind == storepage.ColBuy,
 6440		})
 6441		lp.shown = append(lp.shown, c)
 6442	}
 6443	lp.tbl = &table{cols: cols}
 6444	lp.tbl.onActivate = func(i int) {
 6445		if i >= 0 && i < len(lp.rows) {
 6446			u.show("prod:" + lp.rows[i].Partno)
 6447		}
 6448	}
 6449	lp.fill("")
 6450	return lp
 6451}
 6452
 6453func (lp *listPanel) fill(query string) {
 6454	lp.rows = lp.rows[:0]
 6455	var rows []trow
 6456	query = strings.ToLower(query)
 6457	for _, p := range lp.prods {
 6458		if query != "" && !strings.Contains(strings.ToLower(
 6459			p.Partno+" "+p.Name+" "+p.Subcategory+" "+p.Description1), query) {
 6460			continue
 6461		}
 6462		row := trow{}
 6463		for _, c := range lp.shown {
 6464			cell := storepage.CellFor(c, p)
 6465			row.cells = append(row.cells, tcell_{cell.Text, cellStyle(c, p)})
 6466		}
 6467		rows = append(rows, row)
 6468		lp.rows = append(lp.rows, p)
 6469	}
 6470	lp.tbl.setRows(rows)
 6471}
 6472
 6473// cellStyle dresses a shared cell in the site's colors: links cyan, the
 6474// buy button a button, and an out-of-stock name grey β€” which is the page
 6475// greying a row it will not sell.
 6476func cellStyle(c storepage.Col, p *product.Product) tcell.Style {
 6477	switch c.Kind {
 6478	case storepage.ColName:
 6479		if !storepage.InStock(p) {
 6480			return styleText.Foreground(tcell.GetColor("#808080"))
 6481		}
 6482		return styleLink
 6483	case storepage.ColSubcategory:
 6484		return styleLink
 6485	case storepage.ColBuy:
 6486		return styleButton
 6487	}
 6488	return styleText
 6489}
 6490
 6491func (lp *listPanel) selected() *product.Product {
 6492	if lp.tbl.sel >= 0 && lp.tbl.sel < len(lp.rows) {
 6493		return lp.rows[lp.tbl.sel]
 6494	}
 6495	return nil
 6496}
 6497
 6498// layout places the panel's parts. tblR is where the table's cells go,
 6499// which is inside the frame when there is room for one β€” brd and brdR
 6500// carry that frame and where it is drawn. Both draw and mouse take the
 6501// table's rectangle from here, or a click would land a row or two off
 6502// whatever the frame pushed the table to.
 6503func (lp *listPanel) layout(u *ui, r rect) (head, tblR, artR, filtR, brdR rect, brd *borderArt) {
 6504	hh := len(lp.heading)
 6505	head = rect{r.x + 1, r.y, r.w - 2, hh}
 6506	body := rect{r.x, r.y + hh + 1, r.w, r.h - hh - 1}
 6507	fh := 0
 6508	if lp.filterOn {
 6509		fh = 1
 6510	}
 6511	tw := body.w * 3 / 5
 6512	tblR = rect{body.x + 1, body.y, tw - 2, body.h - fh}
 6513	artR = rect{body.x + tw, body.y, body.w - tw, body.h - fh}
 6514	filtR = rect{body.x + 1, body.y + body.h - 1, body.w - 2, 1}
 6515	if !u.bare {
 6516		tblR, brd, brdR = framed(tblR)
 6517	}
 6518	return
 6519}
 6520
 6521func (lp *listPanel) draw(u *ui, sc tcell.Screen, r rect) {
 6522	head, tblR, artR, filtR, brdR, brd := lp.layout(u, r)
 6523	if brd != nil {
 6524		brd.draw(sc, brdR)
 6525	}
 6526	sink := u.linkSink(lp.links)
 6527	for i, line := range lp.heading {
 6528		printMarkupLinks(sc, head.x, head.y+i, head.w, line, styleText, sink)
 6529	}
 6530	// A row has the two the page's row has: the name opens the product,
 6531	// as the product image does there, and Buy adds it to the cart.
 6532	// Registering them as anchors is what makes one click do it and the
 6533	// pointer light them up. Both select the row first, so the photo
 6534	// beside the table shows what was just acted on.
 6535	lp.tbl.onLink = func(x, y, w, row, col int) {
 6536		if col >= len(lp.shown) {
 6537			return
 6538		}
 6539		kind := lp.shown[col].Kind
 6540		u.links = append(u.links, linkRegion{x, y, w, func() {
 6541			lp.tbl.sel = row
 6542			p := lp.selected()
 6543			if p == nil {
 6544				return
 6545			}
 6546			// What the cell says it is, rather than what column it
 6547			// landed in: the shared cell already carries where it goes
 6548			// and whether it is the row's buy.
 6549			cell := storepage.CellFor(storepage.Col{Kind: kind}, p)
 6550			switch {
 6551			case cell.Buy:
 6552				u.addToCart(p)
 6553			case cell.Href != "":
 6554				if act := u.follow(cell.Href); act != nil {
 6555					act()
 6556				}
 6557			}
 6558		}})
 6559	}
 6560	lp.tbl.draw(sc, tblR)
 6561	if p := lp.selected(); p != nil {
 6562		u.requestArt(&lp.art, imagePath(p), artR.w, artR.h)
 6563	}
 6564	if ar, ok := drawArtwork(sc, artR, lp.art.art); ok && lp.selected() != nil {
 6565		u.want("photo", placed{r: ar, url: imagePath(lp.selected()), fit: "cover"})
 6566	}
 6567	if lp.filterOn {
 6568		printMarkup(sc, filtR.x, filtR.y, 3, "[aqua]/ [-]", styleText)
 6569		curX, _ := lp.filter.drawValue(sc, filtR.x+2, filtR.y, filtR.w-2, true)
 6570		sc.ShowCursor(curX, filtR.y)
 6571	}
 6572}
 6573
 6574func (lp *listPanel) key(u *ui, ev *tcell.EventKey) bool {
 6575	if lp.filterOn {
 6576		switch ev.Key() {
 6577		case tcell.KeyEscape:
 6578			lp.filterOn = false
 6579			lp.filter.text = nil
 6580			lp.filter.cur = 0
 6581			lp.fill("")
 6582			return true
 6583		case tcell.KeyEnter:
 6584			lp.filterOn = false
 6585			return true
 6586		}
 6587		if lp.filter.key(ev) {
 6588			lp.fill(string(lp.filter.text))
 6589			return true
 6590		}
 6591		return ev.Key() == tcell.KeyRune
 6592	}
 6593	if ev.Key() == tcell.KeyRune {
 6594		switch keyRune(ev) {
 6595		case '/':
 6596			lp.filterOn = true
 6597			lp.filter = finput{width: 40}
 6598			return true
 6599		case 'b':
 6600			if p := lp.selected(); p != nil {
 6601				u.addToCart(p)
 6602			}
 6603			return true
 6604		}
 6605	}
 6606	return lp.tbl.key(ev)
 6607}
 6608
 6609func (lp *listPanel) mouse(u *ui, r rect, ev *tcell.EventMouse) bool {
 6610	_, tblR, _, _, _, _ := lp.layout(u, r)
 6611	x, y := ev.Position()
 6612	if ev.Buttons()&(tcell.WheelUp|tcell.WheelDown) != 0 {
 6613		// the wheel drives the table wherever it is rolled, the picture
 6614		// side included
 6615		return lp.tbl.mouse(1, ev)
 6616	}
 6617	if tblR.contains(x, y) {
 6618		return lp.tbl.mouse(y-tblR.y, ev)
 6619	}
 6620	return false
 6621}
 6622
 6623// ---- the product panel ----
 6624
 6625type productPanel struct {
 6626	prod  *product.Product
 6627	ta    *textArea
 6628	art   artHolder
 6629	links []func() // what this page's anchors do; see linkTag
 6630}
 6631
 6632func newProductPanel(u *ui, partno string) panel {
 6633	p := u.cat.find(partno)
 6634	if p == nil {
 6635		return newContentPanel(u, "") // empty
 6636	}
 6637	pp := &productPanel{prod: p}
 6638	pp.ta = newTextArea(productLines(u, p, func(text string, act func()) string {
 6639		pp.links = append(pp.links, act)
 6640		return linkTag(len(pp.links), text)
 6641	}), true)
 6642	return pp
 6643}
 6644
 6645func (pp *productPanel) draw(u *ui, sc tcell.Screen, r rect) {
 6646	ih := r.h * 3 / 5
 6647	imgR := rect{r.x, r.y, r.w, ih}
 6648	txtR := rect{r.x + 1, r.y + ih + 1, r.w - 2, r.h - ih - 1}
 6649	u.requestArt(&pp.art, imagePath(pp.prod), imgR.w, imgR.h)
 6650	if ar, ok := drawArtwork(sc, imgR, pp.art.art); ok {
 6651		u.want("photo", placed{r: ar, url: imagePath(pp.prod), fit: "cover"})
 6652	}
 6653	pp.ta.onLink = u.linkSink(pp.links)
 6654	pp.ta.draw(sc, txtR)
 6655}
 6656
 6657func (pp *productPanel) key(u *ui, ev *tcell.EventKey) bool {
 6658	if ev.Key() == tcell.KeyEnter || keyRune(ev) == 'b' {
 6659		u.addToCart(pp.prod)
 6660		return true
 6661	}
 6662	return pp.ta.key(ev)
 6663}
 6664
 6665func (pp *productPanel) mouse(u *ui, r rect, ev *tcell.EventMouse) bool {
 6666	switch {
 6667	case ev.Buttons()&tcell.WheelUp != 0:
 6668		pp.ta.wheel(true)
 6669	case ev.Buttons()&tcell.WheelDown != 0:
 6670		pp.ta.wheel(false)
 6671	default:
 6672		return false
 6673	}
 6674	return true
 6675}
 6676
 6677func (u *ui) addToCart(p *product.Product) {
 6678	if p.Quantity == "0" {
 6679		u.notice = esc(p.Name) + " is out of stock"
 6680		return
 6681	}
 6682	if _, ok := u.cartQty[p.Partno]; !ok {
 6683		u.cartOrder = append(u.cartOrder, p.Partno)
 6684	}
 6685	u.cartQty[p.Partno]++
 6686	u.cartChanged()
 6687}
 6688
 6689// ---- content pages: about, policy, links ----
 6690
 6691type contentPanel struct {
 6692	ta    *textArea
 6693	links []func() // what this page's anchors do; see linkTag
 6694	// The page is re-rendered when the pane changes width, because the
 6695	// tables in it are laid out to a column count. raw is kept so that
 6696	// costs a re-render and not a re-fetch.
 6697	u     *ui
 6698	raw   string
 6699	year  string
 6700	lastW int
 6701}
 6702
 6703// contentFile mirrors pkg/web: the deployment-local file when present,
 6704// falling back to the committed .example.
 6705func contentFile(path string) string {
 6706	if data, err := os.ReadFile(path); err == nil { //nolint
 6707		return string(data)
 6708	}
 6709	data, err := os.ReadFile(path + ".example") //nolint
 6710	if err != nil {
 6711		return ""
 6712	}
 6713	return string(data)
 6714}
 6715
 6716func newContentPanel(u *ui, name string) *contentPanel {
 6717	cp := &contentPanel{
 6718		u:    u,
 6719		raw:  fetchContent(name),
 6720		year: fmt.Sprintf("%d", time.Now().Year()),
 6721		ta:   newTextArea("[gray]no content/"+esc(name)+".html[-]", true),
 6722	}
 6723	return cp
 6724}
 6725
 6726// render lays the page out for a pane w columns wide, keeping the reader
 6727// where they were: setContent rewinds to the top, which is the right thing
 6728// on a new page and the wrong thing on a resize.
 6729func (cp *contentPanel) render(w int) {
 6730	cp.lastW = w
 6731	if cp.raw == "" {
 6732		return
 6733	}
 6734	text, hrefs := htmlToText(cp.raw, cp.year, w)
 6735	at := cp.ta.scroll
 6736	cp.ta.setContent(text)
 6737	cp.ta.scroll = at
 6738	cp.links = cp.links[:0]
 6739	for _, href := range hrefs {
 6740		cp.links = append(cp.links, cp.u.follow(href))
 6741	}
 6742}
 6743
 6744// follow is what clicking an href does: a path this store serves is a
 6745// page the TUI has of its own, and anything else is a link out.
 6746func (u *ui) follow(href string) func() {
 6747	switch {
 6748	case href == "", href == "#":
 6749		return nil
 6750	case href == "/", href == "/#", href == "#home", href == "/#home":
 6751		return func() { u.show("home") }
 6752	// The site's own menu spells these "/#about" so that they work from a
 6753	// page other than the front one, where a bare "#about" would only
 6754	// append a fragment to the path already there. Both forms are the
 6755	// same page here.
 6756	case href == "#about", href == "#policy", href == "#links",
 6757		href == "/#about", href == "/#policy", href == "/#links":
 6758		page := "content:" + strings.TrimPrefix(strings.TrimPrefix(href, "/"), "#")
 6759		return func() { u.show(page) }
 6760	case strings.HasPrefix(href, "/p/"):
 6761		if p := u.cat.find(strings.TrimPrefix(href, "/p/")); p != nil {
 6762			partno := p.Partno
 6763			return func() { u.show("prod:" + partno) }
 6764		}
 6765		abs := storeURL() + href
 6766		return func() { u.openURL(abs) }
 6767	case strings.HasPrefix(href, "/cat/"):
 6768		// The site spells a subcategory in a URL through escapesubcat
 6769		// ("ΒΌ watt 5%" is quarter-watt-5-pct), so a path only names a
 6770		// page here when the catalog has those names as written. When it
 6771		// does not, the store's own URL resolves it and the link goes
 6772		// out rather than landing on an empty table.
 6773		if t, ok := u.cat.resolve(strings.TrimPrefix(href, "/cat/")); ok {
 6774			return func() { u.show(listPageID(t)) }
 6775		}
 6776		abs := storeURL() + href
 6777		return func() { u.openURL(abs) }
 6778	case strings.HasPrefix(href, "/"):
 6779		// Some other page of this site β€” /logo, /sitemap.xml. The TUI has
 6780		// no view of it, so hand over the address on the store's origin.
 6781		abs := storeURL() + href
 6782		return func() { u.openURL(abs) }
 6783	}
 6784	return func() { u.openURL(href) }
 6785}
 6786
 6787func (cp *contentPanel) draw(u *ui, sc tcell.Screen, r rect) {
 6788	inner := rect{r.x + 1, r.y, r.w - 2, r.h}
 6789	if inner.w > 0 && inner.w != cp.lastW {
 6790		cp.render(inner.w)
 6791	}
 6792	cp.ta.onLink = u.linkSink(cp.links)
 6793	cp.ta.draw(sc, inner)
 6794}
 6795
 6796func (cp *contentPanel) key(u *ui, ev *tcell.EventKey) bool {
 6797	return cp.ta.key(ev)
 6798}
 6799
 6800func (cp *contentPanel) mouse(u *ui, r rect, ev *tcell.EventMouse) bool {
 6801	switch {
 6802	case ev.Buttons()&tcell.WheelUp != 0:
 6803		cp.ta.wheel(true)
 6804	case ev.Buttons()&tcell.WheelDown != 0:
 6805		cp.ta.wheel(false)
 6806	default:
 6807		return false
 6808	}
 6809	return true
 6810}
 6811
 6812
 6813// ===== pkg/tui/widgets.go =====
 6814// Package tui pkg/tui/widgets.go β€” the widget kit: a table, a list, a
 6815// scrollable text area, and a form, written directly on tcell. Small on
 6816// purpose: these are the site's shapes (the category table, the
 6817// catsubcats dropdown, product pages, Add Shipping Info), not a general
 6818// toolkit.
 6819package tui
 6820
 6821import (
 6822	"strings"
 6823
 6824	"github.com/gdamore/tcell/v3"
 6825)
 6826
 6827// ---- textArea: scrollable, optionally word-wrapped markup text ----
 6828
 6829type textArea struct {
 6830	content string
 6831	base    tcell.Style
 6832	wrap    bool
 6833	scroll  int
 6834	lines   [][]seg
 6835	lastW   int
 6836	// onLink, when set, is told where each anchor in the text landed on
 6837	// the screen, so the page can make it clickable. Set before every
 6838	// draw: what is on screen changes with the scroll.
 6839	onLink func(x, y, w, link int)
 6840}
 6841
 6842func newTextArea(content string, wrap bool) *textArea {
 6843	return &textArea{content: content, base: styleText, wrap: wrap}
 6844}
 6845
 6846func (t *textArea) setContent(s string) {
 6847	t.content = s
 6848	t.lastW = 0
 6849	t.scroll = 0
 6850}
 6851
 6852func (t *textArea) draw(sc tcell.Screen, r rect) {
 6853	if r.w != t.lastW || t.lines == nil {
 6854		t.lines = wrapSegs(t.content, r.w, t.base, t.wrap)
 6855		t.lastW = r.w
 6856	}
 6857	if max := len(t.lines) - r.h; t.scroll > max {
 6858		t.scroll = max
 6859	}
 6860	if t.scroll < 0 {
 6861		t.scroll = 0
 6862	}
 6863	for i := 0; i < r.h; i++ {
 6864		li := t.scroll + i
 6865		if li >= len(t.lines) {
 6866			break
 6867		}
 6868		printSegsLinks(sc, r.x, r.y+i, r.w, t.lines[li], t.onLink)
 6869	}
 6870}
 6871
 6872func (t *textArea) key(ev *tcell.EventKey) bool {
 6873	switch ev.Key() {
 6874	case tcell.KeyUp:
 6875		t.scroll--
 6876	case tcell.KeyDown:
 6877		t.scroll++
 6878	case tcell.KeyPgUp:
 6879		t.scroll -= 10
 6880	case tcell.KeyPgDn:
 6881		t.scroll += 10
 6882	case tcell.KeyHome:
 6883		t.scroll = 0
 6884	case tcell.KeyEnd:
 6885		t.scroll = len(t.lines)
 6886	default:
 6887		return false
 6888	}
 6889	return true
 6890}
 6891
 6892func (t *textArea) wheel(up bool) {
 6893	if up {
 6894		t.scroll -= 3
 6895	} else {
 6896		t.scroll += 3
 6897	}
 6898}
 6899
 6900// ---- table: the site's product table ----
 6901
 6902type tcol struct {
 6903	title      string
 6904	width      int // 0 = expands
 6905	alignRight bool
 6906	// link marks the column the row's <a> lives in. Clicking a cell there
 6907	// opens the row, as clicking the link on the page does; clicking any
 6908	// other column selects the row, which is what shows its picture.
 6909	link bool
 6910}
 6911
 6912type tcell_ struct {
 6913	text  string
 6914	style tcell.Style
 6915}
 6916
 6917type trow struct {
 6918	cells []tcell_
 6919}
 6920
 6921type table struct {
 6922	cols       []tcol
 6923	rows       []trow
 6924	sel        int
 6925	offset     int
 6926	onSelect   func(int)
 6927	onActivate func(int)
 6928	// onLink, when set, is told where each drawn link cell landed and
 6929	// which row and column it belongs to, so the page can register it as
 6930	// an anchor β€” which is what makes one click act on it and the pointer
 6931	// light it up. A row has more than one: its name opens the product
 6932	// and its Buy cell adds it to the cart, as the page's row does. Set
 6933	// before every draw, since the rows move as the table scrolls.
 6934	onLink func(x, y, w, row, col int)
 6935}
 6936
 6937func (t *table) setRows(rows []trow) {
 6938	t.rows = rows
 6939	t.offset = 0
 6940	if len(rows) == 0 {
 6941		t.sel = -1
 6942		return
 6943	}
 6944	t.sel = 0
 6945	if t.onSelect != nil {
 6946		t.onSelect(0)
 6947	}
 6948}
 6949
 6950// colWidths distributes r.w across the columns; width-0 columns share
 6951// the remainder.
 6952func (t *table) colWidths(w int) []int {
 6953	ws := make([]int, len(t.cols))
 6954	fixed, flex := 0, 0
 6955	for i, c := range t.cols {
 6956		ws[i] = c.width
 6957		if c.width == 0 {
 6958			flex++
 6959		} else {
 6960			fixed += c.width + 1
 6961		}
 6962	}
 6963	if flex > 0 {
 6964		share := (w - fixed - flex) / flex
 6965		if share < 4 {
 6966			share = 4
 6967		}
 6968		for i, c := range t.cols {
 6969			if c.width == 0 {
 6970				ws[i] = share
 6971			}
 6972		}
 6973	}
 6974	return ws
 6975}
 6976
 6977func (t *table) draw(sc tcell.Screen, r rect) {
 6978	if r.h < 2 {
 6979		return
 6980	}
 6981	ws := t.colWidths(r.w)
 6982	// The page's thead is a filled purple band, so the band is the header
 6983	// rather than only the words in it.
 6984	fillRect(sc, rect{r.x, r.y, r.w, 1}, styleThead)
 6985	x := r.x
 6986	for i, c := range t.cols {
 6987		st := styleThead
 6988		txt := c.title
 6989		if len(txt) > ws[i] {
 6990			txt = txt[:ws[i]]
 6991		}
 6992		pad := ws[i] - len(txt)
 6993		if c.alignRight {
 6994			printMarkup(sc, x+pad, r.y, ws[i], esc(txt), st)
 6995		} else {
 6996			printMarkup(sc, x, r.y, ws[i], esc(txt), st)
 6997		}
 6998		x += ws[i] + 1
 6999	}
 7000	visible := r.h - 1
 7001	if t.sel >= 0 {
 7002		if t.sel < t.offset {
 7003			t.offset = t.sel
 7004		}
 7005		if t.sel >= t.offset+visible {
 7006			t.offset = t.sel - visible + 1
 7007		}
 7008	}
 7009	for row := 0; row < visible; row++ {
 7010		ri := t.offset + row
 7011		if ri >= len(t.rows) {
 7012			break
 7013		}
 7014		y := r.y + 1 + row
 7015		selected := ri == t.sel
 7016		if selected {
 7017			fillRect(sc, rect{r.x, y, r.w, 1}, styleSel)
 7018		}
 7019		x = r.x
 7020		for i, c := range t.rows[ri].cells {
 7021			if i >= len(ws) {
 7022				break
 7023			}
 7024			st := c.style
 7025			if selected {
 7026				st = styleSel
 7027			}
 7028			txt := c.text
 7029			if len(txt) > ws[i] {
 7030				txt = txt[:ws[i]]
 7031			}
 7032			pad := 0
 7033			if t.cols[i].alignRight {
 7034				pad = ws[i] - len(txt)
 7035			}
 7036			n := printMarkup(sc, x+pad, y, ws[i]-pad, esc(txt), st)
 7037			// The anchor covers the text, not the empty column beside
 7038			// it: a link that lights up out to the price would say the
 7039			// blank cells were clickable too. An empty cell offers
 7040			// nothing, which is how an out-of-stock row has no Buy.
 7041			if t.cols[i].link && t.onLink != nil && n > 0 {
 7042				t.onLink(x+pad, y, n, ri, i)
 7043			}
 7044			x += ws[i] + 1
 7045		}
 7046	}
 7047}
 7048
 7049func (t *table) move(d int) {
 7050	if len(t.rows) == 0 {
 7051		return
 7052	}
 7053	t.sel += d
 7054	if t.sel < 0 {
 7055		t.sel = 0
 7056	}
 7057	if t.sel >= len(t.rows) {
 7058		t.sel = len(t.rows) - 1
 7059	}
 7060	if t.onSelect != nil {
 7061		t.onSelect(t.sel)
 7062	}
 7063}
 7064
 7065func (t *table) key(ev *tcell.EventKey) bool {
 7066	switch ev.Key() {
 7067	case tcell.KeyUp:
 7068		t.move(-1)
 7069	case tcell.KeyDown:
 7070		t.move(1)
 7071	case tcell.KeyPgUp:
 7072		t.move(-10)
 7073	case tcell.KeyPgDn:
 7074		t.move(10)
 7075	case tcell.KeyHome:
 7076		t.move(-len(t.rows))
 7077	case tcell.KeyEnd:
 7078		t.move(len(t.rows))
 7079	case tcell.KeyEnter:
 7080		if t.sel >= 0 && t.onActivate != nil {
 7081			t.onActivate(t.sel)
 7082		}
 7083	default:
 7084		return false
 7085	}
 7086	return true
 7087}
 7088
 7089// mouse handles a click or wheel at a position local to the table rect.
 7090//
 7091// A click on the row's link cell never gets here β€” that cell is an anchor
 7092// and the page follows it first. What is left is the rest of the row:
 7093// clicking it selects, which is what puts the row's photo beside the
 7094// table, and clicking the selected row again opens it.
 7095func (t *table) mouse(localY int, ev *tcell.EventMouse) bool {
 7096	btn := ev.Buttons()
 7097	switch {
 7098	// One product per notch. Three is the usual step for scrolling TEXT
 7099	// and wrong here, because the wheel moves the SELECTION: the row
 7100	// picked is the one whose photo is shown and whose name a second
 7101	// click opens, so three at a time skips two products rather than
 7102	// covering ground faster. The dropdown has always moved by one.
 7103	case btn&tcell.WheelUp != 0:
 7104		t.move(-1)
 7105	case btn&tcell.WheelDown != 0:
 7106		t.move(1)
 7107	case btn&tcell.Button1 != 0:
 7108		ri := t.offset + localY - 1 // row 0 is the header
 7109		if localY < 1 || ri < 0 || ri >= len(t.rows) {
 7110			return false
 7111		}
 7112		if ri == t.sel {
 7113			if t.onActivate != nil {
 7114				t.onActivate(ri)
 7115			}
 7116			break
 7117		}
 7118		t.sel = ri
 7119		if t.onSelect != nil {
 7120			t.onSelect(ri)
 7121		}
 7122	default:
 7123		return false
 7124	}
 7125	return true
 7126}
 7127
 7128// ---- list: the dropdown menus ----
 7129
 7130type list struct {
 7131	items  []string // markup labels
 7132	sel    int
 7133	offset int
 7134	onPick func(int)
 7135	// onKey sees keys first β€” the categories tree uses it for its
 7136	// expand/collapse keys.
 7137	onKey func(ev *tcell.EventKey, sel int) bool
 7138}
 7139
 7140func (l *list) draw(sc tcell.Screen, r rect) {
 7141	if l.sel >= 0 {
 7142		if l.sel < l.offset {
 7143			l.offset = l.sel
 7144		}
 7145		if l.sel >= l.offset+r.h {
 7146			l.offset = l.sel - r.h + 1
 7147		}
 7148	}
 7149	for i := 0; i < r.h; i++ {
 7150		li := l.offset + i
 7151		if li >= len(l.items) {
 7152			break
 7153		}
 7154		st := styleLink
 7155		if li == l.sel {
 7156			st = styleSel
 7157			fillRect(sc, rect{r.x, r.y + i, r.w, 1}, styleSel)
 7158		}
 7159		printMarkup(sc, r.x, r.y+i, r.w, l.items[li], st)
 7160	}
 7161}
 7162
 7163func (l *list) key(ev *tcell.EventKey) bool {
 7164	if l.onKey != nil && l.onKey(ev, l.sel) {
 7165		return true
 7166	}
 7167	switch ev.Key() {
 7168	case tcell.KeyUp:
 7169		if l.sel > 0 {
 7170			l.sel--
 7171		}
 7172	case tcell.KeyDown:
 7173		if l.sel < len(l.items)-1 {
 7174			l.sel++
 7175		}
 7176	case tcell.KeyHome:
 7177		l.sel = 0
 7178	case tcell.KeyEnd:
 7179		l.sel = len(l.items) - 1
 7180	case tcell.KeyEnter:
 7181		if l.onPick != nil && l.sel >= 0 {
 7182			l.onPick(l.sel)
 7183		}
 7184	default:
 7185		return false
 7186	}
 7187	return true
 7188}
 7189
 7190func (l *list) mouse(localY int, ev *tcell.EventMouse) bool {
 7191	btn := ev.Buttons()
 7192	switch {
 7193	case btn&tcell.WheelUp != 0:
 7194		if l.sel > 0 {
 7195			l.sel--
 7196		}
 7197	case btn&tcell.WheelDown != 0:
 7198		if l.sel < len(l.items)-1 {
 7199			l.sel++
 7200		}
 7201	case btn&tcell.Button1 != 0:
 7202		li := l.offset + localY
 7203		if li < 0 || li >= len(l.items) {
 7204			return false
 7205		}
 7206		l.sel = li
 7207		if l.onPick != nil {
 7208			l.onPick(li)
 7209		}
 7210	default:
 7211		return false
 7212	}
 7213	return true
 7214}
 7215
 7216// ---- form: Add Shipping Info ----
 7217
 7218type formItem interface {
 7219	label() string
 7220	// drawValue paints the value cell; the form places the cursor.
 7221	drawValue(sc tcell.Screen, x, y, w int, focused bool) (curX int, showCur bool)
 7222	key(ev *tcell.EventKey) bool
 7223	value() string
 7224}
 7225
 7226type finput struct {
 7227	lbl   string
 7228	text  []rune
 7229	cur   int
 7230	width int
 7231}
 7232
 7233func (f *finput) label() string { return f.lbl }
 7234func (f *finput) value() string { return strings.TrimSpace(string(f.text)) }
 7235
 7236func (f *finput) drawValue(sc tcell.Screen, x, y, w int, focused bool) (int, bool) {
 7237	if f.width < w {
 7238		w = f.width
 7239	}
 7240	st := styleField
 7241	fillRect(sc, rect{x, y, w, 1}, st)
 7242	start := 0
 7243	if f.cur >= w {
 7244		start = f.cur - w + 1
 7245	}
 7246	for i := 0; i < w && start+i < len(f.text); i++ {
 7247		sc.SetContent(x+i, y, f.text[start+i], nil, st)
 7248	}
 7249	return x + f.cur - start, focused
 7250}
 7251
 7252func (f *finput) key(ev *tcell.EventKey) bool {
 7253	switch ev.Key() {
 7254	case tcell.KeyRune:
 7255		ins := []rune(ev.Str())
 7256		f.text = append(f.text[:f.cur], append(ins, f.text[f.cur:]...)...)
 7257		f.cur += len(ins)
 7258	case tcell.KeyBackspace, tcell.KeyBackspace2:
 7259		if f.cur > 0 {
 7260			f.text = append(f.text[:f.cur-1], f.text[f.cur:]...)
 7261			f.cur--
 7262		}
 7263	case tcell.KeyDelete:
 7264		if f.cur < len(f.text) {
 7265			f.text = append(f.text[:f.cur], f.text[f.cur+1:]...)
 7266		}
 7267	case tcell.KeyLeft:
 7268		if f.cur > 0 {
 7269			f.cur--
 7270		}
 7271	case tcell.KeyRight:
 7272		if f.cur < len(f.text) {
 7273			f.cur++
 7274		}
 7275	case tcell.KeyHome:
 7276		f.cur = 0
 7277	case tcell.KeyEnd:
 7278		f.cur = len(f.text)
 7279	default:
 7280		return false
 7281	}
 7282	return true
 7283}
 7284
 7285// fdropdown selects among options with ←/β†’, or by typing a prefix (so
 7286// "t","x" lands on TX).
 7287type fdropdown struct {
 7288	lbl  string
 7289	opts []string
 7290	sel  int
 7291}
 7292
 7293func (f *fdropdown) label() string { return f.lbl }
 7294func (f *fdropdown) value() string { return f.opts[f.sel] }
 7295
 7296func (f *fdropdown) drawValue(sc tcell.Screen, x, y, w int, focused bool) (int, bool) {
 7297	st := styleField
 7298	label := f.opts[f.sel]
 7299	if label == "" {
 7300		label = "β€”"
 7301	}
 7302	txt := "β—‚ " + label + " β–Έ"
 7303	if len(txt) > w {
 7304		txt = txt[:w]
 7305	}
 7306	fillRect(sc, rect{x, y, w, 1}, st)
 7307	printMarkup(sc, x, y, w, esc(txt), st)
 7308	return x, false
 7309}
 7310
 7311func (f *fdropdown) key(ev *tcell.EventKey) bool {
 7312	switch {
 7313	case ev.Key() == tcell.KeyLeft:
 7314		if f.sel > 0 {
 7315			f.sel--
 7316		}
 7317	case ev.Key() == tcell.KeyRight:
 7318		if f.sel < len(f.opts)-1 {
 7319			f.sel++
 7320		}
 7321	case ev.Key() == tcell.KeyRune:
 7322		want := strings.ToUpper(ev.Str())
 7323		for i := 1; i <= len(f.opts); i++ {
 7324			o := f.opts[(f.sel+i)%len(f.opts)]
 7325			if strings.HasPrefix(strings.ToUpper(o), want) {
 7326				f.sel = (f.sel + i) % len(f.opts)
 7327				break
 7328			}
 7329		}
 7330	default:
 7331		return false
 7332	}
 7333	return true
 7334}
 7335
 7336type fbutton struct {
 7337	lbl string
 7338	fn  func()
 7339}
 7340
 7341// form lays items out one per row with a button row below, inside a
 7342// bordered box drawn by its owner.
 7343type form struct {
 7344	items    []formItem
 7345	buttons  []fbutton
 7346	focus    int // 0..len(items)-1, then buttons
 7347	cancel   func()
 7348	labelCol int
 7349}
 7350
 7351func newForm(items []formItem, buttons []fbutton, cancel func()) *form {
 7352	f := &form{items: items, buttons: buttons, cancel: cancel}
 7353	for _, it := range items {
 7354		if n := len(it.label()); n > f.labelCol {
 7355			f.labelCol = n
 7356		}
 7357	}
 7358	return f
 7359}
 7360
 7361func (f *form) draw(sc tcell.Screen, r rect) {
 7362	sc.HideCursor()
 7363	for i, it := range f.items {
 7364		y := r.y + i*2
 7365		if y >= r.y+r.h-1 {
 7366			break
 7367		}
 7368		printMarkup(sc, r.x, y, r.w, esc(it.label()), styleLink)
 7369		curX, show := it.drawValue(sc, r.x+f.labelCol+1, y, r.w-f.labelCol-1, f.focus == i)
 7370		if show && f.focus == i {
 7371			sc.ShowCursor(curX, y)
 7372		}
 7373	}
 7374	by := r.y + len(f.items)*2
 7375	x := r.x + 2
 7376	for i, b := range f.buttons {
 7377		st := styleButton
 7378		if f.focus == len(f.items)+i {
 7379			st = styleSel
 7380		}
 7381		lbl := "  " + b.lbl + "  "
 7382		printMarkup(sc, x, by, r.w, esc(lbl), st)
 7383		x += len(lbl) + 2
 7384	}
 7385}
 7386
 7387func (f *form) key(ev *tcell.EventKey) bool {
 7388	switch ev.Key() {
 7389	case tcell.KeyEscape:
 7390		if f.cancel != nil {
 7391			f.cancel()
 7392		}
 7393		return true
 7394	case tcell.KeyTab, tcell.KeyDown:
 7395		f.focus = (f.focus + 1) % (len(f.items) + len(f.buttons))
 7396		return true
 7397	case tcell.KeyBacktab, tcell.KeyUp:
 7398		f.focus--
 7399		if f.focus < 0 {
 7400			f.focus = len(f.items) + len(f.buttons) - 1
 7401		}
 7402		return true
 7403	case tcell.KeyEnter:
 7404		if f.focus >= len(f.items) {
 7405			f.buttons[f.focus-len(f.items)].fn()
 7406		} else {
 7407			f.focus++
 7408		}
 7409		return true
 7410	}
 7411	if f.focus < len(f.items) {
 7412		if f.items[f.focus].key(ev) {
 7413			return true
 7414		}
 7415	}
 7416	// Swallow stray runes so shortcuts never fire while a form is up.
 7417	return ev.Key() == tcell.KeyRune
 7418}
 7419
 7420
 7421// ===== pkg/tui/widgets_js.go =====
 7422//go:build js && wasm
 7423
 7424package tui
 7425
 7426import (
 7427	"image"
 7428	"math"
 7429	"strconv"
 7430	"strings"
 7431	"syscall/js"
 7432
 7433	"github.com/0magnet/calvin"
 7434	"github.com/0magnet/chaosrack/pkg/rasterview"
 7435	"github.com/0magnet/chaosrack/pkg/stlview"
 7436	"github.com/0magnet/websh/widget"
 7437)
 7438
 7439// The parts of the store that a page can show and a terminal cannot are this
 7440// program's own widgets, registered by the program itself (here, and the card
 7441// form in pay_js.go) rather than by the page it runs in: any websh that runs
 7442// this TUI in its page has them, and a terminal without placements shows the
 7443// cells drawn under them instead.
 7444
 7445func init() {
 7446	registerGlobe()
 7447	placementsShown = widget.Shown // asked each frame: the host answers childtty's probe after init
 7448}
 7449
 7450// offerWidgets registers the widgets that need to know the store.
 7451func (u *ui) offerWidgets() { registerCaps(u.sitedomain) }
 7452
 7453// siteURL is the store's address: the one being browsed, else the page's own.
 7454func siteURL() string {
 7455	if s := storeURL(); s != "" {
 7456		return s
 7457	}
 7458	return js.Global().Get("location").Get("origin").String()
 7459}
 7460
 7461// registerGlobe offers the website's landing picture as the widget "globe":
 7462// the home panel lays it over the cells where it draws its own logo and globe
 7463// (websh's placements). From the bottom up:
 7464//
 7465//   - the panel's own cells β€” the half-block logo β€” where nothing above
 7466//     covers them;
 7467//   - the site's logo.svg;
 7468//   - the globe above it, drawn again by the panel's own code in its cells'
 7469//     half blocks: the same mesh, pose and camera, so it lies exactly behind
 7470//     that globe and shows through its wireframe;
 7471//   - the landing view's globe β€” the same renderer, shaders and sphere
 7472//     (chaosrack's stlview) β€” which the person turns by dragging and zooms
 7473//     with the wheel.
 7474//
 7475// And it answers the pointer (see look):
 7476//
 7477//   - a click on the black around the logo stops it all β€” no frames, so no
 7478//     work on the CPU or the GPU β€” and a click on the logo or the globe
 7479//     starts it again;
 7480//   - a double click (or tap) on the logo hides it, showing the panel's own
 7481//     half-block logo under it, and another brings it back;
 7482//   - a double click on the globe steps through: the wireframe alone; the
 7483//     panel's globe alone, turning; no globe; and back to both.
 7484func registerGlobe() {
 7485	widget.Register("globe", mountGlobe)
 7486}
 7487
 7488// look is how the person has left the picture. It is the page's, not one
 7489// placement's: the TUI takes the placement away whenever a menu or the cart
 7490// crosses it, and puts a new one back after.
 7491var look struct {
 7492	paused     bool
 7493	logoHidden bool
 7494	stage      int // globeBoth, globeWire, globeCells, globeNone
 7495}
 7496
 7497const (
 7498	globeBoth = iota
 7499	globeWire
 7500	globeCells
 7501	globeNone
 7502)
 7503
 7504func mountGlobe(el js.Value) func() {
 7505	doc := js.Global().Get("document")
 7506	layer := func(tag, css string) js.Value {
 7507		e := doc.Call("createElement", tag)
 7508		e.Get("style").Set("cssText", "position:absolute;inset:0;width:100%;height:100%;"+css)
 7509		el.Call("append", e)
 7510		return e
 7511	}
 7512	logo := layer("img", "object-fit:contain")
 7513	logo.Set("src", siteURL()+"/i/logo.svg")
 7514	logo.Set("alt", "")
 7515
 7516	// The panel's globe: one pixel per half cell, shown as blocks, as the
 7517	// panel draws it.
 7518	shadow := layer("canvas", "image-rendering:pixelated;opacity:.85")
 7519	ctx := shadow.Call("getContext", "2d")
 7520
 7521	canvas := layer("canvas", "")
 7522	dpr := js.Global().Get("devicePixelRatio").Float()
 7523	if dpr <= 0 {
 7524		dpr = 1
 7525	}
 7526	canvas.Set("width", int(el.Get("clientWidth").Float()*dpr))
 7527	canvas.Set("height", int(el.Get("clientHeight").Float()*dpr))
 7528	g, err := stlview.NewGlobe(canvas)
 7529	if err != nil {
 7530		js.Global().Get("console").Call("warn", "globe: "+err.Error())
 7531		return func() { el.Set("textContent", "") }
 7532	}
 7533	g.Interact(canvas)
 7534
 7535	var img *image.RGBA
 7536	var buf js.Value
 7537	drawShadow := func() {
 7538		cols, _ := strconv.Atoi(el.Get("dataset").Get("cols").String()) //nolint:errcheck // websh sets it; zero draws nothing
 7539		rows, _ := strconv.Atoi(el.Get("dataset").Get("rows").String()) //nolint:errcheck // as above
 7540		if cols <= 0 || rows <= 0 {
 7541			return
 7542		}
 7543		w, h := cols, rows*2
 7544		if img == nil || img.Bounds().Dx() != w || img.Bounds().Dy() != h {
 7545			img = image.NewRGBA(image.Rect(0, 0, w, h))
 7546			buf = js.Global().Get("Uint8ClampedArray").New(len(img.Pix))
 7547			shadow.Set("width", w)
 7548			shadow.Set("height", h)
 7549		}
 7550		// A half cell is as wide as the terminal's font makes it, not
 7551		// square: its width over its height on the page.
 7552		aspect := 1.0
 7553		if ew, eh := el.Get("clientWidth").Float(), el.Get("clientHeight").Float(); ew > 0 && eh > 0 {
 7554			aspect = (ew / float64(w)) / (eh / float64(h))
 7555		}
 7556		// The globe's size on the page: its share of half the height, in a
 7557		// frame whose shorter side, measured on the page, sets the scale.
 7558		scale := g.Radius() * float64(h) / math.Min(float64(w)*aspect, float64(h))
 7559		ax, ay, az := g.Pose()
 7560		verts, lines := g.Mesh()
 7561		globeAt(img, verts, lines, ax, ay, az, g.Distance(), scale, aspect)
 7562		js.CopyBytesToJS(buf, img.Pix)
 7563		ctx.Call("putImageData", js.Global().Get("ImageData").New(buf, w, h), 0, 0)
 7564	}
 7565
 7566	// The panel's globe turns at the panel's own pace (its 140ms tick): it
 7567	// is the panel's globe, and every display frame would cost the tab for
 7568	// it. Its frames run only while it is seen and turning.
 7569	var (
 7570		gone     bool
 7571		shadowOn bool
 7572		raf      js.Value
 7573		frame    js.Func
 7574		last     float64
 7575	)
 7576	frame = js.FuncOf(func(_ js.Value, args []js.Value) any {
 7577		if gone || !shadowOn {
 7578			return nil
 7579		}
 7580		raf = js.Global().Call("requestAnimationFrame", frame)
 7581		if now := args[0].Float(); now-last >= 140 {
 7582			last = now
 7583			drawShadow()
 7584		}
 7585		return nil
 7586	})
 7587	runShadow := func(on bool) {
 7588		if on == shadowOn {
 7589			return
 7590		}
 7591		shadowOn = on
 7592		if on {
 7593			raf = js.Global().Call("requestAnimationFrame", frame)
 7594		} else {
 7595			js.Global().Call("cancelAnimationFrame", raf)
 7596		}
 7597	}
 7598
 7599	shown := func(e js.Value, on bool) {
 7600		v := "hidden"
 7601		if on {
 7602			v = ""
 7603		}
 7604		e.Get("style").Set("visibility", v)
 7605	}
 7606	apply := func() {
 7607		wire := look.stage == globeBoth || look.stage == globeWire
 7608		cells := look.stage == globeBoth || look.stage == globeCells
 7609		shown(logo, !look.logoHidden)
 7610		shown(canvas, wire)
 7611		shown(shadow, cells)
 7612		// Not drawn, the wireframe still turns, for the panel's globe to
 7613		// follow; with no globe at all, nothing runs.
 7614		g.SetDrawn(wire)
 7615		if look.paused || look.stage == globeNone {
 7616			g.Pause()
 7617		} else {
 7618			g.Resume()
 7619		}
 7620		runShadow(cells && !look.paused)
 7621		if cells {
 7622			drawShadow() // as it is now, turning or not
 7623		}
 7624	}
 7625
 7626	// Where on the picture a point (in the element's own pixels) is.
 7627	const (
 7628		onBlack = iota
 7629		onLogo
 7630		onGlobe
 7631	)
 7632	region := func(x, y float64) int {
 7633		w, h := el.Get("clientWidth").Float(), el.Get("clientHeight").Float()
 7634		if r := g.Radius() * h / 2; math.Hypot(x-w/2, y-h/2) <= r {
 7635			return onGlobe
 7636		}
 7637		lx, ly, lw, lh := 0.0, 0.0, w, h
 7638		if nw, nh := logo.Get("naturalWidth").Float(), logo.Get("naturalHeight").Float(); nw > 0 && nh > 0 {
 7639			s := math.Min(w/nw, h/nh) // object-fit: contain
 7640			lw, lh = nw*s, nh*s
 7641			lx, ly = (w-lw)/2, (h-lh)/2
 7642		}
 7643		if x >= lx && x < lx+lw && y >= ly && y < ly+lh {
 7644			return onLogo
 7645		}
 7646		return onBlack
 7647	}
 7648
 7649	// Clicks are counted here rather than taken from dblclick, which a
 7650	// double tap does not reliably give; and a drag that turned the globe is
 7651	// not a click.
 7652	var (
 7653		downX, downY float64
 7654		lastT        float64
 7655		lastAt       = -1
 7656	)
 7657	type handler struct {
 7658		name string
 7659		fn   js.Func
 7660	}
 7661	var handlers []handler
 7662	on := func(name string, f func(e js.Value)) {
 7663		fn := js.FuncOf(func(_ js.Value, args []js.Value) any {
 7664			f(args[0])
 7665			return nil
 7666		})
 7667		el.Call("addEventListener", name, fn, true)
 7668		handlers = append(handlers, handler{name, fn})
 7669	}
 7670	on("pointerdown", func(e js.Value) {
 7671		downX, downY = e.Get("clientX").Float(), e.Get("clientY").Float()
 7672	})
 7673	on("click", func(e js.Value) {
 7674		cx, cy := e.Get("clientX").Float(), e.Get("clientY").Float()
 7675		if math.Hypot(cx-downX, cy-downY) > 5 {
 7676			return // a drag
 7677		}
 7678		box := el.Call("getBoundingClientRect")
 7679		at := region(cx-box.Get("left").Float(), cy-box.Get("top").Float())
 7680		look.paused = at == onBlack
 7681		if t := e.Get("timeStamp").Float(); at == lastAt && t-lastT < 400 {
 7682			switch at {
 7683			case onGlobe:
 7684				look.stage = (look.stage + 1) % 4
 7685			case onLogo:
 7686				look.logoHidden = !look.logoHidden
 7687			}
 7688			lastAt = -1
 7689		} else {
 7690			lastT, lastAt = t, at
 7691		}
 7692		apply()
 7693	})
 7694	apply()
 7695
 7696	return func() {
 7697		gone = true
 7698		runShadow(false)
 7699		frame.Release()
 7700		for _, h := range handlers {
 7701			el.Call("removeEventListener", h.name, h.fn, true)
 7702			h.fn.Release()
 7703		}
 7704		g.Stop()
 7705		el.Set("textContent", "")
 7706	}
 7707}
 7708
 7709// registerCaps offers the domain in the page's double-struck capitals as the
 7710// widget "caps", for the line the home panel prints it on. A terminal has
 7711// them only in fonts that draw them twice the width of a cell, so the panel
 7712// prints plain capitals; a page can set them as the website does, over them.
 7713func registerCaps(domain string) {
 7714	text := calvin.BlackboardBold(strings.ToUpper(domain))
 7715	widget.Register("caps", func(el js.Value) func() {
 7716		el.Get("style").Set("containerType", "size")
 7717		d := js.Global().Get("document").Call("createElement", "div")
 7718		d.Get("style").Set("cssText", "position:absolute;inset:0;display:flex;align-items:center;justify-content:center;"+
 7719			"background:#000;color:#fff;white-space:nowrap;line-height:1;font-size:85cqh;font-family:inherit")
 7720		d.Set("textContent", text)
 7721		el.Call("append", d)
 7722		return func() { el.Set("textContent", "") }
 7723	})
 7724}
 7725
 7726// globeAt draws a globe in the home panel's manner β€” its depth cue, and its
 7727// colors in the panel's sixteen steps β€” over a clear dst. The lines are the
 7728// mesh given (vertices as xyz, indices as endpoint pairs), posed by the angles
 7729// of RxΒ·RyΒ·Rz, seen from dist (0 for none) and filling scale of half the frame,
 7730// 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
 7731// draws itself (Mesh, Pose, Distance, Radius), so a page draws the panel's
 7732// version exactly behind its own β€” the "globe" widget does (widgets_js.go).
 7733func globeAt(dst *image.RGBA, vertices []float32, indices []uint16, ax, ay, az, dist, scale, aspect float64) {
 7734	clear(dst.Pix)
 7735	view := rasterview.View{AngleX: ax, AngleY: ay, AngleZ: az, Dist: dist, BackDim: 0.5, Scale: scale, PixelAspect: aspect}
 7736	g := rasterview.DefaultGradient()
 7737	g.Source = 1 // y, as the page's shader colors it
 7738	view.Render(dst, vertices, indices, g)
 7739	for i := 0; i+3 < len(dst.Pix); i += 4 {
 7740		if dst.Pix[i+3] != 0 {
 7741			for k := range 3 {
 7742				dst.Pix[i+k] = dst.Pix[i+k]&0xF0 | dst.Pix[i+k]>>4 // step16
 7743			}
 7744		}
 7745	}
 7746}
 7747
 7748
 7749// ===== pkg/tui/widgets_native.go =====
 7750//go:build !js || !wasm
 7751
 7752package tui
 7753
 7754// offerWidgets has nothing to offer where there is no page: the cells are
 7755// the picture. See widgets_js.go.
 7756func (u *ui) offerWidgets() {}
 7757
 7758
 7759// ===== pkg/web/app.go =====
 7760// Package web pkg/web/app.go β€” the fiber application, buildable for two hosts.
 7761//
 7762// Serve (native) and the in-tab site server (js/wasm β€” pkg/storepane's
 7763// `serve` command, listening on the bottle vnet loopback) construct the same
 7764// application from the same templates and handlers. AppOpts carries the only
 7765// differences: where the request log goes, and the origin to lean on for
 7766// everything a browser tab must not or cannot hold β€” the Stripe secret key,
 7767// files on disk, and the toolchain that compiles wasm drop-ins.
 7768package web
 7769
 7770import (
 7771	"context"
 7772	"errors"
 7773	"fmt"
 7774	"io"
 7775	"net/http"
 7776	"os"
 7777	"strings"
 7778	"time"
 7779
 7780	"github.com/gofiber/fiber/v3"
 7781	"github.com/gofiber/fiber/v3/middleware/static"
 7782
 7783	"github.com/0magnet/bottle"
 7784)
 7785
 7786// AppOpts selects the host the app is being built for.
 7787type AppOpts struct {
 7788	// LogOutput receives the request log; nil keeps the native default
 7789	// (stdout). The in-tab server points this at its shell's terminal, so
 7790	// browsing the vnet site scrolls an access log like any server.
 7791	LogOutput io.Writer
 7792
 7793	// ProxyOrigin, when non-empty, marks the in-tab role. Routes that need
 7794	// the host machine (wasm compilation, source tarballs, images and fonts
 7795	// on disk, the logo pipeline, CUPS printing) are not registered β€” a GET
 7796	// that matches nothing is fetched from this origin instead β€” and the
 7797	// payment endpoints forward there, so the tab renders the store but
 7798	// never holds a secret key.
 7799	ProxyOrigin string
 7800}
 7801
 7802// NewApp builds the store's fiber application. It does not listen; Serve
 7803// (native) and the in-tab server each own their listener.
 7804func NewApp(o AppOpts) *fiber.App {
 7805	initTMPL()
 7806	logOut := o.LogOutput
 7807	if logOut == nil {
 7808		logOut = os.Stdout
 7809	}
 7810	inTab := o.ProxyOrigin != ""
 7811	// In the tab the drop-ins are named from the origin, not from a disk
 7812	// that carries none of them.
 7813	if inTab {
 7814		serveFromOrigin(o.ProxyOrigin)
 7815	}
 7816
 7817	r := fiber.New(fiber.Config{
 7818		ErrorHandler: func(c fiber.Ctx, err error) error {
 7819			code := fiber.StatusInternalServerError
 7820			var e *fiber.Error
 7821			if errors.As(err, &e) {
 7822				code = e.Code
 7823			}
 7824			c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
 7825			return c.Status(code).SendString(err.Error())
 7826		},
 7827	})
 7828
 7829	r.Use(func(c fiber.Ctx) error {
 7830		start := time.Now()
 7831		err := c.Next()
 7832		status := c.Response().StatusCode()
 7833		// Unmatched routes and handler errors are turned into their real
 7834		// status by the app ErrorHandler after this middleware returns, so
 7835		// c.Response() still reads 200 here. Recover the true code from the
 7836		// error so 404s (and 5xx) are logged accurately rather than as 200.
 7837		if err != nil {
 7838			var fe *fiber.Error
 7839			if errors.As(err, &fe) {
 7840				status = fe.Code
 7841			} else {
 7842				status = fiber.StatusInternalServerError
 7843			}
 7844		}
 7845		lat := time.Since(start)
 7846		colors := c.App().Config().ColorScheme
 7847		ip := fmt.Sprintf("%*s", 15, c.IP())
 7848		ipsStr := strings.Join(c.IPs(), ", ")
 7849		ips := fmt.Sprintf("%*s", 15, ipsStr)
 7850		method := fmt.Sprintf("%-*s", 6, c.Method())
 7851		statCol := statusColor(status, colors) + fmt.Sprintf("%3d", status) + colors.Reset
 7852		methCol := methodColor(c.Method(), colors) + method + colors.Reset
 7853		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
 7854		return err
 7855	})
 7856
 7857	// What anyone may read β€” the catalog, the site, its pages, its images and
 7858	// the store program β€” any origin may read too: a websh served elsewhere
 7859	// runs the store TUI against this site (wasm/store). Nothing that writes,
 7860	// and nothing behind a cookie, is shared this way.
 7861	r.Use(func(c fiber.Ctx) error {
 7862		if m := c.Method(); (m == fiber.MethodGet || m == fiber.MethodHead) && sharedRead(c.Path()) {
 7863			c.Set(fiber.HeaderAccessControlAllowOrigin, "*")
 7864		}
 7865		return c.Next()
 7866	})
 7867
 7868	if !inTab {
 7869		// The desk page's OS layer and browser engine, served ahead of any
 7870		// wasm module: jsfs/vnet must exist before Go captures globalThis.fs.
 7871		jsAsset := func(body []byte) fiber.Handler {
 7872			return func(c fiber.Ctx) error {
 7873				c.Set(fiber.HeaderContentType, "text/javascript; charset=utf-8")
 7874				c.Set(fiber.HeaderCacheControl, "public, max-age=3600")
 7875				return c.Send(body)
 7876			}
 7877		}
 7878		r.Get("/bottle/jsfs.js", jsAsset(bottle.JSFS()))
 7879		r.Get("/bottle/vnet.js", jsAsset(bottle.VNetJS()))
 7880		r.Get("/bottle/proc.js", jsAsset(bottle.ProcJS()))
 7881		serveSourceCode(r)
 7882		serveWASM(r)
 7883		serveBrowseResponder(r)
 7884		r.Get("/logo", logo)
 7885		r.Get("/logo/:width", logo)
 7886		r.Get("/logo/:width/:height", logo)
 7887		// The ports that draw: a product image as ANSI art turned into HTML, and
 7888		// a heading as a FIGlet banner. Nothing on the site links to either β€”
 7889		// they are here for the decorator on /test to call.
 7890		r.Get("/art/:cat/:name", art)
 7891		r.Get("/banner", banner)
 7892		r.Get("/logo.png", sendFile)
 7893		r.Get("/logo.html", sendFile)
 7894		r.Get("/mobilelogo.html", sendFile)
 7895		r.Get("/logolarge.html", sendFile)
 7896		r.Get("/favicon.ico", sendImage)
 7897		if f.Siteimagesrc == "" {
 7898			r.Use("/i", static.New("./img"))
 7899			r.Use("/img", static.New("./img"))
 7900		}
 7901		r.Use("/font", static.New("./font"))
 7902		r.Get("/stl/:filename", func(c fiber.Ctx) error {
 7903			name := c.Params("filename")
 7904			if strings.ContainsAny(name, "/\\..") || strings.Contains(name, "..") {
 7905				return c.SendStatus(fiber.StatusBadRequest)
 7906			}
 7907			return c.SendFile("./img/stl/" + name)
 7908		})
 7909		r.Get("/stl/base64/:filename", stlbase64)
 7910		r.Get("/tui", tuipage)
 7911		r.Get("/desk", deskpage)
 7912	}
 7913
 7914	r.Get("/robots.txt", robots)
 7915	r.Get("/site.webmanifest", func(c fiber.Ctx) error {
 7916		return c.JSON(fiber.Map{
 7917			"name":       f.Sitelongname,
 7918			"short_name": f.Sitename,
 7919			"icons": []fiber.Map{
 7920				{"src": f.Siteimagesrc + "/i/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png"},
 7921				{"src": f.Siteimagesrc + "/i/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png"},
 7922			},
 7923			"theme_color":      "#ffffff",
 7924			"background_color": "#ffffff",
 7925			"display":          "standalone",
 7926		})
 7927	})
 7928	r.Get("/api/products", apiproducts)
 7929	r.Get("/api/site", apisite)
 7930	r.Get("/api/content/:name", apicontent)
 7931	r.Get("/sitemap", sitemap)
 7932	r.Get("/sitemap.xml", sitemap)
 7933	r.Get("/", homepage)
 7934	r.Get("/p/:partno", productpage)
 7935	r.Get("/post/:partno", handlecat)
 7936	r.Get("/p", handlecat)
 7937	r.Get("/cat", handlecat)
 7938	r.Get("/cat/:cat", handlecat)
 7939	r.Get("/cat/:cat/:subcat", handlecat)
 7940	r.Get("/style.css", style)
 7941	r.Get("/font.css", fontcss)
 7942
 7943	if !inTab {
 7944		for _, register := range extraRoutes {
 7945			register(r)
 7946		}
 7947		handleOrder(r)
 7948		// Crypto checkout, if a wallet is configured; the poller that prints
 7949		// a receipt when a payment confirms starts with it.
 7950		handleInvoice(r)
 7951		go WatchInvoices(context.Background())
 7952	} else {
 7953		// The drop-ins this binary carries, ahead of the catch-all so they are
 7954		// answered from the embed rather than fetched back off the origin.
 7955		serveCarriedWASM(r)
 7956		// Money crosses back to the real server: the tab renders the cart,
 7957		// the origin holds the Stripe key and the order book.
 7958		r.Post("/create-payment-intent", proxyToOrigin(o.ProxyOrigin))
 7959		r.Post("/submit-order", proxyToOrigin(o.ProxyOrigin))
 7960		// Everything the tab does not carry β€” images, fonts, STL models,
 7961		// the logo pipeline, /complete's Stripe lookup β€” reads through to
 7962		// the origin. Registered last, so it is the 404 path.
 7963		r.Use(proxyToOrigin(o.ProxyOrigin))
 7964	}
 7965	return r
 7966}
 7967
 7968// proxyToOrigin relays the request to the real origin and copies the answer
 7969// back. Under js/wasm net/http rides the browser's fetch, so a same-origin
 7970// relay needs no credentials or CORS ceremony.
 7971func proxyToOrigin(origin string) fiber.Handler {
 7972	return func(c fiber.Ctx) error {
 7973		method := c.Method()
 7974		if method != fiber.MethodGet && method != fiber.MethodPost {
 7975			return fiber.ErrNotFound
 7976		}
 7977		var body io.Reader
 7978		if method == fiber.MethodPost {
 7979			body = strings.NewReader(string(c.Body()))
 7980		}
 7981		req, err := http.NewRequest(method, origin+c.OriginalURL(), body)
 7982		if err != nil {
 7983			return fiber.ErrBadGateway
 7984		}
 7985		if ct := c.Get(fiber.HeaderContentType); ct != "" {
 7986			req.Header.Set(fiber.HeaderContentType, ct)
 7987		}
 7988		resp, err := http.DefaultClient.Do(req)
 7989		if err != nil {
 7990			return fiber.ErrBadGateway
 7991		}
 7992		defer resp.Body.Close() //nolint:errcheck,gosec
 7993		payload, err := io.ReadAll(resp.Body)
 7994		if err != nil {
 7995			return fiber.ErrBadGateway
 7996		}
 7997		if ct := resp.Header.Get("Content-Type"); ct != "" {
 7998			c.Set(fiber.HeaderContentType, ct)
 7999		}
 8000		return c.Status(resp.StatusCode).Send(payload)
 8001	}
 8002}
 8003
 8004// sharedRead reports whether path is public, read-only data any origin may
 8005// fetch.
 8006func sharedRead(path string) bool {
 8007	if path == "/api/products" || path == "/api/site" || path == "/font.css" || path == "/store.wasm" || path == "/store-tiny.wasm" {
 8008		return true
 8009	}
 8010	for _, p := range []string{"/api/content/", "/i/", "/img/"} {
 8011		if strings.HasPrefix(path, p) {
 8012			return true
 8013		}
 8014	}
 8015	return false
 8016}
 8017
 8018
 8019// ===== pkg/web/art.go =====
 8020package web
 8021
 8022// Two endpoints that draw with the ports: a product image as ANSI art turned
 8023// into HTML, and a heading as a FIGlet banner.
 8024//
 8025// Server side rather than in the wasm decorator, for two different reasons.
 8026// The image art needs a JPEG decode and a dither per product, which is real
 8027// work to do fourteen times in a browser and is the same answer every time β€”
 8028// so it is done once here and cached. The banner needs the FIGlet fonts, and
 8029// there are eight hundred kilobytes of them; embedding that in a decorator to
 8030// draw a dozen headings is the wrong trade, while here they are already in the
 8031// binary.
 8032//
 8033// Nothing on the site links to either. They exist for the decorator on /test to
 8034// call, so the served HTML is unchanged and a browser that does not run the
 8035// wasm sees the page it always saw.
 8036
 8037import (
 8038	"bytes"
 8039	"fmt"
 8040	"io"
 8041	"net/http"
 8042	"os"
 8043	"path/filepath"
 8044	"strconv"
 8045	"strings"
 8046	"sync"
 8047	"time"
 8048
 8049	"github.com/gofiber/fiber/v3"
 8050
 8051	"github.com/0magnet/ansifilter-go/ansifilter"
 8052	"github.com/0magnet/img2txt-go/caca"
 8053	"github.com/0magnet/toilet-go/figlet"
 8054	"github.com/0magnet/toilet-go/fonts"
 8055)
 8056
 8057// artCache keys rendered art by everything that changes it. Both renders are
 8058// pure functions of their inputs and neither is cheap, so nothing is drawn
 8059// twice.
 8060var (
 8061	artCache = map[string]string{}
 8062	artMu    sync.Mutex
 8063)
 8064
 8065func cached(key string, make func() (string, error)) (string, error) {
 8066	artMu.Lock()
 8067	hit, ok := artCache[key]
 8068	artMu.Unlock()
 8069	if ok {
 8070		return hit, nil
 8071	}
 8072	out, err := make()
 8073	if err != nil {
 8074		return "", err
 8075	}
 8076	artMu.Lock()
 8077	artCache[key] = out
 8078	artMu.Unlock()
 8079	return out, nil
 8080}
 8081
 8082// artWidth keeps a request from asking for a canvas big enough to be a denial
 8083// of service. Eighty is a terminal; two hundred is already wider than the
 8084// column the page gives it.
 8085const (
 8086	artMin = 8
 8087	artMax = 200
 8088)
 8089
 8090// art renders one product image as ANSI art and exports it as HTML.
 8091//
 8092// The chain is the one the store already had most of: img2txt-go decodes and
 8093// dithers the image into a libcaca canvas, which exports ANSI β€” the same
 8094// renderer the terminal UI draws products with β€” and ansifilter-go turns that
 8095// ANSI into HTML. Going straight from the canvas to caca's own HTML export
 8096// would skip the middle step and lose the point: the ANSI is what the terminal
 8097// shows, so passing it through is what makes the two agree.
 8098func art(c fiber.Ctx) error {
 8099	cols := 60
 8100	if v, err := strconv.Atoi(c.Query("cols")); err == nil {
 8101		cols = v
 8102	}
 8103	cols = min(max(cols, artMin), artMax)
 8104	cat, name := c.Params("cat"), c.Params("name")
 8105	if !safeSegment(cat) || !safeSegment(name) {
 8106		return c.Status(fiber.StatusBadRequest).SendString("bad path")
 8107	}
 8108	src := imgPath(cat, name)
 8109	out, err := cached(fmt.Sprintf("art\x00%s\x00%d", src, cols), func() (string, error) {
 8110		return renderArt(src, cols)
 8111	})
 8112	if err != nil {
 8113		return c.Status(fiber.StatusNotFound).SendString(err.Error())
 8114	}
 8115	c.Set("Content-Type", "text/html;charset=utf-8")
 8116	c.Set("Cache-Control", "public, max-age=86400")
 8117	return c.SendString(out)
 8118}
 8119
 8120func renderArt(src string, cols int) (string, error) {
 8121	data, err := fetch(src)
 8122	if err != nil {
 8123		return "", err
 8124	}
 8125	im, err := caca.DecodeImage(bytes.NewReader(data))
 8126	if err != nil {
 8127		return "", err
 8128	}
 8129	if im.W == 0 || im.H == 0 {
 8130		return "", fmt.Errorf("image has no size")
 8131	}
 8132	// img2txt's 6x10 font cell, so the art keeps the picture's proportions
 8133	// instead of coming out squashed by the shape of a character.
 8134	rows := cols * im.H * 6 / im.W / 10
 8135	if rows < 1 {
 8136		rows = 1
 8137	}
 8138	cv := caca.NewCanvas(cols, rows)
 8139	cv.SetColorANSI(caca.Default, caca.Transparent)
 8140	cv.Clear()
 8141	im.Dither.SetAlgorithm("fstein")
 8142	cv.DitherBitmap(0, 0, cols, rows, im.Dither, im.Pixels)
 8143	ansi, ok := cv.Export("ansi")
 8144	if !ok {
 8145		return "", fmt.Errorf("canvas will not export ansi")
 8146	}
 8147	g := ansifilter.New(ansifilter.HTML)
 8148	g.SetFragmentCode(true)
 8149	return g.GenerateString(string(ansi)), nil
 8150}
 8151
 8152// banner renders a line of text as a FIGlet banner.
 8153func banner(c fiber.Ctx) error {
 8154	// A query parameter, not a path segment. Fiber hands back path params still
 8155	// percent-encoded, and rendering those gives a banner reading
 8156	// DIODE%20%E2%86%92%201N40XX, which is exactly what went live.
 8157	text := c.Query("text")
 8158	if text == "" {
 8159		return c.Status(fiber.StatusBadRequest).SendString("no text")
 8160	}
 8161	if len(text) > 64 {
 8162		text = text[:64]
 8163	}
 8164	font := c.Query("font")
 8165	if font == "" {
 8166		font = "future"
 8167	}
 8168	width := 200
 8169	if v, err := strconv.Atoi(c.Query("width")); err == nil && v > 0 {
 8170		width = min(v, 400)
 8171	}
 8172	out, err := cached(fmt.Sprintf("banner\x00%s\x00%s\x00%d", text, font, width), func() (string, error) {
 8173		return renderBanner(text, font, width)
 8174	})
 8175	if err != nil {
 8176		return c.Status(fiber.StatusNotFound).SendString(err.Error())
 8177	}
 8178	c.Set("Content-Type", "text/plain;charset=utf-8")
 8179	c.Set("Cache-Control", "public, max-age=86400")
 8180	return c.SendString(out)
 8181}
 8182
 8183func renderBanner(text, font string, width int) (string, error) {
 8184	raw, ok := fonts.Get(font)
 8185	if !ok {
 8186		return "", fmt.Errorf("no font %q", font)
 8187	}
 8188	fnt, err := figlet.ParseFont(raw)
 8189	if err != nil {
 8190		return "", err
 8191	}
 8192	r := figlet.NewRenderer(fnt)
 8193	r.SetWidth(width)
 8194	r.SetMode(figlet.ParseMode("smush"))
 8195	for _, ch := range text {
 8196		r.PutChar(ch)
 8197	}
 8198	cv := r.Flush()
 8199	if cv == nil {
 8200		return "", fmt.Errorf("nothing rendered")
 8201	}
 8202	utf8, ok := cv.Export("utf8")
 8203	if !ok {
 8204		return "", fmt.Errorf("canvas will not export utf8")
 8205	}
 8206	return strings.TrimRight(string(utf8), "\n"), nil
 8207}
 8208
 8209// safeSegment rejects anything that could climb out of the image directory.
 8210// The two path parts come off the wire, so they are the one thing here a
 8211// visitor controls.
 8212func safeSegment(s string) bool {
 8213	if s == "" || s == "." || s == ".." {
 8214		return false
 8215	}
 8216	return !strings.ContainsAny(s, "/\\") && !strings.Contains(s, "..")
 8217}
 8218
 8219// imgPath is where a product image lives: on disk under ./img when the site
 8220// serves its own, and behind Siteimagesrc when it does not β€” the same two cases
 8221// the template's ImgSRC covers.
 8222func imgPath(cat, name string) string {
 8223	if f.Siteimagesrc == "" {
 8224		return filepath.Join("img", cat, name)
 8225	}
 8226	return f.Siteimagesrc + "/i/" + cat + "/" + name
 8227}
 8228
 8229// fetch reads an image from wherever imgPath put it, on disk or over http.
 8230func fetch(src string) ([]byte, error) {
 8231	if !strings.HasPrefix(src, "http://") && !strings.HasPrefix(src, "https://") {
 8232		return os.ReadFile(src) //nolint:gosec // safeSegment has already refused anything with a separator in it
 8233	}
 8234	client := &http.Client{Timeout: 20 * time.Second}
 8235	resp, err := client.Get(src) //nolint:gosec // ImgSRC is the operator's own
 8236	if err != nil {
 8237		return nil, err
 8238	}
 8239	defer resp.Body.Close() //nolint:errcheck // read-side close
 8240	if resp.StatusCode != http.StatusOK {
 8241		return nil, fmt.Errorf("%s", resp.Status)
 8242	}
 8243	return io.ReadAll(io.LimitReader(resp.Body, 16<<20))
 8244}
 8245
 8246
 8247// ===== pkg/web/art_test.go =====
 8248package web
 8249
 8250import (
 8251	"os"
 8252	"path/filepath"
 8253	"strings"
 8254	"testing"
 8255)
 8256
 8257// The two renders have to work end to end, since nothing else exercises the
 8258// chain: img2txt decodes and dithers, the canvas exports ANSI, ansifilter turns
 8259// that into HTML.
 8260func TestRenderArt(t *testing.T) {
 8261	var sample string
 8262	err := filepath.WalkDir("../../img", func(p string, d os.DirEntry, err error) error {
 8263		if err != nil || d.IsDir() || sample != "" {
 8264			return nil //nolint:nilerr // a missing image directory is not this test's business
 8265		}
 8266		switch strings.ToLower(filepath.Ext(p)) {
 8267		case ".jpg", ".jpeg", ".png", ".gif":
 8268			sample = p
 8269		}
 8270		return nil
 8271	})
 8272	if err != nil || sample == "" {
 8273		t.Skip("no product images on disk to render")
 8274	}
 8275	out, err := renderArt(sample, 40)
 8276	if err != nil {
 8277		t.Fatalf("rendering %s: %v", sample, err)
 8278	}
 8279	if !strings.Contains(out, "<span") {
 8280		t.Errorf("no colored spans in the output; ansifilter produced %.80q", out)
 8281	}
 8282	if n := strings.Count(out, "\n"); n < 4 {
 8283		t.Errorf("only %d lines of art, expected the image to fill a few", n)
 8284	}
 8285}
 8286
 8287// A banner has to come back as several lines of art, not as the text itself.
 8288func TestRenderBanner(t *testing.T) {
 8289	out, err := renderBanner("resistor", "future", 200)
 8290	if err != nil {
 8291		t.Fatal(err)
 8292	}
 8293	lines := strings.Split(out, "\n")
 8294	if len(lines) < 3 {
 8295		t.Fatalf("a banner should be several rows tall, got %d: %q", len(lines), out)
 8296	}
 8297	if strings.Contains(out, "resistor") {
 8298		t.Error("the banner is the literal text, so no font was applied")
 8299	}
 8300	t.Logf("banner is %d lines, widest %d", len(lines), widest(lines))
 8301	for _, l := range lines {
 8302		t.Log(l)
 8303	}
 8304}
 8305
 8306func widest(lines []string) int {
 8307	w := 0
 8308	for _, l := range lines {
 8309		if n := len([]rune(l)); n > w {
 8310			w = n
 8311		}
 8312	}
 8313	return w
 8314}
 8315
 8316// The path guard is the only place a visitor's input reaches the filesystem.
 8317func TestSafeSegment(t *testing.T) {
 8318	for _, bad := range []string{"", ".", "..", "../etc", "a/b", `a\b`, "x..y"} {
 8319		if safeSegment(bad) {
 8320			t.Errorf("%q was accepted", bad)
 8321		}
 8322	}
 8323	for _, good := range []string{"diode", "1N4148.jpg", "a-b_c.png"} {
 8324		if !safeSegment(good) {
 8325			t.Errorf("%q was refused", good)
 8326		}
 8327	}
 8328}
 8329
 8330
 8331// ===== pkg/web/browse.go =====
 8332package web
 8333
 8334// The real-origin browse substrate: giving the desk's nested browser a genuine
 8335// origin to render the in-tab store at.
 8336//
 8337// netscrape renders a fetched page into a sandboxed srcdoc with an opaque
 8338// origin, which cannot reach the tab's virtual loopback at all; its relay
 8339// carries stylesheets and images and nothing else. That is enough for a page
 8340// of text and pictures and is structurally unable to run a wasm drop-in: a
 8341// <script src> is never fetched, and an in-page WebAssembly.instantiateStreaming
 8342// could not reach vnet even if it were. So the store inside the desk had a
 8343// cart that could not add and a logo the attractor never drew over.
 8344//
 8345// github.com/0magnet/realorigin fixes the class of problem rather than the
 8346// symptom. The store gets its own browser origin B, distinct from the site's
 8347// origin V, whose network layer is a service worker relaying every fetch back
 8348// to a responder on V β€” which answers it from the in-tab server over vnet. The
 8349// page is then an ordinary document: native subresource loading, real fetch,
 8350// real wasm, cookies and storage of its own.
 8351//
 8352// Two things make it cheap here where it is expensive in general:
 8353//
 8354//   - There is exactly ONE target, the tab's own store, so there is exactly one
 8355//     browse origin. realorigin content-addresses origins precisely so a
 8356//     wildcard can cover a whole address space; one fixed target hashes to one
 8357//     fixed hostname, which needs one ordinary certificate and no wildcard, no
 8358//     DNS-01 and no DNS provider credentials.
 8359//   - B must be a different REGISTRABLE domain from V, not a subdomain, so that
 8360//     untrusted content is fully cross-site from the site holding the keys.
 8361//     That is a deployment fact, and BrowseSuffix is where it is stated.
 8362//
 8363// Everything here is inert until BrowseSuffix and BrowseAddr are both set.
 8364
 8365import (
 8366	"context"
 8367	"github.com/gofiber/fiber/v3"
 8368	"log"
 8369	"net/url"
 8370	"strconv"
 8371	"strings"
 8372
 8373	"github.com/0magnet/realorigin"
 8374
 8375	"github.com/0magnet/m2/pkg/config"
 8376)
 8377
 8378// browseResponderPath is where the app origin serves realorigin's responder β€”
 8379// the trust boundary, which must run first-party on V. The desk page loads it
 8380// before the wasm that configures it.
 8381const browseResponderPath = "/browse-responder.js"
 8382
 8383// browseEnabled reports whether the deployment configured the substrate.
 8384func browseEnabled() bool {
 8385	return config.F.BrowseSuffix != "" && config.F.BrowseAddr != ""
 8386}
 8387
 8388// browseScheme and browsePort describe how a browse origin is reached.
 8389//
 8390// Hosted, that is https on 443 and the port is not in the URL. The local
 8391// model is the exception the platform itself makes: *.localhost resolves to
 8392// the loopback without any DNS and counts as a secure context, so a service
 8393// worker runs there over plain http. It is how realorigin and skywire are
 8394// developed against (.mesh.localhost), and it is the only way to exercise the
 8395// whole path without a wildcard certificate.
 8396func browseScheme() string {
 8397	if isLoopbackSuffix(config.F.BrowseSuffix) {
 8398		return "http"
 8399	}
 8400	return "https"
 8401}
 8402
 8403// browsePort is the port a browse origin is served on, empty when it is the
 8404// scheme's default.
 8405func browsePort() string {
 8406	if !isLoopbackSuffix(config.F.BrowseSuffix) {
 8407		return ""
 8408	}
 8409	if i := strings.LastIndex(config.F.BrowseAddr, ":"); i >= 0 {
 8410		return config.F.BrowseAddr[i+1:]
 8411	}
 8412	return ""
 8413}
 8414
 8415// isLoopbackSuffix reports whether the browse domain is the browser's own
 8416// loopback namespace rather than a real one.
 8417func isLoopbackSuffix(suffix string) bool {
 8418	return strings.HasSuffix(strings.TrimSuffix(suffix, "."), ".localhost")
 8419}
 8420
 8421// appOrigin is V: the origin the responder runs on, and the exact string the
 8422// browse origin posts its handshake to. It has to be what the BROWSER sees,
 8423// not what the site calls itself.
 8424//
 8425// Those are the same thing hosted, where SITEDOMAIN is both the brand and the
 8426// address. They are not the same under the loopback model: SITEDOMAIN is
 8427// assembled from SITENAME and SITEEXT for the masthead, so a development run
 8428// still calls itself magnetosphere.net while the browser is on
 8429// localhost:<WEBPORT>. Posting the handshake to the brand would silently drop
 8430// it and hang the frame on its interstitial forever, which is a bad way to
 8431// find out.
 8432func appOrigin() string {
 8433	if isLoopbackSuffix(config.F.BrowseSuffix) {
 8434		return "http://localhost:" + strconv.Itoa(config.F.WebPort)
 8435	}
 8436	raw := config.F.Sitedomain
 8437	if raw == "" {
 8438		return ""
 8439	}
 8440	if !strings.Contains(raw, "://") {
 8441		raw = "https://" + raw
 8442	}
 8443	u, err := url.Parse(raw)
 8444	if err != nil || u.Host == "" {
 8445		return ""
 8446	}
 8447	return u.Scheme + "://" + u.Host
 8448}
 8449
 8450// serveBrowseResponder puts realorigin's responder β€” the trust boundary β€” on
 8451// the app origin, where it has to run first-party. An earlier design in that
 8452// project put it in a cross-origin helper instead and Storage Partitioning
 8453// stranded it in another partition, which is worth not rediscovering.
 8454func serveBrowseResponder(r *fiber.App) {
 8455	if !browseEnabled() {
 8456		return
 8457	}
 8458	body := realorigin.ResponderJS()
 8459	r.Get(browseResponderPath, func(c fiber.Ctx) error {
 8460		c.Set(fiber.HeaderContentType, "text/javascript; charset=utf-8")
 8461		return c.Send(body)
 8462	})
 8463}
 8464
 8465// StartBrowseOrigin runs the browse-origin bootstrap listener.
 8466//
 8467// It serves two static files and never proxies anything: the service worker at
 8468// its own path and the bootstrap shell at every other. All content travels the
 8469// other way, through the visitor's own tab, so this listener sees none of it
 8470// and nothing about it scales with use.
 8471//
 8472// Returns immediately when the substrate is not configured, which is the
 8473// state of any deployment that has not set up the second domain.
 8474func StartBrowseOrigin(ctx context.Context) {
 8475	if !browseEnabled() {
 8476		return
 8477	}
 8478	app := appOrigin()
 8479	if app == "" {
 8480		log.Printf("browse: SITEURL is not a URL; the browse origin needs one to name its app origin")
 8481		return
 8482	}
 8483	cfg := realorigin.Config{
 8484		Addr:      config.F.BrowseAddr,
 8485		Suffix:    config.F.BrowseSuffix,
 8486		AppOrigin: app,
 8487	}
 8488	if _, err := realorigin.Handler(cfg); err != nil {
 8489		log.Printf("browse: %v", err)
 8490		return
 8491	}
 8492	log.Printf("browse: serving %s on %s for %s β€” each target the tab reaches gets its own origin",
 8493		config.F.BrowseSuffix, config.F.BrowseAddr, app)
 8494	go func() {
 8495		if err := cfg.ListenAndServe(ctx); err != nil && ctx.Err() == nil {
 8496			log.Printf("browse: %v", err)
 8497		}
 8498	}()
 8499}
 8500
 8501
 8502// ===== pkg/web/browse_test.go =====
 8503package web
 8504
 8505import (
 8506	"strings"
 8507	"testing"
 8508)
 8509
 8510// The substrate is off unless BOTH the domain and the listener are named. A
 8511// deployment with neither must behave exactly as it did before.
 8512func TestBrowseIsOffUntilConfigured(t *testing.T) {
 8513	saved := *f
 8514	t.Cleanup(func() { *f = saved })
 8515
 8516	for _, tc := range []struct {
 8517		suffix, addr string
 8518		want         bool
 8519	}{
 8520		{"", "", false},
 8521		{".haltingstate.net", "", false},
 8522		{"", "127.0.0.1:7997", false},
 8523		{".haltingstate.net", "127.0.0.1:7997", true},
 8524	} {
 8525		f.BrowseSuffix, f.BrowseAddr = tc.suffix, tc.addr
 8526		if got := browseEnabled(); got != tc.want {
 8527			t.Errorf("browseEnabled(%q,%q) = %v, want %v", tc.suffix, tc.addr, got, tc.want)
 8528		}
 8529	}
 8530}
 8531
 8532// Hosted is https on the default port; the loopback model is plain http on
 8533// the listener's own port, which is the only way to exercise the whole path
 8534// without a wildcard certificate.
 8535func TestBrowseSchemeAndPort(t *testing.T) {
 8536	saved := *f
 8537	t.Cleanup(func() { *f = saved })
 8538	for _, tc := range []struct {
 8539		suffix, addr string
 8540		scheme, port string
 8541	}{
 8542		{".haltingstate.net", "127.0.0.1:7997", "https", ""},
 8543		{".mesh.localhost", "127.0.0.1:7997", "http", "7997"},
 8544		{".mesh.localhost", ":7997", "http", "7997"},
 8545	} {
 8546		f.BrowseSuffix, f.BrowseAddr = tc.suffix, tc.addr
 8547		if got := browseScheme(); got != tc.scheme {
 8548			t.Errorf("browseScheme(%q) = %q, want %q", tc.suffix, got, tc.scheme)
 8549		}
 8550		if got := browsePort(); got != tc.port {
 8551			t.Errorf("browsePort(%q,%q) = %q, want %q", tc.suffix, tc.addr, got, tc.port)
 8552		}
 8553	}
 8554}
 8555
 8556// B must be a different registrable domain from V, not a subdomain of it:
 8557// different hosts are merely cross-origin, and only different registrable
 8558// domains make untrusted content fully cross-site from the site holding the
 8559// Stripe key.
 8560func TestBrowseSuffixIsNotUnderTheSiteDomain(t *testing.T) {
 8561	saved := *f
 8562	t.Cleanup(func() { *f = saved })
 8563	f.Sitedomain = "magnetosphere.net"
 8564	f.BrowseSuffix, f.BrowseAddr = ".haltingstate.net", "127.0.0.1:7997"
 8565
 8566	if got := appOrigin(); got != "https://magnetosphere.net" {
 8567		t.Errorf("appOrigin() = %q", got)
 8568	}
 8569	if strings.HasSuffix(f.BrowseSuffix, "."+f.Sitedomain) {
 8570		t.Error("the browse domain is under the site's own; it must be a separate registrable domain")
 8571	}
 8572}
 8573
 8574func TestAppOriginFromSiteDomain(t *testing.T) {
 8575	saved := *f
 8576	t.Cleanup(func() { *f = saved })
 8577	f.BrowseSuffix = ".haltingstate.net" // hosted: the brand IS the address
 8578	for _, tc := range []struct{ in, want string }{
 8579		{"magnetosphere.net", "https://magnetosphere.net"},
 8580		{"https://magnetosphere.net", "https://magnetosphere.net"},
 8581		{"http://localhost:8099", "http://localhost:8099"},
 8582		{"", ""},
 8583	} {
 8584		f.Sitedomain = tc.in
 8585		if got := appOrigin(); got != tc.want {
 8586			t.Errorf("appOrigin(%q) = %q, want %q", tc.in, got, tc.want)
 8587		}
 8588	}
 8589}
 8590
 8591// Under the loopback model V is wherever the browser actually has it, not
 8592// what the masthead says. SITEDOMAIN is assembled from SITENAME and SITEEXT
 8593// for branding, so a development run calls itself magnetosphere.net while
 8594// being served from localhost β€” and a handshake posted to the brand is
 8595// silently dropped.
 8596func TestAppOriginIsTheServingOriginUnderLoopback(t *testing.T) {
 8597	saved := *f
 8598	t.Cleanup(func() { *f = saved })
 8599	f.Sitedomain, f.WebPort = "magnetosphere.net", 8099
 8600	f.BrowseSuffix, f.BrowseAddr = ".mesh.localhost", "127.0.0.1:7997"
 8601
 8602	if got, want := appOrigin(), "http://localhost:8099"; got != want {
 8603		t.Errorf("appOrigin() = %q, want %q β€” the brand is not the address here", got, want)
 8604	}
 8605	f.BrowseSuffix = ".haltingstate.net"
 8606	if got, want := appOrigin(), "https://magnetosphere.net"; got != want {
 8607		t.Errorf("hosted appOrigin() = %q, want %q", got, want)
 8608	}
 8609}
 8610
 8611
 8612// ===== pkg/web/catalog.go =====
 8613// Package web pkg/web/catalog.go β€” the in-memory catalog the server sells from.
 8614package web
 8615
 8616import (
 8617	"log"
 8618	"os"
 8619	"sync"
 8620	"time"
 8621
 8622	p "github.com/0magnet/m2/pkg/product"
 8623)
 8624
 8625var (
 8626	allproducts   p.Products
 8627	allproductsMu sync.RWMutex
 8628)
 8629
 8630var lastModTime time.Time
 8631
 8632// LoadCatalog reads the products CSV named in the config into memory,
 8633// logging any data-integrity warnings.
 8634func LoadCatalog() error {
 8635	fileInfo, err := os.Stat(f.ProductsCSV)
 8636	if err != nil {
 8637		return err
 8638	}
 8639	lastModTime = fileInfo.ModTime()
 8640	prods := p.ReadCSV(f.ProductsCSV)
 8641	if warnings := p.ValidateCSV(prods); len(warnings) > 0 {
 8642		for _, w := range warnings {
 8643			log.Println("CSV warning:", w)
 8644		}
 8645	}
 8646	allproductsMu.Lock()
 8647	allproducts = prods
 8648	allproductsMu.Unlock()
 8649	return nil
 8650}
 8651
 8652// WatchCatalog polls the products CSV and reloads it when it changes.
 8653// Run it in a goroutine; it never returns.
 8654func WatchCatalog() {
 8655	for {
 8656		fileInfo, err := os.Stat(f.ProductsCSV)
 8657		if err != nil {
 8658			log.Println("Error getting file info:", err)
 8659			time.Sleep(10 * time.Second)
 8660			continue
 8661		}
 8662
 8663		currentModTime := fileInfo.ModTime()
 8664		if currentModTime != lastModTime {
 8665			log.Println("CSV file has been modified!")
 8666			if err := LoadCatalog(); err != nil {
 8667				log.Println("Error reloading catalog:", err)
 8668			}
 8669		}
 8670
 8671		time.Sleep(10 * time.Second)
 8672	}
 8673}
 8674
 8675// SetCatalog replaces the in-memory catalog directly, for hosts that have no
 8676// CSV on disk to load β€” the in-tab site server seeds this from /api/products,
 8677// which is already the public projection (no cost, location or source info).
 8678func SetCatalog(prods p.Products) {
 8679	allproductsMu.Lock()
 8680	allproducts = prods
 8681	allproductsMu.Unlock()
 8682}
 8683
 8684
 8685// ===== pkg/web/catsubcats_test.go =====
 8686package web
 8687
 8688import (
 8689	"bytes"
 8690	"flag"
 8691	htmpl "html/template"
 8692	"os"
 8693	"strings"
 8694	"testing"
 8695
 8696	p "github.com/0magnet/m2/pkg/product"
 8697)
 8698
 8699// sampleProducts is a catalog small enough to write the expected menu out by
 8700// hand, and shaped to exercise the alignment: counts of one, two and three
 8701// digits, a category with subcategories and one without.
 8702func sampleProducts() p.Products {
 8703	var out p.Products
 8704	add := func(cat, sub string, n int) {
 8705		for i := 0; i < n; i++ {
 8706			out = append(out, p.Product{Enable: "TRUE", Category: cat, Subcategory: sub,
 8707				Partno: cat + sub + string(rune('a'+i%26))})
 8708		}
 8709	}
 8710	add("resistor", "quarter watt 5%", 67)
 8711	add("resistor", "ceramic", 1)
 8712	add("inductor", "", 34)
 8713	return out
 8714}
 8715
 8716// renderMenu runs just the category menu, the way a page does.
 8717func renderMenu(t *testing.T, prods p.Products) string {
 8718	t.Helper()
 8719	saved := allproducts
 8720	allproducts = prods
 8721	defer func() { allproducts = saved }()
 8722	data := htmlTemplateData{}
 8723	data.LenAllProducts = len(prods)
 8724	data.CatsCounts, data.Cats, data.SubCatsCounts, data.SubCatsByCat = getcategories(prods)
 8725	data.Page = "front"
 8726	tmpl := htmpl.New("catsubcats").Funcs(funcs)
 8727	// Read by path rather than through h: the accessors resolve relative to
 8728	// the server's working directory, which is not a test's.
 8729	src, err := os.ReadFile("../../htmpl/catsubcats.html")
 8730	if err != nil {
 8731		t.Fatalf("reading catsubcats: %v", err)
 8732	}
 8733	if _, err := tmpl.Parse(string(src)); err != nil {
 8734		t.Fatalf("parsing catsubcats: %v", err)
 8735	}
 8736	var b bytes.Buffer
 8737	if err := tmpl.Execute(&b, map[string]interface{}{"Page": data, "Category": "", "Subcategory": ""}); err != nil {
 8738		t.Fatalf("executing catsubcats: %v", err)
 8739	}
 8740	return b.String()
 8741}
 8742
 8743// The menu's counts are right-aligned by padding the branch rule itself, so
 8744// every count ends in the same column. These glyph runs are what the site drew
 8745// before the layout moved to tallytree.
 8746func TestCatSubCatsDrawing(t *testing.T) {
 8747	out := renderMenu(t, sampleProducts())
 8748	for _, want := range []string{
 8749		"β”œβ”€β”€102&nbsp;",      // All Products
 8750		"β”œβ”€β”¬β”€68&nbsp;",      // resistor, has subcategories
 8751		"β”‚&nbsp;β”œβ”€67&nbsp;", // quarter watt 5%
 8752		"β”‚&nbsp;└──1&nbsp;", // ceramic, last subcategory, rule stretched two
 8753		"└───34&nbsp;",      // inductor, no subcategories, last category
 8754	} {
 8755		if !strings.Contains(out, want) {
 8756			t.Errorf("menu is missing %q\n\n%s", want, out)
 8757		}
 8758	}
 8759}
 8760
 8761// TestCatSubCatsGolden holds the menu's whole markup, not just its glyphs.
 8762// The layout moved out to tallytree; the anchors, titles, classes and nesting
 8763// around it must not have moved with it.
 8764//
 8765// Regenerate with: go test ./pkg/web -run CatSubCatsGolden -update
 8766func TestCatSubCatsGolden(t *testing.T) {
 8767	got := renderMenu(t, sampleProducts())
 8768	const golden = "testdata/catsubcats.golden"
 8769	if *update {
 8770		if err := os.WriteFile(golden, []byte(got), 0o600); err != nil {
 8771			t.Fatal(err)
 8772		}
 8773		t.Log("golden rewritten")
 8774		return
 8775	}
 8776	want, err := os.ReadFile(golden)
 8777	if err != nil {
 8778		t.Fatalf("%v (run with -update to create it)", err)
 8779	}
 8780	if got != string(want) {
 8781		t.Errorf("menu markup changed.\n got:\n%s\nwant:\n%s", got, want)
 8782	}
 8783}
 8784
 8785var update = flag.Bool("update", false, "rewrite the golden files")
 8786
 8787
 8788// ===== pkg/web/dropinfs.go =====
 8789package web
 8790
 8791// The drop-ins the binary carries, for a server whose disk has none.
 8792//
 8793// The store runs in three places and only two of them have the binaries on a
 8794// filesystem. On a host, CompileWASM writes them next to the working directory
 8795// and the routes read them from there. In a browser tab β€” pkg/storepane's
 8796// `serve`, behind the desk's netscrape window β€” there is no toolchain to build
 8797// them with and no disk that carries them, and the sources are not there
 8798// either, so nothing will ever produce them locally.
 8799//
 8800// So the build stages them into dropins/ and they ride along inside the
 8801// binary. A tab then serves the same cart, attractor and border decoration the
 8802// origin serves, out of itself, with no round trip.
 8803//
 8804// The page binaries (term, and the separate tui/desk) are deliberately NOT
 8805// staged. term.wasm is the binary doing the embedding, and a file cannot
 8806// contain itself; the tab does not serve /tui or /desk anyway β€” those routes
 8807// belong to the host role.
 8808//
 8809// An empty stage is fine and is what a fresh checkout builds: the directive
 8810// matches dropins/README.md, nothing is carried, and the lookup below simply
 8811// says no. The disk and then the origin answer instead.
 8812
 8813import (
 8814	"embed"
 8815	"io/fs"
 8816	"path"
 8817	"strings"
 8818)
 8819
 8820//go:embed dropins
 8821var dropinFS embed.FS
 8822
 8823// embeddedDropin returns the staged bytes of one drop-in, by the file name a
 8824// page asks for ("stl2-tiny.wasm").
 8825func embeddedDropin(file string) ([]byte, bool) {
 8826	// Only the binaries, by a bare name. The directory also holds its own
 8827	// README, which is not something a page may ask this for.
 8828	if file == "" || !strings.HasSuffix(file, ".wasm") || strings.ContainsAny(file, `/\`) {
 8829		return nil, false
 8830	}
 8831	b, err := dropinFS.ReadFile(path.Join("dropins", file))
 8832	if err != nil {
 8833		return nil, false
 8834	}
 8835	return b, true
 8836}
 8837
 8838// embeddedDropinNames lists the staged binaries, so the routes can be
 8839// registered for exactly what the build carried.
 8840func embeddedDropinNames() []string {
 8841	ents, err := fs.ReadDir(dropinFS, "dropins")
 8842	if err != nil {
 8843		return nil
 8844	}
 8845	var out []string
 8846	for _, e := range ents {
 8847		if !e.IsDir() && strings.HasSuffix(e.Name(), ".wasm") {
 8848			out = append(out, e.Name())
 8849		}
 8850	}
 8851	return out
 8852}
 8853
 8854// lookupDropin is embeddedDropin behind a variable so a test can say what the
 8855// build carried. Whether a binary carries a drop-in is fixed at compile time,
 8856// which makes every test of the pick order depend on what happened to be
 8857// staged in the tree it was compiled from; this makes that an input.
 8858var lookupDropin = embeddedDropin
 8859
 8860
 8861// ===== pkg/web/dropinfs_test.go =====
 8862package web
 8863
 8864import (
 8865	"os"
 8866	"path/filepath"
 8867	"testing"
 8868)
 8869
 8870// A fresh checkout stages nothing, and that has to build and behave β€” the
 8871// directive matches only the README, and every lookup says no.
 8872func TestEmbeddedDropinOnAnEmptyStage(t *testing.T) {
 8873	// Whatever this working tree has staged, README.md is never a drop-in.
 8874	if _, ok := embeddedDropin("README.md"); ok {
 8875		t.Error("README.md should not be reachable as a drop-in")
 8876	}
 8877	for _, bad := range []string{"", "../wasmpick.go", "sub/x.wasm", `a\b.wasm`} {
 8878		if _, ok := embeddedDropin(bad); ok {
 8879			t.Errorf("embeddedDropin(%q) should be refused", bad)
 8880		}
 8881	}
 8882	for _, n := range embeddedDropinNames() {
 8883		if filepath.Ext(n) != ".wasm" {
 8884			t.Errorf("staged name %q is not a .wasm", n)
 8885		}
 8886	}
 8887}
 8888
 8889// pageWasm is what keeps term.wasm from being staged into itself.
 8890func TestPageBinariesAreNotStaged(t *testing.T) {
 8891	for _, name := range []string{"term", "tui", "desk"} {
 8892		if !pageWasm[name] {
 8893			t.Errorf("%s must be treated as a page binary, not a drop-in", name)
 8894		}
 8895	}
 8896	for _, name := range []string{"cart", "stl2", "deco"} {
 8897		if pageWasm[name] {
 8898			t.Errorf("%s is a drop-in and must be stageable", name)
 8899		}
 8900	}
 8901}
 8902
 8903// stageDropins takes the smallest build of each drop-in and clears what a
 8904// previous run left, so a source dropped from WasmSRC stops being carried.
 8905func TestStageDropinsTakesTheSmallBuildAndClearsStale(t *testing.T) {
 8906	t.Chdir(t.TempDir())
 8907	dir := filepath.Join("pkg", "web", "dropins")
 8908	if err := os.MkdirAll(dir, 0o750); err != nil {
 8909		t.Fatal(err)
 8910	}
 8911	write := func(name, body string) {
 8912		if err := os.WriteFile(name, []byte(body), 0o600); err != nil {
 8913			t.Fatal(err)
 8914		}
 8915	}
 8916	// Left over from a run when "gone" was still configured.
 8917	write(filepath.Join(dir, "gone-tiny.wasm"), "stale")
 8918
 8919	write("cart.wasm", "cart-go")
 8920	write("cart-tiny.wasm", "cart-tiny")
 8921	write("deco.wasm", "deco-go") // no tinygo build of this one
 8922
 8923	stageDropins([]string{"wasm/cart", "wasm/deco", "wasm/never"})
 8924
 8925	got := map[string]string{}
 8926	ents, err := os.ReadDir(dir)
 8927	if err != nil {
 8928		t.Fatal(err)
 8929	}
 8930	for _, e := range ents {
 8931		b, rerr := os.ReadFile(filepath.Join(dir, e.Name())) //nolint:gosec // test tree
 8932		if rerr != nil {
 8933			t.Fatal(rerr)
 8934		}
 8935		got[e.Name()] = string(b)
 8936	}
 8937	want := map[string]string{
 8938		"cart-tiny.wasm": "cart-tiny", // small build preferred
 8939		"deco.wasm":      "deco-go",   // only build there is
 8940	}
 8941	if len(got) != len(want) {
 8942		t.Fatalf("staged %v, want %v", got, want)
 8943	}
 8944	for k, v := range want {
 8945		if got[k] != v {
 8946			t.Errorf("staged %s = %q, want %q", k, got[k], v)
 8947		}
 8948	}
 8949}
 8950
 8951
 8952// ===== pkg/web/invoice.go =====
 8953package web
 8954
 8955// Crypto checkout: the other button beside Stripe.
 8956//
 8957// The two paths meet where it matters. Both validate every line against the
 8958// server's own catalog before believing a total, and both end by writing an
 8959// order into orders/ and printing a receipt β€” so a paid order looks the same
 8960// however it was paid, and the page in the tray keeps meaning what it means.
 8961//
 8962// Where they differ is when. A card authorizes while the reader waits; a
 8963// chain payment arrives minutes later, from a page that may already be
 8964// closed. So the receipt prints on CONFIRMATION, not on placement β€”
 8965// otherwise every abandoned checkout would put paper in the tray and the
 8966// glance-at-the-printer workflow would stop working.
 8967
 8968import (
 8969	"context"
 8970	"crypto/rand"
 8971	"encoding/hex"
 8972	"encoding/json"
 8973	"errors"
 8974	"fmt"
 8975	"log"
 8976	"os"
 8977	"path/filepath"
 8978	"time"
 8979
 8980	"github.com/gofiber/fiber/v3"
 8981
 8982	"github.com/0magnet/m2/pkg/pay"
 8983	"github.com/0magnet/m2/pkg/pay/sky"
 8984	"github.com/0magnet/m2/pkg/storepage"
 8985)
 8986
 8987// cryptoPay is the configured crypto checkout, or nil when the deployment
 8988// has no wallet set. Nil means the button is not offered at all, which is
 8989// what a store with nothing configured should show rather than an error.
 8990var cryptoPay *cryptoConfig
 8991
 8992type cryptoConfig struct {
 8993	store    *pay.Store
 8994	rater    *pay.Rater
 8995	backend  pay.Backend
 8996	window   time.Duration
 8997	ordersIn string
 8998}
 8999
 9000// initCrypto builds the crypto checkout from the configuration, and reports
 9001// whether it is on.
 9002func initCrypto(ordersDir string) *cryptoConfig {
 9003	if f.SkyXpub == "" {
 9004		return nil
 9005	}
 9006	store, err := pay.NewStore(ordersDir)
 9007	if err != nil {
 9008		log.Printf("crypto checkout off: %v", err)
 9009		return nil
 9010	}
 9011
 9012	// A fixed rate REPLACES the exchange rather than joining it: the
 9013	// lowest-wins rule settles a disagreement between exchanges quoting one
 9014	// market, and a manual figure mixed in just wins whenever it is lower.
 9015	var sources []pay.RateSource
 9016	switch {
 9017	case f.SkyRateFix != "":
 9018		sources = []pay.RateSource{pay.Fixed{Label: "operator", USD: f.SkyRateFix}}
 9019	case f.SkyRatePair != "":
 9020		sources = []pay.RateSource{pay.LBank{Pair: f.SkyRatePair}}
 9021	default:
 9022		log.Printf("crypto checkout off: set a rate pair or a fixed rate")
 9023		return nil
 9024	}
 9025
 9026	mins := f.InvoiceMins
 9027	if mins <= 0 {
 9028		mins = 15
 9029	}
 9030	return &cryptoConfig{
 9031		store:    store,
 9032		rater:    &pay.Rater{Sources: sources, Min: f.RateMin, Max: f.RateMax},
 9033		backend:  &sky.Backend{Xpub: f.SkyXpub, NodeURL: f.SkyNodeURL},
 9034		window:   time.Duration(mins) * time.Minute,
 9035		ordersIn: ordersDir,
 9036	}
 9037}
 9038
 9039// handleInvoice registers the crypto endpoints, if crypto is configured.
 9040func handleInvoice(r *fiber.App) {
 9041	cryptoPay = initCrypto("./orders")
 9042	if cryptoPay == nil {
 9043		return
 9044	}
 9045	r.Post("/create-invoice", createInvoice)
 9046	r.Get("/invoice/:id", invoiceStatus)
 9047	log.Printf("crypto checkout on: skycoin, %s window", cryptoPay.window)
 9048}
 9049
 9050// invoiceRequest is what the cart posts: the same items it sends Stripe.
 9051type invoiceRequest struct {
 9052	Coin  string     `json:"coin"`
 9053	Items []pay.Item `json:"items"`
 9054}
 9055
 9056// createInvoice prices an order in coin and hands back an address.
 9057func createInvoice(c fiber.Ctx) error {
 9058	var req invoiceRequest
 9059	if err := json.Unmarshal(c.Body(), &req); err != nil {
 9060		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid JSON"})
 9061	}
 9062	if len(req.Items) == 0 {
 9063		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "No items in request"})
 9064	}
 9065	if req.Coin != "" && pay.Coin(req.Coin) != cryptoPay.backend.Coin() {
 9066		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Unsupported coin"})
 9067	}
 9068
 9069	total, err := validateOrderItems(req.Items)
 9070	if err != nil {
 9071		log.Printf("invoice validation failed: %v", err)
 9072		if errors.Is(err, errNeedShippingEmail) {
 9073			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
 9074		}
 9075		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Item validation failed"})
 9076	}
 9077	if total < 50 {
 9078		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Order total must be at least $0.50"})
 9079	}
 9080
 9081	ctx, cancel := context.WithTimeout(c.Context(), 20*time.Second)
 9082	defer cancel()
 9083
 9084	rate, source, err := cryptoPay.rater.Quote(ctx)
 9085	if err != nil {
 9086		// No usable price means no sale. Quoting anyway is the mistake the
 9087		// rate layer exists to prevent.
 9088		log.Printf("invoice refused, no rate: %v", err)
 9089		return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "No price available right now"})
 9090	}
 9091	coin := cryptoPay.backend.Coin()
 9092	atomic, err := pay.AtomicFor(total, rate, coin.Decimals())
 9093	if err != nil {
 9094		log.Printf("invoice pricing failed: %v", err)
 9095		return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Unable to price the order"})
 9096	}
 9097
 9098	now := time.Now()
 9099	taken, err := cryptoPay.store.TakenAddresses(coin, now)
 9100	if err != nil {
 9101		log.Printf("invoice address reservation failed: %v", err)
 9102		return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Unable to allocate an address"})
 9103	}
 9104	addr, err := cryptoPay.backend.NextAddress(ctx, taken)
 9105	if err != nil {
 9106		log.Printf("invoice address allocation failed: %v", err)
 9107		return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Unable to allocate an address"})
 9108	}
 9109
 9110	inv := &pay.Invoice{
 9111		ID: newInvoiceID(), Coin: coin, Address: addr, Atomic: atomic,
 9112		FiatCents: total, Rate: rate.FloatString(10), RateSource: source,
 9113		Items: req.Items, Created: now, Expires: now.Add(cryptoPay.window),
 9114		Status: pay.StatusPending,
 9115	}
 9116	if err := cryptoPay.store.Put(inv); err != nil {
 9117		log.Printf("invoice save failed: %v", err)
 9118		return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Unable to save the invoice"})
 9119	}
 9120	return c.JSON(invoiceView(inv))
 9121}
 9122
 9123// invoiceStatus reports one invoice, polling the chain first so a reader
 9124// watching the page sees the payment as soon as it confirms.
 9125func invoiceStatus(c fiber.Ctx) error {
 9126	inv, err := cryptoPay.store.Get(c.Params("id"))
 9127	if err != nil {
 9128		return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "No such invoice"})
 9129	}
 9130	if inv.Status != pay.StatusPaid {
 9131		ctx, cancel := context.WithTimeout(c.Context(), 15*time.Second)
 9132		defer cancel()
 9133		if err := refreshInvoice(ctx, inv); err != nil {
 9134			log.Printf("invoice %s refresh failed: %v", inv.ID, err)
 9135		}
 9136	}
 9137	return c.JSON(invoiceView(inv))
 9138}
 9139
 9140// invoiceView is what a client is told: never the whole record, because the
 9141// items and the rate source are the store's business, not the page's.
 9142func invoiceView(inv *pay.Invoice) fiber.Map {
 9143	return fiber.Map{
 9144		"id":          inv.ID,
 9145		"coin":        string(inv.Coin),
 9146		"address":     inv.Address,
 9147		"amount":      pay.AmountString(inv.Atomic, inv.Coin.Decimals()),
 9148		"uri":         inv.URI(),
 9149		"fiat_cents":  inv.FiatCents,
 9150		"status":      string(inv.Status),
 9151		"paid":        pay.AmountString(inv.PaidAtomic, inv.Coin.Decimals()),
 9152		"outstanding": pay.AmountString(inv.Outstanding(), inv.Coin.Decimals()),
 9153		"expires":     inv.Expires.Format(time.RFC3339),
 9154		"txid":        inv.TxID,
 9155	}
 9156}
 9157
 9158// refreshInvoice asks the chain, records what it finds, and β€” the first time
 9159// an invoice settles β€” writes the order and prints the receipt.
 9160func refreshInvoice(ctx context.Context, inv *pay.Invoice) error {
 9161	atomic, txid, confirmed, err := cryptoPay.backend.Received(ctx, inv.Address)
 9162	if err != nil {
 9163		return err
 9164	}
 9165	changed := inv.Apply(atomic, txid, confirmed, time.Now())
 9166	if inv.Status == pay.StatusPaid && !inv.Mailed {
 9167		inv.Mailed, changed = true, true
 9168		go mailOrder(cryptoPay.ordersIn, string(inv.Coin)+"-"+inv.ID, coinPaidLine(inv), invoiceLocal(inv))
 9169	}
 9170	settled := inv.Status == pay.StatusPaid && !inv.Printed
 9171	if settled {
 9172		if err := completeInvoice(inv); err != nil {
 9173			// The money arrived; failing to print must not lose that.
 9174			log.Printf("invoice %s settled but could not be completed: %v", inv.ID, err)
 9175		} else {
 9176			inv.Printed = true
 9177			changed = true
 9178		}
 9179	}
 9180	if changed {
 9181		return cryptoPay.store.Put(inv)
 9182	}
 9183	return nil
 9184}
 9185
 9186// completeInvoice writes the order beside the card orders and prints it.
 9187func completeInvoice(inv *pay.Invoice) error {
 9188	local := invoiceLocal(inv)
 9189	orderData := map[string]any{
 9190		"clientData":    local,
 9191		"verifiedCents": inv.FiatCents,
 9192		"currency":      "usd",
 9193		"cryptoStatus":  string(inv.Status),
 9194		"coin":          string(inv.Coin),
 9195		"coinAmount":    pay.AmountString(inv.Atomic, inv.Coin.Decimals()),
 9196		"coinPaid":      pay.AmountString(inv.PaidAtomic, inv.Coin.Decimals()),
 9197		"rateUSD":       inv.Rate,
 9198		"rateSource":    inv.RateSource,
 9199		"address":       inv.Address,
 9200		"txid":          inv.TxID,
 9201		"submittedAt":   time.Now().Format(time.RFC3339),
 9202	}
 9203	data, err := json.MarshalIndent(orderData, "", "  ")
 9204	if err != nil {
 9205		return err
 9206	}
 9207	id := string(inv.Coin) + "-" + inv.ID
 9208	path := filepath.Join(cryptoPay.ordersIn, id+".json")
 9209	if err := os.WriteFile(path, data, 0o600); err != nil {
 9210		return err
 9211	}
 9212	receipt, err := buildReceipt(local, id)
 9213	if err != nil {
 9214		return err
 9215	}
 9216	if err := sendToCUPS(receipt, "Order "+id); err != nil {
 9217		_ = os.WriteFile(filepath.Join(cryptoPay.ordersIn, id+".print_failed"), []byte(err.Error()), 0o600) //nolint:errcheck,gosec // best-effort marker; the failure is logged
 9218		return err
 9219	}
 9220	return nil
 9221}
 9222
 9223// WatchInvoices polls live invoices until ctx is done.
 9224//
 9225// The page polls too, but a reader who closes the tab still bought
 9226// something: without this, an order paid after the last poll would sit in
 9227// orders/ unprinted and unnoticed.
 9228func WatchInvoices(ctx context.Context) {
 9229	if cryptoPay == nil {
 9230		return
 9231	}
 9232	t := time.NewTicker(time.Minute)
 9233	defer t.Stop()
 9234	for {
 9235		select {
 9236		case <-ctx.Done():
 9237			return
 9238		case <-t.C:
 9239			live, err := cryptoPay.store.Live()
 9240			if err != nil {
 9241				log.Printf("invoice poll failed: %v", err)
 9242				continue
 9243			}
 9244			for _, inv := range live {
 9245				if err := refreshInvoice(ctx, inv); err != nil {
 9246					log.Printf("invoice %s poll failed: %v", inv.ID, err)
 9247				}
 9248			}
 9249		}
 9250	}
 9251}
 9252
 9253// errNeedShippingEmail is the one validation failure a customer can fix, so
 9254// it is the one a client is told in words.
 9255var errNeedShippingEmail = errors.New("shipping info with an email address is required")
 9256
 9257// validateOrderItems totals an order against the server's own catalog.
 9258//
 9259// Shared with the Stripe path: a client is never believed about a price in
 9260// either. The shipping line is the one the client sets, so it is held to the
 9261// same minimum the form enforces β€” otherwise a hand-written request could
 9262// ship for a cent.
 9263func validateOrderItems(items []pay.Item) (int64, error) {
 9264	total := int64(0)
 9265	var ship *storepage.Shipping
 9266	for _, it := range items {
 9267		if it.Amount <= 0 {
 9268			return 0, fmt.Errorf("non-positive amount for %q", it.ID)
 9269		}
 9270		if storepage.IsShippingID(it.ID) {
 9271			if it.Amount < int64(storepage.MinShippingCents) {
 9272				return 0, fmt.Errorf("shipping below the minimum: %d cents", it.Amount)
 9273			}
 9274			if ship != nil {
 9275				return 0, fmt.Errorf("more than one shipping line")
 9276			}
 9277			sh, ok := storepage.ParseShipping(it.ID)
 9278			if !ok {
 9279				return 0, fmt.Errorf("unreadable shipping line")
 9280			}
 9281			ship = &sh
 9282			total += it.Amount
 9283			continue
 9284		}
 9285		expected, err := validateItemAmount(it.ID, it.Amount)
 9286		if err != nil {
 9287			return 0, err
 9288		}
 9289		total += expected
 9290	}
 9291	// Every order ships, and every order is confirmed by email: the address
 9292	// and the email travel together in the shipping line. Refused here, before
 9293	// a payment or an invoice exists, rather than discovered after.
 9294	if ship == nil || !storepage.ValidEmail(ship.Email) {
 9295		return 0, errNeedShippingEmail
 9296	}
 9297	return total, nil
 9298}
 9299
 9300// newInvoiceID is a random id: it names a file and appears in a URL, and
 9301// must not be guessable from an order number.
 9302func newInvoiceID() string {
 9303	var b [12]byte
 9304	if _, err := rand.Read(b[:]); err != nil {
 9305		return fmt.Sprintf("%d", time.Now().UnixNano())
 9306	}
 9307	return hex.EncodeToString(b[:])
 9308}
 9309
 9310// invoiceLocal is an invoice's lines in the shape a card order's client data
 9311// has, for the receipt and the confirmation email.
 9312func invoiceLocal(inv *pay.Invoice) map[string]any {
 9313	items := make([]map[string]any, 0, len(inv.Items))
 9314	for _, it := range inv.Items {
 9315		items = append(items, map[string]any{"id": it.ID, "amount": it.Amount, "quantity": 1})
 9316	}
 9317	return map[string]any{"items": items}
 9318}
 9319
 9320// coinPaidLine says how a coin order was paid, for the confirmation email.
 9321func coinPaidLine(inv *pay.Invoice) string {
 9322	s := fmt.Sprintf("%s %s (%s)", pay.AmountString(inv.PaidAtomic, inv.Coin.Decimals()), inv.Coin, dollars(inv.FiatCents))
 9323	if inv.TxID != "" {
 9324		s += ", transaction " + inv.TxID
 9325	}
 9326	return s
 9327}
 9328
 9329
 9330// ===== pkg/web/invoice_test.go =====
 9331package web
 9332
 9333import (
 9334	"errors"
 9335	"testing"
 9336
 9337	"github.com/0magnet/m2/pkg/pay"
 9338	"github.com/0magnet/m2/pkg/storepage"
 9339)
 9340
 9341// Both payment paths total an order through this, so a client is never
 9342// believed about a price in one and checked in the other.
 9343func TestValidateOrderItemsHoldsShippingToTheMinimum(t *testing.T) {
 9344	ship := storepage.Shipping{
 9345		Cents: storepage.MinShippingCents, Name: "A", City: "Dallas",
 9346		State: "TX", Country: storepage.DefaultCountry,
 9347		Email: "a@example.com",
 9348	}
 9349
 9350	// A shipping line at the minimum is fine.
 9351	total, err := validateOrderItems([]pay.Item{
 9352		{ID: ship.ID(), Amount: int64(storepage.MinShippingCents)},
 9353	})
 9354	if err != nil {
 9355		t.Fatalf("the minimum was refused: %v", err)
 9356	}
 9357	if total != int64(storepage.MinShippingCents) {
 9358		t.Errorf("total = %d, want %d", total, storepage.MinShippingCents)
 9359	}
 9360
 9361	// A cent of shipping is not. The form enforces this in the browser;
 9362	// a hand-written request does not go through the form.
 9363	if _, err := validateOrderItems([]pay.Item{
 9364		{ID: ship.ID(), Amount: 1},
 9365	}); err == nil {
 9366		t.Error("a one-cent shipping line was accepted")
 9367	}
 9368}
 9369
 9370func TestValidateOrderItemsRejectsNonPositive(t *testing.T) {
 9371	if _, err := validateOrderItems([]pay.Item{{ID: "X-1 X 1", Amount: 0}}); err == nil {
 9372		t.Error("a zero-amount line was accepted")
 9373	}
 9374	if _, err := validateOrderItems([]pay.Item{{ID: "X-1 X 1", Amount: -500}}); err == nil {
 9375		t.Error("a negative line was accepted")
 9376	}
 9377}
 9378
 9379// An unknown part cannot be priced, so it cannot be sold.
 9380func TestValidateOrderItemsRejectsUnknownPart(t *testing.T) {
 9381	if _, err := validateOrderItems([]pay.Item{
 9382		{ID: "NO-SUCH-PART-12345 X 1", Amount: 100},
 9383	}); err == nil {
 9384		t.Error("an unknown part was priced")
 9385	}
 9386}
 9387
 9388// Crypto is off unless a wallet is configured: a store with nothing set
 9389// should show no button rather than one that errors.
 9390func TestCryptoOffWithoutAnXpub(t *testing.T) {
 9391	saved := f.SkyXpub
 9392	f.SkyXpub = ""
 9393	defer func() { f.SkyXpub = saved }()
 9394	if got := initCrypto(t.TempDir()); got != nil {
 9395		t.Error("crypto checkout came up with no xpub configured")
 9396	}
 9397}
 9398
 9399// ...and off when there is a wallet but no way to price it, rather than
 9400// quoting from a guess.
 9401func TestCryptoOffWithoutARate(t *testing.T) {
 9402	savedX, savedP, savedF := f.SkyXpub, f.SkyRatePair, f.SkyRateFix
 9403	f.SkyXpub = "xpub6DH2sNie8sDh7cUbZRXeMEHaJrU72g8UZ45Z8VS26oUc8NMZrfbgpWH1U1osnJDisro6sUDVNL6MbnsAmfDNXDLjC8UfRLuM2YSoPMeY4DS"
 9404	f.SkyRatePair, f.SkyRateFix = "", ""
 9405	defer func() { f.SkyXpub, f.SkyRatePair, f.SkyRateFix = savedX, savedP, savedF }()
 9406	if got := initCrypto(t.TempDir()); got != nil {
 9407		t.Error("crypto checkout came up with no rate source")
 9408	}
 9409}
 9410
 9411// No payment and no invoice without an email to confirm the order to: the
 9412// shipping line carries it, and an order without one is refused before any
 9413// money moves.
 9414func TestValidateOrderItemsWantsAShippingEmail(t *testing.T) {
 9415	ship := storepage.Shipping{Cents: storepage.MinShippingCents, Name: "A", Country: storepage.DefaultCountry}
 9416	cases := map[string][]pay.Item{
 9417		"no shipping line": {},
 9418		"no email":         {{ID: ship.ID(), Amount: int64(storepage.MinShippingCents)}},
 9419	}
 9420	ship.Email = "not an address"
 9421	cases["a bad email"] = []pay.Item{{ID: ship.ID(), Amount: int64(storepage.MinShippingCents)}}
 9422	for name, items := range cases {
 9423		if _, err := validateOrderItems(items); !errors.Is(err, errNeedShippingEmail) {
 9424			t.Errorf("%s: err = %v, want errNeedShippingEmail", name, err)
 9425		}
 9426	}
 9427	ship.Email = "a@example.com"
 9428	two := []pay.Item{{ID: ship.ID(), Amount: 700}, {ID: ship.ID(), Amount: 700}}
 9429	if _, err := validateOrderItems(two); err == nil {
 9430		t.Error("two shipping lines were accepted")
 9431	}
 9432}
 9433
 9434
 9435// ===== pkg/web/navmenu.go =====
 9436package web
 9437
 9438// The category menu's layout.
 9439//
 9440// The drawing β€” β”œ/β”” branches, a ┬ where a category opens into subcategories,
 9441// and the counts right-aligned by padding the rule itself β€” is tallytree's. It
 9442// used to be written out twice: once here in htmpl/catsubcats.html with the
 9443// repeat and sub template functions, and once again in pkg/tui for the terminal,
 9444// whose comment read "reproduces htmpl/catsubcats.html". Two implementations of
 9445// one picture, kept in agreement by hand and tested by neither.
 9446//
 9447// What stays here is the markup. The template asks for the rows and wraps each
 9448// one in the disclosure elements and anchors it needs; it no longer works out
 9449// where anything goes.
 9450
 9451import (
 9452	htmpl "html/template"
 9453
 9454	"github.com/0magnet/tallytree"
 9455)
 9456
 9457// navRow is one line of the menu: the glyphs that lead up to it, its count,
 9458// and what it points at.
 9459//
 9460// Prefix is HTML because its blanks are non-breaking spaces. An ordinary space
 9461// there would collapse and the tree would lose its columns.
 9462type navRow struct {
 9463	Prefix  htmpl.HTML
 9464	Count   int
 9465	Label   string
 9466	HasSubs bool
 9467}
 9468
 9469// navCat is a category row together with the subcategory rows beneath it, which
 9470// is the shape the markup needs: each category is a <details> holding its own
 9471// subcategories.
 9472type navCat struct {
 9473	navRow
 9474	Subs []navRow
 9475}
 9476
 9477// navMenu is the whole menu: the "everything" row, then the categories.
 9478type navMenu struct {
 9479	All  navRow
 9480	Cats []navCat
 9481}
 9482
 9483// navmenu lays the menu out for a page.
 9484//
 9485// Depth is pinned to two because the menu always COULD have subcategories, so
 9486// it reserves room for them even in a catalog where nothing does β€” otherwise a
 9487// flat catalog would draw a narrower tree than the same shop with one
 9488// subcategory added, and the menu would change shape as stock came and went.
 9489//
 9490// Every branch is drawn open: the disclosure elements do the hiding, so the
 9491// rows are always all there.
 9492func navmenu(d htmlTemplateData) navMenu {
 9493	forest := []tallytree.Node{{Label: "All Products", Count: d.LenAllProducts}}
 9494	for _, cat := range d.Cats {
 9495		n := tallytree.Node{Label: cat, Count: d.CatsCounts[cat]}
 9496		for _, sub := range d.SubCatsByCat[cat] {
 9497			n.Children = append(n.Children, tallytree.Node{Label: sub, Count: d.SubCatsCounts[cat][sub]})
 9498		}
 9499		forest = append(forest, n)
 9500	}
 9501	rows := tallytree.Rows(forest, tallytree.Options{
 9502		Depth:  2,
 9503		Glyphs: tallytree.GlyphSet{Blank: "&nbsp;"},
 9504	})
 9505	var m navMenu
 9506	for i, r := range rows {
 9507		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
 9508		switch {
 9509		case i == 0:
 9510			m.All = row
 9511		case r.Depth == 0:
 9512			m.Cats = append(m.Cats, navCat{navRow: row})
 9513		default:
 9514			m.Cats[len(m.Cats)-1].Subs = append(m.Cats[len(m.Cats)-1].Subs, row)
 9515		}
 9516	}
 9517	return m
 9518}
 9519
 9520
 9521// ===== pkg/web/order.go =====
 9522// Package web pkg/web/order.go β€” checkout, order persistence, receipt printing.
 9523package web
 9524
 9525import (
 9526	"bytes"
 9527	"encoding/json"
 9528	"errors"
 9529	"fmt"
 9530	htmpl "html/template"
 9531	"log"
 9532	"os"
 9533	"path/filepath"
 9534	"regexp"
 9535	"strconv"
 9536	"strings"
 9537	"time"
 9538
 9539	"github.com/bitfield/script"
 9540	"github.com/gofiber/fiber/v3"
 9541	"github.com/stripe/stripe-go/v81"
 9542	"github.com/stripe/stripe-go/v81/paymentintent"
 9543
 9544	"github.com/0magnet/m2/pkg/pay"
 9545)
 9546
 9547// validPIID matches Stripe PaymentIntent IDs: "pi_" followed by alphanumeric chars.
 9548// Also allows plain alphanumeric+underscore+hyphen for test order IDs.
 9549var validPIID = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
 9550
 9551func handleOrder(r *fiber.App) {
 9552	r.Get("/checkout.css", func(c fiber.Ctx) error {
 9553		c.Set("Content-Type", "text/css;charset=utf-8")
 9554		_, err := c.Status(fiber.StatusOK).Write([]byte(h.CheckoutCSS()))
 9555		return err
 9556	})
 9557
 9558	r.Get("/complete", func(c fiber.Ctx) error {
 9559		// Complete template
 9560		completetmpl := htmpl.New("index")
 9561		if _, err := completetmpl.Parse(h.CompletePage()); err != nil {
 9562			msg := fmt.Sprintf("Error parsing complete page template: %v", err)
 9563			log.Println(msg)
 9564			return c.Status(fiber.StatusInternalServerError).SendString(msg)
 9565		}
 9566		if _, err := completetmpl.New("wasm").Parse(h.Wasm()); err != nil {
 9567			log.Println("Error parsing wasm template:", err)
 9568			msg := fmt.Sprintf("Error parsing wasm template: %v", err)
 9569			log.Println(msg)
 9570			return c.Status(fiber.StatusInternalServerError).SendString(msg)
 9571		}
 9572		h1 := htmlPageTemplateData
 9573		// The checkout completion page is what the cart wasm drives: it
 9574		// fills in the payment status icon, text and intent id.
 9575		h1.WasmBinary = wasmFor("cart")
 9576		/*
 9577			proto := "http"
 9578			if c.Secure() {
 9579				proto += "s"
 9580			}
 9581		*/
 9582		proto := "https"
 9583		h1.Canonical = proto + `://` + c.Hostname() + c.OriginalURL()
 9584		h1.BaseURL = proto + `://` + c.Hostname()
 9585		h1.RequestHost = c.Hostname()
 9586		h1.Protocol = proto
 9587		h1.Time = time.Now().Format(time.RFC3339Nano)
 9588		h1.Year = fmt.Sprintf("%v", time.Now().Year())
 9589		tmplData := map[string]interface{}{
 9590			"Page": h1,
 9591		}
 9592		var result bytes.Buffer
 9593		err := completetmpl.Execute(&result, tmplData)
 9594		if err != nil {
 9595			msg := fmt.Sprintf("Could not execute html template %v", err)
 9596			log.Println(msg)
 9597			return c.Status(fiber.StatusInternalServerError).SendString(msg)
 9598		}
 9599		c.Set("Content-Type", "text/html;charset=utf-8")
 9600		return c.Status(fiber.StatusOK).Send(result.Bytes())
 9601	})
 9602
 9603	r.Get("/order/:piid", func(c fiber.Ctx) error {
 9604		piid := c.Params("piid")
 9605		if !validPIID.MatchString(piid) {
 9606			return c.Status(fiber.StatusBadRequest).SendString("Invalid order ID")
 9607		}
 9608		order, err := script.File("orders/" + piid + ".json").Bytes()
 9609		if err != nil {
 9610			return c.Status(fiber.StatusNotFound).SendString("Order not found")
 9611		}
 9612		return c.Status(fiber.StatusOK).Send(order)
 9613	})
 9614
 9615	r.Get("/order/:piid/html", func(c fiber.Ctx) error {
 9616		piid := c.Params("piid")
 9617		if !validPIID.MatchString(piid) {
 9618			return c.Status(fiber.StatusBadRequest).SendString("Invalid order ID")
 9619		}
 9620		order, err := script.File("orders/" + piid + ".json").Bytes()
 9621		if err != nil {
 9622			return c.Status(fiber.StatusNotFound).SendString("Order not found")
 9623		}
 9624		var m map[string]interface{}
 9625		if err := json.Unmarshal(order, &m); err != nil {
 9626			return c.Status(500).SendString("failed to unmarshal order json: " + err.Error())
 9627		}
 9628		receipt, err := buildReceipt(m, piid)
 9629		if err != nil {
 9630			return c.Status(500).SendString("failed to build receipt: " + err.Error())
 9631		}
 9632		return c.Status(200).SendString(string(receipt))
 9633	})
 9634
 9635	r.Post("/create-payment-intent", func(c fiber.Ctx) error {
 9636		rawBody := c.Body()
 9637		if rawBody == nil {
 9638			log.Printf("Failed to read raw request body")
 9639			return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to read request body"})
 9640		}
 9641
 9642		var req struct {
 9643			Items []item `json:"items"`
 9644		}
 9645		if err := json.Unmarshal(rawBody, &req); err != nil {
 9646			log.Printf("Failed to parse JSON: %v", err)
 9647			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
 9648		}
 9649
 9650		if len(req.Items) == 0 {
 9651			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "No items in request"})
 9652		}
 9653
 9654		// Validate each item's amount against the server-side product
 9655		// catalog. Shared with the crypto path (see invoice.go) so a client
 9656		// is never believed about a price in one and checked in the other.
 9657		items := make([]pay.Item, 0, len(req.Items))
 9658		for _, it := range req.Items {
 9659			items = append(items, pay.Item{ID: it.ID, Amount: it.Amount})
 9660		}
 9661		total, err := validateOrderItems(items)
 9662		if err != nil {
 9663			log.Printf("Item validation failed: %v", err)
 9664			if errors.Is(err, errNeedShippingEmail) {
 9665				return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
 9666			}
 9667			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Item validation failed"})
 9668		}
 9669
 9670		if total < 50 {
 9671			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Order total must be at least $0.50"})
 9672		}
 9673
 9674		params := &stripe.PaymentIntentParams{
 9675			Amount:   stripe.Int64(total),
 9676			Currency: stripe.String(string(stripe.CurrencyUSD)),
 9677		}
 9678		pi, err := paymentintent.New(params)
 9679		if err != nil {
 9680			log.Printf("Failed to create PaymentIntent: %v", err)
 9681			return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
 9682		}
 9683
 9684		log.Printf("Created PaymentIntent %s for %d cents", pi.ID, total)
 9685		return c.Status(fiber.StatusOK).JSON(fiber.Map{
 9686			"clientSecret":   pi.ClientSecret,
 9687			"dpmCheckerLink": fmt.Sprintf("https://dashboard.stripe.com/settings/payment_methods/review?transaction_id=%s", pi.ID),
 9688		})
 9689	})
 9690
 9691	r.Post("/submit-order", func(c fiber.Ctx) error {
 9692		var requestData struct {
 9693			LocalStorageData map[string]interface{} `json:"localStorageData"`
 9694			PaymentIntentID  string                 `json:"paymentIntentId"`
 9695		}
 9696
 9697		if err := c.Bind().Body(&requestData); err != nil {
 9698			log.Println(err)
 9699			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request data"})
 9700		}
 9701
 9702		if !validPIID.MatchString(requestData.PaymentIntentID) {
 9703			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payment intent ID"})
 9704		}
 9705
 9706		log.Printf("Received payment intent ID: %s\n", requestData.PaymentIntentID)
 9707
 9708		paymentIntent, err := paymentintent.Get(requestData.PaymentIntentID, nil)
 9709		if err != nil {
 9710			log.Printf("Error retrieving payment intent: %v", err)
 9711			return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Unable to verify payment"})
 9712		}
 9713		if paymentIntent.Status != stripe.PaymentIntentStatusSucceeded {
 9714			log.Printf("Payment was not successful, status: %s", paymentIntent.Status)
 9715			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Payment not successful"})
 9716		}
 9717
 9718		ordersDir := "./orders"
 9719		if err := os.MkdirAll(ordersDir, 0o750); err != nil {
 9720			log.Printf("Error creating orders directory: %v", err)
 9721			return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Unable to save order"})
 9722		}
 9723
 9724		filePath := filepath.Join(ordersDir, fmt.Sprintf("%s.json", requestData.PaymentIntentID))
 9725
 9726		// Idempotency: if the order file already exists, don't overwrite or reprint
 9727		if _, err := os.Stat(filePath); err == nil {
 9728			log.Printf("Order %s already exists, skipping duplicate submission", requestData.PaymentIntentID)
 9729			return c.Status(fiber.StatusOK).JSON(fiber.Map{"message": "Order already submitted"})
 9730		}
 9731
 9732		// Include the verified Stripe amount alongside the client-supplied data
 9733		orderData := map[string]interface{}{
 9734			"clientData":    requestData.LocalStorageData,
 9735			"verifiedCents": paymentIntent.Amount,
 9736			"currency":      string(paymentIntent.Currency),
 9737			"stripeStatus":  string(paymentIntent.Status),
 9738			"submittedAt":   time.Now().Format(time.RFC3339),
 9739		}
 9740
 9741		data, err := json.MarshalIndent(orderData, "", "  ")
 9742		if err != nil {
 9743			log.Printf("Error marshaling data to json: %v", err)
 9744			return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Unable to save order"})
 9745		}
 9746		if err := os.WriteFile(filePath, data, 0o600); err != nil {
 9747			log.Printf("Error writing data to file: %v", err)
 9748			return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Unable to save order"})
 9749		}
 9750
 9751		// ---- Print the receipt via CUPS and email the confirmation, off the
 9752		// request so the response is snappy
 9753		paid := paymentIntent.Amount
 9754		go func(pid string, local map[string]interface{}) {
 9755			receipt, err := buildReceipt(local, pid)
 9756			if err != nil {
 9757				log.Printf("build receipt failed: %v", err)
 9758				return
 9759			}
 9760			if err := sendToCUPS(receipt, "Order "+pid); err != nil {
 9761				log.Printf("print failed: %v", err)
 9762				_ = 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
 9763			}
 9764			mailOrder(ordersDir, pid, dollars(paid)+" by card", local)
 9765		}(requestData.PaymentIntentID, requestData.LocalStorageData)
 9766
 9767		return c.Status(fiber.StatusOK).JSON(fiber.Map{"message": "Order submitted successfully"})
 9768	})
 9769
 9770	/*
 9771			r.Post("/reprint/:pid", func(c fiber.Ctx) error {
 9772		    pid := c.Params("pid")
 9773		    b, err := os.ReadFile(filepath.Join("./orders", pid+".json"))
 9774		    if err != nil { return c.Status(404).SendString("not found") }
 9775		    var m map[string]interface{}
 9776		    if err := json.Unmarshal(b, &m); err != nil { return c.Status(500).SendString(err.Error()) }
 9777		    receipt, err := buildReceipt(m, pid)
 9778		    if err != nil { return c.Status(500).SendString(err.Error()) }
 9779		    if err := sendToCUPS(receipt, "Order "+pid); err != nil {
 9780		        return c.Status(500).SendString(err.Error())
 9781		    }
 9782		    return c.SendStatus(204)
 9783		})
 9784	*/
 9785}
 9786
 9787func buildReceipt(local map[string]interface{}, paymentIntentID string) ([]byte, error) {
 9788	// Pretty JSON body from what you already persisted
 9789	body, err := json.MarshalIndent(local, "", "  ")
 9790	if err != nil {
 9791		return nil, err
 9792	}
 9793	// Simple text receipt header
 9794	ts := time.Now().Format("2006-01-02 15:04:05")
 9795	hdr := fmt.Sprintf(
 9796		"==================== ORDER ====================\n"+
 9797			"PaymentIntent: %s\nTime: %s\n===============================================\n\n",
 9798		paymentIntentID, ts,
 9799	)
 9800	// Footer (optional)
 9801	ftr := "\n\n---------------------- END ---------------------\n"
 9802	receipt := append([]byte(hdr), body...)
 9803	receipt = append(receipt, []byte(ftr)...)
 9804	return receipt, nil
 9805}
 9806
 9807// serverPriceCents looks up a product's price from the server-side catalog by part number.
 9808func serverPriceCents(partno string) (int64, error) {
 9809	allproductsMu.RLock()
 9810	prods := allproducts
 9811	allproductsMu.RUnlock()
 9812	for _, prod := range prods {
 9813		if prod.Partno == partno {
 9814			return parsePriceCents(prod.Price), nil
 9815		}
 9816	}
 9817	return 0, fmt.Errorf("product %q not found in catalog", partno)
 9818}
 9819
 9820// parsePriceCents converts a price string like "$1.23" or "1.23" to cents.
 9821func parsePriceCents(s string) int64 {
 9822	if s == "" {
 9823		return 0
 9824	}
 9825	s = strings.TrimPrefix(s, "$")
 9826	f, err := strconv.ParseFloat(s, 64)
 9827	if err != nil {
 9828		return 0
 9829	}
 9830	if f < 0 {
 9831		return -int64(-f*100 + 0.5)
 9832	}
 9833	return int64(f*100 + 0.5)
 9834}
 9835
 9836// validateItemAmount parses a client item ID ("partno X qty"), looks up the
 9837// server-side price, computes the expected total, and returns it. If the
 9838// client-supplied amount doesn't match, an error is returned.
 9839func validateItemAmount(itemID string, clientAmount int64) (int64, error) {
 9840	// Parse "partno X qty"
 9841	parts := strings.SplitN(itemID, " X ", 2)
 9842	if len(parts) != 2 {
 9843		return 0, fmt.Errorf("unexpected item ID format: %q", itemID)
 9844	}
 9845	partno := parts[0]
 9846	qty, err := strconv.Atoi(parts[1])
 9847	if err != nil || qty <= 0 {
 9848		return 0, fmt.Errorf("invalid quantity in item ID %q", itemID)
 9849	}
 9850
 9851	unitCents, err := serverPriceCents(partno)
 9852	if err != nil {
 9853		return 0, err
 9854	}
 9855	expected := unitCents * int64(qty)
 9856	if expected != clientAmount {
 9857		return 0, fmt.Errorf("amount mismatch for %q: client sent %d cents, server expects %d cents", partno, clientAmount, expected)
 9858	}
 9859	return expected, nil
 9860}
 9861
 9862// escape for inclusion inside *double quotes* in a bash command string
 9863func bashEscapeDoubleQuoted(s string) string {
 9864	s = strings.ReplaceAll(s, `\`, `\\`)
 9865	s = strings.ReplaceAll(s, `"`, `\"`)
 9866	s = strings.ReplaceAll(s, "$", `\$`)
 9867	s = strings.ReplaceAll(s, "`", "\\`")
 9868	return s
 9869}
 9870
 9871func sendToCUPS(receipt []byte, title string) error {
 9872	if title == "" {
 9873		title = "Order"
 9874	}
 9875	var cmd strings.Builder
 9876	cmd.WriteString("lp")
 9877
 9878	if f.PrinterName != "" {
 9879		cmd.WriteString(` -d "`)
 9880		cmd.WriteString(bashEscapeDoubleQuoted(f.PrinterName))
 9881		cmd.WriteString(`"`)
 9882	}
 9883
 9884	cmd.WriteString(` -t "`)
 9885	cmd.WriteString(bashEscapeDoubleQuoted(title))
 9886	cmd.WriteString(`"`)
 9887
 9888	if f.CupsOptions != "" {
 9889		for _, opt := range strings.Split(f.CupsOptions, ",") {
 9890			opt = strings.TrimSpace(opt)
 9891			if opt == "" {
 9892				continue
 9893			}
 9894			cmd.WriteString(` -o "`)
 9895			cmd.WriteString(bashEscapeDoubleQuoted(opt))
 9896			cmd.WriteString(`"`)
 9897		}
 9898	}
 9899
 9900	full := fmt.Sprintf(`bash -lc %q`, cmd.String())
 9901
 9902	_, err := script.Echo(string(receipt)).Exec(full).Stdout()
 9903	if err != nil {
 9904		return fmt.Errorf("lp failed: %v", err)
 9905	}
 9906	return nil
 9907}
 9908
 9909
 9910// ===== pkg/web/ordermail.go =====
 9911package web
 9912
 9913// pkg/web/ordermail.go β€” the order confirmation email.
 9914//
 9915// Sent when an order is PAID, from the same two places the receipt prints:
 9916// /submit-order for a card, and the first poll that finds an invoice settled
 9917// for coin. To the customer, at the address on the shipping line, with a
 9918// blind copy to the store β€” so the store's inbox holds every order, as the
 9919// printer tray does.
 9920//
 9921// It is handed to the system's sendmail (SENDMAIL), once per recipient, and
 9922// the mail server takes it from there: queueing, retries, signing, the relay.
 9923// The store holds no mail credential; the sendmail command's own
 9924// configuration does. Once per recipient because mox's sendmail takes one,
 9925// and because the store's copy is a blind one only if it is a separate
 9926// delivery: a Bcc header handed to it would be sent on as it is.
 9927//
 9928// The customer's email never goes to Stripe. It travels in the shipping
 9929// line, as the address does, and only this server reads it.
 9930//
 9931// Off unless MAILFROM and SENDMAIL are set, so a deployment that has not
 9932// configured mail sends none rather than failing every order.
 9933
 9934import (
 9935	"bytes"
 9936	"context"
 9937	"crypto/rand"
 9938	"encoding/hex"
 9939	"errors"
 9940	"fmt"
 9941	"log"
 9942	"mime"
 9943	"mime/quotedprintable"
 9944	"net/mail"
 9945	"os"
 9946	"os/exec"
 9947	"path/filepath"
 9948	"strconv"
 9949	"strings"
 9950	"time"
 9951
 9952	"github.com/0magnet/m2/pkg/storepage"
 9953)
 9954
 9955// orderLine is one line of a paid order, read back from what was stored.
 9956type orderLine struct {
 9957	ID    string
 9958	Qty   int
 9959	Cents int64
 9960}
 9961
 9962// orderLines reads the lines out of an order's client data: "cartItems"
 9963// for a card order (the website's and the terminal's saved cart alike) and
 9964// "items" for a coin order (completeInvoice). Numbers arrive as JSON's
 9965// float64, or as int64 when the map was built here.
 9966func orderLines(local map[string]any) []orderLine {
 9967	raw, _ := local["cartItems"].([]any)
 9968	if raw == nil {
 9969		raw, _ = local["items"].([]any)
 9970	}
 9971	if raw == nil {
 9972		if ms, ok := local["items"].([]map[string]any); ok {
 9973			for _, m := range ms {
 9974				raw = append(raw, m)
 9975			}
 9976		}
 9977	}
 9978	var out []orderLine
 9979	for _, r := range raw {
 9980		m, ok := r.(map[string]any)
 9981		if !ok {
 9982			continue
 9983		}
 9984		id, _ := m["id"].(string)
 9985		if id == "" {
 9986			continue
 9987		}
 9988		l := orderLine{ID: id, Qty: int(num(m["quantity"])), Cents: int64(num(m["amount"]))}
 9989		// A coin order's ids carry the quantity, "partno X qty".
 9990		if p, q, ok := strings.Cut(id, " X "); ok && !storepage.IsShippingID(id) {
 9991			if n, err := strconv.Atoi(q); err == nil {
 9992				l.ID, l.Qty = p, n
 9993			}
 9994		}
 9995		if l.Qty <= 0 {
 9996			l.Qty = 1
 9997		}
 9998		out = append(out, l)
 9999	}
10000	return out
10001}
10002
10003func num(v any) float64 {
10004	switch n := v.(type) {
10005	case float64:
10006		return n
10007	case int:
10008		return float64(n)
10009	case int64:
10010		return float64(n)
10011	}
10012	return 0
10013}
10014
10015// shippingOf is the order's shipping line, read back.
10016func shippingOf(lines []orderLine) (storepage.Shipping, bool) {
10017	for _, l := range lines {
10018		if storepage.IsShippingID(l.ID) {
10019			return storepage.ParseShipping(l.ID)
10020		}
10021	}
10022	return storepage.Shipping{}, false
10023}
10024
10025// productName is the catalog's name for a part, or "" when it has none.
10026func productName(partno string) string {
10027	allproductsMu.RLock()
10028	defer allproductsMu.RUnlock()
10029	for _, p := range allproducts {
10030		if p.Partno == partno {
10031			return p.Name
10032		}
10033	}
10034	return ""
10035}
10036
10037func dollars(c int64) string { return fmt.Sprintf("$%d.%02d", c/100, c%100) }
10038
10039// orderMailBody is the confirmation's text. paid says how, in a line:
10040// "$12.34 by card", or the coin amount and transaction.
10041func orderMailBody(site, orderID, paid string, lines []orderLine) string {
10042	var b strings.Builder
10043	fmt.Fprintf(&b, "Thank you for your order from %s.\n\n", site)
10044	fmt.Fprintf(&b, "Order:  %s\n", orderID)
10045	fmt.Fprintf(&b, "Paid:   %s\n\n", paid)
10046	var total int64
10047	for _, l := range lines {
10048		total += l.Cents
10049		if storepage.IsShippingID(l.ID) {
10050			fmt.Fprintf(&b, "        %-44s %9s\n", "Shipping", dollars(l.Cents))
10051			continue
10052		}
10053		desc := l.ID
10054		if n := productName(l.ID); n != "" {
10055			desc += "  " + n
10056		}
10057		if r := []rune(desc); len(r) > 44 {
10058			desc = string(r[:43]) + "…"
10059		}
10060		fmt.Fprintf(&b, "  %3d x %-44s %9s\n", l.Qty, desc, dollars(l.Cents))
10061	}
10062	fmt.Fprintf(&b, "        %44s %9s\n\n", "Total", dollars(total))
10063	if sh, ok := shippingOf(lines); ok {
10064		b.WriteString("Ships to:\n")
10065		for _, s := range []string{
10066			sh.Name, sh.Address,
10067			strings.TrimSpace(strings.Join([]string{sh.City + ",", sh.State, sh.Zip}, " ")),
10068			sh.Country, sh.Phone,
10069		} {
10070			if s = strings.Trim(s, " ,"); s != "" {
10071				b.WriteString("  " + s + "\n")
10072			}
10073		}
10074		b.WriteString("\n")
10075	}
10076	b.WriteString("Questions about this order? Reply to this email.\n")
10077	return b.String()
10078}
10079
10080// mailOrder sends the confirmation for a paid order, and leaves a marker
10081// beside the order when it cannot: the money arrived, so a mail failure is
10082// logged and marked, never allowed to lose the order.
10083func mailOrder(ordersDir, orderID, paid string, local map[string]any) {
10084	if f.MailFrom == "" || f.Sendmail == "" {
10085		return
10086	}
10087	lines := orderLines(local)
10088	to := ""
10089	name := ""
10090	if sh, ok := shippingOf(lines); ok && storepage.ValidEmail(sh.Email) {
10091		to, name = sh.Email, sh.Name
10092	}
10093	msg, rcpts, err := buildOrderMail(f.Sitedomain, orderID, paid, lines, to, name)
10094	if err == nil {
10095		err = sendMail(msg, rcpts)
10096	}
10097	if err != nil {
10098		log.Printf("order %s: confirmation email failed: %v", orderID, err)
10099		_ = os.WriteFile(filepath.Join(ordersDir, orderID+".mail_failed"), []byte(err.Error()), 0o600) //nolint:errcheck,gosec // best-effort marker; the failure is logged
10100		return
10101	}
10102	log.Printf("order %s: confirmation emailed (%d recipients)", orderID, len(rcpts))
10103}
10104
10105// buildOrderMail composes the message and who it goes to. With no customer
10106// address the store's copy is the only one, and its subject says so.
10107func buildOrderMail(site, orderID, paid string, lines []orderLine, to, name string) ([]byte, []string, error) {
10108	var rcpts []string
10109	if to != "" {
10110		rcpts = append(rcpts, to)
10111	}
10112	if f.MailBcc != "" {
10113		rcpts = append(rcpts, f.MailBcc)
10114	}
10115	if len(rcpts) == 0 {
10116		return nil, nil, fmt.Errorf("no recipient: the order has no email and MAILBCC is unset")
10117	}
10118	subject := fmt.Sprintf("Your order from %s (%s)", site, orderID)
10119	toHeader := (&mail.Address{Name: name, Address: to}).String()
10120	if to == "" {
10121		subject = "[no customer email] Order " + orderID
10122		toHeader = (&mail.Address{Address: f.MailBcc}).String()
10123	}
10124	from := (&mail.Address{Name: site, Address: f.MailFrom}).String()
10125
10126	var body bytes.Buffer
10127	qp := quotedprintable.NewWriter(&body)
10128	if _, err := qp.Write([]byte(strings.ReplaceAll(orderMailBody(site, orderID, paid, lines), "\n", "\r\n"))); err != nil {
10129		return nil, nil, err
10130	}
10131	if err := qp.Close(); err != nil {
10132		return nil, nil, err
10133	}
10134
10135	var m bytes.Buffer
10136	hdr := func(k, v string) { m.WriteString(k + ": " + v + "\r\n") }
10137	hdr("From", from)
10138	hdr("To", toHeader)
10139	hdr("Subject", mime.QEncoding.Encode("utf-8", subject))
10140	hdr("Date", time.Now().Format(time.RFC1123Z))
10141	hdr("Message-ID", "<"+orderID+"."+randHex(6)+"@"+domainOf(f.MailFrom)+">")
10142	hdr("MIME-Version", "1.0")
10143	hdr("Content-Type", "text/plain; charset=utf-8")
10144	hdr("Content-Transfer-Encoding", "quoted-printable")
10145	m.WriteString("\r\n")
10146	m.Write(body.Bytes())
10147	return m.Bytes(), rcpts, nil
10148}
10149
10150func domainOf(addr string) string {
10151	if _, d, ok := strings.Cut(addr, "@"); ok {
10152		return d
10153	}
10154	return "localhost"
10155}
10156
10157func randHex(n int) string {
10158	b := make([]byte, n)
10159	if _, err := rand.Read(b); err != nil {
10160		return strconv.FormatInt(time.Now().UnixNano(), 16)
10161	}
10162	return hex.EncodeToString(b)
10163}
10164
10165// sendMail hands msg to the sendmail command, once for each recipient.
10166// Every recipient is tried: the store's copy still goes when the customer's
10167// address is refused.
10168func sendMail(msg []byte, rcpts []string) error {
10169	argv := strings.Fields(f.Sendmail)
10170	var errs []error
10171	for _, r := range rcpts {
10172		ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
10173		cmd := exec.CommandContext(ctx, argv[0], append(argv[1:len(argv):len(argv)], r)...) //nolint:gosec // the command is the operator's configuration; r passed ValidEmail or is MAILBCC
10174		cmd.Stdin = bytes.NewReader(msg)
10175		out, err := cmd.CombinedOutput()
10176		cancel()
10177		if err != nil {
10178			errs = append(errs, fmt.Errorf("%s: %w: %s", r, err, strings.TrimSpace(string(out))))
10179		}
10180	}
10181	return errors.Join(errs...)
10182}
10183
10184
10185// ===== pkg/web/ordermail_test.go =====
10186package web
10187
10188import (
10189	"io"
10190	"mime/quotedprintable"
10191	"os"
10192	"path/filepath"
10193	"strings"
10194	"testing"
10195
10196	"github.com/0magnet/m2/pkg/storepage"
10197)
10198
10199// fakeSendmail is a sendmail that keeps what it is given: one file per
10200// recipient, named for it, holding the message. fail makes it refuse that
10201// recipient as a real one would.
10202func fakeSendmail(t *testing.T, fail string) (cmd, dir string) {
10203	t.Helper()
10204	dir = t.TempDir()
10205	script := filepath.Join(dir, "sendmail")
10206	body := "#!/bin/sh\n" +
10207		"[ \"$2\" = '" + fail + "' ] && { echo refused >&2; exit 1; }\n" +
10208		"cat > \"" + dir + "/to-$2\"\n"
10209	if err := os.WriteFile(script, []byte(body), 0o700); err != nil { //nolint:gosec // a test script must be executable
10210		t.Fatal(err)
10211	}
10212	return script + " -i", dir
10213}
10214
10215func withMailConfig(t *testing.T, sendmail string) {
10216	t.Helper()
10217	saved := [4]string{f.MailFrom, f.MailBcc, f.Sendmail, f.Sitedomain}
10218	f.MailFrom, f.MailBcc, f.Sendmail, f.Sitedomain = "orders@shop.example", "inbox@shop.example", sendmail, "shop.example"
10219	t.Cleanup(func() { f.MailFrom, f.MailBcc, f.Sendmail, f.Sitedomain = saved[0], saved[1], saved[2], saved[3] })
10220}
10221
10222func delivered(t *testing.T, dir, rcpt string) string {
10223	t.Helper()
10224	b, err := os.ReadFile(filepath.Join(dir, "to-"+rcpt)) //nolint:gosec // a test temp dir
10225	if err != nil {
10226		t.Fatalf("nothing was handed over for %s: %v", rcpt, err)
10227	}
10228	return string(b)
10229}
10230
10231// A paid card order goes to the customer, and separately to the store: the
10232// store's copy is blind because it is its own delivery, and no header names it.
10233func TestMailOrderReachesTheCustomerAndTheStore(t *testing.T) {
10234	cmd, dir := fakeSendmail(t, "")
10235	withMailConfig(t, cmd)
10236	ship := storepage.Shipping{
10237		Name: "Ada Buyer", Address: "1 Main St", City: "Dallas", State: "TX", Zip: "75001",
10238		Country: storepage.DefaultCountry, Email: "ada@example.org",
10239	}
10240	local := map[string]any{"cartItems": []any{
10241		map[string]any{"id": "PN-1", "amount": float64(300), "quantity": float64(2)},
10242		map[string]any{"id": ship.ID(), "amount": float64(700), "quantity": float64(1)},
10243	}}
10244	mailOrder(dir, "pi_123", "$10.00 by card", local)
10245
10246	msg := delivered(t, dir, "ada@example.org")
10247	if delivered(t, dir, "inbox@shop.example") != msg {
10248		t.Error("the store's copy differs from the customer's")
10249	}
10250	head, body, _ := strings.Cut(msg, "\r\n\r\n")
10251	if strings.Contains(head, "inbox@shop.example") {
10252		t.Errorf("the store's copy is named in the headers:\n%s", head)
10253	}
10254	for _, want := range []string{"From: \"shop.example\" <orders@shop.example>", "To: \"Ada Buyer\" <ada@example.org>", "pi_123"} {
10255		if !strings.Contains(head, want) {
10256			t.Errorf("headers lack %q:\n%s", want, head)
10257		}
10258	}
10259	text, err := io.ReadAll(quotedprintable.NewReader(strings.NewReader(body)))
10260	if err != nil {
10261		t.Fatal(err)
10262	}
10263	for _, want := range []string{"2 x PN-1", "$3.00", "Shipping", "$7.00", "$10.00", "Ada Buyer", "Dallas, TX 75001"} {
10264		if !strings.Contains(string(text), want) {
10265			t.Errorf("body lacks %q:\n%s", want, text)
10266		}
10267	}
10268}
10269
10270// An order with no email still reaches the store, saying so.
10271func TestMailOrderWithoutACustomerEmailGoesToTheStore(t *testing.T) {
10272	cmd, dir := fakeSendmail(t, "")
10273	withMailConfig(t, cmd)
10274	local := map[string]any{"items": []map[string]any{{"id": "PN-1 X 3", "amount": int64(900), "quantity": 1}}}
10275	mailOrder(dir, "SKY-abc", "1 SKY ($9.00)", local)
10276	msg := delivered(t, dir, "inbox@shop.example")
10277	if !strings.Contains(msg, "[no customer email]") || !strings.Contains(msg, "3 x PN-1") {
10278		t.Errorf("store copy:\n%s", msg)
10279	}
10280}
10281
10282// A refused customer address does not cost the store its copy, and leaves
10283// a marker beside the order.
10284func TestMailOrderMarksAFailureAndStillCopiesTheStore(t *testing.T) {
10285	cmd, dir := fakeSendmail(t, "ada@example.org")
10286	withMailConfig(t, cmd)
10287	ship := storepage.Shipping{Name: "A", Email: "ada@example.org"}
10288	mailOrder(dir, "pi_9", "$1.00 by card", map[string]any{"cartItems": []any{
10289		map[string]any{"id": ship.ID(), "amount": float64(700), "quantity": float64(1)},
10290	}})
10291	delivered(t, dir, "inbox@shop.example")
10292	b, err := os.ReadFile(filepath.Join(dir, "pi_9.mail_failed")) //nolint:gosec // a test temp dir
10293	if err != nil || !strings.Contains(string(b), "refused") {
10294		t.Errorf("failure marker: %q, %v", b, err)
10295	}
10296}
10297
10298// Unconfigured, nothing is sent and nothing is marked.
10299func TestMailOrderOffByDefault(t *testing.T) {
10300	withMailConfig(t, "")
10301	dir := t.TempDir()
10302	mailOrder(dir, "pi_0", "", map[string]any{})
10303	if ents, _ := os.ReadDir(dir); len(ents) != 0 { //nolint:errcheck // an unreadable dir fails the check anyway
10304		t.Errorf("unconfigured mail left %d files", len(ents))
10305	}
10306}
10307
10308
10309// ===== pkg/web/other.go =====
10310// Package web pkg/web/other.go β€” site-specific drop-in routes (not
10311// committed). Registers via the extraRoutes registry in server.go;
10312// deleting this file removes these routes with no other code changes.
10313// See other.go.example.
10314package web
10315
10316import (
10317	"bytes"
10318	"fmt"
10319	"log"
10320	"strconv"
10321	"strings"
10322
10323	"github.com/gofiber/fiber/v3"
10324)
10325
10326func init() {
10327	extraRoutes = append(extraRoutes, handleOthers)
10328	// The software page is the only one here worth crawling; see demospage.
10329	// /demos is kept as the older spelling of the same page.
10330	extraSitemapURLs = append(extraSitemapURLs, "/software")
10331	extraNavLinks = append(extraNavLinks, NavLink{
10332		Title: "Software",
10333		Href:  "/software",
10334		Desc:  "programs written here, running in your browser",
10335	})
10336}
10337
10338func handleOthers(r *fiber.App) {
10339	r.Get("/coffee", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusTeapot) })
10340	r.Get("/clock", clock)
10341	r.Get("/attractors", attractorspage)
10342	r.Get("/COVID", covidpage)
10343	r.Get("/test", testpage)
10344	r.Get("/software", demospage)
10345	r.Get("/software/", demospage)
10346	// The page was published as /demos first and is linked from the READMEs
10347	// of several repositories, so that spelling keeps working.
10348	r.Get("/demos", demospage)
10349	r.Get("/demos/", demospage)
10350}
10351
10352func clock(c fiber.Ctx) error {
10353	c.Set("Content-Type", "text/html;charset=utf-8")
10354	_, err := c.Status(fiber.StatusOK).Write([]byte(mustReadFileToString("content/clock.html")))
10355	return err
10356}
10357
10358func covidpage(c fiber.Ctx) error {
10359	tmpl, err := mainTmpl()
10360	if err != nil {
10361		msg := fmt.Sprintf("Error parse html template: %v", err)
10362		log.Println(msg)
10363		return c.Status(fiber.StatusInternalServerError).SendString(msg)
10364	}
10365	tmpl0, err := tmpl.Clone()
10366	if err != nil {
10367		msg := fmt.Sprintf("Error cloning template: %v", err)
10368		log.Println(msg)
10369		return c.Status(fiber.StatusInternalServerError).SendString(msg)
10370	}
10371	_, err = tmpl0.New("main").Parse(mustReadFileToString("content/mementomori.html"))
10372	if err != nil {
10373		msg := fmt.Sprintf("Error parsing main template: %v", err)
10374		log.Println(msg)
10375		return c.Status(fiber.StatusInternalServerError).SendString(msg)
10376	}
10377	tmpl = tmpl0
10378	log.Println(c.Get("User-Agent"))
10379	c.Set("Content-Type", "text/html;charset=utf-8")
10380	h1 := pageMeta(c, htmlPageTemplateData)
10381	h1.Page = "hidden"
10382	h1.MetaDesc = "The COVID  ΜΆvΜΆaΜΆcΜΆcΜΆiΜΆnΜΆeΜΆ bioweapon injection genocide and the new dark age of humanity"
10383	//		h1.Mobile = strings.Contains(strings.ToLower(c.Get("User-Agent")), "mobile")
10384	tmplData := map[string]interface{}{
10385		"Page":  h1,
10386		"Prods": allproducts,
10387	}
10388	var result bytes.Buffer
10389	err = tmpl.Execute(&result, tmplData)
10390	if err != nil {
10391		msg := fmt.Sprintf("Error executing template: %v", err)
10392		log.Println(msg)
10393		return c.Status(fiber.StatusInternalServerError).SendString(msg)
10394	}
10395	_, err = c.Status(fiber.StatusOK).Write(collapseNewlines.ReplaceAll(result.Bytes(), []byte("\n")))
10396	return err
10397}
10398
10399// attractorspage renders a chromeless fullscreen page for the
10400// strange-attractor visualizer (no header, footer, cart, or store
10401// nav). Reuses the existing stl2 wasm β€” its URL-path dispatcher
10402// sees "/attractors" and falls into the default branch which
10403// invokes attractor.Run(). Loads the TinyGo or stdlib wasm based
10404// on f.UseTinygo.
10405func attractorspage(c fiber.Ctx) error {
10406	// Newest build wins, with the shim that matches it β€” the same rule the
10407	// template pages use. Hardcoding "-tiny.wasm" here meant this page served
10408	// whatever the last tinygo run produced even when the Go build was newer,
10409	// and paired it with the site-global shim rather than its own.
10410	pick, ok := pickWasm("stl2")
10411	if !ok {
10412		return c.Status(fiber.StatusNotFound).SendString("no stl2 wasm build available")
10413	}
10414	wasmFile := pick.File
10415	html := fmt.Sprintf(`<!DOCTYPE html>
10416<html lang="en">
10417<head>
10418<meta charset="utf-8">
10419<meta name="viewport" content="width=device-width, initial-scale=1">
10420<title>Strange Attractors β€” %s</title>
10421<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.">
10422<meta name="robots" content="index, follow">
10423<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>
10424<script src="%s"></script>
10425<script>
10426if (!WebAssembly.instantiateStreaming) {
10427  WebAssembly.instantiateStreaming = async (resp, importObject) => {
10428    const source = await (await resp).arrayBuffer();
10429    return await WebAssembly.instantiate(source, importObject);
10430  };
10431}
10432const go = new Go();
10433WebAssembly.instantiateStreaming(fetch("/%s"), go.importObject).then((result) => {
10434  go.run(result.instance);
10435}).catch((err) => { console.error("Failed to run WASM:", err); });
10436</script>
10437</head>
10438<body>
10439<canvas id="gocanvas"></canvas>
10440</body>
10441</html>`, f.Sitelongname, f.WasmExecPath, wasmFile)
10442	c.Set("Content-Type", "text/html; charset=utf-8")
10443	return c.Status(fiber.StatusOK).SendString(html)
10444}
10445
10446// ---------------------------------------------------------------------------
10447// /demos β€” the live demos hosted on magnetosphere.net subdomains.
10448//
10449// Every one of these projects publishes its demo from GitHub Pages under a
10450// CNAME of this domain (chaosrack.magnetosphere.net and so on), and until now
10451// nothing on the web linked to them. A page nobody links to is a page a
10452// crawler reaches only through the certificate transparency logs, if at all β€”
10453// so the subdomains existed but were effectively unfindable.
10454//
10455// This is the index that fixes that. It is a real page at a real URL rather
10456// than another anchor on the front page, for two reasons: an anchor section
10457// shares the front page's <title> and description, and this content is not
10458// about electronic surplus; and the front page hides every non-target section
10459// with CSS, so the text would be in the markup but never rendered to a reader.
10460//
10461// It lives in the drop-in rather than in the m2 repo because it is specific to
10462// this deployment β€” no other m2 site has these subdomains. The one piece that
10463// did go upstream is extraSitemapURLs, which is the generic half: a drop-in
10464// route that wants to be crawled needs to be in the sitemap, and there was no
10465// way to say so.
10466// ---------------------------------------------------------------------------
10467
10468// demoAlt is a second build of the same demo, linked beside the first. Several
10469// of these ship a TinyGo build and a standard-Go build of the same program;
10470// the difference is size against completeness, and both are worth reaching.
10471type demoAlt struct {
10472	Label string
10473	Path  string
10474}
10475
10476// demoSite is one demo: where it runs, where its source is, and one sentence
10477// saying what it actually does. The sentence is the part that matters β€” it is
10478// what a search result shows and the only description of these subdomains that
10479// exists anywhere.
10480type demoSite struct {
10481	Name string
10482	Host string
10483	Repo string
10484	What string
10485	Alts []demoAlt
10486
10487	// Pending holds a demo back until its subdomain actually answers over
10488	// HTTPS. GitHub issues a Pages certificate some time after the DNS lands,
10489	// and until it does the link is a TLS error rather than a page.
10490	//
10491	// The entry stays here rather than being deleted and rewritten later,
10492	// because the description is the part that took the thought. Clearing this
10493	// flag is the whole of the work when the certificate arrives β€” and a page
10494	// whose entire purpose is to be crawled must not be the thing that teaches
10495	// a crawler these hostnames are broken.
10496	Pending bool
10497}
10498
10499// demoGroup is a heading over a run of demos.
10500type demoGroup struct {
10501	Title string
10502	Note  string
10503	Sites []demoSite
10504}
10505
10506var demoGroups = []demoGroup{{
10507	Title: "Applications",
10508	Note:  "Whole programs, running in the tab. Nothing is uploaded and nothing runs on a server.",
10509	Sites: []demoSite{
10510		{
10511			Name: "chaosrack",
10512			Host: "chaosrack.magnetosphere.net",
10513			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.",
10514		},
10515		{
10516			Name: "shipyard",
10517			Host: "shipyard.magnetosphere.net",
10518			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.",
10519		},
10520		{
10521			Name: "shipwright",
10522			Host: "shipwright.magnetosphere.net",
10523			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.",
10524		},
10525		{
10526			Name: "netscrape",
10527			Host: "netscrape.magnetosphere.net",
10528			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.",
10529		},
10530		{
10531			Name: "desk",
10532			Host: "desk.magnetosphere.net",
10533			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.",
10534			Alts: []demoAlt{{Label: "standard Go build", Path: "/go/"}},
10535		},
10536		{
10537			Name: "websh",
10538			Host: "websh.magnetosphere.net",
10539			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.",
10540			Alts: []demoAlt{{Label: "standard Go build", Path: "/go/"}},
10541		},
10542		{
10543			Name: "dict",
10544			Host: "dict.magnetosphere.net",
10545			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.",
10546		},
10547		{
10548			Name: "pisano",
10549			Host: "pisano.magnetosphere.net",
10550			What: "Designs made by reducing an integer sequence modulo m β€” the Pisano period made visible β€” with a shell to drive the renderer.",
10551			Alts: []demoAlt{{Label: "standard Go build", Path: "/go/"}},
10552		},
10553	},
10554}, {
10555	Title: "Libraries ported to Go and WebAssembly",
10556	Note:  "JavaScript libraries reimplemented in Go, so a Go program in a tab can use them without a JavaScript dependency.",
10557	Sites: []demoSite{
10558		{
10559			Name: "xterm-go",
10560			Host: "xterm-go.magnetosphere.net",
10561			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.",
10562		},
10563		{
10564			Name: "winbox-go",
10565			Host: "winbox-go.magnetosphere.net",
10566			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.",
10567		},
10568		{
10569			Name: "cosmos-go",
10570			Host: "cosmos-go.magnetosphere.net",
10571			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.",
10572		},
10573		{
10574			Name: "tuiwasm",
10575			Host: "tuiwasm.magnetosphere.net",
10576			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.",
10577			Alts: []demoAlt{{Label: "TinyGo build", Path: "/tinygo/"}},
10578		},
10579	},
10580}, {
10581	Title: "Command-line tools ported to Go",
10582	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.",
10583	Sites: []demoSite{
10584		{
10585			Name: "plot-go",
10586			Host: "plot-go.magnetosphere.net",
10587			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.",
10588		},
10589		{
10590			Name: "img2txt-go",
10591			Host: "img2txt-go.magnetosphere.net",
10592			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.",
10593		},
10594		{
10595			Name: "ansifilter-go",
10596			Host: "ansifilter-go.magnetosphere.net",
10597			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.",
10598		},
10599		{
10600			Name: "calvin",
10601			Host: "calvin.magnetosphere.net",
10602			What: "Text converted to the Calvin S box-drawing font. Type in the box and watch the letters turn.",
10603		},
10604	},
10605}, {
10606	Title: "Interface libraries written here",
10607	Note:  "Not ports of anything β€” original libraries, each with a demo that is the library doing its one job.",
10608	Sites: []demoSite{
10609		{
10610			Name: "rack-go",
10611			Host: "rack-go.magnetosphere.net",
10612			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.",
10613		},
10614	},
10615}, {
10616	// Not written here: these are upstream projects this site's author is a
10617	// contributor to. They are on the software page because the page is the
10618	// honest answer to "what does whoever runs this store work on", and
10619	// leaving them off made that answer look smaller than it is.
10620	//
10621	// Repo is set on both β€” demoRepo defaults to github.com/0magnet/<Name>,
10622	// which is wrong for anything upstream.
10623	Title: "Upstream projects contributed to",
10624	Note:  "Not written here, and not WebAssembly. Listed because a good deal of the work behind this site went into them.",
10625	Sites: []demoSite{{
10626		Name: "skycoin",
10627		Host: "skycoin.magnetosphere.net",
10628		Repo: "https://github.com/skycoin/skycoin",
10629		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.",
10630	}, {
10631		Name: "skywire",
10632		Host: "theskywirenetwork.net",
10633		Repo: "https://github.com/skycoin/skywire",
10634		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.",
10635	}},
10636}}
10637
10638// demoRepo is the source URL for a demo. All of these live under the same
10639// GitHub account and the repository is named after the program.
10640func (d demoSite) demoRepo() string {
10641	if d.Repo != "" {
10642		return d.Repo
10643	}
10644	return "https://github.com/0magnet/" + d.Name
10645}
10646
10647// demosBody renders the page. It is plain markup in the vocabulary the rest of
10648// the site already uses β€” the α›­ table and the ❀ heading from the links page β€”
10649// so it inherits the stylesheet rather than carrying one of its own.
10650func demosBody() string {
10651	var b strings.Builder
10652	// A column of its own. main is full-bleed and centered, which is right for
10653	// the store front but runs prose to both edges of a wide window and puts the
10654	// last table column under the scrollbar.
10655	b.WriteString("<div style='max-width:1200px;margin:0 auto;padding:0 14px'>\n")
10656	b.WriteString("<h1>Software</h1>\n")
10657	b.WriteString("<p>Software written here, running in your browser. ")
10658	b.WriteString("Every one of these is Go compiled to WebAssembly, served from a subdomain of this site, ")
10659	b.WriteString("and open source. Nothing below needs an account, an install, or a round trip to a server.</p>\n")
10660
10661	for _, g := range demoGroups {
10662		// A group every one of whose demos is still waiting on a certificate
10663		// would render as a heading over nothing.
10664		if live(g.Sites) == 0 {
10665			continue
10666		}
10667		b.WriteString("<h2 class='❀'>" + g.Title + "</h2>\n")
10668		if g.Note != "" {
10669			b.WriteString("<p>" + g.Note + "</p>\n")
10670		}
10671		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")
10672		for _, d := range g.Sites {
10673			if d.Pending {
10674				continue
10675			}
10676			b.WriteString("<tr><td style='width:12%'><pre><a title='" + d.Host + "' href='https://" + d.Host + "/'>" + d.Name + "</a>")
10677			for _, a := range d.Alts {
10678				b.WriteString("<br><a title='" + d.Host + a.Path + "' href='https://" + d.Host + a.Path + "'>" + a.Label + "</a>")
10679			}
10680			b.WriteString("</pre></td>")
10681			b.WriteString("<td style='width:62%;padding-right:16px'><pre style='white-space:pre-wrap'>" + d.What + "</pre></td>")
10682			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")
10683		}
10684		b.WriteString("</tbody></table>\n")
10685	}
10686	b.WriteString(deepBody())
10687	b.WriteString("<p><a href='/'>Back to the store</a></p>\n")
10688	b.WriteString("</div>\n")
10689	return b.String()
10690}
10691
10692// demosLD is the structured data for the page: an ItemList of the demos, each
10693// a SoftwareApplication at its own URL.
10694//
10695// This is the half a crawler reads. The list gives every subdomain a name, a
10696// description and a URL in one document, which is the fastest way to get a set
10697// of never-before-linked hostnames into an index.
10698func demosLD(canonical string) string {
10699	var b strings.Builder
10700	b.WriteString("<script type='application/ld+json'>\n{\n")
10701	b.WriteString("\"@context\": \"https://schema.org\",\n")
10702	b.WriteString("\"@type\": \"ItemList\",\n")
10703	b.WriteString("\"name\": \"Demos\",\n")
10704	b.WriteString("\"url\": \"" + canonical + "\",\n")
10705	b.WriteString("\"itemListElement\": [\n")
10706	pos := 0
10707	for _, g := range demoGroups {
10708		for _, d := range g.Sites {
10709			if d.Pending {
10710				continue
10711			}
10712			if pos > 0 {
10713				b.WriteString(",\n")
10714			}
10715			pos++
10716			b.WriteString("{\"@type\": \"ListItem\", \"position\": " + strconv.Itoa(pos) + ", \"item\": {")
10717			b.WriteString("\"@type\": \"SoftwareApplication\", ")
10718			b.WriteString("\"name\": \"" + d.Name + "\", ")
10719			b.WriteString("\"url\": \"https://" + d.Host + "/\", ")
10720			b.WriteString("\"applicationCategory\": \"DeveloperApplication\", ")
10721			b.WriteString("\"operatingSystem\": \"Any (WebAssembly)\", ")
10722			b.WriteString("\"codeRepository\": \"" + d.demoRepo() + "\", ")
10723			b.WriteString("\"description\": \"" + jsonString(d.What) + "\"}}")
10724		}
10725	}
10726	b.WriteString("\n]\n}\n</script>\n")
10727	return b.String()
10728}
10729
10730// jsonString escapes the characters a description could plausibly contain and
10731// JSON cannot carry raw. The blurbs above are written by hand and hold none of
10732// them today; this is here so that editing one cannot silently produce invalid
10733// structured data, which a crawler drops without reporting.
10734func jsonString(s string) string {
10735	r := strings.NewReplacer(`\`, `\\`, `"`, `\"`, "\n", " ", "\t", " ")
10736	return r.Replace(s)
10737}
10738
10739// demospage serves /demos.
10740//
10741// It borrows the site chrome β€” head, header, footer β€” and replaces the "schema"
10742// partial with the ItemList above, rather than the front page's Organization
10743// block.
10744//
10745// It states WasmBinary as empty. That is the default now β€” a page opts in
10746// with wasmFor β€” but saying so here keeps the intent readable: this page is
10747// text, and a crawler meant to read it quickly should not be handed the STL
10748// viewer. Emptying the list is how the templates ask for that; overriding the
10749// "wasm" partial with an empty body does NOT work, because Go templates treat
10750// a whitespace-only body as no definition at all and leave the existing one
10751// standing.
10752func demospage(c fiber.Ctx) error {
10753	tmpl, err := mainTmpl()
10754	if err != nil {
10755		msg := fmt.Sprintf("Error parsing html template: %v", err)
10756		log.Println(msg)
10757		return c.Status(fiber.StatusInternalServerError).SendString(msg)
10758	}
10759	tmpl0, err := tmpl.Clone()
10760	if err != nil {
10761		msg := fmt.Sprintf("Error cloning template: %v", err)
10762		log.Println(msg)
10763		return c.Status(fiber.StatusInternalServerError).SendString(msg)
10764	}
10765	h1 := pageMeta(c, htmlPageTemplateData)
10766	h1.Page = "software"
10767	// One canonical URL for two routes. /demos was the first spelling and is
10768	// linked from several repository READMEs, so it still answers β€” but it
10769	// must not compete with /software for the same content, which is exactly
10770	// what a self-referencing canonical on each would set up.
10771	h1.Canonical = h1.BaseURL + "/software"
10772	h1.Title = "Software β€” Go and WebAssembly in the browser | " + f.Sitelongname
10773	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."
10774	h1.KeyWords = "Go, WebAssembly, wasm, TinyGo, browser demo, xterm.js port, WinBox port, cosmos.gl port, in-browser shell, strange attractor, libcaca img2txt, ansifilter"
10775	h1.WasmBinary = nil
10776
10777	for _, part := range []struct{ name, body string }{
10778		{"main", demosBody()},
10779		{"schema", demosLD(h1.Canonical)},
10780	} {
10781		if _, err := tmpl0.New(part.name).Parse(part.body); err != nil {
10782			msg := fmt.Sprintf("Error parsing %s template: %v", part.name, err)
10783			log.Println(msg)
10784			return c.Status(fiber.StatusInternalServerError).SendString(msg)
10785		}
10786	}
10787
10788	c.Set("Content-Type", "text/html;charset=utf-8")
10789	tmplData := map[string]interface{}{
10790		"Page":  h1,
10791		"Prods": allproducts,
10792	}
10793	var result bytes.Buffer
10794	if err := tmpl0.Execute(&result, tmplData); err != nil {
10795		msg := fmt.Sprintf("Error executing template: %v", err)
10796		log.Println(msg)
10797		return c.Status(fiber.StatusInternalServerError).SendString(msg)
10798	}
10799	_, err = c.Status(fiber.StatusOK).Write(collapseNewlines.ReplaceAll(result.Bytes(), []byte("\n")))
10800	return err
10801}
10802
10803// ---------------------------------------------------------------------------
10804// Deep links.
10805//
10806// Three of the demos take a parameter, so a link can open on a particular
10807// thing rather than on the front of the app. tuiwasm is the striking case: one
10808// wasm binary holds 31 separate programs, and without this the page links to
10809// the binary and every program inside it is invisible.
10810//
10811// This is the cheapest indexable surface here by a distance. Each of these is
10812// a distinct URL that renders distinct content, and the link text is the
10813// demo's own name β€” which is what someone searching for "terminal matrix
10814// animation" or "Langton's ant in the browser" actually types.
10815// ---------------------------------------------------------------------------
10816
10817// deepLink is one parameterized entry point: what to call it, what it opens,
10818// and the query that gets there.
10819type deepLink struct {
10820	Label string
10821	Desc  string
10822	Query string
10823}
10824
10825// deepGroup is a run of deep links into one demo.
10826type deepGroup struct {
10827	Host  string
10828	Title string
10829	Note  string
10830	Links []deepLink
10831}
10832
10833var deepGroups = []deepGroup{{
10834	Host:  "tuiwasm.magnetosphere.net",
10835	Title: "tuiwasm β€” 43 terminal-UI programs, one per link",
10836	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.",
10837	Links: []deepLink{
10838		{"aquarium", "fish swimming past swaying seaweed", "demo=aquarium"},
10839		{"aurora", "curtains of light, folding, with the creases where they turn", "demo=aurora"},
10840		{"boids", "flocking by separation, alignment and cohesion", "demo=boids"},
10841		{"bonsai", "a bonsai tree growing branch by branch", "demo=bonsai"},
10842		{"bounce", "the screensaver logo, and the wait for it to hit a corner", "demo=bounce"},
10843		{"boxes", "tcell's own boxes demo β€” random boxes, timed", "demo=boxes"},
10844		{"charts", "asciigraph β€” a line plot in cells", "demo=charts"},
10845		{"clock", "an analog clock, after aclock", "demo=clock"},
10846		{"colors", "tcell's own colors demo β€” boxes cycling through the palette", "demo=colors"},
10847		{"cube", "a rotating wireframe solid, shaded by depth", "demo=cube"},
10848		{"donut", "a lit torus with a z-buffer", "demo=donut"},
10849		{"fire", "a heat grid seeded with noise and cooled upward", "demo=fire"},
10850		{"fireworks", "shells that rise, burst and droop into willows", "demo=fireworks"},
10851		{"flow", "particles carried through a divergence-free curl-noise field", "demo=flow"},
10852		{"frost", "a crystal growing by diffusion-limited aggregation", "demo=frost"},
10853		{"julia", "a Julia set morphing as its parameter walks the cardioid", "demo=julia"},
10854		{"langton", "Langton's ants: chaos, then the highway", "demo=langton"},
10855		{"lavalamp", "wax that heats, rises, cools and sinks", "demo=lavalamp"},
10856		{"life", "Conway's life, colored by how long a cell has lived", "demo=life"},
10857		{"lightning", "a branching discharge: leader, return stroke, afterglow", "demo=lightning"},
10858		{"matrix", "falling columns of glyphs", "demo=matrix"},
10859		{"maze", "a maze carved by backtracking, then solved", "demo=maze"},
10860		{"metaballs", "blobs that bulge and merge as they approach", "demo=metaballs"},
10861		{"moire", "two drifting ripples interfering", "demo=moire"},
10862		{"parrot", "the party parrot, rolling once around the hue wheel", "demo=parrot"},
10863		{"pendulum", "double pendulums released together, shearing apart", "demo=pendulum"},
10864		{"physarum", "slime mold laying trails and building a transport network", "demo=physarum"},
10865		{"pipes", "pipes growing and turning, with correct elbows", "demo=pipes"},
10866		{"plasma", "summed sine waves of position, offset by time", "demo=plasma"},
10867		{"proxima", "Escape from Proxima 5 β€” gdamore's tcell space shooter", "demo=proxima"},
10868		{"proxima2", "Escape from Proxima 5 on tcell v2 β€” the same game, one major back", "demo=proxima2"},
10869		{"rain", "drops with depth, slant, streaks and splashes", "demo=rain"},
10870		{"reaction", "Gray-Scott reaction-diffusion: spots that divide into a labyrinth", "demo=reaction"},
10871		{"ripple", "a ripple tank: rain falling, rings meeting and interfering", "demo=ripple"},
10872		{"sand", "grains heaping at their angle of repose", "demo=sand"},
10873		{"snow", "flakes that sway, settle and drift into banks", "demo=snow"},
10874		{"starfield", "stars streaming past the viewer", "demo=starfield"},
10875		{"styles", "lipgloss β€” borders, color, alignment", "demo=styles"},
10876		{"tables", "go-pretty β€” the wasm compatibility matrix", "demo=tables"},
10877		{"tunnel", "flying down a textured tube", "demo=tunnel"},
10878		{"unicode", "tcell's own unicode demo β€” wide, combining and emoji glyphs", "demo=unicode"},
10879		{"widgets", "tview β€” a flexbox of lists, tables and text views on tcell v2", "demo=widgets"},
10880		{"wolfram", "elementary cellular automata scrolling upward - 30, 90, 110", "demo=wolfram"},
10881	},
10882}, {
10883	Host:  "plot-go.magnetosphere.net",
10884	Title: "plot-go β€” the same counters through different pipelines",
10885	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.",
10886	Links: []deepLink{
10887		{"avg:5", "a five-sample moving average β€” the default", "p=avg:5"},
10888		{"roc:5", "rate of change over five samples", "p=roc:5"},
10889		{"avg:5|roc:5", "smoothed first, then differentiated", "p=avg:5%7Croc:5"},
10890		{"cma", "a cumulative moving average over the whole run", "p=cma"},
10891	},
10892}, {
10893	Host:  "dict.magnetosphere.net",
10894	Title: "dict β€” a word, looked up on arrival",
10895	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.",
10896	Links: []deepLink{
10897		{"serendipity", "Webster's 1913 on serendipity", "run=dict%20serendipity"},
10898		{"rain", "every sense of rain, across all four sources", "run=dict%20%3Arain"},
10899		{"recieve", "a transposition: the misspelling a subsequence matcher cannot find", "run=dict%20recieve"},
10900		{"aluminium", "the British spelling, folded to the American entry", "run=dict%20aluminium"},
10901	},
10902}}
10903
10904// deepBody renders the deep-link section: one paragraph of links per demo,
10905// rather than a table. A table of 31 one-word rows is mostly borders.
10906func deepBody() string {
10907	var b strings.Builder
10908	b.WriteString("<h2 class='❀'>Straight to one thing</h2>\n")
10909	b.WriteString("<p>Some of these take a parameter, so a link can open on a particular demo, ")
10910	b.WriteString("pipeline or word instead of the front of the app.</p>\n")
10911	for _, g := range deepGroups {
10912		b.WriteString("<h3 style='font-size:10pt'>" + g.Title + "</h3>\n")
10913		b.WriteString("<p style='max-width:70em;margin:0 auto'>" + g.Note + "</p>\n")
10914		b.WriteString("<p style='max-width:70em;margin:6px auto;line-height:1.9'>")
10915		for i, l := range g.Links {
10916			if i > 0 {
10917				b.WriteString(" &middot; ")
10918			}
10919			b.WriteString("<a title=\"" + l.Desc + "\" href='https://" + g.Host + "/?" + l.Query + "'>" + l.Label + "</a>")
10920		}
10921		b.WriteString("</p>\n")
10922	}
10923	return b.String()
10924}
10925
10926// live counts the demos in a group that are ready to be linked.
10927func live(sites []demoSite) int {
10928	n := 0
10929	for _, d := range sites {
10930		if !d.Pending {
10931			n++
10932		}
10933	}
10934	return n
10935}
10936
10937// ---------------------------------------------------------------------------
10938// /test β€” the site with the decoration wasm loaded.
10939//
10940// Deliberately not a gallery. The thing to judge is the site, so this parses
10941// exactly what homepage parses and differs from it in one line: the wasm list.
10942// Diffing /test against / therefore shows the decoration and nothing else, and
10943// when a piece of it is worth keeping the change is to add it to homepage.
10944
10945// testpage serves /test.
10946func testpage(c fiber.Ctx) error {
10947	tmpl, err := mainTmpl()
10948	if err != nil {
10949		return c.Status(fiber.StatusInternalServerError).SendString(fmt.Sprintf("template: %v", err))
10950	}
10951	tmpl0, err := tmpl.Clone()
10952	if err != nil {
10953		return c.Status(fiber.StatusInternalServerError).SendString(fmt.Sprintf("clone: %v", err))
10954	}
10955	for _, part := range []struct{ name, body string }{
10956		{"main", h.FrontPage()},
10957		{"about", h.AboutPage()},
10958		{"policy", h.PolicyPage()},
10959		{"links", h.LinksPage()},
10960	} {
10961		if _, err := tmpl0.New(part.name).Parse(part.body); err != nil {
10962			return c.Status(fiber.StatusInternalServerError).SendString(fmt.Sprintf("%s: %v", part.name, err))
10963		}
10964	}
10965	h1 := pageMeta(c, htmlPageTemplateData)
10966	h1.Page = "front"
10967	h1.Canonical = h1.BaseURL + "/test"
10968	h1.Title = "sandbox β€” " + f.Sitelongname
10969	// The front page's own canvas, plus the decorator. This is the whole of
10970	// the difference from homepage.
10971	h1.WasmBinary = wasmFor("stl2", "deco")
10972	c.Set("Content-Type", "text/html;charset=utf-8")
10973	c.Set("Cache-Control", "no-store")
10974	var out bytes.Buffer
10975	if err := tmpl0.Execute(&out, map[string]interface{}{"Page": h1, "Prods": allproducts}); err != nil {
10976		return c.Status(fiber.StatusInternalServerError).SendString(fmt.Sprintf("execute: %v", err))
10977	}
10978	body := collapseNewlines.ReplaceAll(out.Bytes(), []byte("\n"))
10979	// Injected rather than added to style.css: the rules are generated from the
10980	// live category list, which a static file cannot be. If this graduates it
10981	// wants a template hook rather than a string splice.
10982	body = bytes.Replace(body, []byte("</head>"), []byte(catTargetCSS()+"</head>"), 1)
10983	_, err = c.Status(fiber.StatusOK).Write(body)
10984	return err
10985}
10986
10987// catTargetCSS makes a targeted category show its subcategories' sections too.
10988//
10989// The problem it solves: sections are flat siblings under .✝ and only the one
10990// matching :target is shown, so #cat-resistor displays its heading, its list of
10991// subcategory links, and the "Other Products" table β€” which holds only the
10992// products that have no subcategory. Every resistor has one, so the category
10993// view is a page with no products on it.
10994//
10995// The fix is one rule per category, naming that category's subcategory
10996// sections by the prefix their ids already share:
10997//
10998//	[id="cat-resistor"]:target ~ [id^="cat-resistor-"]{display:block}
10999//
11000// Nesting the sections and using :has() would also work and reads better, but
11001// :has() only reached Firefox in December 2023 while :target has worked since
11002// about 2010 β€” and a browser without :has() would find the nested section
11003// inside a display:none parent, which breaks navigation rather than degrading
11004// it. This uses :target, ~ and [attr^=] only, so it works wherever the
11005// existing navigation does, and needs no second copy of the markup.
11006func catTargetCSS() string {
11007	cats := htmlPageTemplateData.Cats
11008	var b strings.Builder
11009	b.WriteString("<style>/* a targeted category shows its subcategories too */\n")
11010	for _, cat := range cats {
11011		if len(htmlPageTemplateData.SubCatsByCat[cat]) == 0 {
11012			continue
11013		}
11014		// A category whose name plus a dash begins another category's name
11015		// would have its rule match that one's sections as well. Skip it
11016		// rather than emit a rule that reveals the wrong products.
11017		clash := false
11018		for _, other := range cats {
11019			if other != cat && strings.HasPrefix(other, cat+"-") {
11020				clash = true
11021				break
11022			}
11023		}
11024		if clash {
11025			b.WriteString("/* skipped " + cat + ": prefixes another category */\n")
11026			continue
11027		}
11028		id := "cat-" + cat
11029		b.WriteString(`[id="` + id + `"]:target ~ [id^="` + id + `-"]{display:block}` + "\n")
11030	}
11031	b.WriteString("</style>")
11032	return b.String()
11033}
11034
11035
11036// ===== pkg/web/pprof_js.go =====
11037//go:build js && wasm
11038
11039package web
11040
11041import "errors"
11042
11043// StartPprof has nothing to serve profiles on in a page.
11044func StartPprof(string) error { return errors.New("pprof: not in a browser tab") }
11045
11046
11047// ===== pkg/web/pprof_native.go =====
11048//go:build !js || !wasm
11049
11050package web
11051
11052import (
11053	"errors"
11054	"fmt"
11055	"log"
11056	"net"
11057	"net/http"
11058	"net/http/pprof"
11059	"time"
11060)
11061
11062// StartPprof serves Go's profiles (CPU, heap, goroutines, …) on addr, a
11063// listener of their own: they show how the server works and cost CPU to
11064// take, so they are never on the store's. The address must be loopback;
11065// reach it from elsewhere through the box, not the network.
11066func StartPprof(addr string) error {
11067	host, _, err := net.SplitHostPort(addr)
11068	if err != nil {
11069		return fmt.Errorf("pprof: %w", err)
11070	}
11071	if ip := net.ParseIP(host); host != "localhost" && (ip == nil || !ip.IsLoopback()) {
11072		return errors.New("pprof: profiles are served on a loopback address only, such as 127.0.0.1:6061")
11073	}
11074	ln, err := net.Listen("tcp", addr)
11075	if err != nil {
11076		return fmt.Errorf("pprof: %w", err)
11077	}
11078	log.Println("pprof on http://" + ln.Addr().String() + "/debug/pprof/")
11079	go func() {
11080		hs := &http.Server{Handler: pprofMux(), ReadHeaderTimeout: 10 * time.Second}
11081		if err := hs.Serve(ln); err != nil {
11082			log.Println("pprof:", err)
11083		}
11084	}()
11085	return nil
11086}
11087
11088// pprofMux is the profiles' own handler, not http.DefaultServeMux, which
11089// importing net/http/pprof also fills.
11090func pprofMux() *http.ServeMux {
11091	mux := http.NewServeMux()
11092	mux.HandleFunc("/debug/pprof/", pprof.Index)
11093	mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
11094	mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
11095	mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
11096	mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
11097	return mux
11098}
11099
11100
11101// ===== pkg/web/pprof_test.go =====
11102//go:build !js || !wasm
11103
11104package web
11105
11106import (
11107	"net/http"
11108	"net/http/httptest"
11109	"strings"
11110	"testing"
11111)
11112
11113// TestPprofLoopbackOnly: profiles are refused anywhere the network reaches.
11114func TestPprofLoopbackOnly(t *testing.T) {
11115	for _, addr := range []string{":6061", "0.0.0.0:6061", "192.168.1.118:6061", "example.com:6061", "6061"} {
11116		if err := StartPprof(addr); err == nil {
11117			t.Errorf("%q was allowed", addr)
11118		}
11119	}
11120}
11121
11122// TestPprofMux: the profiles are on their own handler.
11123func TestPprofMux(t *testing.T) {
11124	w := httptest.NewRecorder()
11125	pprofMux().ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/debug/pprof/", nil))
11126	if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "goroutine") {
11127		t.Errorf("pprof's own handler: %d %.200s", w.Code, w.Body.String())
11128	}
11129}
11130
11131
11132// ===== pkg/web/server.go =====
11133// Package web pkg/web/server.go β€” the store server: routes and page handlers.
11134package web
11135
11136import (
11137	"bytes"
11138	"context"
11139	"encoding/base64"
11140	"fmt"
11141	"log"
11142	"os"
11143	"path/filepath"
11144	"regexp"
11145	"sort"
11146	"strconv"
11147	"strings"
11148	"sync"
11149
11150	"github.com/gofiber/fiber/v3"
11151
11152	"github.com/bitfield/script"
11153
11154	"github.com/0magnet/m2/pkg/config"
11155	p "github.com/0magnet/m2/pkg/product"
11156	"github.com/0magnet/m2/pkg/storepage"
11157)
11158
11159// f aliases the shared configuration; the name keeps the handler code
11160// identical to its pre-refactor form at the repo root, and drop-in
11161// other.go files keep working unchanged.
11162var f = &config.F
11163
11164var collapseNewlines = regexp.MustCompile(`\n{2,}`)
11165
11166func methodColor(method string, colors fiber.Colors) string {
11167	switch method {
11168	case fiber.MethodGet:
11169		return colors.Cyan
11170	case fiber.MethodPost:
11171		return colors.Green
11172	case fiber.MethodPut:
11173		return colors.Yellow
11174	case fiber.MethodDelete:
11175		return colors.Red
11176	case fiber.MethodPatch:
11177		return colors.White
11178	case fiber.MethodHead:
11179		return colors.Magenta
11180	case fiber.MethodOptions:
11181		return colors.Blue
11182	default:
11183		return colors.Reset
11184	}
11185}
11186
11187func statusColor(code int, colors fiber.Colors) string {
11188	switch {
11189	case code >= fiber.StatusOK && code < fiber.StatusMultipleChoices:
11190		return colors.Green
11191	case code >= fiber.StatusMultipleChoices && code < fiber.StatusBadRequest:
11192		return colors.Blue
11193	case code >= fiber.StatusBadRequest && code < fiber.StatusInternalServerError:
11194		return colors.Yellow
11195	default:
11196		return colors.Red
11197	}
11198}
11199
11200// Serve runs the web store: templates, routes, wasm compilation, and the
11201// http listener. It blocks for the life of the server.
11202func Serve() {
11203	wg := new(sync.WaitGroup)
11204	wg.Add(1)
11205	r := NewApp(AppOpts{})
11206	// The browse origin the desk's nested browser renders the store at. Its
11207	// own listener, because it is a different domain: a wholly static pair of
11208	// files that never sees any of the content it makes renderable.
11209	StartBrowseOrigin(context.Background())
11210	go func() {
11211		err := r.Listen(fmt.Sprintf(":%d", f.WebPort))
11212		if err != nil {
11213			log.Println("Error serving http: ", err)
11214		}
11215		wg.Done()
11216	}()
11217	CompileWASM()
11218	wg.Wait()
11219}
11220
11221func sitemap(c fiber.Ctx) error {
11222	c.Type("xml", "utf-8")
11223	return c.SendString(generateSitemapXML())
11224}
11225
11226// apisite serves the site identity for store clients β€” the browser tui
11227// has no MENV file to source its masthead from.
11228func apisite(c fiber.Ctx) error {
11229	return c.JSON(fiber.Map{
11230		"Sitename":      f.Sitename,
11231		"Siteext":       f.Siteext,
11232		"Sitelongname":  f.Sitelongname,
11233		"Sitetagline":   f.Sitetagline,
11234		"Tgcontact":     f.Tgcontact,
11235		"Tgchannel":     f.Tgchannel,
11236		"Teststripekey": f.Teststripekey,
11237		// The identity fields below appear verbatim in every rendered page,
11238		// so serving them here reveals nothing new; they let the in-tab site
11239		// server render the same masthead and metadata the origin does.
11240		"Sitemeta":           f.Sitemeta,
11241		"Sitedomain":         f.Sitedomain,
11242		"Siteprettyname":     f.Siteprettyname,
11243		"Siteprettynamecap":  f.Siteprettynamecap,
11244		"Siteprettynamecaps": f.Siteprettynamecaps,
11245		"SiteASCIILogo":      f.SiteASCIILogo,
11246		"Stripepk":           f.StripePK,
11247		// The drop-in navigation entries, so a client β€” the terminal build,
11248		// and the TUI served into a browser tab β€” can offer the same Etc menu
11249		// the page does instead of a shorter one missing every drop-in page.
11250		"NavLinks": extraNavLinks,
11251		// The coin the store takes, or "" where it takes none: the page offers
11252		// "Pay in <coin>" only then, and so does the terminal.
11253		"Coin": htmlTemplateData{}.CoinName(),
11254	})
11255}
11256
11257// apicontent serves the stock-page fragments (about/policy/links) raw,
11258// for the tui to render as text.
11259func apicontent(c fiber.Ctx) error {
11260	name := c.Params("name")
11261	switch name {
11262	case "about", "policy", "links":
11263	default:
11264		return c.SendStatus(fiber.StatusNotFound)
11265	}
11266	c.Set("Content-Type", "text/html;charset=utf-8")
11267	return c.SendString(contentFile("content/" + name + ".html"))
11268}
11269
11270// tuipage serves the terminal storefront: the same store TUI `m2 tui`
11271// runs natively, compiled to wasm (the wasm/tui drop-in, listed in
11272// WASMSRC) and drawn into an xterm-go terminal filling the page.
11273func tuipage(c fiber.Ctx) error {
11274	// The joint build where the deployment has one, else this page's own.
11275	// See pageBinary.
11276	binary, execPath := pageBinary("term", "tui")
11277	html := fmt.Sprintf(`<!DOCTYPE html>
11278<html lang="en">
11279<head>
11280<meta charset="utf-8">
11281<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content">
11282<title>%s β€” terminal</title>
11283<meta name="description" content="%s as a terminal: browse the catalog, view products, and fill a cart in a TUI running in your browser.">
11284<meta name="robots" content="index, follow">
11285<link rel="stylesheet" href="/font.css">
11286<style>html,body{margin:0;padding:0;width:100%%;height:100%%;background:#000;overflow:hidden;}#terminal{position:fixed;inset:0;}</style>
11287<script src="/bottle/jsfs.js"></script>
11288<script src="%s"></script>
11289`+procScripts()+`
11290<script>
11291if (!WebAssembly.instantiateStreaming) {
11292  WebAssembly.instantiateStreaming = async (resp, importObject) => {
11293    const source = await (await resp).arrayBuffer();
11294    return await WebAssembly.instantiate(source, importObject);
11295  };
11296}
11297const go = new Go();
11298`+homeReadyJS+`
11299homeReady.then(() => WebAssembly.instantiateStreaming(fetch("/%s"), go.importObject)).then((result) => {
11300  go.run(result.instance);
11301}).catch((err) => { console.error("Failed to run WASM:", err); });
11302</script>
11303</head>
11304<body>
11305<div id="terminal"></div>
11306</body>
11307</html>`, f.Sitedomain, f.Sitelongname, execPath, binary)
11308	c.Set("Content-Type", "text/html; charset=utf-8")
11309	return c.Status(fiber.StatusOK).SendString(html)
11310}
11311
11312// procScripts lets a page's shells run programs from their filesystem as
11313// processes of their own (bottle's proc): the store program, wasm/store, or
11314// any other, built by Go or by TinyGo.
11315func procScripts() string {
11316	return fmt.Sprintf(`<script src="/bottle/proc.js"></script>
11317<script>proc.assets.wasmExecGo = %q; proc.assets.wasmExecTinyGo = %q;</script>`, f.WasmExecPathGo, f.WasmExecPathTinyGo)
11318}
11319
11320// deskpage serves the site as a desktop: the website in a maximized window
11321// and the storefront terminal in another, both launchable from the panel.
11322// Same drop-in mechanism as tuipage, against the 'wasm/desk' source.
11323func deskpage(c fiber.Ctx) error {
11324	// The joint build where the deployment has one, else this page's own.
11325	binary, execPath := pageBinary("term", "desk")
11326	// ?bare / ?fsonly / ?nonet split the page's script layer for debugging:
11327	// bare = none of jsfs/vnet, fsonly = jsfs alone, nonet = no browser.
11328	//
11329	// netscrape is no longer among these. It used to be a JS engine served at
11330	// /netscrape.js; it is now a Go browser compiled INTO the desk wasm, so
11331	// there is no script to leave out β€” ?nonet is honored by the desk itself,
11332	// which reads it from location.search and does not open the window.
11333	scripts := `<script src="/bottle/jsfs.js"></script>
11334<script src="/bottle/vnet.js"></script>` + "\n" + procScripts()
11335	// The browse substrate, when the deployment has a second domain for it.
11336	// The responder must be first-party here and must exist before the wasm
11337	// configures it, so it goes in with the rest of the page scripts; the
11338	// config object tells the desk which origin to render the store at.
11339	if browseEnabled() {
11340		scripts += fmt.Sprintf("\n<script>window.__M2_BROWSE__={suffix:%q,scheme:%q,port:%q};</script>"+
11341			"\n<script src=%q></script>", config.F.BrowseSuffix, browseScheme(), browsePort(), browseResponderPath)
11342	}
11343	switch {
11344	case c.Query("bare") != "":
11345		scripts = ""
11346	case c.Query("fsonly") != "":
11347		scripts = `<script src="/bottle/jsfs.js"></script>`
11348	}
11349	html := fmt.Sprintf(`<!DOCTYPE html>
11350<html lang="en">
11351<head>
11352<meta charset="utf-8">
11353<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content">
11354<title>%s β€” desktop</title>
11355<meta name="description" content="%s as a desktop: the website and the storefront terminal, each in a window.">
11356<meta name="robots" content="index, follow">
11357<link rel="stylesheet" href="/font.css">
11358<style>
11359html,body{margin:0;padding:0;width:100%%;height:100%%;background:#000;overflow:hidden;
11360  font-family:mononokiregular,ui-monospace,monospace;color:#fff;}
11361/* Windows are positioned against this, so it is the element that fills the
11362   page rather than the body. */
11363#desktop{position:fixed;inset:0;}
11364#boot{padding:14px;color:#777;font-size:13px;}
11365</style>
11366%s
11367<script>
11368// Channel providers for the netscrape browser window the desk opens. The
11369// clearnet channel is the page's own fetch β€” enough for this origin and for
11370// anything CORS permits; there is no skysocks here. dmsg has no transport in
11371// this page at all, so it answers 502 rather than hanging.
11372globalThis.__m2Glue = {
11373  fetchClearnet: async function (exit, method, url, body, winId, headers) {
11374    try {
11375      const r = await fetch(url, { method: method || "GET", body: body || undefined, headers: headers || undefined });
11376      const buf = new Uint8Array(await r.arrayBuffer());
11377      const hs = {};
11378      r.headers.forEach(function (v, k) { hs[k] = v; });
11379      return { status: r.status, body: buf, headers: hs };
11380    } catch (e) {
11381      return { status: 502, body: new Uint8Array(), headers: {} };
11382    }
11383  },
11384  fetchDmsg: async function () { return { status: 502, body: new Uint8Array(), headers: {} }; },
11385};
11386
11387// netscrape asks globalThis.__netscrapeFetch(url) for every page and
11388// subresource, and expects a Response. Without one it falls back to a
11389// same-origin /fetch proxy this server does not have, so the transport is
11390// wired here rather than left to that default.
11391//
11392// Note the argument order: netscrape's own loader.js calls
11393// fetchClearnet(url, method, body), but __m2Glue predates it and takes
11394// (exit, method, url, ...). The glue is what the rest of this page uses, so
11395// the shim adapts to the glue rather than the other way round.
11396globalThis.__netscrapeFetch = function (url) {
11397  var u;
11398  try { u = new URL(url, location.href); } catch (e) { return fetch(url); }
11399  var path = (u.pathname || "/") + (u.search || "");
11400  var mesh = /\.(dmsg|skysocks|skynet)$/i.test(u.hostname) || /^[0-9a-f]{66}$/i.test(u.hostname);
11401  // vnet:<port> is the site running INSIDE this tab, on the page's virtual
11402  // loopback. It needs its own name: the desk's own origin is a real
11403  // 127.0.0.1:<port>, so addressing the in-tab server as 127.0.0.1 too sent
11404  // it to the page's ordinary fetch and out to the host, which answered
11405  // ERR_CONNECTION_REFUSED. Nothing outside this page can be reached at
11406  // "vnet", so the routing is unambiguous.
11407  var call;
11408  if (u.hostname === "vnet") {
11409    call = globalThis.vnet.httpFetch(parseInt(u.port || "80", 10), "GET", path, null);
11410  } else if (mesh) {
11411    call = globalThis.__m2Glue.fetchDmsg(u.hostname, "GET", path, null);
11412  } else {
11413    call = globalThis.__m2Glue.fetchClearnet(null, "GET", u.href, null, null, null);
11414  }
11415  return Promise.resolve(call).then(function (r) {
11416    var h = new Headers();
11417    if (r && r.headers) { for (var k in r.headers) { try { h.set(k, r.headers[k]); } catch (e) {} } }
11418    return new Response((r && r.body) || new Uint8Array(0), { status: (r && r.status) || 200, headers: h });
11419  });
11420};
11421</script>
11422<script src="%s"></script>
11423<script>
11424if (!WebAssembly.instantiateStreaming) {
11425  WebAssembly.instantiateStreaming = async (resp, importObject) => {
11426    const source = await (await resp).arrayBuffer();
11427    return await WebAssembly.instantiate(source, importObject);
11428  };
11429}
11430const go = new Go();
11431`+homeReadyJS+`
11432homeReady.then(() => WebAssembly.instantiateStreaming(fetch("/%s"), go.importObject)).then((result) => {
11433  const b = document.getElementById("boot"); if (b) b.remove();
11434  go.run(result.instance);
11435}).catch((err) => { console.error("Failed to run WASM:", err); });
11436</script>
11437</head>
11438<body>
11439<div id="desktop"><div id="boot">loading…</div></div>
11440</body>
11441</html>`, f.Sitedomain, f.Sitelongname, scripts, execPath, binary)
11442	c.Set("Content-Type", "text/html; charset=utf-8")
11443	return c.Status(fiber.StatusOK).SendString(html)
11444}
11445
11446// apiproducts serves the catalog as JSON for store clients (the tui's
11447// --store mode, and the browser tui to come). The business-sensitive
11448// columns the HTML never renders stay private.
11449func apiproducts(c fiber.Ctx) error {
11450	allproductsMu.RLock()
11451	prods := make(p.Products, len(allproducts))
11452	copy(prods, allproducts)
11453	allproductsMu.RUnlock()
11454	for i := range prods {
11455		prods[i].Cost = ""
11456		prods[i].Location = ""
11457		prods[i].Sourceinfo = ""
11458	}
11459	return c.JSON(prods)
11460}
11461
11462// extraRoutes collects route registrars from optional drop-in files
11463// (see other.go.example). A drop-in appends its registrar from init();
11464// deleting the file removes its routes with no other code changes.
11465var extraRoutes []func(*fiber.App)
11466
11467// extraSitemapURLs collects site-relative paths that drop-in routes want
11468// listed in /sitemap.xml. A route that is not linked from anywhere and not
11469// in the sitemap is one a crawler has no way to reach, which for a page that
11470// exists to be found is the whole of the problem.
11471//
11472// Paths only, each beginning with "/": the sitemap is per-host and the base
11473// URL is the deployment's own, so a drop-in cannot and should not name it.
11474var extraSitemapURLs []string
11475
11476// NavLink is an entry a drop-in adds to the site navigation.
11477type NavLink struct {
11478	// Title is the link text, Href where it goes, and Desc the tooltip.
11479	Title, Href, Desc string
11480}
11481
11482// extraNavLinks collects navigation entries from drop-in files, the way
11483// extraRoutes collects their routes.
11484//
11485// A drop-in route that nothing links to is reachable only by someone who
11486// already knows the path, and the stock header cannot name it: the header is
11487// shared by every deployment, and a hardcoded entry would be a broken link
11488// everywhere the drop-in is absent. Registering it here keeps the link and the
11489// route in the same file, so deleting that file removes both.
11490var extraNavLinks []NavLink
11491
11492func logo(c fiber.Ctx) error {
11493	tmpl, err := auxTmpl()
11494	if err != nil {
11495		msg := fmt.Sprintf("Error parsing html template: %v", err)
11496		log.Println(msg)
11497		return c.Status(fiber.StatusInternalServerError).SendString(msg)
11498	}
11499	tmpl0, err := tmpl.Clone()
11500	if err != nil {
11501		msg := fmt.Sprintf("Error cloning template: %v", err)
11502		log.Println(msg)
11503		return c.Status(fiber.StatusInternalServerError).SendString(msg)
11504	}
11505	_, err = tmpl0.New("main").Parse(h.Logo())
11506	if err != nil {
11507		msg := fmt.Sprintf("Error parsing product page template: %v", err)
11508		log.Println(msg)
11509		return c.Status(fiber.StatusInternalServerError).SendString(msg)
11510	}
11511	tmpl = tmpl0
11512	c.Set("Content-Type", "text/html;charset=utf-8")
11513
11514	img2txtFlags := ""
11515	if w, err := strconv.Atoi(c.Params("width")); err == nil {
11516		img2txtFlags = fmt.Sprintf("--width=%d ", w)
11517	}
11518	if h, err := strconv.Atoi(c.Params("height")); err == nil {
11519		img2txtFlags = fmt.Sprintf("--height=%d ", h)
11520	}
11521
11522	logoHTMLslice, err := script.Exec(fmt.Sprintf("bash -c 'img2txt %s logo.jpg | ansifilter -H'", img2txtFlags)).Slice()
11523	if err != nil {
11524		log.Println("error: ", err)
11525		_, err = c.Status(fiber.StatusInternalServerError).Write([]byte(err.Error() + "/n" + strings.Join(logoHTMLslice, "\n")))
11526		return err
11527	}
11528	if len(logoHTMLslice) > 2 {
11529		logoHTMLslice = logoHTMLslice[:len(logoHTMLslice)-3]
11530	}
11531	if len(logoHTMLslice) > 18 {
11532		logoHTMLslice = logoHTMLslice[19:]
11533	}
11534
11535	var result bytes.Buffer
11536	h1 := pageMeta(c, htmlTemplateData{})
11537	h1.Page = "logo"
11538	h1.Title = "logo"
11539	tmplData := map[string]interface{}{
11540		"Content": strings.Join(logoHTMLslice, "\n"),
11541	}
11542	err = tmpl.Execute(&result, tmplData)
11543	if err != nil {
11544		log.Println("error: ", err)
11545		_, err = c.Status(fiber.StatusInternalServerError).Write(result.Bytes())
11546		return err
11547	}
11548	_, err = c.Status(fiber.StatusOK).Write(collapseNewlines.ReplaceAll(result.Bytes(), []byte("\n")))
11549	return err
11550}
11551
11552func robots(c fiber.Ctx) error {
11553	c.Set("Content-Type", "text/plain;charset=utf-8")
11554	// "User-agent: *" alone is a group with no rules in it. That is legal and
11555	// means allow everything, but it says so by omission β€” and a group that
11556	// states nothing is one whose meaning a later edit can change by accident.
11557	// Allow: / states it. The trailing newline is so the Sitemap line is a
11558	// line, which some parsers care about.
11559	_, err := c.Status(fiber.StatusOK).Write([]byte(fmt.Sprintf(
11560		"User-agent: *\nAllow: /\n\nSitemap: https://%s/sitemap.xml\n", c.Hostname())))
11561	return err
11562}
11563
11564func style(c fiber.Ctx) error {
11565	c.Set("Content-Type", "text/css;charset=utf-8")
11566	_, err := c.Status(fiber.StatusOK).Write([]byte(h.StyleCSS()))
11567	return err
11568}
11569
11570// fontcss serves just the @font-face block, so the terminal page can have
11571// the site's face without pulling in the whole stylesheet. The TUI measures
11572// its cell from this font when it opens, which is why it is a separate,
11573// cacheable route rather than something inlined per page load.
11574func fontcss(c fiber.Ctx) error {
11575	c.Set("Content-Type", "text/css;charset=utf-8")
11576	c.Set("Cache-Control", "public, max-age=86400")
11577	_, err := c.Status(fiber.StatusOK).Write([]byte(h.FontCSS()))
11578	return err
11579}
11580
11581func serveWASM(r *fiber.App) {
11582	if f.WasmExecPath != "" {
11583		_, err := script.File(f.WasmExecPath).Bytes()
11584		if err != nil {
11585			log.Printf("Error reading %s: %v\n", f.WasmExecPath, err)
11586		} else { //the wasm exec must be present or none of the webassembly stuff will work ; provided by the golang installaton
11587			r.Get(f.WasmExecPathTinyGo, func(c fiber.Ctx) error {
11588				wasmExecData, err := script.File(f.WasmExecPathTinyGo).Bytes()
11589				if err != nil {
11590					log.Printf("Error reading %s: %v\n", f.WasmExecPathTinyGo, err)
11591					return c.SendStatus(fiber.StatusNotFound)
11592				}
11593				c.Set("Content-Type", "application/js")
11594				_, err = c.Status(fiber.StatusOK).Write(wasmExecData)
11595				return err
11596			})
11597
11598			r.Get(f.WasmExecPathGo, func(c fiber.Ctx) error {
11599				wasmExecData, err := script.File(f.WasmExecPathGo).Bytes()
11600				if err != nil {
11601					log.Printf("Error reading %s: %v\n", f.WasmExecPathGo, err)
11602					return c.SendStatus(fiber.StatusNotFound)
11603				}
11604				c.Set("Content-Type", "application/js")
11605				_, err = c.Status(fiber.StatusOK).Write(wasmExecData)
11606				return err
11607			})
11608
11609			// Register both variants per source: a drop-in tinygo cannot
11610			// compile still serves its Go build, and pages pick whichever
11611			// binary exists.
11612			for _, wasmSRC := range f.WasmSRC {
11613				base := strings.TrimSuffix(filepath.Base(wasmSRC), filepath.Ext(wasmSRC))
11614				for _, suffix := range []string{".wasm", "-tiny.wasm"} {
11615					outputFile := base + suffix
11616					r.Get("/"+outputFile, func(c fiber.Ctx) error {
11617						// A binary this size must revalidate, not cache
11618						// blindly: a browser holding yesterday's wasm
11619						// makes a deploy look like nothing changed.
11620						fi, err := os.Stat(outputFile)
11621						if err != nil {
11622							return c.SendStatus(fiber.StatusInternalServerError)
11623						}
11624						etag := fmt.Sprintf(`"%x-%x"`, fi.ModTime().Unix(), fi.Size())
11625						c.Set("Cache-Control", "no-cache")
11626						c.Set("ETag", etag)
11627						if c.Get("If-None-Match") == etag {
11628							return c.SendStatus(fiber.StatusNotModified)
11629						}
11630						data, err := script.File(outputFile).Bytes()
11631						if err != nil {
11632							script.File(outputFile).Stdout() //nolint
11633							return c.SendStatus(fiber.StatusInternalServerError)
11634						}
11635						c.Set("Content-Type", "application/wasm")
11636						return c.Status(fiber.StatusOK).Send(data)
11637					})
11638				}
11639			}
11640		}
11641	}
11642}
11643
11644func sendFile(c fiber.Ctx) error {
11645	return c.SendFile("." + c.Path())
11646}
11647func sendImage(c fiber.Ctx) error {
11648	c.Set("Content-Type", "image/jpeg")
11649	return c.SendFile("./img" + c.Path())
11650}
11651
11652func stlbase64(c fiber.Ctx) error {
11653	name := c.Params("filename")
11654	if strings.ContainsAny(name, "/\\..") || strings.Contains(name, "..") {
11655		return c.SendStatus(fiber.StatusBadRequest)
11656	}
11657	stlfile, err := script.File("img/stl/" + name).Bytes()
11658	if err != nil {
11659		return c.SendStatus(fiber.StatusNotFound)
11660	}
11661	_, err = c.Status(fiber.StatusOK).Write([]byte("data:model/stl;base64," + base64.StdEncoding.EncodeToString(stlfile)))
11662	return err
11663}
11664
11665type item struct {
11666	ID     string
11667	Amount int64
11668}
11669
11670func cathtmlfunc(c fiber.Ctx) error {
11671	tmpl, err := mainTmpl()
11672	if err != nil {
11673		msg := fmt.Sprintf("Error parsing html template: %v", err)
11674		log.Println(msg)
11675		return c.Status(fiber.StatusInternalServerError).SendString(msg)
11676	}
11677	tmpl0, err := tmpl.Clone()
11678	if err != nil {
11679		msg := fmt.Sprintf("Error cloning html template: %v", err)
11680		log.Println(msg)
11681		return c.Status(fiber.StatusInternalServerError).SendString(msg)
11682	}
11683	_, err = tmpl0.New("main").Parse(h.CategoryPage())
11684	if err != nil {
11685		msg := fmt.Sprintf("Error parsing Category page template: %v", err)
11686		log.Println(msg)
11687		return c.Status(fiber.StatusInternalServerError).SendString(msg)
11688	}
11689	tmpl = tmpl0
11690	var tmplData map[string]interface{}
11691	var result bytes.Buffer
11692	var categoryproducts p.Products
11693	c.Set("Content-Type", "text/html;charset=utf-8")
11694	h1 := pageMeta(c, htmlPageTemplateData)
11695	h1.Title = fmt.Sprintf("%s | %s", func() string {
11696		var str string
11697		if c.Params("partno") != "" {
11698			return "No product matching partno.: " + c.Params("partno") + " | Showing All Products"
11699		}
11700		if c.Params("cat") == "" {
11701			return "All Products"
11702		}
11703		str = fmt.Sprintf("Category: %s", c.Params("cat"))
11704		if c.Params("subcat") != "" {
11705			str += fmt.Sprintf("; Subcategory: %s", c.Params("subcat"))
11706		}
11707		return str
11708	}(), h1.Title)
11709	h1.Page = "category"
11710	// The listing has an Add to cart on every row, and the cart is what
11711	// defines addToCart. Without it every one of those buttons was inert
11712	// β€” the page rendered them, the click found no such function, and
11713	// nothing reached the cart from a category page at all. The small
11714	// build, because this is carried by every listing view.
11715	h1.WasmBinary = wasmSmallFor("cart")
11716	if c.Params("cat") == "" && c.Params("subcat") == "" {
11717		tmplData = map[string]interface{}{
11718			"Products":    allproducts,
11719			"Page":        h1,
11720			"Category":    c.Params("cat"),
11721			"Subcategory": c.Params("subcat"),
11722			"Prods":       allproducts,
11723			"Product":     c.Params("partno"),
11724		}
11725	} else {
11726
11727		for _, prod := range allproducts {
11728			if strings.EqualFold(prod.Category, c.Params("cat")) && (c.Params("subcat") == "" || strings.EqualFold(escapesubcat(prod.Subcategory), c.Params("subcat"))) {
11729				categoryproducts = append(categoryproducts, prod)
11730			}
11731		}
11732		tmplData = map[string]interface{}{
11733			"Products":    categoryproducts,
11734			"Page":        h1,
11735			"Category":    c.Params("cat"),
11736			"Subcategory": c.Params("subcat"),
11737			"Prods":       allproducts,
11738		}
11739	}
11740	err = tmpl.Execute(&result, tmplData)
11741	if err != nil {
11742		msg := fmt.Sprintf("Error execute html template: %v", err)
11743		log.Println(msg)
11744		return c.Status(fiber.StatusInternalServerError).SendString(msg)
11745	}
11746	_, err = c.Status(fiber.StatusOK).Write(collapseNewlines.ReplaceAll(result.Bytes(), []byte("\n")))
11747	return err
11748}
11749
11750func getcats() (cats []string) {
11751	var catsMap = make(map[string]int)
11752	for _, prod := range allproducts {
11753		catsMap[prod.Category]++
11754	}
11755	for cat := range catsMap {
11756		cats = append(cats, cat)
11757	}
11758	return cats
11759}
11760func contains(slice []string, str string) bool {
11761	for _, s := range slice {
11762		if s == str {
11763			return true
11764		}
11765	}
11766	return false
11767}
11768func getcategories(allproducts p.Products) (map[string]int, []string, map[string]map[string]int, map[string][]string) {
11769	categoryCounts := make(map[string]int)
11770	subcategoryCounts := make(map[string]map[string]int)
11771	subcategoriesByCategory := make(map[string][]string)
11772
11773	for _, prod := range allproducts {
11774		if prod.Category != "" {
11775			categoryCounts[prod.Category]++
11776			if prod.Subcategory != "" {
11777				if subcategoryCounts[prod.Category] == nil {
11778					subcategoryCounts[prod.Category] = make(map[string]int)
11779				}
11780				subcategoryCounts[prod.Category][prod.Subcategory]++
11781				if !contains(subcategoriesByCategory[prod.Category], prod.Subcategory) {
11782					subcategoriesByCategory[prod.Category] = append(subcategoriesByCategory[prod.Category], prod.Subcategory)
11783				}
11784			}
11785		}
11786	}
11787
11788	var sortableCategories []struct {
11789		Name  string
11790		Count int
11791	}
11792	for cat, count := range categoryCounts {
11793		sortableCategories = append(sortableCategories, struct {
11794			Name  string
11795			Count int
11796		}{Name: cat, Count: count})
11797	}
11798	sort.Slice(sortableCategories, func(i, j int) bool {
11799		return sortableCategories[i].Count > sortableCategories[j].Count
11800	})
11801	var sortedCategories []string
11802	for _, cat := range sortableCategories {
11803		sortedCategories = append(sortedCategories, cat.Name)
11804		var sortableSubcategories []struct {
11805			Name  string
11806			Count int
11807		}
11808		for subcat, count := range subcategoryCounts[cat.Name] {
11809			sortableSubcategories = append(sortableSubcategories, struct {
11810				Name  string
11811				Count int
11812			}{Name: subcat, Count: count})
11813		}
11814		sort.Slice(sortableSubcategories, func(i, j int) bool {
11815			return sortableSubcategories[i].Count > sortableSubcategories[j].Count
11816		})
11817		var sortedSubcategories []string
11818		for _, subcat := range sortableSubcategories {
11819			sortedSubcategories = append(sortedSubcategories, subcat.Name)
11820		}
11821		subcategoriesByCategory[cat.Name] = sortedSubcategories
11822	}
11823	return categoryCounts, sortedCategories, subcategoryCounts, subcategoriesByCategory
11824}
11825
11826func getsubcats(cat string) (subcats []string) {
11827	var subcatsMap = make(map[string]int)
11828	for _, prod := range allproducts {
11829		if cat == "" || strings.EqualFold(cat, prod.Category) {
11830			if prod.Subcategory != "" {
11831				subcatsMap[escapesubcat(prod.Subcategory)]++
11832			}
11833		}
11834	}
11835	for subcat := range subcatsMap {
11836		subcats = append(subcats, subcat)
11837	}
11838	return subcats
11839}
11840
11841// escapesubcat spells a subcategory the way it appears in a URL.
11842//
11843// The rules live in pkg/storepage now, because both views need them and
11844// only this one had them: the terminal could recognize no /cat/x/y path
11845// whose subcategory had been escaped, so it handed those links to the
11846// browser rather than opening the listing it has.
11847func escapesubcat(sc string) string { return storepage.EscapeSubcat(sc) }
11848
11849func handlecat(c fiber.Ctx) error {
11850	if c.Params("cat") == "" && c.Params("subcat") == "" {
11851		return cathtmlfunc(c)
11852	}
11853	var catexists bool
11854	var subcatexists bool
11855	catexists = false
11856	for _, cat := range getcats() {
11857		if strings.EqualFold(cat, c.Params("cat")) {
11858			catexists = true
11859			break
11860		}
11861	}
11862	subcatexists = false
11863	if c.Params("subcat") != "" {
11864		for _, subcat := range getsubcats("") {
11865			if strings.EqualFold(escapesubcat(subcat), c.Params("subcat")) {
11866				subcatexists = true
11867				break
11868			}
11869		}
11870	}
11871	if c.Params("subcat") != "" && !subcatexists {
11872		log.Printf("subcategory %s does not match any existing subcategory\n", c.Params("subcat"))
11873		return c.Redirect().To("/cat/" + c.Params("cat"))
11874	}
11875	if !catexists {
11876		log.Printf("category %s does not match any existing category\n", c.Params("cat"))
11877		return c.Redirect().To("/cat")
11878	}
11879	if catexists || (catexists && subcatexists) {
11880		return cathtmlfunc(c)
11881	}
11882	return c.SendStatus(fiber.StatusNotFound)
11883}
11884
11885func homepage(c fiber.Ctx) error {
11886	tmpl, err := mainTmpl()
11887	if err != nil {
11888		msg := fmt.Sprintf("Could not parsing html template: %v", err)
11889		log.Println(msg)
11890		return c.Status(fiber.StatusInternalServerError).SendString(msg)
11891	}
11892	tmpl0, err := tmpl.Clone()
11893	if err != nil {
11894		msg := fmt.Sprintf("Error cloning template: %v", err)
11895		log.Println(msg)
11896		return c.Status(fiber.StatusInternalServerError).SendString(msg)
11897	}
11898	_, err = tmpl0.New("main").Parse(h.FrontPage())
11899	if err != nil {
11900		msg := fmt.Sprintf("Error parsing Front Page template: %v", err)
11901		log.Println(msg)
11902		return c.Status(fiber.StatusInternalServerError).SendString(msg)
11903	}
11904	_, err = tmpl0.New("about").Parse(h.AboutPage())
11905	if err != nil {
11906		msg := fmt.Sprintf("Error parsing About Page template: %v", err)
11907		log.Println(msg)
11908		return c.Status(fiber.StatusInternalServerError).SendString(msg)
11909	}
11910	_, err = tmpl0.New("policy").Parse(h.PolicyPage())
11911	if err != nil {
11912		msg := fmt.Sprintf("Error parsing Policy Page template: %v", err)
11913		log.Println(msg)
11914		return c.Status(fiber.StatusInternalServerError).SendString(msg)
11915	}
11916	_, err = tmpl0.New("links").Parse(h.LinksPage())
11917	if err != nil {
11918		msg := fmt.Sprintf("Error parsing Links Page template: %v", err)
11919		log.Println(msg)
11920		return c.Status(fiber.StatusInternalServerError).SendString(msg)
11921	}
11922	tmpl = tmpl0
11923	log.Println(c.Get("User-Agent"))
11924	c.Set("Content-Type", "text/html;charset=utf-8")
11925	h1 := pageMeta(c, htmlPageTemplateData)
11926	// The front page has the canvas stl2 animates into, and deco, which draws
11927	// the borders round the product tables, the ASCII art behind the
11928	// photographs and the headings as FIGlet banners. Both are decorations: the
11929	// HTML is the same either way, and a browser that runs neither gets the
11930	// page it always got.
11931	// The small build of each: these ride on every page view, and which
11932	// build newest-wins picks is decided by whichever toolchain finished
11933	// last β€” a page was serving the 23MB stl2 rather than the 9MB one
11934	// because the Go build had just been rebuilt. Size on a page a
11935	// customer loads is not a thing to leave to build order.
11936	h1.WasmBinary = wasmSmallFor("cart", "stl2", "deco")
11937	tmplData := map[string]interface{}{
11938		"Page":  h1,
11939		"Prods": allproducts,
11940	}
11941	var result bytes.Buffer
11942	err = tmpl.Execute(&result, tmplData)
11943	if err != nil {
11944		msg := fmt.Sprintf("Error executing template: %v", err)
11945		log.Println(msg)
11946		return c.Status(fiber.StatusInternalServerError).SendString(msg)
11947	}
11948	_, err = c.Status(fiber.StatusOK).Write(collapseNewlines.ReplaceAll(result.Bytes(), []byte("\n")))
11949	return err
11950}
11951
11952func productpage(c fiber.Ctx) error {
11953	tmpl, err := mainTmpl()
11954	if err != nil {
11955		msg := fmt.Sprintf("Error parsing html template: %v", err)
11956		log.Println(msg)
11957		return c.Status(fiber.StatusInternalServerError).SendString(msg)
11958	}
11959	tmpl0, err := tmpl.Clone()
11960	if err != nil {
11961		msg := fmt.Sprintf("Error cloning template: %v", err)
11962		log.Println(msg)
11963		return c.Status(fiber.StatusInternalServerError).SendString(msg)
11964	}
11965	_, err = tmpl0.New("main").Parse(h.ProductPage())
11966	if err != nil {
11967		msg := fmt.Sprintf("Error parsing product page template: %v", err)
11968		log.Println(msg)
11969		return c.Status(fiber.StatusInternalServerError).SendString(msg)
11970	}
11971	tmpl = tmpl0
11972	c.Set("Content-Type", "text/html;charset=utf-8")
11973	for _, prod := range allproducts {
11974		if strings.EqualFold(prod.Partno, c.Params("partno")) {
11975			var result bytes.Buffer
11976			h1 := pageMeta(c, htmlPageTemplateData)
11977			h1.Page = "product"
11978			// A product page has the canvas and the package-type element the
11979			// STL viewer reads to pick its model β€” and an Add to cart,
11980			// which needs the cart that defines addToCart.
11981			h1.WasmBinary = wasmSmallFor("cart", "stl2")
11982			h1.Title = fmt.Sprintf("%s | %s", prod.Name, h1.Title)
11983			tmplData := map[string]interface{}{
11984				"Prod":  prod,
11985				"Page":  h1,
11986				"Prods": allproducts,
11987			}
11988			err := tmpl.Execute(&result, tmplData)
11989			if err != nil {
11990				log.Println("error: ", err)
11991				_, err = c.Status(fiber.StatusInternalServerError).Write(result.Bytes())
11992				return err
11993			}
11994			_, err = c.Status(fiber.StatusOK).Write(collapseNewlines.ReplaceAll(result.Bytes(), []byte("\n")))
11995			return err
11996		}
11997	}
11998	log.Printf("product %s does not match any existing product\n", c.Params("partno"))
11999	return c.Status(fiber.StatusNotFound).Redirect().To("/cat")
12000}
12001
12002// homeReadyJS restores the terminal's home directory before the wasm starts,
12003// as a desktop finds ~ where it left it: bottle's jsfs (the filesystem Go's os
12004// package uses in the page) is persisted to IndexedDB for /home only. The
12005// rest of the tree β€” /tmp, /dev, the templates a page seeds β€” is made fresh
12006// each load. The store keeps its cart there (pkg/tui/cartsave.go). Where jsfs
12007// is absent (?bare) or IndexedDB is refused, the wasm starts anyway, with a
12008// cart that lasts the page.
12009const homeReadyJS = `const homeReady = (globalThis.jsfs && jsfs.persist)
12010  ? jsfs.persist.enable("m2-home", { exclude: (p) => !(p === "/home" || p.startsWith("/home/")) }).catch(() => {})
12011  : Promise.resolve();`
12012
12013
12014// ===== pkg/web/shipping.go =====
12015package web
12016
12017// The shipping form, as the page draws it.
12018//
12019// These are methods on the page data rather than FuncMap entries for the
12020// reason wasmpick.go's WasmExecs records: templates here are parsed in
12021// several places and only some of them call .Funcs(funcs), so a template
12022// function is a parse error waiting for whichever page was built bare.
12023// A method travels with the data and every parse site can call it.
12024
12025import (
12026	"github.com/0magnet/m2/pkg/storepage"
12027)
12028
12029// ShippingFields is the form, in order β€” the same list pkg/tui builds its
12030// overlay from and the same field names wasm/cart reads back.
12031func (h htmlTemplateData) ShippingFields() []storepage.Field {
12032	return storepage.ShippingFields
12033}
12034
12035// ShippingStates is the State select's options.
12036func (h htmlTemplateData) ShippingStates() []storepage.Jurisdiction {
12037	return storepage.States
12038}
12039
12040// ShippingCountries is the Country select's options.
12041func (h htmlTemplateData) ShippingCountries() []string { return storepage.Countries }
12042
12043// StatePrompt is the unselected entry at the top of the State select.
12044func (h htmlTemplateData) StatePrompt() string { return storepage.StatePrompt }
12045
12046// MinShipping is the least the store ships for, as the number a min
12047// attribute wants ("7").
12048func (h htmlTemplateData) MinShipping() string {
12049	return itoa(storepage.MinShippingCents / 100)
12050}
12051
12052// MinShippingValue is that same figure as a price ("7.00"), for the
12053// amount field's default.
12054func (h htmlTemplateData) MinShippingValue() string {
12055	c := storepage.MinShippingCents
12056	return itoa(c/100) + "." + pad2(c%100)
12057}
12058
12059func itoa(n int) string {
12060	if n == 0 {
12061		return "0"
12062	}
12063	var b []byte
12064	for n > 0 {
12065		b = append([]byte{byte('0' + n%10)}, b...)
12066		n /= 10
12067	}
12068	return string(b)
12069}
12070
12071func pad2(n int) string {
12072	s := itoa(n)
12073	if len(s) < 2 {
12074		return "0" + s
12075	}
12076	return s
12077}
12078
12079// CoinCheckout reports whether the deployment takes crypto, so the cart
12080// offers the button only where it will work. A method rather than a
12081// FuncMap entry, for the reason at the top of this file.
12082func (h htmlTemplateData) CoinCheckout() bool { return cryptoPay != nil }
12083
12084// CoinName is the coin the store takes, for the button's label.
12085func (h htmlTemplateData) CoinName() string {
12086	if cryptoPay == nil {
12087		return ""
12088	}
12089	return string(cryptoPay.backend.Coin())
12090}
12091
12092
12093// ===== pkg/web/shipping_test.go =====
12094package web
12095
12096import (
12097	"bytes"
12098	htmpl "html/template"
12099	"os"
12100	"strings"
12101	"testing"
12102
12103	"github.com/0magnet/m2/pkg/storepage"
12104)
12105
12106// renderFooter runs the footer partial the way a page does. It is parsed
12107// BARE β€” no .Funcs β€” on purpose: that is the state a template set built
12108// somewhere that forgot the FuncMap is in, and the shipping form must
12109// survive it. See pkg/web/shipping.go.
12110func renderFooter(t *testing.T) string {
12111	t.Helper()
12112	src, err := os.ReadFile("../../htmpl/footer.html")
12113	if err != nil {
12114		t.Fatal(err)
12115	}
12116	tmpl, err := htmpl.New("footer").Parse(string(src))
12117	if err != nil {
12118		t.Fatalf("footer does not parse without a FuncMap: %v", err)
12119	}
12120	var b bytes.Buffer
12121	if err := tmpl.Execute(&b, struct{ Page htmlTemplateData }{}); err != nil {
12122		t.Fatal(err)
12123	}
12124	return b.String()
12125}
12126
12127// The cart wasm reads each control by [name='...']; a renamed or dropped
12128// control silently produces an empty field in the shipping line, so the
12129// generated form must still carry every one of them.
12130func TestShippingFormKeepsItsControlNames(t *testing.T) {
12131	got := renderFooter(t)
12132	for _, f := range storepage.ShippingFields {
12133		if !strings.Contains(got, "name='"+f.Name+"'") {
12134			t.Errorf("no control named %q in the rendered form", f.Name)
12135		}
12136		if !strings.Contains(got, "id='"+f.Name+"'") {
12137			t.Errorf("no id %q in the rendered form", f.Name)
12138		}
12139	}
12140}
12141
12142// Every jurisdiction reaches the page, by code and by name.
12143func TestShippingFormListsEveryState(t *testing.T) {
12144	got := renderFooter(t)
12145	if n := strings.Count(got, "<option value='"); n != len(storepage.States)+len(storepage.Countries)+1 {
12146		t.Errorf("option count = %d, want %d states + %d countries + 1 prompt",
12147			n, len(storepage.States), len(storepage.Countries))
12148	}
12149	for _, s := range storepage.States {
12150		if !strings.Contains(got, "<option value='"+s.Code+"'>"+s.Name+"</option>") {
12151			t.Errorf("missing state option %s / %s", s.Code, s.Name)
12152		}
12153	}
12154}
12155
12156// The minimum is one number now; the form must show it rather than its own.
12157func TestShippingFormUsesTheSharedMinimum(t *testing.T) {
12158	got := renderFooter(t)
12159	if !strings.Contains(got, "min='7'") || !strings.Contains(got, "value='7.00'") {
12160		t.Errorf("amount field does not carry the shared minimum:\n%s", got)
12161	}
12162}
12163
12164// A pipe in any field shifts every field after it in the cart line.
12165func TestShippingRejectsAndScrubsTheSeparator(t *testing.T) {
12166	s := storepage.Shipping{
12167		Cents: 700, Name: "A|B", City: "Dallas", State: "TX", Country: "United States",
12168	}
12169	if msg := s.Validate(); msg == "" {
12170		t.Error("a pipe in Name was accepted")
12171	}
12172	if strings.Count(s.ID(), "|") != 8 {
12173		t.Errorf("ID() did not scrub the separator: %q", s.ID())
12174	}
12175	back, ok := storepage.ParseShipping(s.ID())
12176	if !ok || back.City != "Dallas" || back.State != "TX" {
12177		t.Errorf("round trip lost fields: %+v ok=%v", back, ok)
12178	}
12179}
12180
12181// Below the minimum is refused rather than silently charged.
12182func TestShippingEnforcesTheMinimum(t *testing.T) {
12183	if msg := (storepage.Shipping{Cents: 0}).Validate(); msg == "" {
12184		t.Error("zero shipping was accepted")
12185	}
12186	if msg := (storepage.Shipping{Cents: storepage.MinShippingCents, Email: "a@example.com"}).Validate(); msg != "" {
12187		t.Errorf("the minimum itself was refused: %s", msg)
12188	}
12189}
12190
12191// The email field is on the page's form, and the browser holds it required.
12192func TestShippingFormAsksForAnEmail(t *testing.T) {
12193	got := renderFooter(t)
12194	if !strings.Contains(got, "<input type='email' id='shipping-email' name='shipping-email' required") {
12195		t.Errorf("the shipping form has no required email input:\n%s", got)
12196	}
12197}
12198
12199
12200// ===== pkg/web/sourcecode.go =====
12201// Package web pkg/web/sourcecode.go β€” the store serves its own source.
12202package web
12203
12204import (
12205	"bytes"
12206	"embed"
12207	"fmt"
12208	"io/fs"
12209	"os"
12210	"strings"
12211
12212	"github.com/alecthomas/chroma/v2"
12213	"github.com/alecthomas/chroma/v2/formatters/html"
12214	"github.com/alecthomas/chroma/v2/lexers"
12215	"github.com/alecthomas/chroma/v2/styles"
12216	"github.com/gofiber/fiber/v3"
12217
12218	"github.com/0magnet/m2/pkg/config"
12219	"github.com/0magnet/m2/pkg/product"
12220	"github.com/0magnet/m2/pkg/tui"
12221)
12222
12223//go:embed *.go
12224var srcWeb embed.FS
12225
12226type srcEntry struct {
12227	Prefix string
12228	FS     fs.FS
12229}
12230
12231// goSources holds each package's embedded Go files for /sourcecode/go. A Go
12232// package cannot embed files outside its own directory, so since the code
12233// moved out of the repo root every package embeds its own *.go and is listed
12234// here. The web package's own embed picks up the other.go drop-in when a
12235// deployment builds with one.
12236var goSources = []srcEntry{
12237	{"pkg/config", config.Source},
12238	{"pkg/product", product.Source},
12239	{"pkg/tui", tui.Source},
12240	{"pkg/web", srcWeb},
12241}
12242
12243// RegisterSource adds a package's embedded sources to /sourcecode/go β€” used
12244// by the commands package, which web cannot import without a cycle.
12245func RegisterSource(prefix string, fsys fs.FS) {
12246	goSources = append(goSources, srcEntry{prefix, fsys})
12247}
12248
12249var sourcesWasm []fs.FS
12250var sourceCore = os.DirFS("ui")
12251var sourceHtml = os.DirFS("htmpl")
12252var sourceContent = os.DirFS("content")
12253
12254func serveSourceCode(r *fiber.App) {
12255	for _, wasmSRC := range f.WasmSRC {
12256		sourcesWasm = append(sourcesWasm, os.DirFS(wasmSRC))
12257	}
12258	r.Get("/sourcecode", func(c fiber.Ctx) error {
12259		ret := `<!doctype html>
12260<html lang='en'>
12261<head>
12262<link rel="stylesheet" href="/style.css" type="text/css">
12263</head>
12264<body class='grid-container' style='background-color:black;color:white;'>
12265<a href='/sourcecode/go'>GO</a><br><br>
12266
12267<a href='/sourcecode/html'>HTML</a><br><br>
12268
12269<a href='/sourcecode/content'>Content</a><br><br>
12270
12271<a href='/sourcecode/core'>C.O.R.E.</a><br><br>
12272
12273<a href='/sourcecode/wasm'>WASM</a><br><br>
12274
12275</body>
12276</html>
12277`
12278		c.Set("Content-Type", "text/html;charset=utf-8")
12279		_, err := c.Status(fiber.StatusOK).Write([]byte(ret))
12280		return err
12281	})
12282
12283	r.Get("/sourcecode/html", sourcecodehtml)
12284	r.Get("/sourcecode/content", sourcecodecontent)
12285	r.Get("/sourcecode/go", sourcecodego)
12286	r.Get("/sourcecode/core", sourcecodecore)
12287	r.Get("/sourcecode/wasm", func(c fiber.Ctx) error {
12288		ret := `<!doctype html>
12289<html lang='en'>
12290<head>
12291<link rel="stylesheet" href="/style.css" type="text/css">
12292</head>
12293<body class='grid-container' style='background-color:black;color:white;'>
12294`
12295		for _, wasmSRC := range f.WasmSRC {
12296			pathNameSlc := strings.Split(wasmSRC, "/")
12297			pathName := pathNameSlc[len(pathNameSlc)-1]
12298			ret += `<a href='/sourcecode/wasm/` + pathName + `'>` + pathName + `</a><br>
12299			`
12300		}
12301		ret += `</body></html>
12302		`
12303		c.Set("Content-Type", "text/html;charset=utf-8")
12304		_, err := c.Status(fiber.StatusOK).Write([]byte(ret))
12305		return err
12306	})
12307
12308	for i, wasmSRC := range f.WasmSRC {
12309		pathNameSlc := strings.Split(wasmSRC, "/")
12310		pathName := pathNameSlc[len(pathNameSlc)-1]
12311		r.Get("/sourcecode/wasm/"+pathName, func(c fiber.Ctx) error {
12312			return sourcecode(c, sourcesWasm[i], "dracula", "go")
12313		})
12314	}
12315}
12316
12317func sourcecodehtml(c fiber.Ctx) error {
12318	return sourcecode(c, sourceHtml, "monokai", "html")
12319}
12320func sourcecodecontent(c fiber.Ctx) error {
12321	return sourcecode(c, sourceContent, "monokai", "html")
12322}
12323
12324func sourcecodego(c fiber.Ctx) error {
12325	var builder strings.Builder
12326	for _, src := range goSources {
12327		if err := collectSource(&builder, src.FS, src.Prefix, "go"); err != nil {
12328			return err
12329		}
12330	}
12331	return renderSource(c, builder.String(), "monokai", "go")
12332}
12333
12334func sourcecodecore(c fiber.Ctx) error {
12335	return sourcecode(c, sourceCore, "solarized-dark256", "go")
12336}
12337
12338func sourcecode(c fiber.Ctx, fsys fs.FS, styleName string, lang string) error {
12339	var builder strings.Builder
12340	if err := collectSource(&builder, fsys, "", lang); err != nil {
12341		return err
12342	}
12343	return renderSource(c, builder.String(), styleName, lang)
12344}
12345
12346// collectSource concatenates every .<lang> file in fsys into builder, each
12347// under a banner naming it (with prefix, so files from different packages
12348// keep their repo paths).
12349func collectSource(builder *strings.Builder, fsys fs.FS, prefix string, lang string) error {
12350	return fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
12351		if err != nil {
12352			return err
12353		}
12354		if !d.IsDir() && strings.HasSuffix(path, "."+lang) {
12355			content, err := fs.ReadFile(fsys, path)
12356			if err != nil {
12357				return err
12358			}
12359			name := path
12360			if prefix != "" {
12361				name = prefix + "/" + path
12362			}
12363			builder.WriteString(fmt.Sprintf("// ===== %s =====\n", name))
12364			builder.Write(content)
12365			builder.WriteString("\n\n")
12366		}
12367		return nil
12368	})
12369}
12370
12371func renderSource(c fiber.Ctx, source string, styleName string, lang string) error {
12372	c.Set("Content-Type", "text/html;charset=utf-8")
12373	var buf bytes.Buffer
12374
12375	// Pick lexer & style
12376	lexer := lexers.Get(lang)
12377	if lexer == nil {
12378		lexer = lexers.Fallback
12379	}
12380	lexer = chroma.Coalesce(lexer)
12381
12382	style := styles.Get(styleName)
12383	if style == nil {
12384		style = styles.Fallback
12385	}
12386
12387	// Formatter with line numbers & CSS classes
12388	formatter := html.New(
12389		html.WithLineNumbers(true),
12390		html.WithClasses(true),
12391	)
12392
12393	iterator, err := lexer.Tokenise(nil, source)
12394	if err != nil {
12395		return err
12396	}
12397
12398	// Optional: include CSS in output
12399	var css bytes.Buffer
12400	_ = formatter.WriteCSS(&css, style) //nolint:errcheck // the stylesheet is decoration; the highlighted source is still readable without it
12401	buf.WriteString("<style>")
12402	buf.Write(css.Bytes())
12403	buf.WriteString("</style>")
12404
12405	if err := formatter.Format(&buf, style, iterator); err != nil {
12406		return err
12407	}
12408
12409	_, err = c.Status(fiber.StatusOK).Write(buf.Bytes())
12410	return err
12411}
12412
12413
12414// ===== pkg/web/tmpl.go =====
12415// Package web pkg/web/tmpl.go β€” template plumbing.
12416package web
12417
12418import (
12419	"bytes"
12420	"fmt"
12421	htmpl "html/template"
12422	"log"
12423	"os"
12424	"path/filepath"
12425	"sort"
12426	"strconv"
12427	"strings"
12428	ttmpl "text/template"
12429	"time"
12430
12431	"github.com/gofiber/fiber/v3"
12432
12433	p "github.com/0magnet/m2/pkg/product"
12434)
12435
12436/*
12437//go:embed htmpl/*
12438var templatesFS embed.FS
12439
12440//go:embed content/*
12441var contentFS embed.FS
12442*/
12443/*
12444var (
12445	templatesFS = os.DirFS("htmpl")
12446	contentFS   = os.DirFS("content")
12447)
12448*/
12449/*
12450func mustReadEmbeddedFileToString(path string, fs embed.FS) string {
12451	return string(mustReadEmbeddedFileToBytes(path, fs))
12452}
12453
12454func mustReadEmbeddedFileToBytes(path string, fs embed.FS) []byte {
12455	data, err := fs.ReadFile(path)
12456	if err != nil {
12457		panic(err)
12458	}
12459	return data
12460}
12461*/
12462
12463func mustReadFileToString(path string) string {
12464	return string(mustReadFileToBytes(path))
12465}
12466
12467// optionalReadFileToString returns "" when the file does not exist.
12468// Used for deployment-local assets (e.g. content/font.css) that are
12469// not part of the source repo.
12470func optionalReadFileToString(path string) string {
12471	data, err := os.ReadFile(path) //nolint
12472	if err != nil {
12473		return ""
12474	}
12475	return string(data)
12476}
12477
12478// contentFile returns the deployment-local file when present, falling
12479// back to the committed <path>.example. Lets site operators override
12480// stock pages (about, policy, links) without touching tracked files.
12481func contentFile(path string) string {
12482	if s := optionalReadFileToString(path); s != "" {
12483		return s
12484	}
12485	return mustReadFileToString(path + ".example")
12486}
12487
12488func mustReadFileToBytes(path string) []byte {
12489	data, err := os.ReadFile(path) //nolint
12490	if err != nil {
12491		panic(err)
12492	}
12493	return data
12494}
12495
12496type htmlTemplate struct {
12497	Empty          func() string
12498	Head           func() string
12499	Logo           func() string
12500	Header         func() string
12501	Categories     func() string
12502	CatSubcats     func() string
12503	Footer         func() string
12504	MainPage       func() string
12505	AuxPage        func() string
12506	FrontPage      func() string
12507	CategoryPage   func() string
12508	CategoryPageMD func() string
12509	ProductPage    func() string
12510	ProductPageMD  func() string
12511	Schema         func() string
12512	Cart           func() string
12513	XMLSitemap     func() string
12514	Wasm           func() string
12515	Keys           func() string
12516	AboutPage      func() string
12517	PolicyPage     func() string
12518	LinksPage      func() string
12519	CheckoutPage   func() string
12520	CompletePage   func() string
12521	CheckoutCSS    func() string
12522	StyleCSS       func() string
12523	FontCSS        func() string
12524}
12525
12526var h = htmlTemplate{
12527	Empty:          func() string { return mustReadFileToString("htmpl/empty.html") },
12528	Head:           func() string { return mustReadFileToString("htmpl/head.html") },
12529	Logo:           func() string { return mustReadFileToString("htmpl/logo.html") },
12530	Header:         func() string { return mustReadFileToString("htmpl/header.html") },
12531	Categories:     func() string { return mustReadFileToString("htmpl/categories.html") },
12532	CatSubcats:     func() string { return mustReadFileToString("htmpl/catsubcats.html") },
12533	Footer:         func() string { return mustReadFileToString("htmpl/footer.html") },
12534	MainPage:       func() string { return mustReadFileToString("htmpl/main.html") },
12535	AuxPage:        func() string { return mustReadFileToString("htmpl/auxpage.html") },
12536	FrontPage:      func() string { return mustReadFileToString("htmpl/front.html") },
12537	CategoryPage:   func() string { return mustReadFileToString("htmpl/category.html") },
12538	CategoryPageMD: func() string { return mustReadFileToString("htmpl/category.md") },
12539	ProductPage:    func() string { return mustReadFileToString("htmpl/product.html") },
12540	ProductPageMD:  func() string { return mustReadFileToString("htmpl/product.md") },
12541	Schema:         func() string { return mustReadFileToString("htmpl/schema.html") },
12542	Cart:           func() string { return mustReadFileToString("htmpl/cart.html") },
12543	XMLSitemap:     func() string { return mustReadFileToString("htmpl/sitemap.xml") },
12544	Wasm:           func() string { return mustReadFileToString("htmpl/wasm.html") },
12545	Keys:           func() string { return mustReadFileToString("htmpl/keys.html") },
12546	CompletePage:   func() string { return mustReadFileToString("htmpl/complete.html") },
12547	AboutPage:      func() string { return contentFile("content/about.html") },
12548	PolicyPage:     func() string { return contentFile("content/policy.html") },
12549	LinksPage:      func() string { return contentFile("content/links.html") },
12550	CheckoutPage:   func() string { return mustReadFileToString("content/checkout.html") },
12551	CheckoutCSS:    func() string { return mustReadFileToString("content/checkout.css") },
12552	StyleCSS: func() string {
12553		return fontCSS() + mustReadFileToString("content/style.css")
12554	},
12555	// The terminal page wants the face on its own, without the stylesheet.
12556	FontCSS: func() string { return fontCSS() },
12557}
12558
12559var htmlPageTemplateData htmlTemplateData
12560
12561var funcs = htmpl.FuncMap{
12562	"replace": replace, "mul": mul, "div": div, "safeHTML": safeHTML,
12563	"safeJS": safeJS, "stripProtocol": stripProtocol, "add": add, "sub": sub,
12564	"toFloat": toFloat, "equalsIgnoreCase": equalsIgnoreCase,
12565	"getsubcats": getsubcats, "escapesubcat": escapesubcat,
12566	"sortsubcats": sortsubcats, "repeat": repeat, "subcatlink": subcatlink,
12567	"navmenu": navmenu,
12568}
12569
12570func mainTmpl() (tmpl *htmpl.Template, err error) {
12571	tmpl = htmpl.New("index").Funcs(funcs)
12572	if _, err := tmpl.Parse(h.MainPage()); err != nil {
12573		log.Println("Error parsing index template:", err)
12574		return tmpl, err
12575	}
12576
12577	for _, p := range mainPartials() {
12578		if _, err := tmpl.New(p.Name).Parse(p.Content); err != nil {
12579			log.Printf("Error parsing %s template: %v", p.Name, err)
12580			return tmpl, err
12581		}
12582	}
12583	return tmpl, err
12584}
12585
12586// mainPartialSrc is every template main.html may invoke, besides the
12587// "main" its caller supplies.
12588//
12589// One list of name and loader rather than a list of names and a map to
12590// look them up in: the names have to be readable without loading the
12591// bodies, because a test has no working directory to read them from, and
12592// two collections would let a name arrive with no body behind it.
12593//
12594// The test matters because a missing partial is not a parse error.
12595// html/template notices only at execute time, and then on every page of
12596// the site at once, which is a poor place to find out.
12597var mainPartialSrc = []struct {
12598	Name string
12599	Body func() string
12600}{
12601	{"head", h.Head},
12602	{"schema", h.Schema},
12603	{"header", h.Header},
12604	{"catsubcats", h.CatSubcats},
12605	{"categories", h.Categories},
12606	{"footer", h.Footer},
12607	{"cart", h.Cart},
12608	{"wasm", h.Wasm},
12609	{"keys", h.Keys},
12610}
12611
12612// mainPartials loads them.
12613func mainPartials() []struct{ Name, Content string } {
12614	out := make([]struct{ Name, Content string }, 0, len(mainPartialSrc))
12615	for _, p := range mainPartialSrc {
12616		out = append(out, struct{ Name, Content string }{p.Name, p.Body()})
12617	}
12618	return out
12619}
12620
12621func auxTmpl() (tmpl *htmpl.Template, err error) {
12622	tmpl = htmpl.New("index").Funcs(funcs)
12623	if _, err := tmpl.Parse(h.AuxPage()); err != nil {
12624		log.Println("Error parsing index template:", err)
12625		return tmpl, err
12626	}
12627
12628	partials := []struct {
12629		Name    string
12630		Content string
12631	}{
12632		{"head", h.Head()},
12633		{"schema", h.Empty()},
12634		{"wasm", h.Empty()},
12635	}
12636
12637	for _, p := range partials {
12638		if _, err := tmpl.New(p.Name).Parse(p.Content); err != nil {
12639			log.Printf("Error parsing %s template: %v", p.Name, err)
12640			return tmpl, err
12641		}
12642	}
12643	return tmpl, err
12644}
12645
12646func pageMeta(c fiber.Ctx, base htmlTemplateData) htmlTemplateData {
12647	h := base
12648	host := string(c.Request().Host())
12649	/*
12650		proto := "http"
12651		if c.Secure() {
12652			proto += "s"
12653		}
12654	*/
12655	proto := "https"
12656	h.Canonical = proto + "://" + host + c.OriginalURL()
12657	h.BaseURL = proto + "://" + host
12658	h.RequestHost = host
12659	h.Protocol = proto
12660	h.CatsCounts, h.Cats, h.SubCatsCounts, h.SubCatsByCat = getcategories(allproducts)
12661	h.LenAllProducts = len(allproducts)
12662	h.Time = time.Now().Format(time.RFC3339Nano)
12663	h.Year = fmt.Sprintf("%v", time.Now().Year())
12664	h.MetaDesc = f.Sitemeta
12665	h.ImgBase = h.ImgSRC
12666	if strings.HasPrefix(h.ImgBase, "/") {
12667		h.ImgBase = h.BaseURL + h.ImgBase
12668	}
12669	h.KeyWords = strings.Replace(f.Sitelongname, " ", ", ", -1)
12670	return h
12671}
12672
12673func initTMPL() {
12674	htmlPageTemplateData = htmlTemplateData{
12675		TestMode:           f.Teststripekey,
12676		Title:              f.Sitelongname,
12677		StripePK:           f.StripePK,
12678		SiteName:           f.Sitedomain,
12679		SiteTagLine:        f.Sitetagline,
12680		SiteName1:          htmpl.HTML(checkerBoard(f.Sitedomain)), //nolint
12681		SiteLongName:       f.Sitelongname,
12682		SiteASCIILogo:      htmpl.HTML(f.SiteASCIILogo), //nolint
12683		SitePrettyName:     f.Siteprettyname,
12684		SitePrettyNameCap:  f.Siteprettynamecap,
12685		SitePrettyNameCaps: f.Siteprettynamecaps,
12686		TelegramContact:    f.Tgcontact,
12687		TelegramChannel:    f.Tgchannel,
12688		WasmExecPath:       f.WasmExecPath,
12689		WasmExecRel:        f.WasmExecPath,
12690		Cats:               getcats(),
12691		LenAllProducts:     len(allproducts),
12692		ImgSRC: func() (ret string) {
12693			ret = f.Siteimagesrc
12694			if ret == "" {
12695				ret = "/i"
12696			}
12697			return ret
12698		}(),
12699		Page:     "front",
12700		Time:     time.Now().Format(time.RFC3339Nano),
12701		Year:     fmt.Sprintf("%v", time.Now().Year()),
12702		NavLinks: extraNavLinks,
12703	}
12704	htmlPageTemplateData.CatsCounts, htmlPageTemplateData.Cats, htmlPageTemplateData.SubCatsCounts, htmlPageTemplateData.SubCatsByCat = getcategories(allproducts)
12705	// No wasm by default; a page opts in with wasmFor. Defaulting to all of
12706	// them is what had every page in the site fetching stl2 (7.9MB) for a
12707	// canvas it does not have, and asking for the tui and desk binaries,
12708	// which have no TinyGo build and answer 500. Those two pages build their
12709	// own HTML and loader anyway, so they never read this list.
12710	htmlPageTemplateData.WasmBinary = nil
12711
12712}
12713
12714// wasmFor returns only the configured binaries named, so a page carries the
12715// wasm it can actually use and nothing else.
12716//
12717// Every page used to carry all of them, and each binary opens by looking for
12718// the element it drives and returning when that element is not there β€” so an
12719// ordinary category page fetched several megabytes, instantiated it, and got
12720// a handful of early returns. stl2 is the expensive one at 7.9MB, and only
12721// the front page and a product page have the canvas it draws into.
12722//
12723// It also stops the binaries that have no TinyGo build yet from being asked
12724// for on every page in the site: those fetches answer 500, which is two
12725// failed requests per page view for something the page could not use anyway.
12726//
12727// Names match on the part before the suffix, so a caller says "stl2" and gets
12728// whichever of stl2.wasm or stl2-tiny.wasm this build is configured for.
12729func wasmFor(names ...string) []wasmPick { return wasmPicks(pickWasm, names) }
12730
12731// wasmSmallFor is wasmFor for a binary a page carries on every view
12732// rather than when the reader asks for it: it takes the tinygo build
12733// where there is one. See pickWasmSmall.
12734func wasmSmallFor(names ...string) []wasmPick { return wasmPicks(pickWasmSmall, names) }
12735
12736func wasmPicks(pick func(string) (wasmPick, bool), names []string) []wasmPick {
12737	// WasmSRC lists the sources THIS process builds, and a page must not
12738	// name a drop-in the deployment never compiles. The tab compiles
12739	// nothing and its list is empty, so the same gate there would drop
12740	// every binary before it was looked for; the origin, which pick asks
12741	// directly, is the authority instead.
12742	inTab := servingInTab()
12743	configured := map[string]bool{}
12744	if !inTab {
12745		for _, src := range f.WasmSRC {
12746			configured[strings.TrimSuffix(filepath.Base(src), filepath.Ext(src))] = true
12747		}
12748	}
12749	ret := make([]wasmPick, 0, len(names))
12750	for _, want := range names {
12751		if !inTab && !configured[want] {
12752			continue
12753		}
12754		if p, ok := pick(want); ok {
12755			ret = append(ret, p)
12756		}
12757	}
12758	return ret
12759}
12760
12761type xmlTemplateData struct {
12762	BaseURL      string
12763	Extra        []string
12764	Cats         []string
12765	SubCatsByCat map[string][]string
12766	Products     p.Products
12767	Update       string
12768}
12769
12770func generateSitemapXML() string {
12771	xmlSitemapTemplateData := xmlTemplateData{
12772		BaseURL:  "https://" + f.Sitedomain,
12773		Products: allproducts,
12774		Extra:    extraSitemapURLs,
12775		Update:   time.Now().Format("2006-01-02"),
12776	}
12777	_, xmlSitemapTemplateData.Cats, _, xmlSitemapTemplateData.SubCatsByCat = getcategories(allproducts)
12778	var err1 error
12779	xtmpl, err1 := ttmpl.New("index").Funcs(ttmpl.FuncMap{"getsubcats": getsubcats}).Parse(h.XMLSitemap())
12780	if err1 != nil {
12781		log.Println("Error parsing index template:", err1)
12782	}
12783	var result bytes.Buffer
12784	err1 = xtmpl.Execute(&result, xmlSitemapTemplateData)
12785	if err1 != nil {
12786		log.Println("error: ", err1)
12787	}
12788	return result.String()
12789}
12790
12791func toFloat(s string) float64 {
12792	if s == "" {
12793		return 0.0
12794	}
12795	f, err := strconv.ParseFloat(s, 64)
12796	if err != nil {
12797		return 0.0
12798	}
12799	return f
12800}
12801
12802func checkerBoard(input string) string {
12803	var result strings.Builder
12804	for i, char := range input {
12805		// Wrap every other letter with the specified HTML
12806		if i%2 == 0 {
12807			result.WriteString(fmt.Sprintf("<span class='nv'>%c</span>", char))
12808		} else {
12809			result.WriteRune(char)
12810		}
12811	}
12812	return result.String()
12813}
12814
12815type htmlTemplateData struct {
12816	Title     string
12817	MetaDesc  string
12818	Canonical string
12819	BaseURL   string
12820	ImgSRC    string // url where images are hosted
12821	// ImgBase is ImgSRC made absolute. og:image and schema.org image must be
12822	// absolute URLs: a link unfurler has no page to resolve a relative one
12823	// against and most simply drop it, which is why every share of this site
12824	// showed no picture. ImgSRC may already name another host, so this cannot
12825	// be done by pasting BaseURL in front of it in the template.
12826	ImgBase            string
12827	OrdersURL          string // url where checkout is served from
12828	SiteName           string
12829	SiteTagLine        string
12830	SiteName1          htmpl.HTML //checkerboard - alternate swap text & bg color
12831	SiteLongName       string
12832	SitePrettyName     string //π•„π•’π•˜π•Ÿπ•–π•₯𝕠𝕀𝕑𝕙𝕖𝕣𝕖.π•Ÿπ•–π•₯
12833	SitePrettyNameCap  string //π•„π•’π•˜π•Ÿπ•–π•₯𝕠𝕀𝕑𝕙𝕖𝕣𝕖.π•Ÿπ•–π•₯
12834	SitePrettyNameCaps string //π•„π”Έπ”Ύβ„•π”Όπ•‹π•†π•Šβ„™β„π”Όβ„π”Ό.ℕ𝔼𝕋
12835	SiteASCIILogo      htmpl.HTML
12836	TelegramContact    string
12837	TelegramChannel    string
12838	Protocol           string
12839	RequestHost        string
12840	KeyWords           string
12841	Style              htmpl.HTML
12842	Heading            htmpl.HTML
12843	StripePK           string
12844	Cats               []string
12845	CatsCounts         map[string]int
12846	SubCatsCounts      map[string]map[string]int
12847	SubCatsByCat       map[string][]string
12848	LenAllProducts     int
12849	Mobile             bool
12850	Gocanvas           htmpl.HTML
12851	WasmBinary         []wasmPick
12852	WasmExecPath       string
12853	WasmExecRel        string
12854	StyleFontFace      htmpl.CSS
12855	Message            htmpl.HTML
12856	Page               string
12857	Year               string
12858	Time               string
12859	AboutHTML          htmpl.HTML
12860	LinksHTML          htmpl.HTML
12861	PolicyHTML         htmpl.HTML
12862	TestMode           bool
12863
12864	// NavLinks are the entries drop-in routes added to the header; see
12865	// extraNavLinks.
12866	NavLinks []NavLink
12867}
12868
12869func equalsIgnoreCase(a, b string) bool {
12870	return strings.EqualFold(strings.Join(strings.Fields(a), ""), strings.Join(strings.Fields(b), ""))
12871}
12872
12873func replace(s, o, n string) string {
12874	return strings.ReplaceAll(s, o, n)
12875}
12876func mul(a, b float64) float64 {
12877	return a * b
12878}
12879func div(a, b float64) float64 {
12880	return a / b
12881}
12882func add(a, b int) int {
12883	return a + b
12884}
12885func sub(a, b int) int {
12886	return a - b
12887}
12888func safeHTML(s string) htmpl.HTML {
12889	return htmpl.HTML(s) //nolint
12890}
12891func safeJS(s string) htmpl.JS {
12892	return htmpl.JS(s) //nolint
12893}
12894func stripProtocol(s string) string {
12895	return strings.Replace(strings.Replace(s, "https://", "", -1), "http://", "", -1)
12896}
12897func repeat(s string, count int) string {
12898	var result string
12899	for i := 0; i < count; i++ {
12900		result += s
12901	}
12902	return result
12903}
12904func sortsubcats(subcats []string, counts map[string]map[string]int) []string {
12905	sort.Slice(subcats, func(i, j int) bool {
12906		catI, catJ := subcats[i], subcats[j]
12907		countI, countJ := counts[catI]["count"], counts[catJ]["count"]
12908		return countI > countJ
12909	})
12910	return subcats
12911}
12912
12913func subcatlink(subcategory string) string {
12914	s := subcategory
12915	s = strings.ReplaceAll(s, "ΒΌ", "quarter-")
12916	s = strings.ReplaceAll(s, "Β½", "half-")
12917	s = strings.ReplaceAll(s, "1/16", "sixteenth-")
12918	s = strings.ReplaceAll(s, "%", "-pct")
12919	s = strings.ReplaceAll(s, "  ", " ")
12920	s = strings.ReplaceAll(s, "watt1", "watt-1")
12921	s = strings.ReplaceAll(s, "watt5", "watt-5")
12922	s = strings.ReplaceAll(s, " ", "-")
12923	s = strings.ReplaceAll(s, "--", "-")
12924	return s
12925}
12926
12927// fontCSS is the deployment's @font-face block, read from disk. It is a
12928// plain function rather than a method on h because h's own initializer
12929// needs it, and a var cannot refer to itself.
12930func fontCSS() string { return optionalReadFileToString("content/font.css") }
12931
12932
12933// ===== pkg/web/tmpl_test.go =====
12934package web
12935
12936import (
12937	"os"
12938	"regexp"
12939	"testing"
12940)
12941
12942var tmplInvoke = regexp.MustCompile(`\{\{\s*template\s+"([^"]+)"`)
12943
12944// Every partial main.html asks for has to be registered with it.
12945//
12946// html/template does not notice a missing one when the page is parsed β€”
12947// only when it is executed, and then on every page of the site at once.
12948// That is a poor place to find out, and a new partial is exactly the
12949// change that forgets the registration.
12950func TestMainTemplateInvokesOnlyRegisteredPartials(t *testing.T) {
12951	src, err := os.ReadFile("../../htmpl/main.html")
12952	if err != nil {
12953		t.Fatalf("read main.html: %v", err)
12954	}
12955	known := map[string]bool{
12956		// Supplied by the handler for the page being rendered.
12957		"main": true,
12958	}
12959	for _, p := range mainPartialSrc {
12960		known[p.Name] = true
12961	}
12962	found := tmplInvoke.FindAllSubmatch(src, -1)
12963	if len(found) == 0 {
12964		t.Fatal("main.html invokes no templates at all, which cannot be right")
12965	}
12966	for _, m := range found {
12967		if name := string(m[1]); !known[name] {
12968			t.Errorf("main.html invokes %q, which mainTmpl does not register", name)
12969		}
12970	}
12971}
12972
12973// Every registration names a distinct template and carries a loader for
12974// it. The loaders read from the working directory, so this checks the
12975// shape rather than calling them.
12976func TestMainPartialsAreWellFormed(t *testing.T) {
12977	seen := map[string]bool{}
12978	for _, p := range mainPartialSrc {
12979		if p.Name == "" || p.Body == nil {
12980			t.Errorf("partial %+v has no name or no body", p.Name)
12981		}
12982		if seen[p.Name] {
12983			t.Errorf("%q is registered twice", p.Name)
12984		}
12985		seen[p.Name] = true
12986	}
12987}
12988
12989
12990// ===== pkg/web/toolchain_js.go =====
12991//go:build js && wasm
12992
12993package web
12994
12995// haveToolchain reports whether this build can shell out to a Go
12996// compiler. In a page it cannot: there is no Go, no tinygo, and nothing
12997// to exec them with.
12998//
12999// This exists because the whole m2 command tree now runs in the browser
13000// shell, so `m2 wasm` and `m2 run` are reachable there β€” and every
13001// failure path in wasmbuild.go is a log.Fatal, which is os.Exit, which
13002// in a page takes down the wasm instance and every terminal in it. A
13003// command that cannot do its job should say so and return non-zero, not
13004// kill the tab.
13005const haveToolchain = false
13006
13007
13008// ===== pkg/web/toolchain_native.go =====
13009//go:build !js || !wasm
13010
13011package web
13012
13013// haveToolchain reports whether this build can shell out to a Go
13014// compiler. Natively it can.
13015const haveToolchain = true
13016
13017
13018// ===== pkg/web/wasmbuild.go =====
13019// Package web pkg/web/wasmbuild.go β€” compiling the drop-in wasm apps.
13020package web
13021
13022import (
13023	"encoding/json"
13024	"fmt"
13025	"io"
13026	"io/fs"
13027	"log"
13028	"net/http"
13029	"os"
13030	"os/exec"
13031	"path/filepath"
13032	"regexp"
13033	"strconv"
13034
13035	"github.com/gofiber/fiber/v3"
13036	"strings"
13037	"time"
13038
13039	"github.com/bitfield/script"
13040	"github.com/briandowns/spinner"
13041
13042	"github.com/0magnet/m2/pkg/config"
13043)
13044
13045func CompileWASM() {
13046	if !haveToolchain {
13047		// Nothing here to build with; see toolchain_js.go.
13048		log.Println("wasm: no Go toolchain in this environment; nothing compiled")
13049		return
13050	}
13051	s := spinner.New(spinner.CharSets[14], 25*time.Millisecond)
13052	s.Suffix = " Compiling wasm..."
13053	// Each build runs on the system's default Go first; only if that fails does
13054	// it retry on a pinned Go toolchain. The pinned version is normally resolved
13055	// dynamically β€” tinygo trails the latest Go release, so on failure we read
13056	// the Go range tinygo supports from its own error and pin the newest matching
13057	// Go patch (see resolveCompatibleToolchain). That keeps the site on the newest
13058	// Go whenever tinygo supports it and self-heals across Go bumps with no
13059	// hardcoded version. GOTOOLCHAINFALLBACK in the MENV config is an OPTIONAL
13060	// offline backstop, used only if the dynamic lookup can't reach the release
13061	// list; leave it empty for fully dynamic behavior.
13062	offlineFallback := config.ScriptExecString("${GOTOOLCHAINFALLBACK}")
13063
13064	// Stage the drop-ins BEFORE building anything, from whatever the last run
13065	// left on disk, because a page binary embeds them (dropinfs.go) and
13066	// go:embed is read when it compiles.
13067	//
13068	// Staging after the builds instead would make the embed current within a
13069	// single run, and it is tempting for that reason. It also puts the tinygo
13070	// builds β€” stl2's is about twenty minutes β€” in front of every term.wasm
13071	// rebuild, so a one-line server edit would not reach the site for half an
13072	// hour. Staging first costs the opposite and much less: when a drop-in's
13073	// SOURCE changes, the copy inside the page binary is one restart behind.
13074	//
13075	// That is affordable because the embedded copy is a fallback and nothing
13076	// else. The host serves the drop-ins off disk, where they are current the
13077	// moment the build finishes, and a tab whose carried copy is stale is
13078	// still a tab serving a working cart. Nothing reads the embed unless the
13079	// disk has nothing.
13080	stageDropins(dropInSources())
13081
13082	for _, wasmSRC := range f.WasmSRC {
13083		ascend := strings.Repeat("../", len(strings.Split(wasmSRC, "/")))
13084		outputFile := baseName(wasmSRC) + ".wasm"
13085		mk := func(tc string) string {
13086			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)
13087		}
13088		buildWasmWithFallback(mk, offlineFallback, s, true)
13089	}
13090	if !f.UseTinygo {
13091		return
13092	}
13093	for _, wasmSRC := range f.WasmSRC {
13094		ascend := strings.Repeat("../", len(strings.Split(wasmSRC, "/")))
13095		outputFile := baseName(wasmSRC) + "-tiny.wasm"
13096		mk := func(tc string) string {
13097			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)
13098		}
13099		// The Go build above succeeded and serves; a drop-in that tinygo
13100		// cannot compile (e.g. one using net/http, which tinygo's shim
13101		// does not build) must not take the whole store down.
13102		buildWasmWithFallback(mk, offlineFallback, s, false)
13103	}
13104}
13105
13106// dropInSources is WasmSRC minus the page binaries β€” the sources whose builds
13107// a page loads alongside itself, and so the ones worth carrying.
13108func dropInSources() []string {
13109	var out []string
13110	for _, src := range f.WasmSRC {
13111		if !pageWasm[baseName(src)] {
13112			out = append(out, src)
13113		}
13114	}
13115	return out
13116}
13117
13118// pageWasm names the binaries that ARE a page rather than something a page
13119// loads alongside itself. They are not staged into the embed: the tab does not
13120// serve /tui or /desk, and term.wasm is the binary doing the embedding β€” a
13121// file cannot contain itself.
13122var pageWasm = map[string]bool{"term": true, "tui": true, "desk": true}
13123
13124func baseName(wasmSRC string) string {
13125	return strings.TrimSuffix(filepath.Base(wasmSRC), filepath.Ext(wasmSRC))
13126}
13127
13128// stageDropins copies the smallest build of each drop-in into the directory
13129// dropinfs.go embeds, so the page binaries compiled next carry them.
13130//
13131// Best effort throughout. A tree without the staging directory β€” a binary
13132// running somewhere other than its own source β€” simply stages nothing and the
13133// build carries nothing, which is the same as a fresh checkout.
13134func stageDropins(srcs []string) {
13135	dir := filepath.Join("pkg", "web", "dropins")
13136	// os.Root confines every name below to this directory. The names are
13137	// derived from WasmSRC, which is configuration rather than a request, but
13138	// "configuration cannot traverse" is a claim about the config file and not
13139	// about this code; the root makes it a property of this code.
13140	root, err := os.OpenRoot(dir)
13141	if err != nil {
13142		log.Printf("wasm: no %s to stage into; page binaries will carry no drop-ins", dir)
13143		return
13144	}
13145	defer root.Close() //nolint:errcheck // read-mostly; the writes below are closed individually
13146
13147	// Clear first, so a drop-in dropped from WasmSRC stops being carried.
13148	if ents, rerr := fs.ReadDir(root.FS(), "."); rerr == nil {
13149		for _, e := range ents {
13150			if !e.IsDir() && strings.HasSuffix(e.Name(), ".wasm") {
13151				_ = root.Remove(e.Name()) //nolint:errcheck // stale copy; the writes below are what matter
13152			}
13153		}
13154	}
13155
13156	for _, src := range srcs {
13157		name := baseName(src)
13158		// Smallest first, matching what a page is given to load.
13159		for _, cand := range []string{name + "-tiny.wasm", name + ".wasm"} {
13160			b, rerr := os.ReadFile(cand) //nolint:gosec // a path this process just built
13161			if rerr != nil {
13162				continue
13163			}
13164			if werr := writeInRoot(root, cand, b); werr != nil {
13165				log.Printf("wasm: staging %s: %v", cand, werr)
13166				continue
13167			}
13168			log.Printf("wasm: staged %s (%d bytes) for the in-tab server", cand, len(b))
13169			break
13170		}
13171	}
13172}
13173
13174// writeInRoot creates one file under root and writes it whole.
13175func writeInRoot(root *os.Root, name string, b []byte) error {
13176	fh, err := root.Create(name)
13177	if err != nil {
13178		return err
13179	}
13180	if _, err = fh.Write(b); err != nil {
13181		_ = fh.Close() //nolint:errcheck // the write error is the one to report
13182		return err
13183	}
13184	return fh.Close()
13185}
13186
13187// runBash runs an inner bash script and returns its combined stdout+stderr, so
13188// the caller can both log it and parse compiler errors out of it.
13189func runBash(inner string) (string, error) {
13190	// The command is built by this package, not taken from a request.
13191	out, err := exec.Command("bash", "-c", inner).CombinedOutput() //nolint:gosec
13192	return string(out), err
13193}
13194
13195// buildWasmWithFallback builds wasm on the system Go first (mkCmd("")). On
13196// failure it resolves a Go toolchain tinygo can use β€” dynamically from tinygo's
13197// own error output, or the offlineFallback if the release list is unreachable β€”
13198// and retries with GOTOOLCHAIN pinned to it. When required, a build that still
13199// fails is fatal; otherwise it is logged and skipped (the pages fall back to
13200// whichever binary exists). mkCmd receives the string to place before the
13201// build command: "" for the default, or "GOTOOLCHAIN=… ".
13202func buildWasmWithFallback(mkCmd func(toolchainPrefix string) string, offlineFallback string, s *spinner.Spinner, required bool) {
13203	log.Println("Compiling wasm with:")
13204	log.Println(mkCmd(""))
13205	s.Start()
13206	out, err := runBash(mkCmd(""))
13207	s.Stop()
13208	log.Print(out)
13209	if err == nil {
13210		log.Println("Compiled wasm!")
13211		return
13212	}
13213	tc := resolveCompatibleToolchain(out)
13214	if tc == "" {
13215		tc = offlineFallback
13216	}
13217	if tc == "" {
13218		if required {
13219			log.Fatalf("wasm build failed and no compatible Go toolchain could be resolved: %v", err)
13220		}
13221		log.Printf("optional wasm build failed (no compatible toolchain): %v", err)
13222		return
13223	}
13224	log.Printf("wasm build failed on the default Go; retrying with GOTOOLCHAIN=%s", tc)
13225	s.Start()
13226	out, err = runBash(mkCmd("GOTOOLCHAIN=" + tc + " "))
13227	s.Stop()
13228	log.Print(out)
13229	if err != nil {
13230		if required {
13231			log.Fatal(err)
13232		}
13233		log.Printf("optional wasm build failed: %v", err)
13234		return
13235	}
13236	log.Printf("Compiled wasm (Go toolchain %s)!", tc)
13237}
13238
13239// resolveCompatibleToolchain reads tinygo's supported Go range from a failed
13240// build's output (e.g. "requires go version 1.19 through 1.26, got go1.27") and
13241// returns the newest released Go patch of that ceiling minor (e.g. "go1.26.7"),
13242// or "" if the range can't be parsed or the release list can't be fetched.
13243func resolveCompatibleToolchain(buildOutput string) string {
13244	m := regexp.MustCompile(`through (\d+)\.(\d+)`).FindStringSubmatch(buildOutput)
13245	if m == nil {
13246		return ""
13247	}
13248	minor := m[1] + "." + m[2]
13249
13250	client := &http.Client{Timeout: 20 * time.Second}
13251	resp, err := client.Get("https://go.dev/dl/?mode=json&include=all")
13252	if err != nil {
13253		return ""
13254	}
13255	defer func() { _ = resp.Body.Close() }() //nolint:errcheck,gosec
13256	body, err := io.ReadAll(resp.Body)
13257	if err != nil {
13258		return ""
13259	}
13260	var rels []struct {
13261		Version string `json:"version"`
13262	}
13263	if err := json.Unmarshal(body, &rels); err != nil {
13264		return ""
13265	}
13266
13267	// Newest patch of go<minor> (require a patch number: GOTOOLCHAIN rejects a
13268	// bare "go1.26" β€” it must be a full toolchain version like go1.26.7).
13269	re := regexp.MustCompile(`^go` + regexp.QuoteMeta(minor) + `\.(\d+)$`)
13270	best, bestPatch := "", -1
13271	for _, r := range rels {
13272		mm := re.FindStringSubmatch(r.Version)
13273		if mm == nil {
13274			continue
13275		}
13276		// A version that does not parse yields 0, which loses the comparison
13277		// below, so an unparsable tag is skipped rather than erroring.
13278		if p, _ := strconv.Atoi(mm[1]); p > bestPatch { //nolint:errcheck
13279			bestPatch, best = p, r.Version
13280		}
13281	}
13282	return best
13283}
13284
13285func ldflags(s string) (ss string) {
13286	checkFiles, err := script.FindFiles(s).Slice()
13287	if err != nil {
13288		log.Fatal(err)
13289	}
13290	if f.LDFlagsX != "" {
13291		for _, s := range checkFiles {
13292			res, err := script.File(s).Match(strings.Split(f.LDFlagsX, "=")[0]).String()
13293			if err != nil {
13294				log.Fatal(err)
13295			}
13296			if res != "" {
13297				ss += fmt.Sprintf(` -X 'main.%s' `, f.LDFlagsX)
13298				break
13299			}
13300		}
13301	}
13302	for _, s := range checkFiles {
13303		res, err := script.File(s).Match("wasmName").String()
13304		if err != nil {
13305			log.Fatal(err)
13306		}
13307		if res != "" {
13308			ss += fmt.Sprintf(` -X 'main.wasmName=%s' `, strings.TrimSuffix(filepath.Base(s), filepath.Ext(s))+".wasm")
13309			break
13310		}
13311	}
13312	if ss != "" {
13313		ss = `-ldflags="` + ss + `"`
13314	}
13315	return ss
13316}
13317
13318// serveCarriedWASM registers a route for every drop-in the build staged into
13319// the binary, and for the runtime shims a page needs to start one.
13320//
13321// It is the tab's copy of serveWASM. serveWASM reads the working directory and
13322// is registered only in the host role, which is right there and wrong here: a
13323// tab has no working directory carrying binaries and never will. Everything
13324// unmatched falls through to the read-through proxy, so a drop-in the build did
13325// not stage still reaches the origin; this only means the common ones do not
13326// have to.
13327//
13328// Registered BEFORE the proxy catch-all, and only for files actually carried,
13329// so a route never shadows a path the origin could answer better.
13330func serveCarriedWASM(r *fiber.App) {
13331	for _, file := range embeddedDropinNames() {
13332		body, ok := embeddedDropin(file)
13333		if !ok {
13334			continue
13335		}
13336		r.Get("/"+file, func(c fiber.Ctx) error {
13337			c.Set(fiber.HeaderContentType, "application/wasm")
13338			// Immutable: this one is compiled into the binary serving it, so
13339			// it cannot change without the page that loaded it changing too.
13340			c.Set(fiber.HeaderCacheControl, "public, max-age=31536000, immutable")
13341			return c.Send(body)
13342		})
13343	}
13344}
13345
13346
13347// ===== pkg/web/wasmpick.go =====
13348package web
13349
13350// Which build of each wasm drop-in a page loads, and which runtime shim starts
13351// it.
13352//
13353// USETINYGO does not mean "tinygo only". wasmbuild.go builds the Go variant of
13354// every source FIRST and unconditionally (that build is `required`), and only
13355// then, if the flag is set, additionally builds the tinygo variants as
13356// best-effort. So the usual state on the server is that BOTH stl2.wasm and
13357// stl2-tiny.wasm exist.
13358//
13359// The serving side did not know that. The old wasmBinary() named "<x>-tiny.wasm" for
13360// every source whenever the flag was on, without looking at the disk, which had
13361// two consequences:
13362//
13363//   - A fresh Go build was ignored in favor of a stale tinygo one. The Go build
13364//     of stl2 takes about 4 seconds and the tinygo build about 22 minutes, so
13365//     during that window the page served a binary from before the edit β€” a
13366//     22-minute feedback loop for a 4-second change.
13367//   - A tinygo build that FAILED still had its name emitted. wasmbuild.go says
13368//     a drop-in tinygo cannot compile "must not take the whole store down" and
13369//     that "the pages fall back to whichever binary exists", but only tuipage
13370//     and deskpage ever did that stat; template pages asked for a file that was
13371//     not there.
13372//
13373// So the choice is made per binary, from the disk, newest wins.
13374
13375import (
13376	"net/http"
13377	"os"
13378	"strings"
13379	"sync"
13380)
13381
13382// wasmPick is one binary a page will load together with the shim that can start
13383// it. The two travel together because they must match: tinygo's wasm_exec.js
13384// cannot start a stdlib Go binary (it throws a LinkError on the gojs imports)
13385// and the stdlib shim cannot start a tinygo one.
13386type wasmPick struct {
13387	File     string // "stl2.wasm" or "stl2-tiny.wasm"
13388	ExecPath string // the wasm_exec.js matching its toolchain
13389	GoGlobal string // the JS global holding that shim's Go constructor
13390}
13391
13392// The two shims both define globalThis.Go, so a page needing both must load
13393// them one after another and stash each constructor before the next overwrites
13394// it. These are the names it stashes them under.
13395const (
13396	goGlobalStd  = "__GoStd"
13397	goGlobalTiny = "__GoTiny"
13398)
13399
13400// newerFile reports which of two paths has the later modification time.
13401// Missing files lose; if neither exists, ok is false.
13402func newerFile(a, b string) (path string, ok bool) {
13403	sa, ea := os.Stat(a)
13404	sb, eb := os.Stat(b)
13405	switch {
13406	case ea == nil && eb == nil:
13407		if sb.ModTime().After(sa.ModTime()) {
13408			return b, true
13409		}
13410		return a, true
13411	case ea == nil:
13412		return a, true
13413	case eb == nil:
13414		return b, true
13415	}
13416	return "", false
13417}
13418
13419// pickWasm chooses the build of one drop-in to serve.
13420//
13421// When the tinygo build is not wanted at all the Go build is the only candidate,
13422// so a site that never sets USETINYGO behaves exactly as before.
13423func pickWasm(name string) (wasmPick, bool) {
13424	if servingInTab() {
13425		if p, ok := pickCarried(name); ok {
13426			return p, true
13427		}
13428		return pickFromOrigin(name)
13429	}
13430	std := name + ".wasm"
13431	if !f.UseTinygo {
13432		if _, err := os.Stat(std); err != nil {
13433			return wasmPick{}, false
13434		}
13435		return wasmPick{File: std, ExecPath: f.WasmExecPathGo, GoGlobal: goGlobalStd}, true
13436	}
13437	which, ok := newerFile(std, name+"-tiny.wasm")
13438	if !ok {
13439		return wasmPick{}, false
13440	}
13441	if strings.HasSuffix(which, "-tiny.wasm") {
13442		return wasmPick{File: which, ExecPath: f.WasmExecPathTinyGo, GoGlobal: goGlobalTiny}, true
13443	}
13444	return wasmPick{File: which, ExecPath: f.WasmExecPathGo, GoGlobal: goGlobalStd}, true
13445}
13446
13447// pickWasmSmall chooses the SMALLER build of a drop-in: the tinygo one
13448// when it exists, whatever the modification times say.
13449//
13450// pickWasm's newest-wins rule is right for a binary a page loads because
13451// that binary is the point of the page β€” during the twenty-odd minutes a
13452// tinygo build takes, serving the four-second Go build is what keeps the
13453// edit visible. It is the wrong rule for one every page carries whether
13454// the reader uses it or not. The cart is that: 4.5MB as a Go build
13455// against 1MB as a tinygo one, fetched on every catalog page view to
13456// keep a list of part numbers in localStorage.
13457//
13458// Falls back to pickWasm, so a site with no tinygo β€” or one whose tinygo
13459// build of this source failed β€” gets the Go build rather than nothing.
13460func pickWasmSmall(name string) (wasmPick, bool) {
13461	if servingInTab() {
13462		if p, ok := pickCarried(name); ok {
13463			return p, true
13464		}
13465		return pickFromOrigin(name)
13466	}
13467	if f.UseTinygo {
13468		if tiny := name + "-tiny.wasm"; fileExists(tiny) {
13469			return wasmPick{File: tiny, ExecPath: f.WasmExecPathTinyGo, GoGlobal: goGlobalTiny}, true
13470		}
13471	}
13472	return pickWasm(name)
13473}
13474
13475func fileExists(path string) bool {
13476	_, err := os.Stat(path)
13477	return err == nil
13478}
13479
13480// pageBinary picks the binary a full-page app loads, taking the first of
13481// names the deployment has actually built, and the tinygo build of it
13482// where there is one.
13483//
13484// The names are given joint-first. /tui and /desk were two binaries of
13485// about fifty megabytes each that were all but the same fifty β€” the desk
13486// links pkg/storepane to put a store in a window, and the storefront page
13487// is pkg/storepane and nothing else β€” so wasm/term is the two together,
13488// eight kilobytes larger than the desk alone and serving both. A
13489// deployment that builds it gets it; one that still lists the separate
13490// drop-ins gets those, and nothing has to change at once.
13491//
13492// The shim travels with the pick because the two are not interchangeable:
13493// tinygo's wasm_exec.js throws a LinkError on a stdlib Go binary's gojs
13494// imports, and the stdlib one cannot start a tinygo binary.
13495func pageBinary(names ...string) (file, execPath string) {
13496	for _, n := range names {
13497		if f.UseTinygo && fileExists(n+"-tiny.wasm") {
13498			return n + "-tiny.wasm", f.WasmExecPathTinyGo
13499		}
13500		if fileExists(n + ".wasm") {
13501			return n + ".wasm", f.WasmExecPathGo
13502		}
13503	}
13504	// Nothing built yet. Name the app's own Go build, so a page served
13505	// during the first compile asks for the file that is about to exist
13506	// rather than for nothing at all.
13507	return names[len(names)-1] + ".wasm", f.WasmExecPathGo
13508}
13509
13510// wasmExec is one runtime shim a page must load.
13511type wasmExec struct {
13512	Path     string
13513	GoGlobal string
13514}
13515
13516// wasmExecsFor returns the shims a set of picks needs, deduplicated and in a
13517// stable order. A page with a tinygo binary and a Go binary on it gets both.
13518func wasmExecsFor(picks []wasmPick) []wasmExec {
13519	var out []wasmExec
13520	seen := map[string]bool{}
13521	for _, p := range picks {
13522		if p.ExecPath == "" || seen[p.GoGlobal] {
13523			continue
13524		}
13525		seen[p.GoGlobal] = true
13526		out = append(out, wasmExec{Path: p.ExecPath, GoGlobal: p.GoGlobal})
13527	}
13528	return out
13529}
13530
13531// WasmExecs is the shim list for this page's binaries, as a METHOD rather than
13532// a template function.
13533//
13534// It was a FuncMap entry, and that broke the whole site: templates are parsed
13535// in several places here and only some of them call .Funcs(funcs), so a page
13536// whose set was built bare β€” order.go's, for one β€” died at parse time with
13537// `function "wasmexecs" not defined` and served a 500. A method travels with
13538// the data instead of with the template set, so every set can call it and no
13539// new parse site has to remember anything.
13540func (h htmlTemplateData) WasmExecs() []wasmExec { return wasmExecsFor(h.WasmBinary) }
13541
13542// The in-tab server's filesystem is not the authority on which drop-ins a
13543// page may load.
13544//
13545// When the store runs inside the page (pkg/storepane's `serve`, behind the
13546// desk's netscrape window) its jsfs carries the templates and content the
13547// render code reads and nothing else β€” images, fonts and models already
13548// read through to the origin on a miss, and so do the wasm binaries. But
13549// the picking above asks the local disk, which in the tab holds none of
13550// them, so every page rendered there came out with no drop-in referenced
13551// at all: no cart, no decoration, and no attractor over the logo. The
13552// binaries were reachable the whole time; nothing ever named them.
13553//
13554// So in the tab the origin is asked instead, once per name.
13555
13556// originSrc is the origin an in-tab server's misses read through to, and
13557// what it has learned about which binaries that origin serves. base is
13558// empty in the host process, where the disk is the authority and none of
13559// this runs.
13560var originSrc struct {
13561	mu   sync.Mutex
13562	base string
13563	seen map[string]bool
13564}
13565
13566// serveFromOrigin marks this process the in-tab server. NewApp calls it
13567// with the same origin it gives the read-through proxy.
13568func serveFromOrigin(base string) {
13569	originSrc.mu.Lock()
13570	defer originSrc.mu.Unlock()
13571	originSrc.base, originSrc.seen = base, map[string]bool{}
13572}
13573
13574// servingInTab reports whether this is the in-page server.
13575func servingInTab() bool {
13576	originSrc.mu.Lock()
13577	defer originSrc.mu.Unlock()
13578	return originSrc.base != ""
13579}
13580
13581// originHas reports whether the origin serves a file, asking it at most
13582// once per name and remembering the answer. A binary the origin does not
13583// have goes unreferenced, which is the answer a missing file gives on a
13584// host.
13585func originHas(file string) bool {
13586	originSrc.mu.Lock()
13587	base, seen := originSrc.base, originSrc.seen
13588	if have, ok := seen[file]; ok {
13589		originSrc.mu.Unlock()
13590		return have
13591	}
13592	originSrc.mu.Unlock()
13593
13594	have := false
13595	if req, err := http.NewRequest(http.MethodHead, base+"/"+file, nil); err == nil {
13596		if resp, derr := http.DefaultClient.Do(req); derr == nil {
13597			_ = resp.Body.Close() //nolint:errcheck // nothing was read from it
13598			have = resp.StatusCode == http.StatusOK
13599		}
13600	}
13601
13602	originSrc.mu.Lock()
13603	seen[file] = have
13604	originSrc.mu.Unlock()
13605	return have
13606}
13607
13608// pickFromOrigin chooses a drop-in the way the tab must: smallest build
13609// the origin actually serves.
13610//
13611// It does not consult UseTinygo. That flag says whether THIS process
13612// should run tinygo over the sources, and the tab has no toolchain and
13613// built nothing; what matters is which builds exist at the other end. The
13614// tab also pays for every byte twice β€” once over the network into the
13615// page, once through the proxy into the frame β€” so the small build is
13616// wanted wherever there is one.
13617// pickCarried chooses a drop-in the binary itself carries. It runs before the
13618// origin is asked, so a tab with a staged build answers out of itself.
13619func pickCarried(name string) (wasmPick, bool) {
13620	if tiny := name + "-tiny.wasm"; carriesDropin(tiny) {
13621		return wasmPick{File: tiny, ExecPath: f.WasmExecPathTinyGo, GoGlobal: goGlobalTiny}, true
13622	}
13623	if std := name + ".wasm"; carriesDropin(std) {
13624		return wasmPick{File: std, ExecPath: f.WasmExecPathGo, GoGlobal: goGlobalStd}, true
13625	}
13626	return wasmPick{}, false
13627}
13628
13629// carriesDropin reports whether the build staged this file into the binary.
13630func carriesDropin(file string) bool {
13631	_, ok := lookupDropin(file)
13632	return ok
13633}
13634
13635func pickFromOrigin(name string) (wasmPick, bool) {
13636	if tiny := name + "-tiny.wasm"; originHas(tiny) {
13637		return wasmPick{File: tiny, ExecPath: f.WasmExecPathTinyGo, GoGlobal: goGlobalTiny}, true
13638	}
13639	if std := name + ".wasm"; originHas(std) {
13640		return wasmPick{File: std, ExecPath: f.WasmExecPathGo, GoGlobal: goGlobalStd}, true
13641	}
13642	return wasmPick{}, false
13643}
13644
13645
13646// ===== pkg/web/wasmpick_test.go =====
13647package web
13648
13649import (
13650	"bytes"
13651	htmpl "html/template"
13652	"net/http"
13653	"net/http/httptest"
13654	"os"
13655	"path/filepath"
13656	"strings"
13657	"testing"
13658	"time"
13659)
13660
13661// touch writes a file with an explicit mtime, so "newest wins" can be tested
13662// without sleeping between writes.
13663func touch(t *testing.T, path string, age time.Duration) {
13664	t.Helper()
13665	if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
13666		t.Fatal(err)
13667	}
13668	when := time.Now().Add(-age)
13669	if err := os.Chtimes(path, when, when); err != nil {
13670		t.Fatal(err)
13671	}
13672}
13673
13674func TestNewerFile(t *testing.T) {
13675	dir := t.TempDir()
13676	a := filepath.Join(dir, "a.wasm")
13677	b := filepath.Join(dir, "b.wasm")
13678
13679	if _, ok := newerFile(a, b); ok {
13680		t.Error("neither file exists, but newerFile reported a winner")
13681	}
13682
13683	touch(t, a, time.Hour)
13684	if got, ok := newerFile(a, b); !ok || got != a {
13685		t.Errorf("only a exists: got %q ok=%v, want %q true", got, ok, a)
13686	}
13687	if got, ok := newerFile(b, a); !ok || got != a {
13688		t.Errorf("only a exists (as second arg): got %q ok=%v, want %q true", got, ok, a)
13689	}
13690
13691	// b newer than a: b must win from either argument position, or the
13692	// selection would depend on the order the caller happened to pass them.
13693	touch(t, b, time.Minute)
13694	if got, _ := newerFile(a, b); got != b {
13695		t.Errorf("b is newer: got %q, want %q", got, b)
13696	}
13697	if got, _ := newerFile(b, a); got != b {
13698		t.Errorf("b is newer (arg order swapped): got %q, want %q", got, b)
13699	}
13700}
13701
13702// A tie must not flap between the two on successive page loads.
13703func TestNewerFileTieIsStable(t *testing.T) {
13704	dir := t.TempDir()
13705	a := filepath.Join(dir, "a.wasm")
13706	b := filepath.Join(dir, "b.wasm")
13707	touch(t, a, time.Hour)
13708	touch(t, b, time.Hour)
13709	first, _ := newerFile(a, b)
13710	for i := 0; i < 5; i++ {
13711		if got, _ := newerFile(a, b); got != first {
13712			t.Fatalf("tie is not stable: %q then %q", first, got)
13713		}
13714	}
13715}
13716
13717// The shim must match the build, and a page carrying both toolchains must get
13718// both shims under distinct globals β€” the whole point of the aliasing.
13719func TestWasmExecsForDedupesAndKeepsBoth(t *testing.T) {
13720	picks := []wasmPick{
13721		{File: "stl2-tiny.wasm", ExecPath: "/tiny/wasm_exec.js", GoGlobal: goGlobalTiny},
13722		{File: "deco-tiny.wasm", ExecPath: "/tiny/wasm_exec.js", GoGlobal: goGlobalTiny},
13723		{File: "cart.wasm", ExecPath: "/go/wasm_exec.js", GoGlobal: goGlobalStd},
13724	}
13725	got := wasmExecsFor(picks)
13726	if len(got) != 2 {
13727		t.Fatalf("got %d shims, want 2 (one per toolchain): %+v", len(got), got)
13728	}
13729	if got[0].GoGlobal != goGlobalTiny || got[1].GoGlobal != goGlobalStd {
13730		t.Errorf("shims out of order or wrong globals: %+v", got)
13731	}
13732	if goGlobalTiny == goGlobalStd {
13733		t.Error("the two globals must differ, or the second shim overwrites the first")
13734	}
13735}
13736
13737func TestWasmExecsForSkipsEmptyPath(t *testing.T) {
13738	if got := wasmExecsFor([]wasmPick{{File: "x.wasm"}}); len(got) != 0 {
13739		t.Errorf("a pick with no shim path yielded %+v, want none", got)
13740	}
13741}
13742
13743// The wasm partial must render from a template set built WITHOUT .Funcs().
13744//
13745// This is the bug that took the site down: the shim list was a FuncMap entry,
13746// but templates are parsed in several places here and only some call
13747// .Funcs(funcs). A page whose set was built bare died at parse time with
13748// `function "wasmexecs" not defined` and served a 500 for the whole front page.
13749// A method on the data has no such dependency, and this test is the proof.
13750func TestWasmPartialParsesWithoutFuncMap(t *testing.T) {
13751	src, err := os.ReadFile("../../htmpl/wasm.html")
13752	if err != nil {
13753		t.Skip("template not present:", err)
13754	}
13755	tmpl := htmpl.New("index")
13756	if _, err := tmpl.New("wasm").Parse(string(src)); err != nil {
13757		t.Fatalf("wasm.html needs a FuncMap it may not get: %v", err)
13758	}
13759	data := struct{ Page htmlTemplateData }{Page: htmlTemplateData{
13760		WasmBinary: []wasmPick{
13761			{File: "stl2-tiny.wasm", ExecPath: "/tiny/wasm_exec.js", GoGlobal: goGlobalTiny},
13762			{File: "cart.wasm", ExecPath: "/go/wasm_exec.js", GoGlobal: goGlobalStd},
13763		},
13764	}}
13765	var buf bytes.Buffer
13766	if err := tmpl.ExecuteTemplate(&buf, "wasm", data); err != nil {
13767		t.Fatalf("executing the wasm partial: %v", err)
13768	}
13769	out := buf.String()
13770	// Both shims, both stashed under distinct globals, and each binary using
13771	// the one that matches it.
13772	for _, want := range []string{
13773		"/tiny/wasm_exec.js", "/go/wasm_exec.js",
13774		`window["` + goGlobalTiny + `"] = Go`, `window["` + goGlobalStd + `"] = Go`,
13775		"stl2-tiny.wasm", "cart.wasm",
13776	} {
13777		if !strings.Contains(out, want) {
13778			t.Errorf("rendered wasm partial is missing %q", want)
13779		}
13780	}
13781}
13782
13783// A page with no wasm must render nothing at all β€” not an empty <script> that
13784// would still try to construct a Go runtime.
13785func TestWasmPartialEmptyWhenNoBinaries(t *testing.T) {
13786	src, err := os.ReadFile("../../htmpl/wasm.html")
13787	if err != nil {
13788		t.Skip("template not present:", err)
13789	}
13790	tmpl := htmpl.New("index")
13791	if _, err := tmpl.New("wasm").Parse(string(src)); err != nil {
13792		t.Fatal(err)
13793	}
13794	var buf bytes.Buffer
13795	data := struct{ Page htmlTemplateData }{}
13796	if err := tmpl.ExecuteTemplate(&buf, "wasm", data); err != nil {
13797		t.Fatal(err)
13798	}
13799	if strings.Contains(buf.String(), "<script") {
13800		t.Errorf("a page with no wasm still emitted a script tag:\n%s", buf.String())
13801	}
13802}
13803
13804// The cart rides on every catalog page, so it takes the tinygo build
13805// where there is one β€” 1MB against 4.5MB, fetched whether or not the
13806// reader ever opens the cart. pickWasm's newest-wins rule is right for a
13807// binary that IS the page and wrong for one that merely travels with it.
13808func TestPickWasmSmallPrefersTheTinyBuild(t *testing.T) {
13809	t.Chdir(t.TempDir())
13810	saved := *f
13811	t.Cleanup(func() { *f = saved })
13812	f.UseTinygo = true
13813	f.WasmExecPathGo = "/go.js"
13814	f.WasmExecPathTinyGo = "/tiny.js"
13815
13816	write := func(name string) {
13817		if err := os.WriteFile(name, []byte("x"), 0o600); err != nil {
13818			t.Fatal(err)
13819		}
13820	}
13821	// The Go build written LAST, so newest-wins would take it.
13822	write("cart-tiny.wasm")
13823	time.Sleep(10 * time.Millisecond)
13824	write("cart.wasm")
13825
13826	if got, ok := pickWasm("cart"); !ok || got.File != "cart.wasm" {
13827		t.Fatalf("pickWasm took %+v, want the newer cart.wasm β€” the premise of this test", got)
13828	}
13829	got, ok := pickWasmSmall("cart")
13830	if !ok {
13831		t.Fatal("pickWasmSmall found nothing")
13832	}
13833	if got.File != "cart-tiny.wasm" {
13834		t.Errorf("pickWasmSmall took %q, want cart-tiny.wasm", got.File)
13835	}
13836	if got.ExecPath != f.WasmExecPathTinyGo || got.GoGlobal != goGlobalTiny {
13837		t.Errorf("tiny build paired with %+v, want tinygo's shim", got)
13838	}
13839}
13840
13841// With no tinygo build β€” or no tinygo at all β€” the Go build is served
13842// rather than nothing.
13843func TestPickWasmSmallFallsBackToTheGoBuild(t *testing.T) {
13844	t.Chdir(t.TempDir())
13845	saved := *f
13846	t.Cleanup(func() { *f = saved })
13847	f.UseTinygo = true
13848	f.WasmExecPathGo = "/go.js"
13849	if err := os.WriteFile("cart.wasm", []byte("x"), 0o600); err != nil {
13850		t.Fatal(err)
13851	}
13852	got, ok := pickWasmSmall("cart")
13853	if !ok || got.File != "cart.wasm" || got.GoGlobal != goGlobalStd {
13854		t.Errorf("with no tinygo build got %+v, want the Go one", got)
13855	}
13856	f.UseTinygo = false
13857	if got, ok := pickWasmSmall("cart"); !ok || got.File != "cart.wasm" {
13858		t.Errorf("with tinygo off got %+v, want the Go one", got)
13859	}
13860}
13861
13862// The joint build wins when the deployment has one, and the page's own
13863// is the fallback β€” so a site that has not rebuilt yet keeps working.
13864func TestPageBinaryPrefersTheJointBuild(t *testing.T) {
13865	t.Chdir(t.TempDir())
13866	saved := *f
13867	t.Cleanup(func() { *f = saved })
13868	f.UseTinygo = false
13869	f.WasmExecPathGo = "/go.js"
13870	f.WasmExecPathTinyGo = "/tiny.js"
13871	write := func(name string) {
13872		if err := os.WriteFile(name, []byte("x"), 0o600); err != nil {
13873			t.Fatal(err)
13874		}
13875	}
13876
13877	// Only the separate builds so far: each page gets its own.
13878	write("tui.wasm")
13879	write("desk.wasm")
13880	if got, _ := pageBinary("term", "tui"); got != "tui.wasm" {
13881		t.Errorf("with no joint build /tui got %q, want tui.wasm", got)
13882	}
13883	if got, _ := pageBinary("term", "desk"); got != "desk.wasm" {
13884		t.Errorf("with no joint build /desk got %q, want desk.wasm", got)
13885	}
13886
13887	// The joint build arrives and both pages move to it.
13888	write("term.wasm")
13889	for _, page := range []string{"tui", "desk"} {
13890		got, exec := pageBinary("term", page)
13891		if got != "term.wasm" {
13892			t.Errorf("/%s got %q, want term.wasm", page, got)
13893		}
13894		if exec != f.WasmExecPathGo {
13895			t.Errorf("/%s paired term.wasm with %q, want the Go shim", page, exec)
13896		}
13897	}
13898}
13899
13900// The tinygo build of a name beats the Go build of that same name, and a
13901// name with neither is skipped rather than named.
13902func TestPageBinaryTinygoAndSkipping(t *testing.T) {
13903	t.Chdir(t.TempDir())
13904	saved := *f
13905	t.Cleanup(func() { *f = saved })
13906	f.UseTinygo = true
13907	f.WasmExecPathGo = "/go.js"
13908	f.WasmExecPathTinyGo = "/tiny.js"
13909
13910	if err := os.WriteFile("term-tiny.wasm", []byte("x"), 0o600); err != nil {
13911		t.Fatal(err)
13912	}
13913	got, exec := pageBinary("term", "tui")
13914	if got != "term-tiny.wasm" || exec != f.WasmExecPathTinyGo {
13915		t.Errorf("got %q with %q, want the tinygo joint build and its shim", got, exec)
13916	}
13917
13918	// Nothing built at all: name the page's own Go build rather than "",
13919	// so a page served during the first compile asks for the file that is
13920	// about to exist.
13921	t.Chdir(t.TempDir())
13922	if got, _ := pageBinary("term", "desk"); got != "desk.wasm" {
13923		t.Errorf("with nothing built got %q, want desk.wasm", got)
13924	}
13925}
13926
13927// The in-tab server has none of the binaries on its own filesystem. It must
13928// still name them: they read through to the origin like every other file it
13929// does not carry. Before this, every page it rendered referenced no drop-in
13930// at all β€” which is what left the attractor missing from the store inside
13931// the desk's browser window while the same page had it on the origin.
13932func TestPickFromOriginNamesWhatTheOriginServes(t *testing.T) {
13933	noDropins(t)         // this case is about the origin, not what the build carried
13934	t.Chdir(t.TempDir()) // deliberately empty: the tab's disk has no wasm
13935	saved := *f
13936	t.Cleanup(func() { *f = saved })
13937	f.UseTinygo = false // the tab has no toolchain and never built anything
13938	f.WasmExecPathGo = "/go.js"
13939	f.WasmExecPathTinyGo = "/tiny.js"
13940
13941	var asked []string
13942	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
13943		asked = append(asked, r.Method+" "+r.URL.Path)
13944		switch r.URL.Path {
13945		case "/stl2-tiny.wasm", "/cart.wasm":
13946			w.WriteHeader(http.StatusOK)
13947		default:
13948			w.WriteHeader(http.StatusNotFound)
13949		}
13950	}))
13951	t.Cleanup(srv.Close)
13952
13953	if servingInTab() {
13954		t.Fatal("a fresh process must not think it is in a tab")
13955	}
13956	serveFromOrigin(srv.URL)
13957	t.Cleanup(func() { serveFromOrigin("") })
13958	if !servingInTab() {
13959		t.Fatal("serveFromOrigin did not mark the in-tab role")
13960	}
13961
13962	// A small build at the origin wins even though UseTinygo is off here:
13963	// the flag describes this process's toolchain, not the other end.
13964	got, ok := pickWasmSmall("stl2")
13965	if !ok || got.File != "stl2-tiny.wasm" || got.ExecPath != "/tiny.js" || got.GoGlobal != goGlobalTiny {
13966		t.Errorf("stl2 = %+v, %v; want stl2-tiny.wasm on the tinygo shim", got, ok)
13967	}
13968	// Only a Go build at the origin: take it, with the matching shim.
13969	got, ok = pickWasmSmall("cart")
13970	if !ok || got.File != "cart.wasm" || got.ExecPath != "/go.js" || got.GoGlobal != goGlobalStd {
13971		t.Errorf("cart = %+v, %v; want cart.wasm on the Go shim", got, ok)
13972	}
13973	// Nothing at the origin: name nothing, as a missing file does on a host.
13974	if got, ok = pickWasm("deco"); ok {
13975		t.Errorf("deco = %+v, %v; want no pick", got, ok)
13976	}
13977
13978	// Each name is asked about once and the answer kept.
13979	before := len(asked)
13980	for range 3 {
13981		pickWasmSmall("stl2")
13982		pickWasm("deco")
13983	}
13984	if len(asked) != before {
13985		t.Errorf("origin re-probed: %v", asked[before:])
13986	}
13987	for _, a := range asked {
13988		if !strings.HasPrefix(a, http.MethodHead+" ") {
13989			t.Errorf("probe %q should be a HEAD: a drop-in is up to 22MB", a)
13990		}
13991	}
13992}
13993
13994// wasmPicks gates on WasmSRC, which names the sources this process builds.
13995// In the tab that list is empty β€” it builds nothing β€” and the gate dropped
13996// every binary before the pick was reached, which is why naming them from
13997// the origin alone was not enough.
13998func TestWasmPicksIgnoresWasmSRCInTheTab(t *testing.T) {
13999	noDropins(t)
14000	t.Chdir(t.TempDir())
14001	saved := *f
14002	t.Cleanup(func() { *f = saved })
14003	f.WasmSRC = nil // as in the tab: nothing configured to build
14004	f.WasmExecPathTinyGo = "/tiny.js"
14005
14006	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
14007		if r.URL.Path == "/cart-tiny.wasm" {
14008			w.WriteHeader(http.StatusOK)
14009			return
14010		}
14011		w.WriteHeader(http.StatusNotFound)
14012	}))
14013	t.Cleanup(srv.Close)
14014
14015	// On a host with nothing configured, nothing is named.
14016	if got := wasmSmallFor("cart"); len(got) != 0 {
14017		t.Errorf("host with empty WasmSRC named %+v, want nothing", got)
14018	}
14019
14020	serveFromOrigin(srv.URL)
14021	t.Cleanup(func() { serveFromOrigin("") })
14022	got := wasmSmallFor("cart", "stl2")
14023	if len(got) != 1 || got[0].File != "cart-tiny.wasm" {
14024		t.Errorf("in-tab picks = %+v, want just cart-tiny.wasm", got)
14025	}
14026}
14027
14028// noDropins makes the test behave as a binary built from a tree that staged
14029// nothing, whatever this one was built from.
14030func noDropins(t *testing.T) {
14031	t.Helper()
14032	saved := lookupDropin
14033	lookupDropin = func(string) ([]byte, bool) { return nil, false }
14034	t.Cleanup(func() { lookupDropin = saved })
14035}
14036
14037// A carried drop-in is answered from the binary; the origin is only asked for
14038// what the build did not stage. The tab pays a round trip per name otherwise,
14039// and pays it on the first render of every page.
14040func TestCarriedDropinBeatsTheOrigin(t *testing.T) {
14041	t.Chdir(t.TempDir())
14042	saved := *f
14043	t.Cleanup(func() { *f = saved })
14044	f.WasmSRC, f.UseTinygo = nil, false
14045	f.WasmExecPathGo, f.WasmExecPathTinyGo = "/go.js", "/tiny.js"
14046
14047	var asked []string
14048	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
14049		asked = append(asked, r.URL.Path)
14050		w.WriteHeader(http.StatusOK) // the origin has everything
14051	}))
14052	t.Cleanup(srv.Close)
14053
14054	carried := map[string][]byte{"stl2-tiny.wasm": []byte("staged")}
14055	savedLookup := lookupDropin
14056	lookupDropin = func(file string) ([]byte, bool) { b, ok := carried[file]; return b, ok }
14057	t.Cleanup(func() { lookupDropin = savedLookup })
14058
14059	serveFromOrigin(srv.URL)
14060	t.Cleanup(func() { serveFromOrigin("") })
14061
14062	if got, ok := pickWasmSmall("stl2"); !ok || got.File != "stl2-tiny.wasm" {
14063		t.Errorf("stl2 = %+v %v, want the carried stl2-tiny.wasm", got, ok)
14064	}
14065	if len(asked) != 0 {
14066		t.Errorf("origin asked about a carried drop-in: %v", asked)
14067	}
14068	// Not carried: the origin answers, as before.
14069	if got, ok := pickWasmSmall("cart"); !ok || got.File != "cart-tiny.wasm" {
14070		t.Errorf("cart = %+v %v, want cart-tiny.wasm from the origin", got, ok)
14071	}
14072	if len(asked) == 0 {
14073		t.Error("origin should have been asked about the uncarried drop-in")
14074	}
14075}
14076
14077
14078// ===== cmd/m2/commands/gen.go =====
14079// Package commands cmd/m2/commands/gen.go β€” the `m2 gen` config template.
14080package commands
14081
14082import (
14083	"fmt"
14084
14085	"github.com/spf13/cobra"
14086)
14087
14088var genCmd = &cobra.Command{
14089	Use:   "gen",
14090	Short: "generate conf template",
14091	Long:  "generate conf template",
14092	// To the command's own writer, not os.Stdout. They are the same
14093	// thing at a shell prompt and not in a page, where the command runs
14094	// inside a wasm instance whose stdout goes to the JS console β€” so
14095	// `m2 gen > site.conf` wrote an empty file and `m2 gen | head`
14096	// printed nothing at all.
14097	RunE: func(cmd *cobra.Command, _ []string) error {
14098		_, err := fmt.Fprint(cmd.OutOrStdout(), envfiletemplate)
14099		return err
14100	},
14101}
14102
14103const envfiletemplate = `#########################################################################
14104#	M2 CONFIG
14105#
14106# Copy to <yoursite>.conf and run with:  MENV=<yoursite>.conf m2 run
14107# This file is sourced by bash; use shell syntax.
14108# Comment a value with # to use the built-in default.
14109#########################################################################
14110
14111### Stripe Configuration ################################################
14112
14113#-- Live and test API keys - REQUIRED for checkout
14114# https://dashboard.stripe.com/apikeys
14115STRIPELIVEPK='pk_live_...'
14116STRIPELIVESK='sk_live_...'
14117STRIPETESTPK='pk_test_...'
14118STRIPETESTSK='sk_test_...'
14119
14120#-- Use the test keys instead of the live keys
14121TESTSTRIPEKEY=true
14122
14123### Product Data ########################################################
14124
14125#-- Products CSV path (see products.example.csv for the schema)
14126PRODUCTSCSV='products.csv'
14127
14128### Site Identity #######################################################
14129
14130#-- Image subdomain, no trailing slash (ex. 'https://img.example.com')
14131# empty = serve images from ./img
14132SITEIMAGESRC=''
14133
14134#-- Orders subdomain, no trailing slash (ex. 'https://pay.example.com')
14135SITEORDERSURL=''
14136
14137#-- Website (Host) Name - domain minus extension (ex. 'example')
14138SITENAME='example'
14139
14140#-- Website Domain Extension (ex. '.com' '.net')
14141SITEEXT='.com'
14142
14143#-- Site Long Name (ex. 'example electronic surplus')
14144SITELONGNAME='example web store'
14145
14146#-- Site Tag Line
14147SITETAGLINE='an example web store'
14148
14149#-- Site Meta Description (SEO)
14150SITEMETA='an example web store selling example things'
14151
14152#-- Telegram contact + channel; username only, no 'https://t.me/'
14153TGCONTACT=''
14154TGCHANNEL=''
14155
14156### Web Server ##########################################################
14157
14158#-- Port to serve http on
14159WEBPORT='9883'
14160
14161
14162### WebAssembly #########################################################
14163
14164#-- Compile wasm with tinygo (smaller output) in addition to go
14165USETINYGO=true
14166
14167#-- wasm source directories, relative paths
14168# 'wasm/cart' powers checkout β€” keep it. 'wasm/term' is the terminal
14169# storefront at /tui and the desktop at /desk in one binary: the desk
14170# links the storefront anyway, so the two separately were fifty
14171# megabytes of very nearly the same fifty. 'wasm/tui' and 'wasm/desk'
14172# still build one page each if you would rather have them apart.
14173# Additional entries are drop-in wasm apps: any dir under wasm/ with a
14174# main package (a self-contained module with its own vendor/ also
14175# works). Empty () disables all wasm.
14176WASMSRC=('wasm/cart' 'wasm/term')
14177
14178### Receipt Printing (CUPS) #############################################
14179
14180#-- CUPS printer name (default: system default)
14181PRINTERNAME=''
14182
14183#-- CUPS options, comma separated (ex. 'media=Custom.80x200mm,fit-to-page')
14184CUPSOPTIONS=''
14185
14186#-- timeout for lp command
14187LPTIMEOUT='10s'
14188
14189### Terminal UI ##########################################################
14190
14191#-- m2 tui client mode: browse this store over http instead of the
14192# local products csv / img / content files (ex. 'https://example.com')
14193STOREURL=''
14194`
14195
14196
14197// ===== cmd/m2/commands/root.go =====
14198// Package commands cmd/m2/commands/root.go β€” the m2 CLI.
14199package commands
14200
14201import (
14202	"embed"
14203	"log"
14204
14205	"github.com/spf13/cobra"
14206	"github.com/stripe/stripe-go/v81"
14207
14208	"github.com/0magnet/calvin/clihelp"
14209
14210	"github.com/0magnet/m2/pkg/config"
14211	"github.com/0magnet/m2/pkg/web"
14212)
14213
14214//go:embed *.go
14215var source embed.FS
14216
14217// f aliases the shared configuration, as in pkg/web.
14218var f = &config.F
14219
14220func init() {
14221	stripe.EnableTelemetry = false
14222	RootCmd.CompletionOptions.DisableDefaultCmd = true
14223	RootCmd.AddCommand(
14224		runCmd,
14225		genCmd,
14226		wasmCmd,
14227		tuiCmd,
14228		serveCmd,
14229	)
14230	RootCmd.SetHelpCommand(&cobra.Command{Hidden: true})
14231	// The banner keeps the domain, which is what this serves and what the
14232	// binary is short for.
14233	clihelp.Init(RootCmd, "magnetosphere.net")
14234
14235	web.RegisterSource("cmd/m2/commands", source)
14236}
14237
14238func init() {
14239	runCmd.Flags().SortFlags = false
14240	config.AddStringFlag([]*cobra.Command{runCmd}, &f.ProductsCSV, "products csv file")
14241	config.AddBoolFlag([]*cobra.Command{runCmd, wasmCmd}, &f.Teststripekey, "use stripe test api keys instead of live key")
14242	config.AddStringFlag([]*cobra.Command{runCmd, wasmCmd}, &f.StripeliveSK, "stripe live api sk")
14243	config.AddStringFlag([]*cobra.Command{runCmd, wasmCmd}, &f.StripelivePK, "stripe live api pk")
14244	config.AddStringFlag([]*cobra.Command{runCmd, wasmCmd}, &f.StripetestSK, "stripe test api sk")
14245	config.AddStringFlag([]*cobra.Command{runCmd, wasmCmd}, &f.StripetestPK, "stripe test api pk")
14246	config.AddIntFlag([]*cobra.Command{runCmd}, &f.WebPort, "port to serve on")
14247	config.AddStringFlag([]*cobra.Command{runCmd}, &f.Siteimagesrc, "domain for images - leave blank to serve images")
14248	config.AddStringFlag([]*cobra.Command{runCmd}, &f.Siteordersurl, "domain for orders - leave blank for same domain")
14249	config.AddStringFlag([]*cobra.Command{runCmd}, &f.Sitename, "site name")
14250	config.AddStringFlag([]*cobra.Command{runCmd}, &f.Siteext, "site domain extension")
14251	config.AddStringFlag([]*cobra.Command{runCmd}, &f.Sitelongname, "site long name")
14252	config.AddStringFlag([]*cobra.Command{runCmd}, &f.Sitetagline, "site tag line")
14253	config.AddStringFlag([]*cobra.Command{runCmd}, &f.Sitemeta, "site meta")
14254	config.AddStringFlag([]*cobra.Command{runCmd}, &f.Tgcontact, "telegram contact")
14255	config.AddStringFlag([]*cobra.Command{runCmd}, &f.Tgchannel, "telegram channel")
14256	config.AddBoolFlag([]*cobra.Command{runCmd}, &f.UseTinygo, "use tinygo instead of go to compile wasm")
14257	config.AddStringSliceFlag([]*cobra.Command{runCmd, wasmCmd}, &f.WasmSRC, "wasm source code files RELATIVE PATHS without '..'")
14258	config.AddStringFlag([]*cobra.Command{runCmd}, &f.PrinterName, "CUPS printer name (default: system default)")
14259	config.AddStringFlag([]*cobra.Command{runCmd}, &f.CupsOptions, "e.g. 'media=Custom.80x200mm,fit-to-page'")
14260	config.AddStringFlag([]*cobra.Command{runCmd}, &f.BrowseSuffix, "browse-origin domain for the desk browser - a DIFFERENT registrable domain from this site")
14261	config.AddStringFlag([]*cobra.Command{runCmd}, &f.BrowseAddr, "listen address for the browse-origin bootstrap")
14262	config.AddStringFlag([]*cobra.Command{runCmd}, &f.PprofAddr, "serve Go's profiles on this loopback address, e.g. 127.0.0.1:6061 (empty: none)")
14263	config.AddStringFlag([]*cobra.Command{runCmd}, &f.SkyXpub, "account-level skycoin xpub for crypto checkout - NOT the reward account")
14264	config.AddStringFlag([]*cobra.Command{runCmd}, &f.SkyNodeURL, "skycoin node API root for settlement")
14265	config.AddStringFlag([]*cobra.Command{runCmd}, &f.SkyRatePair, "exchange pair to price skycoin from, e.g. skycoin_usdt")
14266	config.AddStringFlag([]*cobra.Command{runCmd}, &f.SkyRateFix, "a fixed USD rate per SKY, instead of an exchange")
14267	config.AddStringFlag([]*cobra.Command{runCmd}, &f.RateMin, "refuse a crypto quote below this USD rate")
14268	config.AddStringFlag([]*cobra.Command{runCmd}, &f.RateMax, "refuse a crypto quote above this USD rate")
14269	config.AddIntFlag([]*cobra.Command{runCmd}, &f.InvoiceMins, "minutes a crypto quote is held before expiring")
14270	config.AddDurationFlag([]*cobra.Command{runCmd}, &f.LpTimeout, "timeout for lp command")
14271	config.AddStringFlag([]*cobra.Command{tuiCmd}, &f.Storeurl, "browse this store over http (ex. 'https://magnetosphere.net') instead of local files")
14272	config.AddStringFlag([]*cobra.Command{runCmd}, &f.MailFrom, "order confirmation email From address (empty: no email)")
14273	config.AddStringFlag([]*cobra.Command{runCmd}, &f.MailBcc, "the store's blind copy of every order confirmation")
14274	config.AddStringFlag([]*cobra.Command{runCmd}, &f.Sendmail, "command order confirmations are piped to, the recipient appended, e.g. '/usr/local/bin/mox sendmail'")
14275}
14276
14277// RootCmd is the top-level m2 command.
14278var RootCmd = &cobra.Command{
14279	Use:   "m2",
14280	Short: "web store server",
14281	Long:  "web store server",
14282}
14283
14284// Execute executes the root cli command
14285func Execute() {
14286	if err := RootCmd.Execute(); err != nil {
14287		log.Fatal("Failed to execute command: ", err)
14288	}
14289}
14290
14291
14292// ===== cmd/m2/commands/run.go =====
14293// Package commands cmd/m2/commands/run.go β€” the `m2 run` web server command.
14294package commands
14295
14296import (
14297	"fmt"
14298	"log"
14299	"os"
14300	"strings"
14301
14302	"github.com/spf13/cobra"
14303	"golang.org/x/text/cases"
14304	"golang.org/x/text/language"
14305
14306	"github.com/0magnet/calvin"
14307	"github.com/0magnet/calvin/clihelp"
14308
14309	"github.com/0magnet/m2/pkg/config"
14310	"github.com/0magnet/m2/pkg/web"
14311)
14312
14313var runCmd = &cobra.Command{
14314	Use:   "run",
14315	Short: "run the web application",
14316	// The subcommand carries the same banner as the root, so `m2 run --help`
14317	// is not a plainer screen than `m2 --help`.
14318	Long: clihelp.Banner("magnetosphere.net") + "\n\n" + func() string {
14319		helptext := `Run the web application
14320Generate a config file first
14321
14322Config defaults file may also be specified with:
14323MENV=m2.conf m2 run
14324OR
14325MENV=/path/to/m2.conf m2 run
14326print the MENV file template with:
14327m2 gen`
14328		if config.MENV == "" {
14329			return helptext
14330		}
14331		if _, err := os.Stat(config.MENV); err == nil {
14332			return `Run the web application
14333
14334menv file detected: ` + config.MENV
14335		}
14336		return helptext
14337	}(),
14338	RunE: func(cmd *cobra.Command, _ []string) error {
14339		f.Sitedomain = f.Sitename + f.Siteext
14340		log.Println(" Initializing " + f.Sitedomain)
14341		out := cmd.OutOrStdout()
14342		// A banner that will not print is not a reason to refuse to serve.
14343		fmt.Fprintln(out, calvin.BlackboardBold(f.Sitedomain)) //nolint:errcheck
14344		fmt.Fprintln(out, calvin.AsciiFont(f.Sitedomain))      //nolint:errcheck
14345		config.InitStripe()
14346		f.Siteprettyname = calvin.BlackboardBold(f.Sitedomain) //"π•„π•’π•˜π•Ÿπ•–π•₯𝕠𝕀𝕑𝕙𝕖𝕣𝕖.π•Ÿπ•–π•₯"
14347		c := cases.Title(language.English)
14348		f.Siteprettynamecap = calvin.BlackboardBold(c.String(f.Sitedomain))         //"π•„π•’π•˜π•Ÿπ•–π•₯𝕠𝕀𝕑𝕙𝕖𝕣𝕖.π•Ÿπ•–π•₯"
14349		f.Siteprettynamecaps = calvin.BlackboardBold(strings.ToUpper(f.Sitedomain)) //"π•„π”Έπ”Ύβ„•π”Όπ•‹π•†π•Šβ„™β„π”Όβ„π”Ό.ℕ𝔼𝕋"
14350		f.SiteASCIILogo = strings.Replace(strings.Replace(calvin.AsciiFont(f.Sitedomain), " ", "&nbsp;", -1), "\n", "<br>\n", -1)
14351
14352		if f.UseTinygo {
14353			f.WasmExecPath = f.WasmExecPathTinyGo
14354			f.Buildwasmwith = f.Tinygobuild
14355		}
14356		if len(f.WasmSRC) == 0 {
14357			f.WasmExecPath = ""
14358			f.Buildwasmwith = ""
14359		}
14360		log.Println("Checking for products CSV")
14361		log.Println("Reading products CSV")
14362		if err := web.LoadCatalog(); err != nil {
14363			return fmt.Errorf("loading the catalog: %w", err)
14364		}
14365		if f.PprofAddr != "" {
14366			if err := web.StartPprof(f.PprofAddr); err != nil {
14367				return err
14368			}
14369		}
14370		go web.WatchCatalog()
14371		web.Serve()
14372		return nil
14373	},
14374}
14375
14376
14377// ===== cmd/m2/commands/serve.go =====
14378package commands
14379
14380import (
14381	"log"
14382	"net/http"
14383	"time"
14384
14385	"github.com/spf13/cobra"
14386)
14387
14388var serveAddr, serveDir string
14389
14390// serveCmd hosts a directory over HTTP so the wasm demo can be opened in a
14391// browser. Go serves .wasm with the right MIME type, and this needs only the
14392// Go toolchain this project already uses.
14393//
14394//	m2 serve --dir public --addr :8000
14395var serveCmd = &cobra.Command{
14396	Use:   "serve",
14397	Short: "serve a static directory over HTTP (the demo build)",
14398	Long:  "Host a directory over HTTP so the wasm demo can be opened in a browser.",
14399	Args:  cobra.NoArgs,
14400	RunE: func(*cobra.Command, []string) error {
14401		log.Printf("serving %s on http://localhost%s", serveDir, serveAddr)
14402		// An http.Server rather than ListenAndServe: the bare helper sets no
14403		// timeouts, so a stalled client holds a connection forever. The write
14404		// timeout is generous because a .wasm binary is large and a slow link
14405		// should not have its download cut off mid-file.
14406		srv := &http.Server{
14407			Addr:              serveAddr,
14408			Handler:           http.FileServer(http.Dir(serveDir)),
14409			ReadHeaderTimeout: 10 * time.Second,
14410			ReadTimeout:       30 * time.Second,
14411			WriteTimeout:      5 * time.Minute,
14412			IdleTimeout:       2 * time.Minute,
14413		}
14414		return srv.ListenAndServe()
14415	},
14416}
14417
14418func init() {
14419	serveCmd.Flags().StringVar(&serveAddr, "addr", ":8080", "listen address")
14420	serveCmd.Flags().StringVar(&serveDir, "dir", ".", "directory to serve")
14421}
14422
14423
14424// ===== cmd/m2/commands/tui.go =====
14425// Package commands cmd/m2/commands/tui.go β€” the `m2 tui` terminal storefront.
14426package commands
14427
14428import (
14429	"errors"
14430	"fmt"
14431
14432	"github.com/spf13/cobra"
14433
14434	"github.com/0magnet/m2/pkg/product"
14435	"github.com/0magnet/m2/pkg/tui"
14436)
14437
14438var tuiCmd = &cobra.Command{
14439	Use:   "tui",
14440	Short: "browse the store in the terminal",
14441	Long: `Browse the store in a terminal UI.
14442
14443Reads the same config as 'run':
14444MENV=m2.conf m2 tui
14445
14446As a client of a running store (catalog, images, and checkout over
14447http; no local files needed):
14448m2 tui --storeurl https://magnetosphere.net`,
14449	// RunE with returned errors rather than log.Fatal: this command also
14450	// runs inside a page, and log.Fatal is os.Exit β€” which there takes down
14451	// the whole wasm instance, the shell it was typed into and every other
14452	// window on the desk with it.
14453	RunE: func(cmd *cobra.Command, _ []string) error {
14454		var prods product.Products
14455		if f.Storeurl != "" {
14456			var err error
14457			prods, err = tui.FetchCatalog(f.Storeurl)
14458			if err != nil {
14459				return fmt.Errorf("could not fetch the catalog from %s: %w", f.Storeurl, err)
14460			}
14461		} else {
14462			if f.ProductsCSV == "" {
14463				f.ProductsCSV = "products.csv"
14464			}
14465			prods = product.ReadCSV(f.ProductsCSV)
14466		}
14467		if len(prods) == 0 {
14468			return errors.New("no products in the catalog")
14469		}
14470		// The context carries how to get a screen where tcell.NewScreen is
14471		// the wrong answer β€” a browser, where it has to be bound to the
14472		// terminal this was typed into. See tui.WithScreen.
14473		return tui.Run(cmd.Context(), prods)
14474	},
14475}
14476
14477
14478// ===== cmd/m2/commands/wasm.go =====
14479// Package commands cmd/m2/commands/wasm.go β€” the `m2 wasm` compile command.
14480package commands
14481
14482import (
14483	"errors"
14484
14485	"github.com/spf13/cobra"
14486
14487	"github.com/0magnet/m2/pkg/config"
14488	"github.com/0magnet/m2/pkg/web"
14489)
14490
14491var wasmCmd = &cobra.Command{
14492	Use:   "wasm",
14493	Short: "compile wasm",
14494	Long:  "compile wasm",
14495	RunE: func(_ *cobra.Command, _ []string) error {
14496		if len(f.WasmSRC) == 0 {
14497			return errors.New("no wasm source code specified")
14498		}
14499		config.InitStripe()
14500		web.CompileWASM()
14501		return nil
14502	},
14503}
14504
14505