main.go

// Command hn is the desktop/web entry point for the HN app. The widget tree,
// Root, and Config live in the importable examples/hn/ui package, keeping the
// entry point trivial and the UI easy to test and reuse.
package main

import (
	"log"

	"github.com/doug/gophics/app"
	"github.com/doug/gophics/examples/hn/ui"
)

func main() {
	if err := app.Run(ui.Root(), ui.Config()); err != nil {
		log.Fatal(err)
	}
}

mobile/hnmobile.go

// Package hnmobile is the gomobile-bind surface for the hn app.
//
// It holds only what is hn's own, which is nothing but building the tree.
// Everything generic — the frame loop, input, lifecycle, accessibility — is on
// shell/mobile.Bridge, which the CLI binds alongside this package, so a host
// calls those methods on the Bridge that Start returns.
//
// gomobile cannot bind package main and carries only a restricted vocabulary
// across the boundary, so this thin adapter is what a host talks to.
package hnmobile

import (
	"github.com/doug/gophics/app"
	"github.com/doug/gophics/shell/mobile"

	hn "github.com/doug/gophics/examples/hn/ui"
)

// Start builds the app and returns the bridge the host drives it through.
//
// Call it once, before anything else. On failure it returns a nil bridge and
// the error to show — two results because the second is an error, which is the
// one shape gomobile allows.
func Start() (*mobile.Bridge, error) {
	h, err := app.NewHandler(hn.Root(), hn.Config())
	if err != nil {
		return nil, err
	}
	return mobile.NewBridge(h), nil
}

ui/api.go

package ui

import (
	"context"
	"encoding/json"
	"fmt"
	"html"
	"strings"
	"time"

	"github.com/doug/gophics/fetch"
	"github.com/doug/gophics/layout"
	"github.com/doug/gophics/paint"
)

// Item is a HackerNews item (story or comment) from the Firebase API.
type Item struct {
	ID          int    `json:"id"`
	Type        string `json:"type"`
	By          string `json:"by"`
	Title       string `json:"title"`
	Text        string `json:"text"`
	URL         string `json:"url"`
	Score       int    `json:"score"`
	Descendants int    `json:"descendants"`
	Kids        []int  `json:"kids"`
	Time        int64  `json:"time"`
}

// API is the data source; Live hits the real Firebase endpoints, tests use
// a fixture implementation.
type API interface {
	TopStories(ctx context.Context) ([]int, error)
	Item(ctx context.Context, id int) (Item, error)
}

const apiTimeout = 15 * time.Second

type liveAPI struct{}

func newLiveAPI() *liveAPI { return &liveAPI{} }

// get fetches url and decodes the JSON into v.
//
// gophics/fetch rather than net/http directly, because this app is also built
// for the browser and net/http costs 2MB of gzipped wasm there for a socket
// path that cannot run — see that package. The timeout is the caller's
// context, so it is visible here rather than buried in a client.
func (a *liveAPI) get(ctx context.Context, url string, v any) error {
	ctx, cancel := context.WithTimeout(ctx, apiTimeout)
	defer cancel()
	b, err := fetch.Get(ctx, url)
	if err != nil {
		return err
	}
	return json.Unmarshal(b, v)
}

func (a *liveAPI) TopStories(ctx context.Context) ([]int, error) {
	var ids []int
	err := a.get(ctx, "https://hacker-news.firebaseio.com/v0/topstories.json", &ids)
	return ids, err
}

func (a *liveAPI) Item(ctx context.Context, id int) (Item, error) {
	var it Item
	err := a.get(ctx, fmt.Sprintf("https://hacker-news.firebaseio.com/v0/item/%d.json", id), &it)
	return it, err
}

// Comment is a flattened comment with its nesting depth.
type Comment struct {
	Item
	Depth int
}

// loadComments fetches a story's comment tree, up to limit comments.
func loadComments(ctx context.Context, api API, story Item, limit int) []Comment {
	return streamComments(ctx, api, story, limit, nil)
}

