03 — Style Sampler

One Go codebase, six page layouts, two deployment targets. The same templates render a multi-page site either from a classic HTTP server (main.go) or entirely inside the browser via a service worker (main_wasm.go). Both entry points register the same *http.ServeMux — navigation, routing, and rendering code paths are identical.

The model function is the same five-lines-of-teletype used in example 01Print("Hello world."), a five-count loop with one-second sleeps, Print("Done."). Every layout renders the current lofigui buffer, so whichever style you click into auto-refreshes in place while the counter ticks up. The point of this example is to show that adding layout variety on top of the same model is a matter of templates, not plumbing.

Every page's navbar carries a Start / Cancel widget driven by the library — the model runs on demand, cancellable mid-flight, and the page you're looking at keeps polling until the buffer stops growing.

Interactivity level: 6 — WASM (browser-only, service worker)

Stopped — navbar shows Start button, no model output yet
Stopped — Start button in the navbar, waiting for the user
Running — navbar shows Cancel, teletype output growing
Running — Cancel button visible, teletype output growing

Both captures are the Scrolling layout — one of five views rendered from the same model. The teletype content looks very similar to example 01; the interesting bit is that four other layouts show the same buffer at the same time, each with its own navbar.


How lofigui helps here

lofigui is deliberately small. For this example it contributes five things:

  1. A print-style output bufferlofigui.Print / Printf accumulate HTML in the global lofigui buffer. Every layout's handler reads lofigui.Buffer() on each request, so the teletype content is identical in every layout and grows in place as the model runs.
  2. A template-inheritance loaderlofigui.NewControllerFromFS parses base.html alongside the named child template, so Go stdlib {{block}}/{{define}} works with embedded filesystems — the only source a WASM build can reach.
  3. One render call, same shape in both buildsctrl.RenderTemplate(w, ctx) writes to an http.ResponseWriter regardless of whether that writer was handed over by net/http.ListenAndServe or by go-wasm-http-server inside a service worker.
  4. Lifecycle wiring out of the boxapp.RegisterLifecycle(mux, model) installs GET /start and GET /cancel handlers, and app.StatusControls(basePrefix) renders a Running/Stopped tag plus Start/Cancel links as an HTML fragment. Drop {{.status}} into any navbar and every page drives the same singleton model.
  5. Built-in /assets/bulma.min.csslofigui.ServeBulma is registered next to the app's routes so Bulma loads without a CDN round-trip, on server and WASM.

Everything else — the routes, the templates, the navigation — is plain Go standard library.


Layout styles

Style Navbar Layout Use case
Scrolling Default, scrolls with page Single column Simple tools (examples 01, 02)
Fixed Pinned to top Single column Dashboards needing persistent nav
Three-Panel Nav Top + left sidebar Left nav, right content Multi-page apps with page tree
Three-Panel Controls Top + left sidebar Left form, right output CLI tools with parameter dialogs
Full Width Minimal top bar Single wide column Maximum output area

Template inheritance

All styles share a base template using Go's html/template {{block}} / {{define}} inheritance:

base.html                        → <html>, <head>, <base>, Bulma, {{block "navbar" .}}, {{block "content" .}}
style_scrolling.html             → extends base: teal scrolling navbar + content
style_fixed.html                 → extends base: red fixed navbar + padded content
style_three_panel_nav.html       → extends base: yellow navbar + columns layout
...

base.html declares blocks with {{block "name" .}}default{{end}}. Each child template overrides them with {{define "name"}}...{{end}}:

<!-- base.html -->
<head>
  <base href="{{.base}}">
  <link rel="stylesheet" href="/assets/bulma.min.css">
  …
</head>
<body>
  {{block "navbar" .}}{{end}}
  {{block "content" .}}{{end}}
</body>
<!-- style_scrolling.html -->
{{define "navbar"}}<nav class="navbar is-primary">…</nav>{{end}}
{{define "content"}}<section class="section">{{.results}}</section>{{end}}
<base href="{{.base}}"> lets the same template work at both the site root ("/") and under a service-worker scope ("/03_style_sampler/wasm_demo/"). The navbar/sidebar links use relative hrefs like style/scrolling and "" — the browser resolves them against <base>, so they stay inside the SW scope in WASM and resolve to site root on the server.

The model — the same shape as example 01

The teletype that every layout displays is the same print-loop-print pattern from example 01, with the count bumped so the model runs long enough to navigate between layouts while it's working:

func model(app *lofigui.App) {
    lofigui.Print("Hello world.")
    for i := 0; i < 20; i++ {
        app.Sleep(1 * time.Second)
        lofigui.Printf("Count %d", i)
    }
    lofigui.Print("Done.")
}

