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 01 — Print("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)
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:
- A print-style output buffer —
lofigui.Print/Printfaccumulate HTML in the global lofigui buffer. Every layout's handler readslofigui.Buffer()on each request, so the teletype content is identical in every layout and grows in place as the model runs. - A template-inheritance loader —
lofigui.NewControllerFromFSparsesbase.htmlalongside the named child template, so Go stdlib{{block}}/{{define}}works with embedded filesystems — the only source a WASM build can reach. - One render call, same shape in both builds —
ctrl.RenderTemplate(w, ctx)writes to anhttp.ResponseWriterregardless of whether that writer was handed over bynet/http.ListenAndServeor bygo-wasm-http-serverinside a service worker. - Lifecycle wiring out of the box —
app.RegisterLifecycle(mux, model)installsGET /startandGET /cancelhandlers, andapp.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. - Built-in
/assets/bulma.min.css—lofigui.ServeBulmais 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.
Print + Sleep, and you can drop it in unchanged from a simpler example.
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 /).
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
}
net/http types — no syscall/js, no WASM-only imports. Both the server and WASM builds hand this mux to their respective serving runtimes.
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.
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:
- GET
/03_style_sampler/wasm_demo/→ staticindex.html(bootstrap). - Bootstrap JS registers
sw.jsscoped at/03_style_sampler/wasm_demo/, loadsmain.wasm, and redirects to./. ./is now intercepted by the SW.wasmhttp_sw.jsforwards the fetch as anhttp.Requestinto the WASM mux.- The mux's
GET /{$}handler rendershome.htmland returns the HTML. - 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.
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.