// streamComments fetches breadth-first and assembles depth-first.
//
// Those are two different orders on purpose. Reading order is depth-first — a
// comment followed by its replies, indented — but *fetching* in that order
// means descending one deep reply chain before the second top-level comment is
// even requested, over 80 serial round trips. Fetching by level instead makes
// every id at one depth a single concurrent batch, so the top-level comments,
// which are what the reader actually wants first, land in one trip.
//
// onProgress, when non-nil, is called after each level with the tree as
// assembled so far, so the thread fills in from the top instead of showing a
// spinner until the last reply arrives.
func streamComments(ctx context.Context, api API, story Item, limit int, onProgress func([]Comment)) []Comment {
	loaded := map[int]Item{}
	frontier := story.Kids
	var out []Comment

	for len(frontier) > 0 && ctx.Err() == nil {
		items := fetchItems(ctx, api, frontier, nil)

		var next []int
		for _, it := range items {
			loaded[it.ID] = it
			// A comment with no text is deleted or dead; it is not shown, and
			// its replies are not pursued — matching what the depth-first walk
			// did, so a dead subtree does not cost a level of fetching.
			if it.Text != "" {
				next = append(next, it.Kids...)
			}
		}

		out = assembleComments(story, loaded, limit)
		if onProgress != nil {
			onProgress(out)
		}
		if len(out) >= limit {
			break // the tree is already longer than anything that will be shown
		}
		frontier = next
	}
	return out
}

// assembleComments walks the story depth-first over whatever has been loaded,
// which is what puts a reply directly under its parent at depth+1. Ids not yet
// fetched are simply absent, so the same walk works on a partial tree.
func assembleComments(story Item, loaded map[int]Item, limit int) []Comment {
	out := make([]Comment, 0, limit)
	var walk func(ids []int, depth int)
	walk = func(ids []int, depth int) {
		for _, id := range ids {
			if len(out) >= limit {
				return
			}
			it, ok := loaded[id]
			if !ok || it.Text == "" {
				continue
			}
			out = append(out, Comment{Item: it, Depth: depth})
			walk(it.Kids, depth+1)
		}
	}
	walk(story.Kids, 0)
	return out
}

// plainText strips HN's comment HTML down to displayable text: paragraph
// breaks preserved, tags dropped, entities unescaped.
func plainText(s string) string {
	var b strings.Builder
	for _, sp := range parseSpans(s, spanStyle{}) {
		b.WriteString(sp.Text)
	}
	return strings.TrimSpace(b.String())
}

// spanStyle carries the colors parseSpans assigns per span kind.
type spanStyle struct {
	Text, Link, Emph paint.Color
}

// parseSpans converts HN's comment HTML subset (<p>, <a href>, <i>,
// <code>/<pre>) into rich spans: links tappable, italics emphasized,
// entities unescaped, paragraph breaks preserved.
func parseSpans(s string, style spanStyle) []layout.RichSpan {
	var spans []layout.RichSpan
	var cur strings.Builder
	var href string
	emph := false

	flush := func() {
		if cur.Len() == 0 {
			return
		}
		sp := layout.RichSpan{Text: html.UnescapeString(cur.String()), Color: style.Text}
		if href != "" {
			sp.Link, sp.Color = href, style.Link
		} else if emph {
			sp.Color = style.Emph
		}
		spans = append(spans, sp)
		cur.Reset()
	}

	for i := 0; i < len(s); {
		if s[i] != '<' {
			cur.WriteByte(s[i])
			i++
			continue
		}
		end := strings.IndexByte(s[i:], '>')
		if end < 0 {
			break
		}
		tag := s[i+1 : i+end]
		i += end + 1
		lower := strings.ToLower(tag)
		switch {
		case lower == "p" || lower == "/p":
			flush()
			if len(spans) > 0 {
				spans = append(spans, layout.RichSpan{Text: "\n\n", Color: style.Text})
			}
		case strings.HasPrefix(lower, "a "):
			flush()
			if j := strings.Index(lower, `href="`); j >= 0 {
				rest := tag[j+6:]
				if before, _, ok := strings.Cut(rest, "\""); ok {
					href = html.UnescapeString(before)
				}
			}
		case lower == "/a":
			flush()
			href = ""
		case lower == "i" || lower == "em":
			flush()
			emph = true
		case lower == "/i" || lower == "/em":
			flush()
			emph = false
		}
	}
	flush()
	// Trim a leading paragraph break.
	if len(spans) > 0 && strings.TrimSpace(spans[0].Text) == "" {
		spans = spans[1:]
	}
	return spans
}

