diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 00000000..63e6bc5d
--- /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 (wasmgen)",
+ "type": "go",
+ "request": "launch",
+ "mode": "auto",
+ "program": "${workspaceFolder}/go/cmd/wasmgen",
+ "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..603f36e2
--- /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/wasmgen ./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 f3810310..6c441de8 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -34,7 +34,12 @@ 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`, and `webui` — a Tailwind-styled component kit ported
+from `web/kit`; author components in pure Go compiled to WebAssembly; all stdlib-only), and
+`cmd/{bundle,migrate,loc,passgen,typecheck,wasmgen}`. 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/bundler/public_tailwind.go b/go/bundler/public_tailwind.go
new file mode 100644
index 00000000..f6e52a37
--- /dev/null
+++ b/go/bundler/public_tailwind.go
@@ -0,0 +1,19 @@
+package bundler
+
+// CompileTailwind compiles a Tailwind v4 entry stylesheet with kjol's native
+// engine, discovering utility candidates from the given source globs (relative
+// to baseDir). The scanner is language-agnostic — it extracts candidate class
+// tokens from any text file — so this works for markup authored in Go (e.g. the
+// go-wasm-web example's webui components) just as well as .tsx/.html.
+//
+// entryCSS is the stylesheet source (typically `@import "tailwindcss";` plus an
+// `@theme { … }` block). Returns minified CSS. This is the app-bundler engine
+// (twCompile/scanSources) exposed for consumers that don't go through Build.
+func CompileTailwind(entryCSS, baseDir string, sourceGlobs []string) (string, error) {
+ candidates := scanSources(baseDir, sourceGlobs)
+ compiled, _, err := twCompile(entryCSS, baseDir, candidates)
+ if err != nil {
+ return "", err
+ }
+ return m.String("text/css", compiled)
+}
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..8655c063
--- /dev/null
+++ b/go/cmd/examples/go-wasm-web/README.md
@@ -0,0 +1,89 @@
+# 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, the **`kjol/webui` component kit**, **Tailwind CSS**
+(compiled by kjol's own engine), 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,webui}`); 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`, `/server`, **`/data`** (client-side fetching), and **`/kit`** (a UI-kit
+"kitchen-sink" demo of the webui components) 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.
+
+**Data fetching** (`/data`) shows both directions of `kjol/httputil`: the server
+answers `/api/quotes` with `httputil.RespondGob([]Quote)` and the client decodes
+it straight back into `[]Quote` with `httputil.FetchGob` (the same Go type on
+both ends — no JSON); and a **user-entered** GitHub repo (`owner/name`) is
+fetched with `httputil.FetchJSON` into a tagged Go struct. The client HTTP
+transport is `wasmruntime.FetchBytes`, injected once via
+`httputil.SetClientTransport`.
+
+Styling is **Tailwind**: the dev server (and `build.sh`) run `kjol/cmd/twcss`,
+which scans the Go markup + the `webui` kit for utility classes and compiles
+`css/app.css` → `wwwroot/app.css` with kjol's native Tailwind v4 engine. There is
+**no Bootstrap and no hand-written CSS**. (`build.sh` does a one-off build.)
+
+## 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)
+ kit.go /kit — UI-kit demo page showcasing kjol/webui components
+ data.go /data — client fetch: gob from /api/quotes + third-party JSON
+ server_counter.go //gowasm:server component (server-only; clicks-over-time chart)
+ *.gen.go GENERATED by kjol/cmd/wasmgen (routes, layout dispatch, stubs)
+css/app.css Tailwind entry (@import "tailwindcss" + @theme tokens)
+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/ wasmboot.js (+ generated app.css, 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/httputil` | gob/JSON responders + typed client fetch (`RespondGob`, `FetchGob`, `FetchJSON`) | the `/data` page + the `/api/quotes` handler |
+| `kjol/cmd/wasmgen` | 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 `wasmgen` 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..95e1e4fa
--- /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"
+ ui "kjol/webui"
+)
+
+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(Attr("class", "space-y-6"),
+ Div(
+ H2(Attr("class", "text-2xl font-semibold tracking-tight text-text-heading"), Text("Charts — go-chart (SSR + hydrate)")),
+ P(Attr("class", "mt-1 text-neutral-500"),
+ Text("Rendered to SVG on the server, hydrated on the client; Shuffle re-renders client-side.")),
+ ),
+ ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Shuffle data", OnClick: func() { data.Set(randomValues()) }}),
+ Div(Attr("class", "grid gap-4 lg:grid-cols-12"),
+ Div(Attr("class", "lg:col-span-7 rounded-default border border-neutral-200 bg-white p-3 shadow-xs overflow-auto"), Raw(barSVG(values))),
+ Div(Attr("class", "lg:col-span-5 rounded-default border border-neutral-200 bg-white p-3 shadow-xs 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..9e2daa73
--- /dev/null
+++ b/go/cmd/examples/go-wasm-web/app/client.gen.go
@@ -0,0 +1,13 @@
+// Code generated by wasmgen. 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/data.go b/go/cmd/examples/go-wasm-web/app/data.go
new file mode 100644
index 00000000..553960bf
--- /dev/null
+++ b/go/cmd/examples/go-wasm-web/app/data.go
@@ -0,0 +1,147 @@
+package app
+
+import (
+ "strconv"
+ "strings"
+
+ "kjol/httputil"
+ . "kjol/vdom"
+ ui "kjol/webui"
+)
+
+// Quote is the payload the /api/quotes endpoint returns. The server encodes a
+// []Quote with httputil.RespondGob; the client decodes it straight back into
+// []Quote — the SAME Go type, no JSON, no hand-written unmarshalling.
+type Quote struct {
+ Author string
+ Text string
+}
+
+// repoInfo is a subset of GitHub's repo JSON (a third-party API), tagged for
+// json decoding.
+type repoInfo struct {
+ FullName string `json:"full_name"`
+ Description string `json:"description"`
+ Stars int `json:"stargazers_count"`
+}
+
+//gowasm:page /data layout=app
+func DataPage(d Deps) func() *VNode {
+ // (1) gob from our own server via httputil.RespondGob / FetchGob.
+ quotes := NewSignal([]Quote{})
+ qLoading := NewSignal(true)
+ qErr := NewSignal("")
+ // (2) JSON from a third-party API (GitHub), for a user-entered repo.
+ repo := NewSignal(repoInfo{})
+ rLoading := NewSignal(true)
+ rErr := NewSignal("")
+ repoQuery := NewSignal("golang/go")
+ started := false
+
+ // fetchRepo loads owner/name from the GitHub API into the repo signal.
+ fetchRepo := func(q string) {
+ q = strings.Trim(strings.TrimSpace(q), "/")
+ if q == "" {
+ rErr.Set("enter a repo as owner/name")
+ rLoading.Set(false)
+ return
+ }
+ rErr.Set("")
+ rLoading.Set(true)
+ httputil.FetchJSON("https://api.github.com/repos/"+q, func(r repoInfo, err error) {
+ if err != nil {
+ rErr.Set(err.Error())
+ } else {
+ repo.Set(r)
+ }
+ rLoading.Set(false)
+ })
+ }
+
+ return func() *VNode {
+ // Fire the initial fetches once, on the client (no transport on the server,
+ // so SSR ships the loading state and the client takes over).
+ if !started {
+ started = true
+ httputil.FetchGob("/api/quotes", func(qs []Quote, err error) {
+ if err != nil {
+ qErr.Set(err.Error())
+ } else {
+ quotes.Set(qs)
+ }
+ qLoading.Set(false)
+ })
+ fetchRepo(repoQuery.Get())
+ }
+
+ return Div(Attr("class", "space-y-8"),
+ Div(
+ H2(Attr("class", "text-2xl font-semibold tracking-tight text-text-heading"), Text("Data fetching")),
+ P(Attr("class", "mt-1 text-neutral-500"), Text("Two client-side fetches: gob from our own server, and JSON from a third-party API you choose.")),
+ ),
+
+ ui.Card("",
+ ui.CardHeader("", Text("gob — from our server")),
+ P(Attr("class", "mb-3 text-sm text-neutral-500"),
+ Text("The client GETs /api/quotes; the server responds with httputil.RespondGob "+
+ "(a gob-encoded []Quote) and httputil.FetchGob decodes it straight into []Quote — "+
+ "the same Go type on both ends, no JSON.")),
+ quotesBody(qLoading.Get(), qErr.Get(), quotes.Get()),
+ ),
+
+ ui.Card("",
+ ui.CardHeader("", Text("JSON — from a third-party API")),
+ P(Attr("class", "mb-3 text-sm text-neutral-500"),
+ Text("Enter a GitHub repo; the client GETs api.github.com and httputil.FetchJSON "+
+ "decodes the response into a Go struct with `json:\"…\"` tags.")),
+ row("mb-4 flex items-end gap-2",
+ row("flex grow flex-col gap-1 max-w-sm",
+ ui.FormLabel(ui.FormLabelProps{}, Text("GitHub repo (owner/name)")),
+ ui.FormInput(ui.FormInputProps{
+ Value: repoQuery.Get(),
+ Placeholder: "golang/go",
+ OnInput: func(v string) { repoQuery.Set(v) },
+ }),
+ ),
+ ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Fetch", OnClick: func() { fetchRepo(repoQuery.Get()) }}),
+ ),
+ repoBody(rLoading.Get(), rErr.Get(), repo.Get()),
+ ),
+ )
+ }
+}
+
+func quotesBody(loading bool, failed string, quotes []Quote) *VNode {
+ switch {
+ case failed != "":
+ return ui.Alert(ui.AlertRed, "Fetch failed", Text(failed))
+ case loading:
+ return ui.Loader()
+ default:
+ cards := make([]*VNode, 0, len(quotes))
+ for _, q := range quotes {
+ cards = append(cards, ui.BorderCard("",
+ P(Attr("class", "text-neutral-800"), Text("“"+q.Text+"”")),
+ P(Attr("class", "mt-2 text-sm text-neutral-500"), Text("— "+q.Author)),
+ ))
+ }
+ return row("grid gap-3 sm:grid-cols-2", cards...)
+ }
+}
+
+func repoBody(loading bool, failed string, r repoInfo) *VNode {
+ switch {
+ case failed != "":
+ return ui.Alert(ui.AlertRed, "Fetch failed", Text(failed))
+ case loading:
+ return ui.Loader()
+ default:
+ return ui.BorderCard("",
+ row("flex items-center gap-2",
+ Strong(Attr("class", "text-neutral-800"), Text(r.FullName)),
+ ui.Badge(ui.BadgeProps{Color: ui.BadgeAmber}, Text("★ "+strconv.Itoa(r.Stars))),
+ ),
+ P(Attr("class", "mt-2 text-sm text-neutral-600"), Text(r.Description)),
+ )
+ }
+}
diff --git a/go/cmd/examples/go-wasm-web/app/kit.go b/go/cmd/examples/go-wasm-web/app/kit.go
new file mode 100644
index 00000000..63b0bc14
--- /dev/null
+++ b/go/cmd/examples/go-wasm-web/app/kit.go
@@ -0,0 +1,186 @@
+package app
+
+import (
+ . "kjol/vdom"
+ ui "kjol/webui"
+)
+
+// row is a flex/grid container helper (appends *VNode children as Mods).
+func row(class string, children ...*VNode) *VNode {
+ mods := []Mod{Attr("class", class)}
+ for _, c := range children {
+ mods = append(mods, c)
+ }
+ return Div(mods...)
+}
+
+// kitSection wraps a labeled demo block in a card.
+func kitSection(title string, body ...*VNode) *VNode {
+ return ui.Card("",
+ ui.CardHeader("", Text(title)),
+ row("flex flex-col gap-4", body...),
+ )
+}
+
+func ptRow(name, plan string, status *VNode) *VNode {
+ td := func(cls string, c *VNode) *VNode { return El("td", Attr("class", "px-3 py-2 text-sm "+cls), c) }
+ return El("tr",
+ td("text-neutral-800", Text(name)),
+ td("text-neutral-600", Text(plan)),
+ El("td", Attr("class", "px-3 py-2 text-sm text-right"), status),
+ )
+}
+
+//gowasm:page /kit layout=app
+func KitPage(d Deps) func() *VNode {
+ // Interactive demos own their state via signals (a write re-renders).
+ tab := NewSignal(0)
+ acc := NewSignal(0)
+ notify := NewSignal(true)
+ span := NewSignal("week")
+ modalOpen := NewSignal(false)
+ menuOpen := NewSignal(false)
+ name := NewSignal("")
+ email := NewSignal("")
+ plan := NewSignal("pro")
+
+ return func() *VNode {
+ return Div(Attr("class", "space-y-8"),
+ Div(
+ H2(Attr("class", "text-2xl font-semibold tracking-tight text-text-heading"), Text("UI Kit")),
+ P(Attr("class", "mt-1 text-neutral-500"),
+ Text("The kjol/webui components, ported from the Solid.js kit and styled with Tailwind. "+
+ "Interactive components are driven by signals; overlays and floating elements render "+
+ "in their static form (see the note at the bottom).")),
+ ),
+
+ kitSection("Buttons",
+ row("flex flex-wrap items-center gap-2",
+ ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Primary"}),
+ ui.Button(ui.ButtonProps{Color: ui.ButtonGreen, Text: "Green"}),
+ ui.Button(ui.ButtonProps{Color: ui.ButtonRed, Text: "Red"}),
+ ui.Button(ui.ButtonProps{Color: ui.ButtonBlue, Text: "Blue"}),
+ ui.Button(ui.ButtonProps{Color: ui.ButtonNeutral, Text: "Neutral"}),
+ ),
+ row("flex flex-wrap items-center gap-2",
+ ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Outline: true, Text: "Outline"}),
+ ui.Button(ui.ButtonProps{Color: ui.ButtonRed, Outline: true, Text: "Danger"}),
+ ui.Button(ui.ButtonProps{Color: ui.ButtonGreen, Small: true, Icon: "check", Text: "Small + icon"}),
+ ui.Button(ui.ButtonProps{Color: ui.ButtonNeutral, Icon: "plus"}),
+ ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Disabled", Disabled: true}),
+ ),
+ ),
+
+ kitSection("Badges",
+ row("flex flex-wrap items-center gap-2",
+ ui.Badge(ui.BadgeProps{Color: ui.BadgeGreen}, Text("active")),
+ ui.Badge(ui.BadgeProps{Color: ui.BadgeRed}, Text("failed")),
+ ui.Badge(ui.BadgeProps{Color: ui.BadgeBlue}, Text("info")),
+ ui.Badge(ui.BadgeProps{Color: ui.BadgeAmber, Pill: true}, Text("pending")),
+ ui.Badge(ui.BadgeProps{Color: ui.BadgeNeutral}, Text("default")),
+ ui.Badge(ui.BadgeProps{Color: ui.BadgeMuted}, Text("muted")),
+ ),
+ ),
+
+ kitSection("Alerts",
+ ui.Alert(ui.AlertBlue, "Heads up", Text("An informational message with a header.")),
+ ui.Alert(ui.AlertGreen, "", Text("A success alert without a header.")),
+ ui.Alert(ui.AlertYellow, "Warning", Text("Something needs your attention.")),
+ ui.Alert(ui.AlertRed, "Error", Text("Something went wrong.")),
+ ),
+
+ kitSection("Toggles & segmented control",
+ ui.ToggleSwitch(notify.Get(), func(v bool) { notify.Set(v) }, "Email notifications", "Send me product updates", false, ""),
+ ui.SegmentedButtons([]ui.SegmentedButtonOption{
+ {Value: "day", Label: "Day"},
+ {Value: "week", Label: "Week"},
+ {Value: "month", Label: "Month"},
+ }, span.Get(), func(v string) { span.Set(v) }, false, "max-w-xs"),
+ ),
+
+ kitSection("Tabs",
+ ui.TabGroup(ui.TabGroupProps{
+ Items: []ui.TabItem{
+ {Title: "Overview", Content: P(Attr("class", "pt-3 text-sm text-neutral-600"), Text("The overview panel."))},
+ {Title: "Details", Content: P(Attr("class", "pt-3 text-sm text-neutral-600"), Text("The details panel."))},
+ {Title: "Activity", Badge: 3, Content: P(Attr("class", "pt-3 text-sm text-neutral-600"), Text("The activity panel (3 new)."))},
+ },
+ ActiveIndex: tab.Get(),
+ OnTabChange: func(i int) { tab.Set(i) },
+ }),
+ ),
+
+ kitSection("Accordion",
+ ui.SingleAccordion([]ui.AccordionItemData{
+ {Title: "What is gowasm?", Content: P(Attr("class", "text-sm text-neutral-600"), Text("A tiny Go→WebAssembly UI engine."))},
+ {Title: "Is it isomorphic?", Content: P(Attr("class", "text-sm text-neutral-600"), Text("Yes — the same Go renders on the server (SSR) and hydrates on the client."))},
+ {Title: "How is it styled?", Content: P(Attr("class", "text-sm text-neutral-600"), Text("Tailwind utility classes, compiled by kjol's native Tailwind engine."))},
+ }, acc.Get(), func(i int) { acc.Set(i) }),
+ ),
+
+ kitSection("Forms",
+ row("grid gap-4 sm:grid-cols-3",
+ row("flex flex-col gap-1", ui.FormLabel(ui.FormLabelProps{}, Text("Name")),
+ ui.FormInput(ui.FormInputProps{Value: name.Get(), Placeholder: "Ada Lovelace", OnInput: func(v string) { name.Set(v) }})),
+ row("flex flex-col gap-1", ui.FormLabel(ui.FormLabelProps{}, Text("Email")),
+ ui.FormEmailInput(ui.FormInputProps{Value: email.Get(), Placeholder: "ada@example.com", OnInput: func(v string) { email.Set(v) }}, true)),
+ row("flex flex-col gap-1", ui.FormLabel(ui.FormLabelProps{}, Text("Plan")),
+ ui.FormSelect(ui.FormSelectProps{Value: plan.Get(), OnChange: func(v string) { plan.Set(v) }},
+ ui.FormOption("free", "Free", false),
+ ui.FormOption("pro", "Pro", false),
+ ui.FormOption("enterprise", "Enterprise", false))),
+ ),
+ P(Attr("class", "text-xs text-neutral-500"),
+ Text("Live: name=\""+name.Get()+"\" email=\""+email.Get()+"\" plan=\""+plan.Get()+"\"")),
+ ),
+
+ kitSection("Table",
+ ui.PrettyTable(
+ []ui.PrettyTableColumn{
+ {DisplayName: "Name"},
+ {DisplayName: "Plan"},
+ {DisplayName: "Status", DisplayPosition: ui.PrettyTableColRight},
+ },
+ ui.PrettyTableOptions{Hover: true, Alternate: true, SurroundingBorder: true, HeaderBorderY: true},
+ ptRow("Ada Lovelace", "Pro", ui.Badge(ui.BadgeProps{Color: ui.BadgeGreen}, Text("active"))),
+ ptRow("Alan Turing", "Free", ui.Badge(ui.BadgeProps{Color: ui.BadgeNeutral}, Text("trial"))),
+ ptRow("Grace Hopper", "Enterprise", ui.Badge(ui.BadgeProps{Color: ui.BadgeBlue}, Text("invited"))),
+ ),
+ ),
+
+ kitSection("Overlays (interactive)",
+ row("flex flex-wrap items-center gap-4",
+ // Modal, toggled by a signal.
+ ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Open modal", OnClick: func() { modalOpen.Set(true) }}),
+ // Menu, toggled by a signal.
+ ui.Menu("relative inline-block",
+ ui.MenuTrigger(func() { menuOpen.Set(!menuOpen.Get()) }, "",
+ ui.Button(ui.ButtonProps{Color: ui.ButtonWhite, Text: "Menu ▾"})),
+ ui.MenuContent(menuOpen.Get(), ui.MenuPlacementBottomStart, "",
+ ui.MenuItem(ui.MenuItemProps{Icon: "check", OnClick: func() { menuOpen.Set(false) }}, Text("Profile")),
+ ui.MenuItem(ui.MenuItemProps{OnClick: func() { menuOpen.Set(false) }}, Text("Settings")),
+ ui.MenuDivider(""),
+ ui.MenuItem(ui.MenuItemProps{OnClick: func() { menuOpen.Set(false) }}, Text("Sign out")),
+ ),
+ ),
+ // Tooltip (pure CSS hover).
+ ui.HoverTooltip(Span(Text("A CSS-only tooltip")), "top", "",
+ ui.Button(ui.ButtonProps{Color: ui.ButtonNeutral, Outline: true, Text: "Hover me"})),
+ ),
+ ui.Modal(ui.ModalProps{
+ IsOpen: modalOpen.Get(),
+ OnClose: func() { modalOpen.Set(false) },
+ Size: ui.ModalMedium,
+ Header: H3(Attr("class", "text-lg font-semibold text-text-heading"), Text("Example modal")),
+ },
+ P(Attr("class", "text-neutral-600"), Text("This modal is toggled by a signal; the backdrop and close button round-trip through OnClose.")),
+ ),
+ ),
+
+ ui.Alert(ui.AlertGray, "About this page",
+ Text("Floating/positioned components (menus, tooltips, modals, dropdowns) are ported as "+
+ "structure + Tailwind + signal/event wiring — the neutral runtime has no floating-ui, "+
+ "portals, or element measurement, so positioning is approximated with static classes.")),
+ )
+ }
+}
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..eef647a4
--- /dev/null
+++ b/go/cmd/examples/go-wasm-web/app/pages.go
@@ -0,0 +1,203 @@
+// Package app holds the go-wasm-web example's pages and components as
+// standalone, platform-neutral functions (SSR on the server, hydrate on the
+// client). UI is built from the kjol webui kit + Tailwind utility classes.
+//
+// Directives (processed by kjol/cmd/wasmgen at build time):
+//
+// //gowasm:page [static] [layout=] a route (static => SSR'd)
+// //gowasm:layout a func(Deps, *VNode) *VNode wrapper
+// //gowasm:server (see server_counter.go) a server component
+package app
+
+//go:generate go run kjol/cmd/wasmgen .
+
+import (
+ "strconv"
+
+ . "kjol/vdom"
+ ui "kjol/webui"
+)
+
+// Deps are the client-only capabilities, injected so 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 content with shared chrome (declared with //gowasm:layout,
+// selected per route via `layout=`; 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.
+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(Attr("class", "py-10"),
+ H2(Attr("class", "text-xl font-semibold text-neutral-800 mb-2"), Text("Page not found")),
+ P(Attr("class", "text-neutral-500"), Text("No route matches "+path+".")),
+ )
+}
+
+// --- layouts (Tailwind chrome) -------------------------------------------
+
+//gowasm:layout public
+func PublicLayout(d Deps, content *VNode) *VNode {
+ return Div(
+ Nav(Attr("class", "site-nav sticky top-0 z-10 border-b border-neutral-200 bg-white"),
+ Div(Attr("class", "mx-auto flex max-w-5xl items-center gap-2 px-4 py-3"),
+ A(Attr("class", "text-lg font-semibold tracking-tight text-text-heading no-underline"), Attr("href", "/"), navigate(d, "/"), Text("gowasm")),
+ Ul(Attr("class", "ml-auto flex items-center gap-1"),
+ navItem(d, "/", "Home", false),
+ navItem(d, "/about", "About", false),
+ Li(Attr("class", "ml-2"),
+ A(Attr("class", "inline-flex items-center gap-1 rounded-default bg-primary px-3 py-1.5 text-sm font-medium text-white no-underline hover:bg-primary-hover"),
+ Attr("href", "/chart"), navigate(d, "/chart"), Text("Open app →"))),
+ ))),
+ Main(Attr("class", "mx-auto max-w-5xl px-4 py-8"),
+ content,
+ Footer(Attr("class", "mt-12 border-t border-neutral-200 pt-4 text-sm text-neutral-400"),
+ Text("gowasm — pure-Go components compiled to WebAssembly.")),
+ ),
+ )
+}
+
+//gowasm:layout app
+func AppLayout(d Deps, content *VNode) *VNode {
+ return Div(Attr("class", "min-h-screen"),
+ Nav(Attr("class", "app-nav border-b border-neutral-800 bg-neutral-900"),
+ Div(Attr("class", "mx-auto flex max-w-5xl items-center gap-2 px-4 py-3"),
+ A(Attr("class", "text-lg font-semibold tracking-tight text-text-on-dark no-underline"), Attr("href", "/chart"), navigate(d, "/chart"), Text("gowasm · app")),
+ Ul(Attr("class", "ml-4 flex items-center gap-1"),
+ navItem(d, "/chart", "Chart", true),
+ navItem(d, "/server", "Server", true),
+ navItem(d, "/data", "Data", true),
+ navItem(d, "/kit", "UI Kit", true)),
+ Ul(Attr("class", "ml-auto flex items-center"),
+ navItem(d, "/", "Home", true)),
+ )),
+ Main(Attr("class", "mx-auto max-w-5xl px-4 py-8"), content),
+ )
+}
+
+// navItem is a nav link with an active state; dark switches to on-dark colors.
+func navItem(d Deps, path, label string, dark bool) *VNode {
+ active := d.Path() == path
+ var cls string
+ switch {
+ case dark && active:
+ cls = "active rounded-default px-3 py-1.5 text-sm font-medium bg-white/10 text-white"
+ case dark:
+ cls = "rounded-default px-3 py-1.5 text-sm font-medium text-neutral-300 hover:bg-white/5 hover:text-white"
+ case active:
+ cls = "active rounded-default px-3 py-1.5 text-sm font-medium bg-neutral-100 text-neutral-900"
+ default:
+ cls = "rounded-default px-3 py-1.5 text-sm font-medium text-neutral-600 hover:bg-neutral-100 hover:text-neutral-900"
+ }
+ return Li(A(Attr("class", cls+" no-underline"), Attr("href", path), navigate(d, path), Text(label)))
+}
+
+// navigate intercepts a link click for client-side SPA navigation (Navigate is
+// nil on the server, 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 flex items-center gap-3 rounded-default border border-neutral-200 bg-white px-4 py-3 shadow-xs"),
+ Span(Attr("class", "font-medium text-neutral-700"), Text(label+": ")),
+ Strong(Attr("class", "badge inline-flex min-w-8 items-center justify-center rounded-full bg-primary px-2.5 py-0.5 text-sm font-semibold text-white"), Text(itoa(count.Get()))),
+ Div(Attr("class", "ml-auto flex gap-1"),
+ ui.Button(ui.ButtonProps{Color: ui.ButtonSecondary, Small: true, Text: "−", OnClick: func() { count.Update(func(v int) int { return v - 1 }) }}),
+ ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Small: true, Text: "+", OnClick: func() { count.Update(func(v int) int { return v + 1 }) }}),
+ ),
+ )
+}
+
+//gowasm:page / static layout=public
+func HomePage(d Deps) func() *VNode {
+ a := NewSignal(0)
+ b := NewSignal(0)
+ dark := NewSignal(false)
+ return func() *VNode {
+ return Div(Attr("class", "space-y-8"),
+ Div(
+ H2(Attr("class", "text-2xl font-semibold tracking-tight text-text-heading"), Text("Home — component composition")),
+ P(Attr("class", "mt-1 text-neutral-500"), Text("Two counters; the total is derived across them. Server-rendered, then hydrated.")),
+ ),
+ Div(Attr("class", "grid gap-3 sm:grid-cols-2"),
+ Counter("Apples", a),
+ Counter("Bananas", b),
+ ),
+ ui.Alert(ui.AlertBlue, "",
+ Span(Text("Combined total: ")),
+ Strong(Attr("class", "font-semibold"), Text(itoa(a.Get()+b.Get()))),
+ ),
+ ui.Card("",
+ ui.CardHeader("", Text("webui kit")),
+ Div(Attr("class", "flex flex-wrap items-center gap-2"),
+ ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Primary"}),
+ ui.Button(ui.ButtonProps{Color: ui.ButtonGreen, Text: "Green"}),
+ ui.Button(ui.ButtonProps{Color: ui.ButtonRed, Outline: true, Text: "Danger"}),
+ ui.Button(ui.ButtonProps{Color: ui.ButtonNeutral, Small: true, Icon: "check", Text: "Small"}),
+ ui.Badge(ui.BadgeProps{Color: ui.BadgeGreen}, Text("active")),
+ ui.Badge(ui.BadgeProps{Color: ui.BadgeAmber, Pill: true}, Text("pending")),
+ ),
+ Div(Attr("class", "mt-4"),
+ ui.ToggleSwitch(dark.Get(), func(v bool) { dark.Set(v) }, "Dark mode", "Just a demo toggle", false, "")),
+ ),
+ )
+ }
+}
+
+//gowasm:page /about static layout=public
+func AboutPage(d Deps) func() *VNode {
+ return func() *VNode {
+ return Div(Attr("class", "space-y-6"),
+ H2(Attr("class", "text-2xl font-semibold tracking-tight text-text-heading"), Text("About")),
+ ui.Card("",
+ P(Attr("class", "text-neutral-600 leading-relaxed"),
+ Text("Components are standalone Go 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. The UI kit (kjol/webui) is a Go port of the Solid.js "+
+ "component kit, styled with Tailwind.")),
+ ),
+ ui.Alert(ui.AlertGreen, "Neutral + isomorphic",
+ Text("This page's markup runs on the server (SSR) and hydrates on the client from the same Go code.")),
+ )
+ }
+}
+
+//gowasm:page /server layout=app
+func ServerPage(d Deps) func() *VNode {
+ // ServerCounter is a server component — 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.
+ counter := ServerCounter()
+ return func() *VNode {
+ return Div(Attr("class", "space-y-6"),
+ H2(Attr("class", "text-2xl font-semibold tracking-tight text-text-heading"), Text("Server component")),
+ P(Attr("class", "text-neutral-500"),
+ Text("This counter runs on the server. Its state lives there; clicks round-trip "+
+ "and the returned render merges into the DOM.")),
+ 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..de63ba83
--- /dev/null
+++ b/go/cmd/examples/go-wasm-web/app/routes.gen.go
@@ -0,0 +1,44 @@
+// Code generated by wasmgen. 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),
+ "/data": DataPage(d),
+ "/kit": KitPage(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",
+ "/data": "app",
+ "/kit": "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..f5a12724
--- /dev/null
+++ b/go/cmd/examples/go-wasm-web/app/server.gen.go
@@ -0,0 +1,11 @@
+// Code generated by wasmgen. 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..236dc082
--- /dev/null
+++ b/go/cmd/examples/go-wasm-web/app/server_counter.go
@@ -0,0 +1,117 @@
+//go:build !(js && wasm)
+
+package app
+
+import (
+ "bytes"
+ "strconv"
+ "time"
+
+ chart "github.com/wcharczuk/go-chart/v2"
+
+ . "kjol/vdom"
+ ui "kjol/webui"
+)
+
+// 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 ui.Card("",
+ Div(Attr("class", "flex items-center gap-2 mb-3"),
+ Span(Attr("class", "text-neutral-700"), Text("Server counter: ")),
+ Strong(Attr("class", "badge inline-flex items-center rounded-full bg-green-700 px-2.5 py-0.5 text-sm font-semibold text-white"), Text(strconv.Itoa(count.Get()))),
+ Div(Attr("class", "ml-auto flex gap-1"),
+ ui.Button(ui.ButtonProps{Color: ui.ButtonSecondary, Small: true, Text: "−", OnClick: func() { bump(-1) }}),
+ ui.Button(ui.ButtonProps{Color: ui.ButtonGreen, Small: true, Text: "+", OnClick: func() { bump(1) }}),
+ ),
+ ),
+ Div(Attr("class", "rounded-default border border-neutral-200 bg-white p-2 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..cd3980c4
--- /dev/null
+++ b/go/cmd/examples/go-wasm-web/build.sh
@@ -0,0 +1,36 @@
+#!/usr/bin/env bash
+# Pre-compile step: directive codegen, Tailwind CSS, WebAssembly build, JS shim.
+# Run the dev server instead (go run ./server) for hot reload; this is for a
+# one-off/production-style build. Run from anywhere.
+set -euo pipefail
+
+cd "$(dirname "$0")"
+KJOL_GO="$(cd ../../.. && pwd)"
+
+echo "==> Generating directive glue (//gowasm:page, //gowasm:layout, //gowasm:server)"
+go run kjol/cmd/wasmgen ./app
+
+echo "==> Compiling Tailwind CSS -> wwwroot/app.css (scanning webui + Go markup)"
+# Run twcss from the kjol module root so the Tailwind engine's deps resolve there
+# (not in this example module — keeps its go.mod lean).
+( cd "$KJOL_GO" && go run ./cmd/twcss \
+ -entry cmd/examples/go-wasm-web/css/app.css \
+ -out cmd/examples/go-wasm-web/wwwroot/app.css \
+ -base . \
+ 'webui/**/*.go' \
+ 'cmd/examples/go-wasm-web/app/**/*.go' \
+ 'cmd/examples/go-wasm-web/server/**/*.go' )
+
+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)"
+shim="$GOROOT/lib/wasm/wasm_exec.js" # Go >= 1.24
+[ -f "$shim" ] || shim="$GOROOT/misc/wasm/wasm_exec.js" # Go <= 1.23
+rm -f wwwroot/wasm_exec.js # GOROOT copy is read-only; remove before overwriting
+cp "$shim" wwwroot/wasm_exec.js
+chmod u+w wwwroot/wasm_exec.js
+
+echo "==> Done. Run the server with: go run ./server"
+echo " then open http://localhost:8085"
diff --git a/go/cmd/examples/go-wasm-web/css/app.css b/go/cmd/examples/go-wasm-web/css/app.css
new file mode 100644
index 00000000..b4367c30
--- /dev/null
+++ b/go/cmd/examples/go-wasm-web/css/app.css
@@ -0,0 +1,14 @@
+@import "tailwindcss";
+
+/* App-side design tokens the webui kit references (Tailwind v4 @theme). Brand
+ values live with the app; the kit stays generic. */
+@theme {
+ --radius-default: 0.375rem;
+
+ --color-primary: #4f46e5;
+ --color-primary-hover: #4338ca;
+
+ --color-text-heading: #111827;
+ --color-text-on-dark: #f9fafb;
+ --color-text-on-dark-muted: #9ca3af;
+}
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..461d9bd1
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..6cb2fb6e
--- /dev/null
+++ b/go/cmd/examples/go-wasm-web/server/main.go
@@ -0,0 +1,117 @@
+// 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"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+
+ "kjol/httputil"
+ "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", "css", "../../../webui", "../../../vdom", "../../../wasmruntime", "../../../rsc"}, // example + kjol engine + kit
+ Build: buildWasm,
+ Render: render,
+ Document: document,
+ Handle: apiRoutes,
+ }))
+}
+
+// apiRoutes registers the example's API endpoints. /api/quotes responds with a
+// gob-encoded []app.Quote (via httputil.RespondGob) — the /data page fetches and
+// decodes it on the client with encoding/gob (Go types end to end, no JSON).
+func apiRoutes(mux *http.ServeMux) {
+ mux.HandleFunc("GET /api/quotes", func(w http.ResponseWriter, r *http.Request) {
+ httputil.RespondGob(w, http.StatusOK, sampleQuotes())
+ })
+}
+
+func sampleQuotes() []app.Quote {
+ return []app.Quote{
+ {Author: "Rob Pike", Text: "A little copying is better than a little dependency."},
+ {Author: "Rob Pike", Text: "Don't communicate by sharing memory; share memory by communicating."},
+ {Author: "Ken Thompson", Text: "When in doubt, use brute force."},
+ {Author: "Alan Kay", Text: "The best way to predict the future is to invent it."},
+ }
+}
+
+// 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 + `
+
+
+