diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 00000000..f6443d88
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,35 @@
+{
+ // Debug configs for the gowasm example. cwd is the example dir because the
+ // dev server resolves ./wwwroot, ./app, ./wasm and the watched engine dir
+ // relative to it.
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "name": "gowasm: dev server",
+ "type": "go",
+ "request": "launch",
+ "mode": "auto",
+ "program": "${workspaceFolder}/go/cmd/examples/go-wasm-web/server",
+ "cwd": "${workspaceFolder}/go/cmd/examples/go-wasm-web",
+ "args": ["-addr", ":8085"]
+ },
+ {
+ "name": "gowasm: dev server (no watch)",
+ "type": "go",
+ "request": "launch",
+ "mode": "auto",
+ "program": "${workspaceFolder}/go/cmd/examples/go-wasm-web/server",
+ "cwd": "${workspaceFolder}/go/cmd/examples/go-wasm-web",
+ "args": ["-watch=false"]
+ },
+ {
+ "name": "gowasm: codegen (gowasmgen)",
+ "type": "go",
+ "request": "launch",
+ "mode": "auto",
+ "program": "${workspaceFolder}/go/cmd/gowasmgen",
+ "cwd": "${workspaceFolder}/go/cmd/examples/go-wasm-web",
+ "args": ["./app"]
+ }
+ ]
+}
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
new file mode 100644
index 00000000..eb232aa4
--- /dev/null
+++ b/.vscode/tasks.json
@@ -0,0 +1,73 @@
+{
+ // Tasks for the kjol repo. The gowasm example is a nested module at
+ // go/cmd/examples/go-wasm-web, so its tasks set cwd there; the module-wide
+ // Go tasks run in go/.
+ "version": "2.0.0",
+ "tasks": [
+ {
+ "label": "gowasm: dev server (hot reload)",
+ "detail": "Run the go-wasm-web example: codegen + SSR + hot reload on :8085",
+ "type": "shell",
+ "command": "go run ./server",
+ "options": { "cwd": "${workspaceFolder}/go/cmd/examples/go-wasm-web" },
+ "isBackground": true,
+ "problemMatcher": {
+ "owner": "go",
+ "pattern": {
+ "regexp": "^(.*):(\\d+):(\\d+):\\s+(.*)$",
+ "file": 1, "line": 2, "column": 3, "message": 4
+ },
+ "background": {
+ "activeOnStart": true,
+ "beginsPattern": "rebuilding",
+ "endsPattern": "serving|reloading clients"
+ }
+ },
+ "presentation": { "reveal": "always", "panel": "dedicated", "clear": true },
+ "group": { "kind": "build", "isDefault": true }
+ },
+ {
+ "label": "gowasm: build (build.sh)",
+ "detail": "One-off build of the example: codegen + wasm + stage wasm_exec.js",
+ "type": "shell",
+ "command": "./build.sh",
+ "options": { "cwd": "${workspaceFolder}/go/cmd/examples/go-wasm-web" },
+ "problemMatcher": ["$go"],
+ "group": "build"
+ },
+ {
+ "label": "gowasm: codegen",
+ "detail": "Regenerate app/*.gen.go from //gowasm: directives",
+ "type": "shell",
+ "command": "go run kjol/cmd/gowasmgen ./app",
+ "options": { "cwd": "${workspaceFolder}/go/cmd/examples/go-wasm-web" },
+ "problemMatcher": ["$go"],
+ "group": "build"
+ },
+ {
+ "label": "kjol: build ./...",
+ "detail": "Build the whole kjol module (excludes the nested example)",
+ "type": "shell",
+ "command": "go build ./...",
+ "options": { "cwd": "${workspaceFolder}/go" },
+ "problemMatcher": ["$go"],
+ "group": "build"
+ },
+ {
+ "label": "kjol: vet ./...",
+ "type": "shell",
+ "command": "go vet ./...",
+ "options": { "cwd": "${workspaceFolder}/go" },
+ "problemMatcher": ["$go"],
+ "group": "test"
+ },
+ {
+ "label": "kjol: test ./...",
+ "type": "shell",
+ "command": "go test ./...",
+ "options": { "cwd": "${workspaceFolder}/go" },
+ "problemMatcher": ["$go"],
+ "group": { "kind": "test", "isDefault": true }
+ }
+ ]
+}
diff --git a/CLAUDE.md b/CLAUDE.md
index 32163973..451a5ea5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -34,7 +34,11 @@ Language-first, **not** feature-first. Consequence: the **bundler is Go** and li
### go/ — module `kjol`
Packages, imported as `kjol/`: `appenv basic chrono config csv dbutil finance httputil
-l4g security snailmail validation bundler`, plus `cmd/{bundle,migrate,loc,passgen,typecheck}`.
+l4g security snailmail validation bundler`, plus the **gowasm** web-UI engine (`vdom`
+`wasmruntime` `rsc` `wasmdevserver` — author components in pure Go compiled to WebAssembly;
+all stdlib-only), and `cmd/{bundle,migrate,loc,passgen,typecheck,gowasmgen}`. A runnable
+example lives in `cmd/examples/go-wasm-web` (its own nested module so its go-chart dep stays
+out of kjol).
Build / test (run from repo root):
```
diff --git a/go/cmd/examples/go-wasm-web/README.md b/go/cmd/examples/go-wasm-web/README.md
new file mode 100644
index 00000000..92b72ac8
--- /dev/null
+++ b/go/cmd/examples/go-wasm-web/README.md
@@ -0,0 +1,71 @@
+# go-wasm-web — example app for the gowasm engine
+
+A runnable example of kjol's **gowasm** engine: author UI **components in pure
+Go**, compiled to **WebAssembly**, with **SSR + hydration**, **Next.js-style
+server components**, layouts, and a **flash-free, state-preserving hot reload**.
+No custom markup, no JSX — just Go. The engine lives in top-level kjol packages
+(`kjol/go/{vdom,wasmruntime,rsc,wasmdevserver}`); this directory is only the app
+that consumes them.
+
+## Run it
+
+```sh
+cd cmd/examples/go-wasm-web
+go run ./server # codegen + SSR + hot reload at http://localhost:8085
+```
+
+Open http://localhost:8085. `/` and `/about` use the light **public** layout,
+`/chart` and `/server` use the dark **app** layout. Edit any `.go` file and the
+browser hot-swaps the new wasm **without a full reload or a flash**, preserving
+page state; a build failure shows the Go compiler output as an overlay.
+
+(`build.sh` does a one-off build instead of running the dev server.)
+
+## This is a separate module
+
+`go.mod` here declares its own module (`gowasmweb`) with `replace kjol => ../../..`,
+so the app's `go-chart` dependency (and freetype / x/image) stays out of kjol —
+the engine packages (`vdom`, `wasmruntime`, `rsc`, `wasmdevserver`) are
+**stdlib-only**. `go build ./...` at the kjol root does not descend into this
+nested module; build it from this directory.
+
+## Layout
+
+```
+app/ the application — neutral, standalone functions (no central struct)
+ pages.go Deps + Shell + App/Public layouts + nav + Counter + pages
+ chart.go Chart page (go-chart, renders on both sides)
+ server_counter.go //gowasm:server component (server-only; clicks-over-time chart)
+ *.gen.go GENERATED by kjol/cmd/gowasmgen (routes, layout dispatch, stubs)
+wasm/ the js/wasm client entry point (main_native.go is a host stub)
+server/ the dev-server main: injects Build/Render/Document into wasmdevserver
+wwwroot/ bootstrap.js, bootstrap.min.css (+ generated wasm_exec.js, app.wasm)
+```
+
+## How it maps onto the engine (top-level `kjol` packages)
+
+| Engine package | Role | This app's use |
+|---|---|---|
+| `kjol/vdom` | neutral virtual DOM (native + wasm): `VNode`, builders, `Signal`, `RenderHTML` | pages build `*VNode`; `server` SSRs with `vdom.RenderHTML` |
+| `kjol/wasmruntime` | wasm client runtime: reconcile, `Run`/`Hydrate`, router, fetch, HMR state | `wasm/main.go` calls `Hydrate`/`Run` |
+| `kjol/rsc` | stateless server components over HTTP (gob) | `//gowasm:server` + the generated client stub |
+| `kjol/wasmdevserver` | reusable dev server: SSR, `/rsc`, hot reload, error overlay | `server/main.go` fills a `wasmdevserver.Config` |
+| `kjol/cmd/gowasmgen` | directive codegen → `app/*.gen.go` | run by `buildWasm` and `//go:generate` |
+
+The **golden rule** holds: `wasmdevserver` imports no app code. The app injects
+`Build` (how to compile the wasm), `Render` (SSR a route → HTML), and `Document`
+(wrap it in a page) via `wasmdevserver.Config` — the same coupling inversion kjol
+uses elsewhere.
+
+## Directives (expanded by `gowasmgen` at build time)
+
+```go
+//gowasm:page / static layout=public // a route; `static` SSRs it, `layout=` wraps it
+func HomePage(d Deps) func() *VNode { ... }
+
+//gowasm:layout public // chrome for pages that opt into layout=public
+func PublicLayout(d Deps, content *VNode) *VNode { ... }
+
+//gowasm:server // runs on the server; calling it looks identical
+func ServerCounter() func() *VNode { count := NewSignal(0); ... }
+```
diff --git a/go/cmd/examples/go-wasm-web/app/chart.go b/go/cmd/examples/go-wasm-web/app/chart.go
new file mode 100644
index 00000000..ed18e2e4
--- /dev/null
+++ b/go/cmd/examples/go-wasm-web/app/chart.go
@@ -0,0 +1,81 @@
+package app
+
+import (
+ "bytes"
+ "io"
+ "math/rand"
+
+ chart "github.com/wcharczuk/go-chart/v2"
+
+ . "kjol/vdom"
+)
+
+var chartLabels = []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}
+
+// fixed initial data so the server SSR and the client's first render match.
+func fixedChartData() []int { return []int{42, 17, 63, 28, 55, 9, 71} }
+
+func randomValues() []int {
+ v := make([]int, len(chartLabels))
+ for i := range v {
+ v[i] = rand.Intn(95) + 5
+ }
+ return v
+}
+
+func renderSVG(c interface {
+ Render(chart.RendererProvider, io.Writer) error
+}) string {
+ var buf bytes.Buffer
+ if c.Render(chart.SVG, &buf) != nil {
+ return "chart error
"
+ }
+ return buf.String()
+}
+
+func barSVG(values []int) string {
+ bars := make([]chart.Value, len(values))
+ for i, v := range values {
+ bars[i] = chart.Value{Value: float64(v), Label: chartLabels[i%len(chartLabels)]}
+ }
+ return renderSVG(&chart.BarChart{
+ Title: "Weekly values (bar)",
+ TitleStyle: chart.Style{FontSize: 15},
+ Background: chart.Style{Padding: chart.Box{Top: 48, Left: 16, Right: 16, Bottom: 16}},
+ Height: 320, BarWidth: 48, Bars: bars,
+ })
+}
+
+func pieSVG(values []int) string {
+ vs := make([]chart.Value, len(values))
+ for i, v := range values {
+ vs[i] = chart.Value{Value: float64(v), Label: chartLabels[i%len(chartLabels)]}
+ }
+ return renderSVG(&chart.PieChart{
+ Title: "Share by day (pie)",
+ TitleStyle: chart.Style{FontSize: 15},
+ Background: chart.Style{Padding: chart.Box{Top: 48}},
+ Width: 320, Height: 320, Values: vs,
+ })
+}
+
+//gowasm:page /chart static layout=app
+func ChartPage(d Deps) func() *VNode {
+ data := NewSignal(fixedChartData())
+ return func() *VNode {
+ values := data.Get()
+ return Div(
+ H2(Attr("class", "h4 mb-3"), Text("Charts — go-chart (SSR + hydrate)")),
+ P(Attr("class", "text-secondary"),
+ Text("Rendered to SVG on the server, hydrated on the client; Shuffle re-renders client-side.")),
+ Button(Attr("class", "btn btn-primary mb-3"),
+ On(EVENT_CLICK, func() { data.Set(randomValues()) }), Text("Shuffle Data")),
+ Div(Attr("class", "row"),
+ Div(Attr("class", "col-12 col-lg-7 mb-3"),
+ Div(Attr("class", "border rounded p-2 bg-white overflow-auto"), Raw(barSVG(values)))),
+ Div(Attr("class", "col-12 col-lg-5 mb-3"),
+ Div(Attr("class", "border rounded p-2 bg-white overflow-auto"), Raw(pieSVG(values)))),
+ ),
+ )
+ }
+}
diff --git a/go/cmd/examples/go-wasm-web/app/client.gen.go b/go/cmd/examples/go-wasm-web/app/client.gen.go
new file mode 100644
index 00000000..72d36515
--- /dev/null
+++ b/go/cmd/examples/go-wasm-web/app/client.gen.go
@@ -0,0 +1,13 @@
+// Code generated by gowasmgen. DO NOT EDIT.
+
+//go:build js && wasm
+
+package app
+
+import (
+ "kjol/rsc"
+ "kjol/vdom"
+)
+
+// ServerCounter is a generated client stub for the server component of the same name.
+func ServerCounter() func() *vdom.VNode { return rsc.Mount("ServerCounter") }
diff --git a/go/cmd/examples/go-wasm-web/app/pages.go b/go/cmd/examples/go-wasm-web/app/pages.go
new file mode 100644
index 00000000..77dec767
--- /dev/null
+++ b/go/cmd/examples/go-wasm-web/app/pages.go
@@ -0,0 +1,193 @@
+// Package app holds the application's pages and components as standalone
+// functions (no central App struct). It is platform-neutral, so the SAME code
+// renders on the server (SSR) and hydrates on the client.
+//
+// Directives (processed by cmd/gowasmgen at build time):
+//
+// //gowasm:page [static] [layout=]
+// marks a page factory as a route. `static`
+// pre-renders it on the server (SSR);
+// `layout=` wraps it in a //gowasm:layout.
+// //gowasm:layout marks a func(Deps, *VNode) *VNode as a named
+// layout that wraps a page's content.
+// //gowasm:server (see server_counter.go) marks a component
+// that runs on the server; the generated
+// client stub makes calling it identical to
+// calling any other component.
+//
+// The Routes() map, StaticPaths set, and RouteLayout/LayoutFor dispatch are all
+// generated from these directives.
+package app
+
+//go:generate go run kjol/cmd/gowasmgen .
+
+import (
+ "strconv"
+
+ . "kjol/vdom"
+)
+
+// Deps are the client-only capabilities, injected so the pages stay neutral.
+type Deps struct {
+ Path func() string
+ Navigate func(string)
+}
+
+func itoa(n int) string { return strconv.Itoa(n) }
+
+// Layout wraps a page's rendered content with shared chrome (nav, footer, …).
+// Layouts are declared with //gowasm:layout and selected per route via a page's
+// `layout=` directive; the generated LayoutFor dispatches by name.
+type Layout func(d Deps, content *VNode) *VNode
+
+// Shell renders the current route's page inside its declared layout. `routes` is
+// the generated route table; `d.Path()` selects both the page and its layout.
+func Shell(d Deps, routes map[string]func() *VNode) *VNode {
+ path := d.Path()
+ var content *VNode
+ if page := routes[path]; page != nil {
+ content = page()
+ } else {
+ content = notFound(path)
+ }
+ return LayoutFor(d, path, content)
+}
+
+func notFound(path string) *VNode {
+ return Div(
+ H2(Attr("class", "h4 mb-3"), Text("Page not found")),
+ P(Attr("class", "text-secondary"), Text("No route matches "+path+".")),
+ )
+}
+
+// --- layouts (selected per route via `layout=` in //gowasm:page) ---------
+
+// PublicLayout is the chrome for public/marketing pages: a light navbar with a
+// call-to-action into the app, and a footer. The func(Deps, *VNode) *VNode shape
+// is what //gowasm:layout expects.
+//
+//gowasm:layout public
+func PublicLayout(d Deps, content *VNode) *VNode {
+ return Div(
+ Nav(Attr("class", "navbar navbar-expand bg-light border-bottom mb-4"),
+ Div(Attr("class", "container"),
+ A(Attr("class", "navbar-brand fw-bold"), Attr("href", "/"), navigate(d, "/"), Text("gowasm")),
+ Ul(Attr("class", "navbar-nav ms-auto align-items-center"),
+ navItem(d, "/", "Home"),
+ navItem(d, "/about", "About"),
+ Li(Attr("class", "nav-item ms-2"),
+ A(Attr("class", "btn btn-sm btn-primary"), Attr("href", "/chart"),
+ navigate(d, "/chart"), Text("Open app →"))),
+ ))),
+ Main(Attr("class", "container"),
+ content,
+ Footer(Attr("class", "text-secondary small border-top mt-5 pt-3"),
+ Text("gowasm public site — a tiny Blazor-like engine in Go.")),
+ ),
+ )
+}
+
+// AppLayout is the chrome for the application itself: a dark app navbar listing
+// the app's sections, plus a link back to the public site.
+//
+//gowasm:layout app
+func AppLayout(d Deps, content *VNode) *VNode {
+ return Div(
+ Nav(Attr("class", "navbar navbar-expand navbar-dark bg-dark mb-4"),
+ Div(Attr("class", "container"),
+ A(Attr("class", "navbar-brand fw-bold"), Attr("href", "/chart"), navigate(d, "/chart"), Text("gowasm · app")),
+ Ul(Attr("class", "navbar-nav me-auto"),
+ navItem(d, "/chart", "Chart"),
+ navItem(d, "/server", "Server")),
+ Ul(Attr("class", "navbar-nav"),
+ navItem(d, "/", "Home")),
+ )),
+ Main(Attr("class", "container"), content),
+ )
+}
+
+// navItem is a nav link that carries an active state on the current route.
+func navItem(d Deps, path, label string) *VNode {
+ cls := "nav-link"
+ if d.Path() == path {
+ cls += " active"
+ }
+ return Li(Attr("class", "nav-item"),
+ A(Attr("class", cls), Attr("href", path), navigate(d, path), Text(label)))
+}
+
+// navigate intercepts a link click for client-side SPA navigation. On the server
+// Navigate is nil, so the anchor falls back to a normal navigation.
+func navigate(d Deps, path string) Mod {
+ return OnEvent(EVENT_CLICK, func(e Event) {
+ if d.Navigate != nil {
+ e.PreventDefault()
+ d.Navigate(path)
+ }
+ })
+}
+
+// Counter is a presentational client component; state is owned by the caller.
+func Counter(label string, count *Signal[int]) *VNode {
+ return Div(Attr("class", "counter card mb-2"),
+ Div(Attr("class", "card-body py-2 d-flex align-items-center"),
+ Span(Attr("class", "me-2 fw-semibold"), Text(label+": ")),
+ Strong(Attr("class", "badge text-bg-primary me-2"), Text(itoa(count.Get()))),
+ Div(Attr("class", "btn-group btn-group-sm ms-auto"),
+ Button(Attr("class", "btn btn-outline-secondary"),
+ On(EVENT_CLICK, func() { count.Update(func(v int) int { return v - 1 }) }), Text("−")),
+ Button(Attr("class", "btn btn-outline-primary"),
+ On(EVENT_CLICK, func() { count.Update(func(v int) int { return v + 1 }) }), Text("+")),
+ )))
+}
+
+//gowasm:page / static layout=public
+func HomePage(d Deps) func() *VNode {
+ a := NewSignal(0)
+ b := NewSignal(0)
+ return func() *VNode {
+ return Div(
+ H2(Attr("class", "h4 mb-3"), Text("Home — component composition")),
+ P(Attr("class", "text-secondary"), Text("Two counters; the total is derived across them. Server-rendered, then hydrated.")),
+ Counter("Apples", a),
+ Counter("Bananas", b),
+ Div(Attr("class", "alert alert-info d-flex justify-content-between align-items-center mt-3"),
+ Span(Text("Combined total: ")),
+ Strong(Attr("class", "fs-5"), Text(itoa(a.Get()+b.Get())))),
+ )
+ }
+}
+
+//gowasm:page /about static layout=public
+func AboutPage(d Deps) func() *VNode {
+ return func() *VNode {
+ return Div(
+ H2(Attr("class", "h4 mb-3"), Text("About")),
+ P(Attr("class", "lead"),
+ Text("Components are standalone functions; calling a server component looks "+
+ "identical to calling a client one — the //gowasm:server directive and the "+
+ "build-time codegen wire up the round-trip. Static routes are SSR'd; the rest "+
+ "render on the client.")),
+ )
+ }
+}
+
+//gowasm:page /server layout=app
+func ServerPage(d Deps) func() *VNode {
+ // ServerCounter is a server component — but calling it is just like calling
+ // any component. On the client this resolves to a generated stub that mounts
+ // it over /rsc; on the server it's the real function. (Not `static`: the
+ // client renders the stub, which round-trips, so there's nothing stable to
+ // pre-render + hydrate.)
+ counter := ServerCounter()
+ return func() *VNode {
+ return Div(
+ H2(Attr("class", "h4 mb-3"), Text("Server component")),
+ P(Attr("class", "text-secondary"),
+ Text("This counter runs on the server. Its state lives there; clicks round-trip "+
+ "and the returned render merges into the DOM. The call site is identical to a "+
+ "client component.")),
+ counter(),
+ )
+ }
+}
diff --git a/go/cmd/examples/go-wasm-web/app/routes.gen.go b/go/cmd/examples/go-wasm-web/app/routes.gen.go
new file mode 100644
index 00000000..3c1a67b7
--- /dev/null
+++ b/go/cmd/examples/go-wasm-web/app/routes.gen.go
@@ -0,0 +1,40 @@
+// Code generated by gowasmgen. DO NOT EDIT.
+package app
+
+import "kjol/vdom"
+
+// Routes maps each //gowasm:page path to its instantiated render function.
+func Routes(d Deps) map[string]func() *vdom.VNode {
+ return map[string]func() *vdom.VNode{
+ "/": HomePage(d),
+ "/about": AboutPage(d),
+ "/chart": ChartPage(d),
+ "/server": ServerPage(d),
+ }
+}
+
+// StaticPaths are the routes the server pre-renders (SSR); others render client-side.
+var StaticPaths = map[string]bool{
+ "/": true,
+ "/about": true,
+ "/chart": true,
+}
+
+// RouteLayout maps each route to the name of the layout that wraps it.
+var RouteLayout = map[string]string{
+ "/": "public",
+ "/about": "public",
+ "/chart": "app",
+ "/server": "app",
+}
+
+// LayoutFor wraps a page's content in the layout declared for its route.
+func LayoutFor(d Deps, path string, content *vdom.VNode) *vdom.VNode {
+ switch RouteLayout[path] {
+ case "app":
+ return AppLayout(d, content)
+ case "public":
+ return PublicLayout(d, content)
+ }
+ return AppLayout(d, content)
+}
diff --git a/go/cmd/examples/go-wasm-web/app/server.gen.go b/go/cmd/examples/go-wasm-web/app/server.gen.go
new file mode 100644
index 00000000..8a6fec37
--- /dev/null
+++ b/go/cmd/examples/go-wasm-web/app/server.gen.go
@@ -0,0 +1,11 @@
+// Code generated by gowasmgen. DO NOT EDIT.
+
+//go:build !(js && wasm)
+
+package app
+
+import "kjol/rsc"
+
+func init() {
+ rsc.Register("ServerCounter", ServerCounter)
+}
diff --git a/go/cmd/examples/go-wasm-web/app/server_counter.go b/go/cmd/examples/go-wasm-web/app/server_counter.go
new file mode 100644
index 00000000..a98bf87b
--- /dev/null
+++ b/go/cmd/examples/go-wasm-web/app/server_counter.go
@@ -0,0 +1,118 @@
+//go:build !(js && wasm)
+
+package app
+
+import (
+ "bytes"
+ "strconv"
+ "time"
+
+ chart "github.com/wcharczuk/go-chart/v2"
+
+ . "kjol/vdom"
+)
+
+// ServerCounter is a SERVER component — note it's written exactly like a client
+// component (same builders, signals, On handlers). The //gowasm:server directive
+// makes the build generate a client stub so calling ServerCounter() on the
+// frontend is identical to calling any component; the state and this render run
+// on the server (its chart is computed there with go-chart), and clicks
+// round-trip over /rsc.
+//
+// The chart plots the counter value against the wall-clock time of each click
+// (milliseconds since the first click), so spacing clicks out spreads the data
+// points along the x-axis. Because the component is stateless on the server, the
+// click points live in a signal that round-trips with the rest of its state
+// (a plain slice would reset on every request).
+//
+//gowasm:server
+func ServerCounter() func() *VNode {
+ count := NewSignal(0)
+ points := NewSignal([]clickPoint{})
+ bump := func(delta int) {
+ count.Set(count.Get() + delta)
+ points.Set(append(points.Get(), clickPoint{T: time.Now().UnixMilli(), V: count.Get()}))
+ }
+ return func() *VNode {
+ return Div(Attr("class", "card"),
+ Div(Attr("class", "card-body"),
+ Div(Attr("class", "d-flex align-items-center gap-2 mb-2"),
+ Span(Text("Server counter: ")),
+ Strong(Attr("class", "badge text-bg-success fs-6"), Text(strconv.Itoa(count.Get()))),
+ Button(Attr("class", "btn btn-sm btn-outline-secondary"),
+ On(EVENT_CLICK, func() { bump(-1) }), Text("−")),
+ Button(Attr("class", "btn btn-sm btn-success"),
+ On(EVENT_CLICK, func() { bump(1) }), Text("+")),
+ ),
+ Div(Attr("class", "border rounded p-2 bg-white overflow-auto"),
+ Raw(clickChartSVG(points.Get()))),
+ ),
+ )
+ }
+}
+
+// clickPoint records one click: its wall-clock time and the resulting counter
+// value. Exported fields so the signal's JSON snapshot round-trips it.
+type clickPoint struct {
+ T int64 // click time, Unix milliseconds
+ V int // counter value after the click
+}
+
+// clickChartSVG plots counter value vs. time-of-click (ms since the first
+// click) as a line graph. Explicit axis ranges keep it valid for the tricky
+// cases (a single click, or several clicks within the same millisecond).
+func clickChartSVG(points []clickPoint) string {
+ if len(points) == 0 {
+ return `Click + / − to plot the counter over time (ms since the first click).`
+ }
+ t0 := points[0].T
+ xs := make([]float64, len(points))
+ ys := make([]float64, len(points))
+ minY, maxY := 0.0, 0.0 // keep the zero baseline in view for context
+ for i, p := range points {
+ xs[i] = float64(p.T - t0)
+ ys[i] = float64(p.V)
+ if ys[i] < minY {
+ minY = ys[i]
+ }
+ if ys[i] > maxY {
+ maxY = ys[i]
+ }
+ }
+ maxX := xs[len(xs)-1]
+ if maxX <= 0 {
+ maxX = 1 // rapid or single clicks: avoid a zero-width x-range
+ }
+ if minY == maxY {
+ maxY++ // avoid a zero-height y-range
+ }
+ graph := chart.Chart{
+ Title: "Counter over time (computed on the server)",
+ TitleStyle: chart.Style{FontSize: 14},
+ Background: chart.Style{Padding: chart.Box{Top: 48, Left: 20, Right: 20, Bottom: 40}},
+ Height: 260,
+ XAxis: chart.XAxis{
+ Name: "ms since first click",
+ Range: &chart.ContinuousRange{Min: 0, Max: maxX},
+ },
+ YAxis: chart.YAxis{
+ Name: "counter",
+ Range: &chart.ContinuousRange{Min: minY, Max: maxY},
+ },
+ Series: []chart.Series{
+ chart.ContinuousSeries{
+ XValues: xs,
+ YValues: ys,
+ Style: chart.Style{
+ StrokeColor: chart.ColorGreen, StrokeWidth: 2,
+ DotColor: chart.ColorGreen, DotWidth: 4, // a dot at each click
+ },
+ },
+ },
+ }
+ var buf bytes.Buffer
+ if graph.Render(chart.SVG, &buf) != nil {
+ return `chart error`
+ }
+ return buf.String()
+}
diff --git a/go/cmd/examples/go-wasm-web/build.sh b/go/cmd/examples/go-wasm-web/build.sh
new file mode 100755
index 00000000..588e7131
--- /dev/null
+++ b/go/cmd/examples/go-wasm-web/build.sh
@@ -0,0 +1,24 @@
+#!/usr/bin/env bash
+# Pre-compile step: run the directive codegen, build the Go client to WebAssembly,
+# and stage the JS shim. Run the dev server instead (go run ./server) for hot
+# reload; this script is for a one-off/production-style build. Run from anywhere.
+set -euo pipefail
+
+cd "$(dirname "$0")"
+
+echo "==> Generating directive glue (//gowasm:page, //gowasm:layout, //gowasm:server)"
+go run kjol/cmd/gowasmgen ./app
+
+echo "==> Compiling ./wasm to wwwroot/app.wasm (GOOS=js GOARCH=wasm)"
+GOOS=js GOARCH=wasm go build -o wwwroot/app.wasm ./wasm
+
+echo "==> Copying Go's wasm_exec.js shim into wwwroot/"
+GOROOT="$(go env GOROOT)"
+if [ -f "$GOROOT/lib/wasm/wasm_exec.js" ]; then
+ cp "$GOROOT/lib/wasm/wasm_exec.js" wwwroot/wasm_exec.js # Go >= 1.24
+else
+ cp "$GOROOT/misc/wasm/wasm_exec.js" wwwroot/wasm_exec.js # Go <= 1.23
+fi
+
+echo "==> Done. Run the server with: go run ./server"
+echo " then open http://localhost:8085"
diff --git a/go/cmd/examples/go-wasm-web/go.mod b/go/cmd/examples/go-wasm-web/go.mod
new file mode 100644
index 00000000..406847cf
--- /dev/null
+++ b/go/cmd/examples/go-wasm-web/go.mod
@@ -0,0 +1,18 @@
+// The example is its own module so its go-chart dependency (and freetype /
+// x/image) stays out of the kjol module — kjol's engine packages are
+// stdlib-only. kjol is resolved locally via the replace below (no publish step).
+module gowasmweb
+
+go 1.26.3
+
+require (
+ github.com/wcharczuk/go-chart/v2 v2.1.2
+ kjol v0.0.0
+)
+
+require (
+ github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
+ golang.org/x/image v0.18.0 // indirect
+)
+
+replace kjol => ../../..
diff --git a/go/cmd/examples/go-wasm-web/go.sum b/go/cmd/examples/go-wasm-web/go.sum
new file mode 100644
index 00000000..d8c9226a
--- /dev/null
+++ b/go/cmd/examples/go-wasm-web/go.sum
@@ -0,0 +1,66 @@
+github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
+github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/wcharczuk/go-chart/v2 v2.1.2 h1:Y17/oYNuXwZg6TFag06qe8sBajwwsuvPiJJXcUcLL6E=
+github.com/wcharczuk/go-chart/v2 v2.1.2/go.mod h1:Zi4hbaqlWpYajnXB2K22IUYVXRXaLfSGNNR7P4ukyyQ=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
+golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
+golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
+golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
+golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
+golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
+golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
+golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
+golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
+golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
+golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
+golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
+golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
+golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
+golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
diff --git a/go/cmd/examples/go-wasm-web/server/__debug_bin283327599 b/go/cmd/examples/go-wasm-web/server/__debug_bin283327599
new file mode 100755
index 00000000..85d39529
Binary files /dev/null and b/go/cmd/examples/go-wasm-web/server/__debug_bin283327599 differ
diff --git a/go/cmd/examples/go-wasm-web/server/main.go b/go/cmd/examples/go-wasm-web/server/main.go
new file mode 100644
index 00000000..7e7b685d
--- /dev/null
+++ b/go/cmd/examples/go-wasm-web/server/main.go
@@ -0,0 +1,82 @@
+// Command server runs the go-wasm-web example on kjol's reusable wasmdevserver:
+// it SSRs the app's static routes, hosts the /rsc server-component endpoint, and
+// hot-swaps the wasm into the browser on change. It shows the coupling
+// inversion — the framework (wasmdevserver) imports no app code; the app injects
+// Build/Render/Document here.
+//
+// Run it from THIS directory (the relative paths below are resolved against it):
+//
+// go run ./server # from cmd/examples/go-wasm-web
+package main
+
+import (
+ "flag"
+ "log"
+ "os"
+ "os/exec"
+ "path/filepath"
+
+ "kjol/vdom"
+ "kjol/wasmdevserver"
+
+ "gowasmweb/app"
+)
+
+func main() {
+ addr := flag.String("addr", ":8085", "listen address")
+ watch := flag.Bool("watch", true, "watch sources, rebuild wasm, hot-reload")
+ flag.Parse()
+
+ log.Fatal(wasmdevserver.Serve(wasmdevserver.Config{
+ Addr: *addr,
+ Dir: "./wwwroot",
+ Watch: *watch,
+ WatchDirs: []string{"app", "wasm", "../../../vdom", "../../../wasmruntime", "../../../rsc"}, // example + kjol engine
+ Build: buildWasm,
+ Render: render,
+ Document: document,
+ }))
+}
+
+// render SSRs a static route's #app inner HTML; ok=false ships an empty #app
+// (client-rendered). It's the same neutral render the client runs, so the client
+// hydrates it.
+func render(path string) (string, bool) {
+ if !app.StaticPaths[path] {
+ return "", false
+ }
+ deps := app.Deps{Path: func() string { return path }} // Navigate is nil on the server
+ return vdom.RenderHTML(app.Shell(deps, app.Routes(deps))), true
+}
+
+// document wraps the server-rendered inner HTML in the page shell. No whitespace
+// between and the markup, so hydration's childNodes line up. The
+// dev server injects the livereload script before
+` + inner + `
+
+
+