// domain extracts the host for the story's meta line.
func domain(url string) string {
	s := strings.TrimPrefix(strings.TrimPrefix(url, "https://"), "http://")
	s = strings.TrimPrefix(s, "www.")
	if i := strings.IndexByte(s, '/'); i >= 0 {
		s = s[:i]
	}
	return s
}

ui/config.go

package ui

import (
	"golang.org/x/image/font/gofont/gobold"
	"golang.org/x/image/font/gofont/goregular"

	"github.com/doug/gophics/app"
	"github.com/doug/gophics/geom"
)

// Config returns the app's window/runtime configuration. It lives alongside
// Root in this importable package so both are reusable and testable apart from
// the entry point.
func Config() app.Config {
	return app.Config{
		Title:        "gophics · hn",
		Size:         geom.Size{W: 480, H: 720},
		Background:   Background(),
		Font:         goregular.TTF,
		FontFamilies: map[string][]byte{"bold": gobold.TTF},
		// The API is built once and never varies by position in the tree, so
		// it is provided to the whole app here rather than nested around it.
		// A test swaps in a fake by overriding this field.
		Provide: []any{newLiveAPI()},
	}
}

ui/fetch.go

package ui

import (
	"context"
	"sync"
)

// fetchConcurrency bounds in-flight item requests.
//
// The HN API has no batch endpoint — a story list of 30 is 31 requests and a
// comment thread of 80 is 80, and that is inherent. What is not inherent is
// doing them one at a time: serially, opening a story took 80 round trips
// before it drew anything, which is where the multi-second stall came from.
// Eight is well inside what Firebase serves without complaint, and turns those
// 80 trips into ten.
const fetchConcurrency = 8

// fetchItems resolves ids concurrently and returns the ones that loaded, in
// the order they were asked for — the feed is ranked, so arrival order is not
// an acceptable substitute.
//
// onPrefix, when non-nil, is called each time the finished run from the front
// grows, with the items resolved so far. That is what lets the UI paint the
// first stories while the tail is still in flight. Reporting on *any*
// completion instead would let item 20 appear above item 3 and then jump when
// 3 landed; a prefix only grows, so the list only ever appends.
func fetchItems(ctx context.Context, api API, ids []int, onPrefix func([]Item)) []Item {
	var (
		mu       sync.Mutex
		out      = make([]Item, len(ids))
		ok       = make([]bool, len(ids)) // loaded successfully
		resolved = make([]bool, len(ids)) // request finished, either way
		sent     int                      // length of the prefix already reported
	)

	// collect must be called with mu held.
	collect := func(n int) []Item {
		res := make([]Item, 0, n)
		for i := 0; i < n; i++ {
			if ok[i] {
				res = append(res, out[i])
			}
		}
		return res
	}

	sem := make(chan struct{}, fetchConcurrency)
	var wg sync.WaitGroup
	for i, id := range ids {
		if ctx.Err() != nil {
			break
		}
		wg.Add(1)
		go func(i, id int) {
			defer wg.Done()
			select {
			case sem <- struct{}{}:
				defer func() { <-sem }()
			case <-ctx.Done():
				return
			}
			// Checked again after acquiring: a request that waited behind the
			// semaphore may be for a page the reader has since left.
			if ctx.Err() != nil {
				return
			}
			it, err := api.Item(ctx, id)

			mu.Lock()
			defer mu.Unlock()
			resolved[i] = true
			if err == nil {
				out[i], ok[i] = it, true
			}
			// A failed item still advances the prefix — otherwise one dead id
			// holds every story behind it off the screen.
			grew := false
			for sent < len(ids) && resolved[sent] {
				sent++
				grew = true
			}
			if grew && onPrefix != nil {
				onPrefix(collect(sent))
			}
		}(i, id)
	}
	wg.Wait()

	mu.Lock()
	defer mu.Unlock()
	return collect(len(ids))
}