The 20-count loop is deliberately long so you have time to navigate between layouts while the model is running — and that reveals the point below.

Continuity with example 01. Nothing about layout changes requires a different model. The point of this example is that templates do the layout work; the app logic is still Print + Sleep, and you can drop it in unchanged from a simpler example.
One model, many views. The five layouts are not five separate apps — they are five templates rendering the same singleton model. Start the model on Scrolling, then click over to Fixed or Three-Panel: the model keeps running, the buffer keeps growing, and the navbar in every layout reflects the same Running/Stopped state. Hitting Cancel on any page cancels the one shared model. This is [App.StartAction]'s singleton active-model concept made visible — it's the same reason app.StatusControls only needs the current path and the base prefix to render the right widget everywhere.

Start / Cancel — baked into lofigui

Unlike the earlier version of this example, the model isn't kicked off at startup. The page loads in a Stopped state; clicking Start in the navbar fires the model, and the HTTP Refresh polling takes over. Cancel stops it mid-flight without shutting the server down.

Two library calls do all the work:

// buildMux, in model.go
app.RegisterLifecycle(mux, model, basePrefix)            // wires GET /start and GET /cancel
// …
"status": app.StatusControls(basePrefix, r.URL.Path),   // drops the widget into every page's context

The current request's path is threaded into the widget so the generated links embed ?from=<current-page>; the handlers read that back and redirect to it. The Referer header would normally do this job, but service workers don't reliably forward Referer, so we carry the return URL explicitly.

app.StatusControls renders a small HTML fragment that every navbar template embeds as {{.status}}:

<!-- style_scrolling.html -->
<div class="navbar-end">
  <div class="navbar-item"><span class="tag is-success">Default Navbar</span></div>
  <div class="navbar-item">{{.status}}</div>
</div>

When stopped, the fragment is a green "Stopped" tag + a Start link; when running, it flips to a yellow "Running" tag + a Cancel link. The basePrefix argument means the generated hrefs stay inside the service-worker scope in WASM (/03_style_sampler/wasm_demo/start) and resolve to the site root on the server (/start).

RegisterLifecycle's /cancel handler is different from the root-level App.HandleCancel used by App.Run: it calls EndAction but does not shut the server down, because a multi-page app needs to keep serving after a cancel. Both handlers redirect back to the page you came from via the Referer header (falling back to /).

One singleton model across the whole app. The App holds a single action-running flag, so hitting Start from any page drives the same model, and the Running/Stopped state in every navbar reflects the same global reality. Hitting Start while one is already running is a no-op.

Shared mux — model.go

Both main.go and main_wasm.go delegate the routing table to model.go. It embeds templates/, parses every layout against base.html, and returns a *http.ServeMux with all routes wired up:

//go:embed templates
var templateFS embed.FS

var pathToTemplate = map[string]string{
    "/":                           "home.html",
    "/style/scrolling":            "style_scrolling.html",
    "/style/fixed":                "style_fixed.html",
    "/style/three-panel-nav":      "style_three_panel_nav.html",
    "/style/three-panel-controls": "style_three_panel_controls.html",
    "/style/fullwidth":            "style_fullwidth.html",
}

func buildMux(app *lofigui.App, basePrefix string) *http.ServeMux {
    controllers := loadControllers()
    mux := http.NewServeMux()
    for p, name := range pathToTemplate {
        tpl := name
        pattern := "GET " + p
        if p == "/" { pattern = "GET /{$}" }
        mux.HandleFunc(pattern, func(w http.ResponseWriter, r *http.Request) {
            app.WriteRefreshHeader(w) // HTTP Refresh while model is running
            controllers[tpl].RenderTemplate(w, lofigui.TemplateContext{
                "results":      template.HTML(lofigui.Buffer()),
                "current_path": r.URL.Path,
                "base":         basePrefix,
                "status":       app.StatusControls(basePrefix, r.URL.Path), // Running/Stopped + Start/Cancel
            })
        })
    }
    app.RegisterLifecycle(mux, model, basePrefix) // GET /start, GET /cancel
    mux.HandleFunc("GET /favicon.ico",          lofigui.ServeFavicon)
    mux.HandleFunc("GET /assets/bulma.min.css", lofigui.ServeBulma)
    return mux
}
Everything interesting lives here. Template parsing, route registration, and render calls all use net/http types — no syscall/js, no WASM-only imports. Both the server and WASM builds hand this mux to their respective serving runtimes.
Auto-refresh while polling. app.WriteRefreshHeader(w) emits Refresh: 1 only while the model is running; once the model returns, the header is absent and the page stops reloading. The handler reads the live lofigui.Buffer() on each request — no snapshotting, no per-handler state — so any layout you're looking at grows in place as the model prints.