ui/ui.go

// Command hn is a HackerNews client, the app that drove the scrolling, text
// and navigation work to completion. Lazy story feed with fling scrolling, Navigator-driven
// pages with slide transitions, rich comments with tappable links, async
// loading over the real Firebase API — on desktop and web from one
// codebase.
package ui

import (
	"fmt"

	"github.com/doug/gophics/geom"
	"github.com/doug/gophics/layout"
	"github.com/doug/gophics/paint"
	"github.com/doug/gophics/theme"
	"github.com/doug/gophics/widget"
)

// colBg is the light-theme background. It is used before a widget context
// exists (app.Config.Background, via Background()) and by tests. Inside the
// tree every color comes from theme.Of(ctx), so the whole app follows the
// platform light/dark scheme automatically.
var colBg = theme.Light().Bg

// commentStyle is the light-theme span palette — used by tests and as a
// default. commentRow builds a themed palette per frame from theme.Of(ctx).
var commentStyle = spanStyle{
	Text: theme.Light().Text,
	Link: theme.Light().Primary,
	Emph: theme.Light().Muted,
}

// HN is the root widget: the Navigator over the feed.
type HN struct {
	PageSize int // stories to load (0 → 60)
}

func (h HN) pageSize() int {
	if h.PageSize == 0 {
		return 60
	}
	return h.PageSize
}

func (h HN) Build(ctx widget.Ctx) widget.Widget {
	// Resolve the theme from the platform color scheme and provide it to the
	// tree, so every page below reads colors with theme.Of(ctx) and the whole
	// app follows light/dark automatically.
	th := theme.Auto(ctx)
	// Only the theme is provided here. It is derived — recomputed each build so
	// the app follows the system's dark mode — so it belongs in the tree, where
	// changing it rebuilds the subtree that reads it. The API is the opposite:
	// built once at startup, identical everywhere, and provided app-wide from
	// Config.
	//
	// Either way the pages carry only data (which story), so they stay plain
	// serializable values — which is what lets `gophics dev` restore your
	// navigation on a hot restart (see threadPage's registration below).
	return widget.Provide[theme.Theme]{
		Value: th,
		Child: widget.Fill{Color: th.Bg, Child: widget.Navigator{Home: feedPage{N: h.pageSize()}}},
	}
}

func init() {
	// Register the pushable page(s) so the Navigator's stack survives a
	// state-preserving hot-restart.
	widget.RegisterSnapshotType[threadPage]()
}

func header(th theme.Theme, title string, lead widget.Widget) widget.Widget {
	if lead == nil {
		lead = widget.Text{Value: "Y", Size: th.Type.Heading, Color: th.OnPrimary}
	}
	return widget.Padding{Insets: geom.InsetsSymmetric(12, 10),
		Child: widget.Row(
			lead,
			widget.Sized{W: 10},
			widget.Expand(widget.Text{Value: title, Font: "bold", Size: th.Type.Heading, Color: th.OnPrimary}),
		),
	}
}

func backButton(ctx widget.Ctx) widget.Widget {
	th := theme.Of(ctx)
	nav := ctx.MustOf[widget.Nav]()
	return widget.Interactive{
		Gestures: widget.Gestures{OnTap: nav.Pop},
		Child: widget.Padding{Insets: geom.InsetsSymmetric(6, 4),
			Child: widget.Text{Value: "‹ Back", Size: th.Type.Body, Color: th.OnPrimary}},
	}
}

func page(ctx widget.Ctx, headerW, body widget.Widget) widget.Widget {
	th := theme.Of(ctx)
	// Pad by the platform safe areas (status bar / notch / keyboard); the
	// header bar's color extends behind the top inset.
	in := ctx.SafeInsets()
	col := widget.Column(
		widget.Decorated{Color: th.Primary, Child: widget.Padding{
			Insets: geom.Insets{Top: in.Top, Left: in.Left, Right: in.Right},
			Child:  headerW,
		}},
		widget.Expand(widget.Padding{
			Insets: geom.Insets{Left: in.Left, Right: in.Right, Bottom: in.Bottom},
			Child:  widget.SelectionArea{Child: body},
		}),
	)
	col.CrossAlign = layout.CrossStretch
	// Pages carry their own opaque background so slide transitions cover
	// the page beneath.
	content := widget.Decorated{Color: th.Bg, Child: col}

	// Responsive: on a wide viewport (desktop browser, large window, wide
	// terminal) center a comfortable reading column with gutters; on a narrow
	// one (phone, mobile web) fill the width unchanged.
	return widget.LayoutBuilder{Build: func(cs layout.Constraints) widget.Widget {
		const maxW = 720
		if !cs.BoundedW() || cs.Max.W <= maxW+96 {
			return content
		}
		row := widget.Row(
			widget.Expand(widget.Sized{}),
			widget.Sized{W: maxW, Child: content},
			widget.Expand(widget.Sized{}),
		)
		row.CrossAlign = layout.CrossStretch
		// Border reads as a subtle neutral frame around the reading column.
		return widget.Decorated{Color: th.Border, Child: row}
	}}
}

// feedPage lists top stories. It carries only data; the API comes from context.
type feedPage struct {
	N int
}

func (f feedPage) CreateState() widget.State { return &feedState{} }

// feed is the result of one load. Grouping the three values means they move
// together: there is no assignment that leaves an error showing beside stale
// stories, or a spinner running after the items arrived. The zero value is the
// initial state — no items, no error, not yet loaded.
type feed struct {
	items []Item
	err   error
	done  bool
}

type feedState struct {
	widget.StateBase[feedPage]
	feed feed
	// refreshing is presentation, not result: it says the load was triggered by
	// a pull rather than by opening the page, and so shows a different spinner.
	refreshing bool
}

// stateHook lets tests observe the mounted feed state.
var stateHook func(*feedState)

func (s *feedState) Init(ctx widget.Ctx) {
	if stateHook != nil {
		stateHook(s)
	}
	s.fetch(ctx)
}

// fetch loads the top stories on a background goroutine and swaps them in.
// Used for the initial load and for pull-to-refresh.
//
// The context comes from the widget, so leaving the page stops the load: the
// per-item walk below is the expensive part, and without cancellation a feed
// that is closed a moment after opening keeps fetching every one of them.
func (s *feedState) fetch(ctx widget.Ctx) {
	lifetime := ctx.Lifetime()
	api := ctx.MustOf[API]()
	n := s.W().N
	go func() {
		ids, err := api.TopStories(lifetime)
		if err != nil {
			s.PostState(func() { s.feed, s.refreshing = feed{err: err, done: true}, false })
			return
		}
		if len(ids) > n {
			ids = ids[:n]
		}
		// Concurrent, and streamed: each time the run of resolved items from
		// the top grows, show it. The list is ranked, so it fills in from the
		// first story down and never reorders under the reader.
		show := func(items []Item, done bool) {
			keep := items[:0]
			for _, it := range items {
				if it.Title != "" {
					keep = append(keep, it)
				}
			}
			s.PostState(func() {
				s.feed = feed{items: keep, done: done}
				if done {
					s.refreshing = false
				}
			})
		}
		items := fetchItems(lifetime, api, ids, func(partial []Item) { show(partial, false) })
		if lifetime.Err() != nil {
			return // the feed is gone; nothing wants these
		}
		show(items, true)
	}()
}