The server — main.go

//go:build !(js && wasm)

func main() {
    app := lofigui.NewApp()
    app.SetRefreshTime(1)

    fmt.Println("Style Sampler running at http://localhost:1340")
    http.ListenAndServe(":1340", buildMux(app, "/"))
}

Three lines. No RunModel at startup — the model is kicked off by the user via the navbar's Start button, and RegisterLifecycle (inside buildMux) wires the handler that launches the goroutine. Base prefix is "/" because the server hosts the app at the site root.


The WASM entry point — main_wasm.go

//go:build js && wasm

func main() {
    app := lofigui.NewApp()
    app.SetRefreshTime(1)

    base := strings.TrimSuffix(lofigui.WASMScopePath(), "/") + "/"
    if _, err := wasmhttp.Serve(buildMux(app, base)); err != nil { panic(err) }
    select {} // keep the Go runtime alive to service SW fetches
}

Same setup as main.go, then wasmhttp.Serve registers every handler in the mux on the service-worker fetch pipeline. lofigui.WASMScopePath() returns the scope the SW is registered at (e.g. /03_style_sampler/wasm_demo/), which becomes the <base href> so the templates' relative links — including the Start/Cancel links emitted by StatusControls(base) — land back inside the scope.

Why two files at all? syscall/js (indirectly imported by go-wasm-http-server) does not link on non-WASM targets, and http.ListenAndServe is a runtime no-op under WASM. //go:build tags pick the right entry point at compile time; no runtime switches, no stubs, no conditional imports.

Same code, two targets

Aspect Server (main.go) WASM (main_wasm.go)
Build tag !(js && wasm) js && wasm
App setup NewApp + SetRefreshTime(1) (model starts on click) identical
Routing table buildMux(app, "/") from model.go buildMux(app, scopePath) from model.go
Serving runtime http.ListenAndServe(":1340", mux) wasmhttp.Serve(mux) + select{}
Request shape http.Request / http.ResponseWriter http.Request / http.ResponseWriter
Browser navigation HTTP request → Go handler fetch intercepted by SW → Go handler
Template render ctrl.RenderTemplate(w, ctx) ctrl.RenderTemplate(w, ctx) — same call
<base href> "/" "/03_style_sampler/wasm_demo/"

The model is unchanged between builds — literally the same five lines of Go from example 01. buildMux is shared. The render call is the same function. The only real differences are the entry-point setup and the <base> prefix.


The service-worker bootstrap (how navigation actually works)

wasmassets.Deploy (invoked by go run ./cmd/wasm-deploy in the Taskfile) produces a self-contained demo directory at docs/03_style_sampler/wasm_demo/:

wasm_demo/
  index.html          — SW bootstrap (registers sw.js, loads WASM, then redirects to "./")
  sw.js               — service worker
  wasmhttp_sw.js      — go-wasm-http-server SW runtime
  main.wasm           — the Go binary
  wasm_exec.js        — Go's stdlib WASM loader
  bulma.min.css       — vendored Bulma next to the bootstrap
  demo.html           — recovery stub; unregisters the SW if the demo gets stuck

The sequence the browser sees:

  1. GET /03_style_sampler/wasm_demo/ → static index.html (bootstrap).
  2. Bootstrap JS registers sw.js scoped at /03_style_sampler/wasm_demo/, loads main.wasm, and redirects to ./.
  3. ./ is now intercepted by the SW. wasmhttp_sw.js forwards the fetch as an http.Request into the WASM mux.
  4. The mux's GET /{$} handler renders home.html and returns the HTML.
  5. User clicks a nav link (href="style/scrolling"). Browser resolves it against <base href="/03_style_sampler/wasm_demo/">, fetches /03_style_sampler/wasm_demo/style/scrolling. SW intercepts, strips the scope prefix, Go sees /style/scrolling, mux calls the scrolling handler.
No JavaScript bridge needed. The old version of this example exposed a goRenderPage function to JS and routed clicks through a custom app.js. Under the service-worker pattern the browser does normal HTTP navigation; the SW is the only integration point between JS and Go. The Go code is unaware it's running in WASM.

Binary size

The WASM binary includes html/template, net/http, the embedded templates, go-wasm-http-server, and lofigui itself. Expect ~10–11 MB uncompressed (~2.8 MB gzipped) — a bit bigger than the old syscall/js-bridge version, but the entire model and routing code now compiles from the same source as the server build.