func (s *feedState) Build(ctx widget.Ctx) widget.Widget {
	th := theme.Of(ctx)
	var body widget.Widget
	switch f := s.feed; {
	case !f.done:
		body = widget.Center(widget.Text{Value: "loading…", Size: th.Type.Body, Color: th.Muted})
	case f.err != nil:
		body = widget.Center(widget.Text{Value: f.err.Error(), Wrap: true, Size: th.Type.Body, Color: th.Muted})
	default:
		nav := ctx.MustOf[widget.Nav]()
		body = widget.LazyList{
			Count:           len(s.feed.items),
			EstimatedExtent: 66,
			Build:           func(i int) widget.Widget { return s.storyRow(th, nav, i) },
			Refreshing:      s.refreshing,
			OnRefresh: func() {
				s.SetState(func() { s.refreshing = true })
				s.fetch(ctx)
			},
		}
	}
	return page(ctx, header(th, "Hacker News", nil), body)
}

func (s *feedState) storyRow(th theme.Theme, nav widget.Nav, i int) widget.Widget {
	st := s.feed.items[i]
	meta := fmt.Sprintf("%d points · %s · %d comments", st.Score, st.By, st.Descendants)
	if d := domain(st.URL); d != "" {
		meta = d + " · " + meta
	}
	title := widget.Column(
		widget.Text{Value: st.Title, Font: "bold", Size: th.Type.Heading, Color: th.Text, Wrap: true},
		widget.Sized{H: 4},
		widget.Text{Value: meta, Size: th.Type.Caption, Color: th.Muted},
	)
	title.CrossAlign = layout.CrossStart
	row := widget.Row(
		widget.Sized{W: 34, Child: widget.Text{Value: fmt.Sprintf("%d.", i+1), Size: th.Type.Label, Color: th.Muted}},
		widget.Expand(title),
	)
	row.CrossAlign = layout.CrossStart
	return theme.Tappable{
		OnTap:      func() { nav.Push(threadPage{Story: st}) },
		Background: th.Surface,
		Pad:        geom.InsetsSymmetric(12, 10),
		Haptic:     true, // a selection tick when opening a story
		Child:      row,
	}
}

// threadPage shows one story's comments. It carries only the story (plain
// serializable data); the API comes from context. That makes it registerable
// so a hot-restart can rebuild it and land you back on the same thread.
type threadPage struct {
	Story Item
}

func (t threadPage) CreateState() widget.State { return &threadState{} }

type threadState struct {
	widget.StateBase[threadPage]
	comments []Comment
	loading  bool
}

func (s *threadState) Init(ctx widget.Ctx) {
	s.loading = true
	lifetime := ctx.Lifetime()
	api := ctx.MustOf[API]()
	story := s.W().Story
	go func() {
		// Reported per level, so the top-level comments draw after one round
		// trip instead of after the last reply in the tree.
		comments := streamComments(lifetime, api, story, 80, func(partial []Comment) {
			s.PostState(func() {
				if len(partial) > 0 {
					s.comments, s.loading = partial, false
				}
			})
		})
		s.PostState(func() { s.comments, s.loading = comments, false })
	}()
}

func (s *threadState) Build(ctx widget.Ctx) widget.Widget {
	th := theme.Of(ctx)
	st := s.W().Story
	var body widget.Widget
	if s.loading {
		body = widget.Center(widget.Text{Value: "loading comments…", Size: th.Type.Body, Color: th.Muted})
	} else {
		n := len(s.comments)
		openURL := func(u string) { _ = ctx.OpenURL(u) }
		// A thread is for reading, and reading includes quoting: without a
		// SelectionArea every Text here is inert and a comment cannot be
		// copied out. Wrapping the list rather than each row is what makes a
		// drag across two comments one continuous selection.
		body = widget.SelectionArea{Child: widget.LazyList{
			Count:           n + 1,
			EstimatedExtent: 90,
			Build: func(i int) widget.Widget {
				if i == 0 {
					return storyHeaderCell(th, st, openURL)
				}
				return commentRow(th, s.comments[i-1], openURL)
			},
		}}
	}
	return page(ctx, header(th, st.Title, backButton(ctx)), body)
}

func storyHeaderCell(th theme.Theme, st Item, openURL func(string)) widget.Widget {
	meta := fmt.Sprintf("%d points by %s · %d comments", st.Score, st.By, st.Descendants)
	kids := []widget.Widget{
		widget.Text{Value: st.Title, Font: "bold", Size: th.Type.Heading, Color: th.Text, Wrap: true},
		widget.Sized{H: 6},
		widget.Text{Value: meta, Size: th.Type.Caption, Color: th.Muted},
	}
	if st.URL != "" {
		kids = append(kids, widget.Sized{H: 6}, widget.Rich{
			Spans:  []layout.RichSpan{{Text: domain(st.URL) + " ↗", Color: th.Primary, Link: st.URL}},
			Size:   th.Type.Label,
			OnLink: openURL,
		})
	}
	col := widget.Column(kids...)
	col.CrossAlign = layout.CrossStart
	return widget.Decorated{Color: th.Surface,
		Child: widget.Padding{All: 14, Child: col}}
}

func commentRow(th theme.Theme, c Comment, openURL func(string)) widget.Widget {
	style := spanStyle{Text: th.Text, Link: th.Primary, Emph: th.Muted}
	body := widget.Column(
		widget.Text{Value: c.By, Size: th.Type.Label, Color: th.Primary},
		widget.Sized{H: 4},
		widget.Rich{Spans: parseSpans(c.Text, style), Size: th.Type.Body, OnLink: openURL},
	)
	body.CrossAlign = layout.CrossStart
	return widget.Padding{
		Insets: geom.Insets{Top: 6, Left: 12 + float32(c.Depth)*16, Right: 12},
		Child: widget.Decorated{Color: th.Surface,
			Child: widget.Padding{All: 10, Child: body}},
	}
}

// Root returns the HN app widget over the live API.
func Root() widget.Widget { return HN{} }

// Background returns the app background color.
func Background() paint.Color { return colBg }

web/devserve.go

//go:build ignore

// Dev server for the HN web build: rebuilds the GPU wasm on every page load
// and serves everything with no-store headers, so the browser can never run a
// stale build (plain `go build` output is otherwise cached hard by browsers,
// which makes iterating on the wasm maddening).
//
// Run from the repo root:
//
//	go run ./examples/hn/web/devserve.go
//
// then open http://localhost:8100/. Each reload rebuilds and reloads fresh.
package main

import (
	"log"
	"net/http"
	"os"
	"os/exec"
	"sync"
	"time"
)

const (
	webDir = "examples/hn/web"
	pkg    = "./examples/hn/"
	addr   = ":8100"
)

var buildMu sync.Mutex

func buildWasm() (string, error) {
	buildMu.Lock()
	defer buildMu.Unlock()
	start := time.Now()
	cmd := exec.Command("go", "build", "-o", webDir+"/hn.wasm", pkg)
	cmd.Env = append(os.Environ(), "GOOS=js", "GOARCH=wasm")
	if out, err := cmd.CombinedOutput(); err != nil {
		return string(out), err
	}
	log.Printf("rebuilt hn.wasm in %s", time.Since(start).Round(time.Millisecond))
	return "", nil
}

func main() {
	fs := http.FileServer(http.Dir(webDir))
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
		w.Header().Set("Pragma", "no-cache")
		// A page load rebuilds the wasm before serving the document, so the
		// hn.wasm the browser fetches next is always current.
		if r.URL.Path == "/" || r.URL.Path == "/index.html" {
			if out, err := buildWasm(); err != nil {
				log.Printf("build failed: %v\n%s", err, out)
				http.Error(w, "build failed:\n"+out, http.StatusInternalServerError)
				return
			}
		}
		fs.ServeHTTP(w, r)
	})
	log.Printf("HN web dev server on %s — rebuilds wasm per load, no cache; open http://localhost%s/", addr, addr)
	log.Fatal(http.ListenAndServe(addr, nil))
}