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 in watch mode. +func document(inner string) string { + return ` + + + + +gowasm — a tiny Blazor-like engine + + + +
` + inner + `
+ + + +` +} + +// buildWasm runs the directive codegen (kjol/cmd/wasmgen), compiles the Tailwind +// CSS (kjol/cmd/twcss, scanning the Go markup + webui kit), then compiles ./wasm +// to wwwroot/app.wasm. Returned combined output is shown in the browser overlay +// on failure. +func buildWasm() ([]byte, error) { + if out, err := exec.Command("go", "run", "kjol/cmd/wasmgen", "./app").CombinedOutput(); err != nil { + return out, err + } + // Compile Tailwind from the kjol module root (so the engine's deps resolve), + // scanning the webui kit + this example's Go markup for utility candidates. + tw := exec.Command("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") + tw.Dir = "../../.." // kjol/go + if out, err := tw.CombinedOutput(); err != nil { + return out, err + } + cmd := exec.Command("go", "build", "-o", filepath.Join("wwwroot", "app.wasm"), "./wasm") + cmd.Env = append(os.Environ(), "GOOS=js", "GOARCH=wasm") + return cmd.CombinedOutput() +} diff --git a/go/cmd/examples/go-wasm-web/wasm/main.go b/go/cmd/examples/go-wasm-web/wasm/main.go new file mode 100644 index 00000000..a1216992 --- /dev/null +++ b/go/cmd/examples/go-wasm-web/wasm/main.go @@ -0,0 +1,33 @@ +//go:build js && wasm + +// Command wasm is the client entry point: it wires client capabilities into the +// (generated) routes, then hydrates the server-rendered DOM or renders fresh. +package main + +import ( + "gowasmweb/app" + "kjol/httputil" + "kjol/vdom" + "kjol/wasmruntime" +) + +func main() { + // Collect the signals created during setup so their values can be preserved + // across an in-place hot swap. On a normal load RestoreState is nil (fresh + // state); after a dev hot-reload it carries the previous instance's values, + // which NewSignal restores by creation order. + httputil.SetClientTransport(wasmruntime.FetchBytes) // typed gob/json fetches for app pages + collector := vdom.BeginCollect(wasmruntime.RestoreState()) + router := wasmruntime.NewRouter() + deps := app.Deps{Path: router.Path, Navigate: router.Navigate} + routes := app.Routes(deps) + vdom.EndCollect() // signals created later (during renders) aren't preserved + wasmruntime.PreserveState(collector) + + render := func() *vdom.VNode { return app.Shell(deps, routes) } + if wasmruntime.HasServerContent() { + wasmruntime.Hydrate(render) // static route: adopt the server-rendered DOM + } else { + wasmruntime.Run(render) // client-rendered route + } +} diff --git a/go/cmd/examples/go-wasm-web/wasm/main_native.go b/go/cmd/examples/go-wasm-web/wasm/main_native.go new file mode 100644 index 00000000..c4837820 --- /dev/null +++ b/go/cmd/examples/go-wasm-web/wasm/main_native.go @@ -0,0 +1,9 @@ +//go:build !(js && wasm) + +// The wasm client entry point (main.go) builds only under GOOS=js GOARCH=wasm. +// This native placeholder keeps the package buildable on the host so a plain +// `go build ./...` succeeds; the real client is built by build.sh / the dev +// server with GOOS=js GOARCH=wasm. +package main + +func main() {} diff --git a/go/cmd/examples/go-wasm-web/wwwroot/app.css b/go/cmd/examples/go-wasm-web/wwwroot/app.css new file mode 100644 index 00000000..e176512b --- /dev/null +++ b/go/cmd/examples/go-wasm-web/wwwroot/app.css @@ -0,0 +1,100 @@ +@layer theme,base,components,utilities;@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', + 'Noto Color Emoji';--font-serif:ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', + monospace;--color-red-50:oklch(97.1% 0.013 17.38);--color-red-100:oklch(93.6% 0.032 17.717);--color-red-200:oklch(88.5% 0.062 18.334);--color-red-300:oklch(80.8% 0.114 19.571);--color-red-400:oklch(70.4% 0.191 22.216);--color-red-500:oklch(63.7% 0.237 25.331);--color-red-600:oklch(57.7% 0.245 27.325);--color-red-700:oklch(50.5% 0.213 27.518);--color-red-800:oklch(44.4% 0.177 26.899);--color-red-900:oklch(39.6% 0.141 25.723);--color-red-950:oklch(25.8% 0.092 26.042);--color-orange-50:oklch(98% 0.016 73.684);--color-orange-100:oklch(95.4% 0.038 75.164);--color-orange-200:oklch(90.1% 0.076 70.697);--color-orange-300:oklch(83.7% 0.128 66.29);--color-orange-400:oklch(75% 0.183 55.934);--color-orange-500:oklch(70.5% 0.213 47.604);--color-orange-600:oklch(64.6% 0.222 41.116);--color-orange-700:oklch(55.3% 0.195 38.402);--color-orange-800:oklch(47% 0.157 37.304);--color-orange-900:oklch(40.8% 0.123 38.172);--color-orange-950:oklch(26.6% 0.079 36.259);--color-amber-50:oklch(98.7% 0.022 95.277);--color-amber-100:oklch(96.2% 0.059 95.617);--color-amber-200:oklch(92.4% 0.12 95.746);--color-amber-300:oklch(87.9% 0.169 91.605);--color-amber-400:oklch(82.8% 0.189 84.429);--color-amber-500:oklch(76.9% 0.188 70.08);--color-amber-600:oklch(66.6% 0.179 58.318);--color-amber-700:oklch(55.5% 0.163 48.998);--color-amber-800:oklch(47.3% 0.137 46.201);--color-amber-900:oklch(41.4% 0.112 45.904);--color-amber-950:oklch(27.9% 0.077 45.635);--color-yellow-50:oklch(98.7% 0.026 102.212);--color-yellow-100:oklch(97.3% 0.071 103.193);--color-yellow-200:oklch(94.5% 0.129 101.54);--color-yellow-300:oklch(90.5% 0.182 98.111);--color-yellow-400:oklch(85.2% 0.199 91.936);--color-yellow-500:oklch(79.5% 0.184 86.047);--color-yellow-600:oklch(68.1% 0.162 75.834);--color-yellow-700:oklch(55.4% 0.135 66.442);--color-yellow-800:oklch(47.6% 0.114 61.907);--color-yellow-900:oklch(42.1% 0.095 57.708);--color-yellow-950:oklch(28.6% 0.066 53.813);--color-lime-50:oklch(98.6% 0.031 120.757);--color-lime-100:oklch(96.7% 0.067 122.328);--color-lime-200:oklch(93.8% 0.127 124.321);--color-lime-300:oklch(89.7% 0.196 126.665);--color-lime-400:oklch(84.1% 0.238 128.85);--color-lime-500:oklch(76.8% 0.233 130.85);--color-lime-600:oklch(64.8% 0.2 131.684);--color-lime-700:oklch(53.2% 0.157 131.589);--color-lime-800:oklch(45.3% 0.124 130.933);--color-lime-900:oklch(40.5% 0.101 131.063);--color-lime-950:oklch(27.4% 0.072 132.109);--color-green-50:oklch(98.2% 0.018 155.826);--color-green-100:oklch(96.2% 0.044 156.743);--color-green-200:oklch(92.5% 0.084 155.995);--color-green-300:oklch(87.1% 0.15 154.449);--color-green-400:oklch(79.2% 0.209 151.711);--color-green-500:oklch(72.3% 0.219 149.579);--color-green-600:oklch(62.7% 0.194 149.214);--color-green-700:oklch(52.7% 0.154 150.069);--color-green-800:oklch(44.8% 0.119 151.328);--color-green-900:oklch(39.3% 0.095 152.535);--color-green-950:oklch(26.6% 0.065 152.934);--color-emerald-50:oklch(97.9% 0.021 166.113);--color-emerald-100:oklch(95% 0.052 163.051);--color-emerald-200:oklch(90.5% 0.093 164.15);--color-emerald-300:oklch(84.5% 0.143 164.978);--color-emerald-400:oklch(76.5% 0.177 163.223);--color-emerald-500:oklch(69.6% 0.17 162.48);--color-emerald-600:oklch(59.6% 0.145 163.225);--color-emerald-700:oklch(50.8% 0.118 165.612);--color-emerald-800:oklch(43.2% 0.095 166.913);--color-emerald-900:oklch(37.8% 0.077 168.94);--color-emerald-950:oklch(26.2% 0.051 172.552);--color-teal-50:oklch(98.4% 0.014 180.72);--color-teal-100:oklch(95.3% 0.051 180.801);--color-teal-200:oklch(91% 0.096 180.426);--color-teal-300:oklch(85.5% 0.138 181.071);--color-teal-400:oklch(77.7% 0.152 181.912);--color-teal-500:oklch(70.4% 0.14 182.503);--color-teal-600:oklch(60% 0.118 184.704);--color-teal-700:oklch(51.1% 0.096 186.391);--color-teal-800:oklch(43.7% 0.078 188.216);--color-teal-900:oklch(38.6% 0.063 188.416);--color-teal-950:oklch(27.7% 0.046 192.524);--color-cyan-50:oklch(98.4% 0.019 200.873);--color-cyan-100:oklch(95.6% 0.045 203.388);--color-cyan-200:oklch(91.7% 0.08 205.041);--color-cyan-300:oklch(86.5% 0.127 207.078);--color-cyan-400:oklch(78.9% 0.154 211.53);--color-cyan-500:oklch(71.5% 0.143 215.221);--color-cyan-600:oklch(60.9% 0.126 221.723);--color-cyan-700:oklch(52% 0.105 223.128);--color-cyan-800:oklch(45% 0.085 224.283);--color-cyan-900:oklch(39.8% 0.07 227.392);--color-cyan-950:oklch(30.2% 0.056 229.695);--color-sky-50:oklch(97.7% 0.013 236.62);--color-sky-100:oklch(95.1% 0.026 236.824);--color-sky-200:oklch(90.1% 0.058 230.902);--color-sky-300:oklch(82.8% 0.111 230.318);--color-sky-400:oklch(74.6% 0.16 232.661);--color-sky-500:oklch(68.5% 0.169 237.323);--color-sky-600:oklch(58.8% 0.158 241.966);--color-sky-700:oklch(50% 0.134 242.749);--color-sky-800:oklch(44.3% 0.11 240.79);--color-sky-900:oklch(39.1% 0.09 240.876);--color-sky-950:oklch(29.3% 0.066 243.157);--color-blue-50:oklch(97% 0.014 254.604);--color-blue-100:oklch(93.2% 0.032 255.585);--color-blue-200:oklch(88.2% 0.059 254.128);--color-blue-300:oklch(80.9% 0.105 251.813);--color-blue-400:oklch(70.7% 0.165 254.624);--color-blue-500:oklch(62.3% 0.214 259.815);--color-blue-600:oklch(54.6% 0.245 262.881);--color-blue-700:oklch(48.8% 0.243 264.376);--color-blue-800:oklch(42.4% 0.199 265.638);--color-blue-900:oklch(37.9% 0.146 265.522);--color-blue-950:oklch(28.2% 0.091 267.935);--color-indigo-50:oklch(96.2% 0.018 272.314);--color-indigo-100:oklch(93% 0.034 272.788);--color-indigo-200:oklch(87% 0.065 274.039);--color-indigo-300:oklch(78.5% 0.115 274.713);--color-indigo-400:oklch(67.3% 0.182 276.935);--color-indigo-500:oklch(58.5% 0.233 277.117);--color-indigo-600:oklch(51.1% 0.262 276.966);--color-indigo-700:oklch(45.7% 0.24 277.023);--color-indigo-800:oklch(39.8% 0.195 277.366);--color-indigo-900:oklch(35.9% 0.144 278.697);--color-indigo-950:oklch(25.7% 0.09 281.288);--color-violet-50:oklch(96.9% 0.016 293.756);--color-violet-100:oklch(94.3% 0.029 294.588);--color-violet-200:oklch(89.4% 0.057 293.283);--color-violet-300:oklch(81.1% 0.111 293.571);--color-violet-400:oklch(70.2% 0.183 293.541);--color-violet-500:oklch(60.6% 0.25 292.717);--color-violet-600:oklch(54.1% 0.281 293.009);--color-violet-700:oklch(49.1% 0.27 292.581);--color-violet-800:oklch(43.2% 0.232 292.759);--color-violet-900:oklch(38% 0.189 293.745);--color-violet-950:oklch(28.3% 0.141 291.089);--color-purple-50:oklch(97.7% 0.014 308.299);--color-purple-100:oklch(94.6% 0.033 307.174);--color-purple-200:oklch(90.2% 0.063 306.703);--color-purple-300:oklch(82.7% 0.119 306.383);--color-purple-400:oklch(71.4% 0.203 305.504);--color-purple-500:oklch(62.7% 0.265 303.9);--color-purple-600:oklch(55.8% 0.288 302.321);--color-purple-700:oklch(49.6% 0.265 301.924);--color-purple-800:oklch(43.8% 0.218 303.724);--color-purple-900:oklch(38.1% 0.176 304.987);--color-purple-950:oklch(29.1% 0.149 302.717);--color-fuchsia-50:oklch(97.7% 0.017 320.058);--color-fuchsia-100:oklch(95.2% 0.037 318.852);--color-fuchsia-200:oklch(90.3% 0.076 319.62);--color-fuchsia-300:oklch(83.3% 0.145 321.434);--color-fuchsia-400:oklch(74% 0.238 322.16);--color-fuchsia-500:oklch(66.7% 0.295 322.15);--color-fuchsia-600:oklch(59.1% 0.293 322.896);--color-fuchsia-700:oklch(51.8% 0.253 323.949);--color-fuchsia-800:oklch(45.2% 0.211 324.591);--color-fuchsia-900:oklch(40.1% 0.17 325.612);--color-fuchsia-950:oklch(29.3% 0.136 325.661);--color-pink-50:oklch(97.1% 0.014 343.198);--color-pink-100:oklch(94.8% 0.028 342.258);--color-pink-200:oklch(89.9% 0.061 343.231);--color-pink-300:oklch(82.3% 0.12 346.018);--color-pink-400:oklch(71.8% 0.202 349.761);--color-pink-500:oklch(65.6% 0.241 354.308);--color-pink-600:oklch(59.2% 0.249 0.584);--color-pink-700:oklch(52.5% 0.223 3.958);--color-pink-800:oklch(45.9% 0.187 3.815);--color-pink-900:oklch(40.8% 0.153 2.432);--color-pink-950:oklch(28.4% 0.109 3.907);--color-rose-50:oklch(96.9% 0.015 12.422);--color-rose-100:oklch(94.1% 0.03 12.58);--color-rose-200:oklch(89.2% 0.058 10.001);--color-rose-300:oklch(81% 0.117 11.638);--color-rose-400:oklch(71.2% 0.194 13.428);--color-rose-500:oklch(64.5% 0.246 16.439);--color-rose-600:oklch(58.6% 0.253 17.585);--color-rose-700:oklch(51.4% 0.222 16.935);--color-rose-800:oklch(45.5% 0.188 13.697);--color-rose-900:oklch(41% 0.159 10.272);--color-rose-950:oklch(27.1% 0.105 12.094);--color-slate-50:oklch(98.4% 0.003 247.858);--color-slate-100:oklch(96.8% 0.007 247.896);--color-slate-200:oklch(92.9% 0.013 255.508);--color-slate-300:oklch(86.9% 0.022 252.894);--color-slate-400:oklch(70.4% 0.04 256.788);--color-slate-500:oklch(55.4% 0.046 257.417);--color-slate-600:oklch(44.6% 0.043 257.281);--color-slate-700:oklch(37.2% 0.044 257.287);--color-slate-800:oklch(27.9% 0.041 260.031);--color-slate-900:oklch(20.8% 0.042 265.755);--color-slate-950:oklch(12.9% 0.042 264.695);--color-gray-50:oklch(98.5% 0.002 247.839);--color-gray-100:oklch(96.7% 0.003 264.542);--color-gray-200:oklch(92.8% 0.006 264.531);--color-gray-300:oklch(87.2% 0.01 258.338);--color-gray-400:oklch(70.7% 0.022 261.325);--color-gray-500:oklch(55.1% 0.027 264.364);--color-gray-600:oklch(44.6% 0.03 256.802);--color-gray-700:oklch(37.3% 0.034 259.733);--color-gray-800:oklch(27.8% 0.033 256.848);--color-gray-900:oklch(21% 0.034 264.665);--color-gray-950:oklch(13% 0.028 261.692);--color-zinc-50:oklch(98.5% 0 0);--color-zinc-100:oklch(96.7% 0.001 286.375);--color-zinc-200:oklch(92% 0.004 286.32);--color-zinc-300:oklch(87.1% 0.006 286.286);--color-zinc-400:oklch(70.5% 0.015 286.067);--color-zinc-500:oklch(55.2% 0.016 285.938);--color-zinc-600:oklch(44.2% 0.017 285.786);--color-zinc-700:oklch(37% 0.013 285.805);--color-zinc-800:oklch(27.4% 0.006 286.033);--color-zinc-900:oklch(21% 0.006 285.885);--color-zinc-950:oklch(14.1% 0.005 285.823);--color-neutral-50:oklch(98.5% 0 0);--color-neutral-100:oklch(97% 0 0);--color-neutral-200:oklch(92.2% 0 0);--color-neutral-300:oklch(87% 0 0);--color-neutral-400:oklch(70.8% 0 0);--color-neutral-500:oklch(55.6% 0 0);--color-neutral-600:oklch(43.9% 0 0);--color-neutral-700:oklch(37.1% 0 0);--color-neutral-800:oklch(26.9% 0 0);--color-neutral-900:oklch(20.5% 0 0);--color-neutral-950:oklch(14.5% 0 0);--color-stone-50:oklch(98.5% 0.001 106.423);--color-stone-100:oklch(97% 0.001 106.424);--color-stone-200:oklch(92.3% 0.003 48.717);--color-stone-300:oklch(86.9% 0.005 56.366);--color-stone-400:oklch(70.9% 0.01 56.259);--color-stone-500:oklch(55.3% 0.013 58.071);--color-stone-600:oklch(44.4% 0.011 73.639);--color-stone-700:oklch(37.4% 0.01 67.558);--color-stone-800:oklch(26.8% 0.007 34.298);--color-stone-900:oklch(21.6% 0.006 56.043);--color-stone-950:oklch(14.7% 0.004 49.25);--color-mauve-50:oklch(98.5% 0 0);--color-mauve-100:oklch(96% 0.003 325.6);--color-mauve-200:oklch(92.2% 0.005 325.62);--color-mauve-300:oklch(86.5% 0.012 325.68);--color-mauve-400:oklch(71.1% 0.019 323.02);--color-mauve-500:oklch(54.2% 0.034 322.5);--color-mauve-600:oklch(43.5% 0.029 321.78);--color-mauve-700:oklch(36.4% 0.029 323.89);--color-mauve-800:oklch(26.3% 0.024 320.12);--color-mauve-900:oklch(21.2% 0.019 322.12);--color-mauve-950:oklch(14.5% 0.008 326);--color-olive-50:oklch(98.8% 0.003 106.5);--color-olive-100:oklch(96.6% 0.005 106.5);--color-olive-200:oklch(93% 0.007 106.5);--color-olive-300:oklch(88% 0.011 106.6);--color-olive-400:oklch(73.7% 0.021 106.9);--color-olive-500:oklch(58% 0.031 107.3);--color-olive-600:oklch(46.6% 0.025 107.3);--color-olive-700:oklch(39.4% 0.023 107.4);--color-olive-800:oklch(28.6% 0.016 107.4);--color-olive-900:oklch(22.8% 0.013 107.4);--color-olive-950:oklch(15.3% 0.006 107.1);--color-mist-50:oklch(98.7% 0.002 197.1);--color-mist-100:oklch(96.3% 0.002 197.1);--color-mist-200:oklch(92.5% 0.005 214.3);--color-mist-300:oklch(87.2% 0.007 219.6);--color-mist-400:oklch(72.3% 0.014 214.4);--color-mist-500:oklch(56% 0.021 213.5);--color-mist-600:oklch(45% 0.017 213.2);--color-mist-700:oklch(37.8% 0.015 216);--color-mist-800:oklch(27.5% 0.011 216.9);--color-mist-900:oklch(21.8% 0.008 223.9);--color-mist-950:oklch(14.8% 0.004 228.8);--color-taupe-50:oklch(98.6% 0.002 67.8);--color-taupe-100:oklch(96% 0.002 17.2);--color-taupe-200:oklch(92.2% 0.005 34.3);--color-taupe-300:oklch(86.8% 0.007 39.5);--color-taupe-400:oklch(71.4% 0.014 41.2);--color-taupe-500:oklch(54.7% 0.021 43.1);--color-taupe-600:oklch(43.8% 0.017 39.3);--color-taupe-700:oklch(36.7% 0.016 35.7);--color-taupe-800:oklch(26.8% 0.011 36.5);--color-taupe-900:oklch(21.4% 0.009 43.1);--color-taupe-950:oklch(14.7% 0.004 49.3);--color-black:#000;--color-white:#fff;--spacing:0.25rem;--breakpoint-sm:40rem;--breakpoint-md:48rem;--breakpoint-lg:64rem;--breakpoint-xl:80rem;--breakpoint-2xl:96rem;--container-3xs:16rem;--container-2xs:18rem;--container-xs:20rem;--container-sm:24rem;--container-md:28rem;--container-lg:32rem;--container-xl:36rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--container-6xl:72rem;--container-7xl:80rem;--text-xs:0.75rem;--text-xs--line-height:calc(1 / 0.75);--text-sm:0.875rem;--text-sm--line-height:calc(1.25 / 0.875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--text-6xl:3.75rem;--text-6xl--line-height:1;--text-7xl:4.5rem;--text-7xl--line-height:1;--text-8xl:6rem;--text-8xl--line-height:1;--text-9xl:8rem;--text-9xl--line-height:1;--font-weight-thin:100;--font-weight-extralight:200;--font-weight-light:300;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--font-weight-extrabold:800;--font-weight-black:900;--tracking-tighter:-0.05em;--tracking-tight:-0.025em;--tracking-normal:0em;--tracking-wide:0.025em;--tracking-wider:0.05em;--tracking-widest:0.1em;--leading-tight:1.25;--leading-snug:1.375;--leading-normal:1.5;--leading-relaxed:1.625;--leading-loose:2;--radius-xs:0.125rem;--radius-sm:0.25rem;--radius-md:0.375rem;--radius-lg:0.5rem;--radius-xl:0.75rem;--radius-2xl:1rem;--radius-3xl:1.5rem;--radius-4xl:2rem;--shadow-2xs:0 1px rgb(0 0 0 / 0.05);--shadow-xs:0 1px 2px 0 rgb(0 0 0 / 0.05);--shadow-sm:0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);--shadow-md:0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);--shadow-lg:0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);--shadow-xl:0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);--shadow-2xl:0 25px 50px -12px rgb(0 0 0 / 0.25);--inset-shadow-2xs:inset 0 1px rgb(0 0 0 / 0.05);--inset-shadow-xs:inset 0 1px 1px rgb(0 0 0 / 0.05);--inset-shadow-sm:inset 0 2px 4px rgb(0 0 0 / 0.05);--drop-shadow-xs:0 1px 1px rgb(0 0 0 / 0.05);--drop-shadow-sm:0 1px 2px rgb(0 0 0 / 0.15);--drop-shadow-md:0 3px 3px rgb(0 0 0 / 0.12);--drop-shadow-lg:0 4px 4px rgb(0 0 0 / 0.15);--drop-shadow-xl:0 9px 7px rgb(0 0 0 / 0.1);--drop-shadow-2xl:0 25px 25px rgb(0 0 0 / 0.15);--text-shadow-2xs:0px 1px 0px rgb(0 0 0 / 0.15);--text-shadow-xs:0px 1px 1px rgb(0 0 0 / 0.2);--text-shadow-sm:0px 1px 0px rgb(0 0 0 / 0.075), 0px 1px 1px rgb(0 0 0 / 0.075), 0px 2px 2px rgb(0 0 0 / 0.075);--text-shadow-md:0px 1px 1px rgb(0 0 0 / 0.1), 0px 1px 2px rgb(0 0 0 / 0.1), 0px 2px 4px rgb(0 0 0 / 0.1);--text-shadow-lg:0px 1px 2px rgb(0 0 0 / 0.1), 0px 3px 2px rgb(0 0 0 / 0.1), 0px 4px 8px rgb(0 0 0 / 0.1);--ease-in:cubic-bezier(0.4, 0, 1, 1);--ease-out:cubic-bezier(0, 0, 0.2, 1);--ease-in-out:cubic-bezier(0.4, 0, 0.2, 1);--animate-spin:spin 1s linear infinite;--animate-ping:ping 1s cubic-bezier(0, 0, 0.2, 1) infinite;--animate-pulse:pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;--animate-bounce:bounce 1s infinite;--blur-xs:4px;--blur-sm:8px;--blur-md:12px;--blur-lg:16px;--blur-xl:24px;--blur-2xl:40px;--blur-3xl:64px;--perspective-dramatic:100px;--perspective-near:300px;--perspective-normal:500px;--perspective-midrange:800px;--perspective-distant:1200px;--aspect-video:16 / 9;--default-transition-duration:150ms;--default-transition-timing-function:cubic-bezier(0.4, 0, 0.2, 1);--default-font-family:var(--font-sans);--default-font-feature-settings:initial;--default-font-variation-settings:initial;--default-mono-font-family:var(--font-mono);--default-mono-font-feature-settings:initial;--default-mono-font-variation-settings:initial;--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}}@keyframes spin{to{transform:rotate(360deg)}}@keyframes ping{75%,100%{transform:scale(2);opacity:0}}@keyframes pulse{50%{opacity:.5}}@keyframes bounce{0%,100%{transform:translateY(-25%);animation-timing-function:cubic-bezier(.8,0,1,1)}50%{transform:none;animation-timing-function:cubic-bezier(0,0,.2,1)}}@layer base{*,::after,::before,::backdrop,::file-selector-button{box-sizing:border-box;margin:0;padding:0;border:0 solid}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;tab-size:4;font-family:--theme( --default-font-family,ui-sans-serif,system-ui,sans-serif,'Apple Color Emoji','Segoe UI Emoji','Segoe UI Symbol','Noto Color Emoji' );font-feature-settings:--theme(--default-font-feature-settings,normal);font-variation-settings:--theme(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:--theme( --default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,'Liberation Mono','Courier New',monospace );font-feature-settings:--theme(--default-mono-font-feature-settings,normal);font-variation-settings:--theme(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea,::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;border-radius:0;background-color:initial;opacity:1}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports(not (-webkit-appearance:-apple-pay-button)) or (contain-intrinsic-size:1px){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit,::-webkit-datetime-edit-year-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]),::file-selector-button{appearance:button}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer utilities{.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.invisible{visibility:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{inset:0}.inset-y-0{inset-block:0}.-top-\[6px\]{top:calc(6px * -1)}.top-0{top:0}.top-1\/2{top:calc(1/2 * 100%)}.top-20{top:calc(var(--spacing) * 20)}.top-4{top:calc(var(--spacing) * 4)}.top-full{top:100%}.-right-2{right:calc(var(--spacing) * -2)}.-right-\[6px\]{right:calc(6px * -1)}.right-0{right:0}.right-0\.5{right:calc(var(--spacing) * .5)}.right-4{right:calc(var(--spacing) * 4)}.right-8{right:calc(var(--spacing) * 8)}.right-full{right:100%}.-bottom-\[6px\]{bottom:calc(6px * -1)}.bottom-0{bottom:0}.bottom-4{bottom:calc(var(--spacing) * 4)}.bottom-full{bottom:100%}.-left-\[6px\]{left:calc(6px * -1)}.left-0{left:0}.left-1\/2{left:calc(1/2 * 100%)}.left-4{left:calc(var(--spacing) * 4)}.left-full{left:100%}.isolate{isolation:isolate}.z-10{z-index:10}.z-150{z-index:150}.z-200{z-index:200}.z-50{z-index:50}.z-\[100\]{z-index:100}.z-\[110\]{z-index:110}.z-\[1\]{z-index:1}.z-\[200\]{z-index:200}.z-\[51\]{z-index:51}.col-span-1{grid-column:span 1/span 1}.m-0{margin:0}.-mx-1\.5{margin-inline:calc(var(--spacing) * -1.5)}.mx-3{margin-inline:calc(var(--spacing) * 3)}.mx-4{margin-inline:calc(var(--spacing) * 4)}.mx-\[0\.15rem\]{margin-inline:.15rem}.mx-auto{margin-inline:auto}.my-1{margin-block:var(--spacing)}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-12{margin-top:calc(var(--spacing) * 12)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mr-1{margin-right:var(--spacing)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mr-5{margin-right:calc(var(--spacing) * 5)}.mb-1{margin-bottom:var(--spacing)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.ml-1{margin-left:var(--spacing)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-3{margin-left:calc(var(--spacing) * 3)}.ml-4{margin-left:calc(var(--spacing) * 4)}.ml-auto{margin-left:auto}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.aspect-square{aspect-ratio:1/1}.h-0{height:0}.h-0\.5{height:calc(var(--spacing) * .5)}.h-1{height:var(--spacing)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-72{height:calc(var(--spacing) * 72)}.h-8{height:calc(var(--spacing) * 8)}.h-\[30px\]{height:30px}.h-\[38px\]{height:38px}.h-dvh{height:100dvh}.h-full{height:100%}.h-screen{height:100vh}.max-h-60{max-height:calc(var(--spacing) * 60)}.max-h-96{max-height:calc(var(--spacing) * 96)}.max-h-\[calc\(100dvh_-_5rem\)\]{max-height:calc(100dvh - 5rem)}.max-h-\[calc\(100vh_-_7rem\)\]{max-height:calc(100vh - 7rem)}.max-h-dvh{max-height:100dvh}.min-h-0{min-height:0}.min-h-9{min-height:calc(var(--spacing) * 9)}.min-h-\[6\.5rem\]{min-height:6.5rem}.min-h-screen{min-height:100vh}.w-0{width:0}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-4{width:calc(var(--spacing) * 4)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-auto{width:auto}.w-full{width:100%}.w-max{width:max-content}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-64{max-width:calc(var(--spacing) * 64)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-7xl{max-width:var(--container-7xl)}.max-w-80{max-width:calc(var(--spacing) * 80)}.max-w-\[11rem\]{max-width:11rem}.max-w-\[14rem\]{max-width:14rem}.max-w-\[16rem\]{max-width:16rem}.max-w-\[20rem\]{max-width:20rem}.max-w-\[90rem\]{max-width:90rem}.max-w-full{max-width:100%}.max-w-md{max-width:var(--container-md)}.max-w-none{max-width:none}.max-w-screen{max-width:100vw}.max-w-sm{max-width:var(--container-sm)}.max-w-xl{max-width:var(--container-xl)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:0}.min-w-48{min-width:calc(var(--spacing) * 48)}.min-w-5{min-width:calc(var(--spacing) * 5)}.min-w-72{min-width:calc(var(--spacing) * 72)}.min-w-8{min-width:calc(var(--spacing) * 8)}.min-w-\[10rem\]{min-width:10rem}.min-w-\[16rem\]{min-width:16rem}.min-w-\[240px\]{min-width:240px}.min-w-\[7rem\]{min-width:7rem}.min-w-\[8rem\]{min-width:8rem}.min-w-\[9rem\]{min-width:9rem}.min-w-full{min-width:100%}.flex-1{flex:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.table-fixed{table-layout:fixed}.border-collapse{border-collapse:collapse}.-translate-x-1\/2{--tw-translate-x:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-x-0\.5{--tw-translate-x:calc(var(--spacing) * 0.5);translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-x-\[18px\]{--tw-translate-x:18px;translate:var(--tw-translate-x)var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x)var(--tw-translate-y)}.scale-110{--tw-scale-x:110%;--tw-scale-y:110%;--tw-scale-z:110%;scale:var(--tw-scale-x)var(--tw-scale-y)}.rotate-180{rotate:180deg}.transform{transform:var(--tw-rotate-x,)var(--tw-rotate-y,)var(--tw-rotate-z,)var(--tw-skew-x,)var(--tw-skew-y,)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-default{cursor:default}.cursor-pointer{cursor:pointer}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-7{grid-template-columns:repeat(7,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-row{flex-direction:row}.flex-row-reverse{flex-direction:row-reverse}.flex-nowrap{flex-wrap:nowrap}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.items-stretch{align-items:stretch}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.justify-start{justify-content:flex-start}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-8{gap:calc(var(--spacing) * 8)}.gap-\[2px\]{gap:2px}.space-y-6{ :where(& > :not(:last-child)) { --tw-space-y-reverse: 0; margin-block-start: calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse)); margin-block-end: calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse))); }}.space-y-8{ :where(& > :not(:last-child)) { --tw-space-y-reverse: 0; margin-block-start: calc(calc(var(--spacing) * 8) * var(--tw-space-y-reverse)); margin-block-end: calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse))); }}.self-end{align-self:flex-end}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-default{border-radius:var(--radius-default)}.rounded-full{border-radius:calc(infinity * 1px)}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.rounded-l-default{border-top-left-radius:var(--radius-default);border-bottom-left-radius:var(--radius-default)}.rounded-l-none{border-top-left-radius:0;border-bottom-left-radius:0}.rounded-b-default{border-bottom-right-radius:var(--radius-default);border-bottom-left-radius:var(--radius-default)}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-4{border-style:var(--tw-border-style);border-width:4px}.border-x{border-inline-style:var(--tw-border-style);border-inline-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-t-2{border-top-style:var(--tw-border-style);border-top-width:2px}.border-t-\[6px\]{border-top-style:var(--tw-border-style);border-top-width:6px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-r-0{border-right-style:var(--tw-border-style);border-right-width:0}.border-r-\[6px\]{border-right-style:var(--tw-border-style);border-right-width:6px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-b-\[6px\]{border-bottom-style:var(--tw-border-style);border-bottom-width:6px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-4{border-left-style:var(--tw-border-style);border-left-width:4px}.border-l-\[6px\]{border-left-style:var(--tw-border-style);border-left-width:6px}.border-none{--tw-border-style:none;border-style:none}.border-emerald-300{border-color:var(--color-emerald-300)}.border-green-200{border-color:var(--color-green-200)}.border-green-500{border-color:var(--color-green-500)}.border-neutral-100{border-color:var(--color-neutral-100)}.border-neutral-200{border-color:var(--color-neutral-200)}.border-neutral-300{border-color:var(--color-neutral-300)}.border-neutral-700{border-color:var(--color-neutral-700)}.border-neutral-800{border-color:var(--color-neutral-800)}.border-primary{border-color:var(--color-primary)}.border-red-200{border-color:var(--color-red-200)}.border-red-500{border-color:var(--color-red-500)}.border-sky-200{border-color:var(--color-sky-200)}.border-sky-700{border-color:var(--color-sky-700)}.border-yellow-200{border-color:var(--color-yellow-200)}.border-t-neutral-800{border-top-color:var(--color-neutral-800)}.border-t-sky-700{border-top-color:var(--color-sky-700)}.border-t-transparent{border-top-color:transparent}.border-r-neutral-800{border-right-color:var(--color-neutral-800)}.border-r-transparent{border-right-color:transparent}.border-b-neutral-800{border-bottom-color:var(--color-neutral-800)}.border-b-transparent{border-bottom-color:transparent}.border-l-green-700{border-left-color:var(--color-green-700)}.border-l-neutral-300{border-left-color:var(--color-neutral-300)}.border-l-neutral-400{border-left-color:var(--color-neutral-400)}.border-l-neutral-800{border-left-color:var(--color-neutral-800)}.border-l-red-700{border-left-color:var(--color-red-700)}.border-l-sky-800{border-left-color:var(--color-sky-800)}.border-l-transparent{border-left-color:transparent}.border-l-yellow-500{border-left-color:var(--color-yellow-500)}.\!bg-primary{background-color:var(--color-primary)!important}.bg-amber-100{background-color:var(--color-amber-100)}.bg-amber-700{background-color:var(--color-amber-700)}.bg-black\/10{background-color:color-mix(in oklab,var(--color-black) 10%,transparent)}.bg-black\/30{background-color:color-mix(in oklab,var(--color-black) 30%,transparent)}.bg-black\/5{background-color:color-mix(in oklab,var(--color-black) 5%,transparent)}.bg-black\/50{background-color:color-mix(in oklab,var(--color-black) 50%,transparent)}.bg-emerald-100{background-color:var(--color-emerald-100)}.bg-green-50{background-color:var(--color-green-50)}.bg-green-700{background-color:var(--color-green-700)}.bg-green-900{background-color:var(--color-green-900)}.bg-neutral-100{background-color:var(--color-neutral-100)}.bg-neutral-200{background-color:var(--color-neutral-200)}.bg-neutral-300{background-color:var(--color-neutral-300)}.bg-neutral-50{background-color:var(--color-neutral-50)}.bg-neutral-500{background-color:var(--color-neutral-500)}.bg-neutral-600{background-color:var(--color-neutral-600)}.bg-neutral-700{background-color:var(--color-neutral-700)}.bg-neutral-800{background-color:var(--color-neutral-800)}.bg-neutral-900{background-color:var(--color-neutral-900)}.bg-orange-500{background-color:var(--color-orange-500)}.bg-orange-600{background-color:var(--color-orange-600)}.bg-primary{background-color:var(--color-primary)}.bg-primary\/10{background-color:color-mix(in oklab,var(--color-primary) 10%,transparent)}.bg-red-50{background-color:var(--color-red-50)}.bg-red-600{background-color:var(--color-red-600)}.bg-red-700{background-color:var(--color-red-700)}.bg-red-900{background-color:var(--color-red-900)}.bg-sky-50{background-color:var(--color-sky-50)}.bg-sky-600{background-color:var(--color-sky-600)}.bg-sky-700{background-color:var(--color-sky-700)}.bg-sky-800{background-color:var(--color-sky-800)}.bg-sky-900{background-color:var(--color-sky-900)}.bg-transparent{background-color:initial}.bg-white{background-color:var(--color-white)}.bg-white\/10{background-color:color-mix(in oklab,var(--color-white) 10%,transparent)}.bg-yellow-400{background-color:var(--color-yellow-400)}.bg-yellow-50{background-color:var(--color-yellow-50)}.bg-yellow-700{background-color:var(--color-yellow-700)}.fill-current{fill:currentcolor}.p-0{padding:0}.p-0\.5{padding:calc(var(--spacing) * .5)}.p-1{padding:var(--spacing)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-8{padding:calc(var(--spacing) * 8)}.px-1{padding-inline:var(--spacing)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-7{padding-inline:calc(var(--spacing) * 7)}.px-8{padding-inline:calc(var(--spacing) * 8)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-10{padding-block:calc(var(--spacing) * 10)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-5{padding-block:calc(var(--spacing) * 5)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-px{padding-block:1px}.pt-1\.5{padding-top:calc(var(--spacing) * 1.5)}.pt-10{padding-top:calc(var(--spacing) * 10)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pr-14{padding-right:calc(var(--spacing) * 14)}.pr-3{padding-right:calc(var(--spacing) * 3)}.pr-9{padding-right:calc(var(--spacing) * 9)}.pb-0\.5{padding-bottom:calc(var(--spacing) * .5)}.pb-1{padding-bottom:var(--spacing)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pb-\[calc\(1rem_\+_env\(safe-area-inset-bottom\,0px\)\)\]{padding-bottom:calc(1rem + env(safe-area-inset-bottom,0px))}.pl-4{padding-left:calc(var(--spacing) * 4)}.pl-8{padding-left:calc(var(--spacing) * 8)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-middle{vertical-align:middle}.font-\[inherit\]{font-family:inherit}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[0\.5rem\]{font-size:.5rem}.text-\[10px\]{font-size:10px}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-light{--tw-font-weight:var(--font-weight-light);font-weight:var(--font-weight-light)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.whitespace-nowrap{white-space:nowrap}.\!text-white{color:var(--color-white)!important}.text-amber-600{color:var(--color-amber-600)}.text-black{color:var(--color-black)}.text-current{color:currentcolor}.text-emerald-700{color:var(--color-emerald-700)}.text-gray-900{color:var(--color-gray-900)}.text-green-600{color:var(--color-green-600)}.text-green-700{color:var(--color-green-700)}.text-green-900{color:var(--color-green-900)}.text-neutral-100{color:var(--color-neutral-100)}.text-neutral-200{color:var(--color-neutral-200)}.text-neutral-300{color:var(--color-neutral-300)}.text-neutral-400{color:var(--color-neutral-400)}.text-neutral-500{color:var(--color-neutral-500)}.text-neutral-600{color:var(--color-neutral-600)}.text-neutral-700{color:var(--color-neutral-700)}.text-neutral-800{color:var(--color-neutral-800)}.text-neutral-900{color:var(--color-neutral-900)}.text-orange-600{color:var(--color-orange-600)}.text-primary{color:var(--color-primary)}.text-red-500{color:var(--color-red-500)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-red-900{color:var(--color-red-900)}.text-sky-700{color:var(--color-sky-700)}.text-sky-900{color:var(--color-sky-900)}.text-text-heading{color:var(--color-text-heading)}.text-text-on-dark{color:var(--color-text-on-dark)}.text-text-on-dark-muted{color:var(--color-text-on-dark-muted)}.text-white{color:var(--color-white)}.text-yellow-600{color:var(--color-yellow-600)}.text-yellow-700{color:var(--color-yellow-700)}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,)var(--tw-slashed-zero,)var(--tw-numeric-figure,)var(--tw-numeric-spacing,)var(--tw-numeric-fraction,)}.no-underline{text-decoration-line:none}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-0{opacity:0%}.opacity-50{opacity:50%}.shadow-\[0_4px_12px_rgba\(0\,0\,0\,0\.15\)\]{--tw-shadow:0 4px 12px var(--tw-shadow-color, rgba(0,0,0,0.15));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[inset_0_0_0_1px_currentColor\]{--tw-shadow:inset 0 0 0 1px var(--tw-shadow-color, currentColor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-none{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xs{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[color\,border-color\]{transition-property:color,border-color;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[opacity\,transform\]{transition-property:opacity,transform;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[width\]{transition-property:width;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-150{--tw-duration:150ms;transition-duration:150ms}.duration-200{--tw-duration:200ms;transition-duration:200ms}.duration-300{--tw-duration:300ms;transition-duration:300ms}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.group-focus-within\:visible{&:is(:where(.group):focus-within *){visibility:visible}}.group-focus-within\:opacity-100{&:is(:where(.group):focus-within *){opacity:100%}}.group-hover\:visible{&:is(:where(.group):hover *){@media(hover:hover){visibility: visible;}}}.group-hover\:opacity-100{&:is(:where(.group):hover *){@media(hover:hover){opacity: 100%;}}}.file\:mr-2{&::file-selector-button{margin-right:calc(var(--spacing) * 2)}}.file\:ml-1{&::file-selector-button{margin-left:var(--spacing)}}.file\:cursor-pointer{&::file-selector-button{cursor:pointer}}.file\:rounded-default{&::file-selector-button{border-radius:var(--radius-default)}}.file\:border{&::file-selector-button{border-style:var(--tw-border-style);border-width:1px}}.file\:border-neutral-300{&::file-selector-button{border-color:var(--color-neutral-300)}}.file\:bg-neutral-100{&::file-selector-button{background-color:var(--color-neutral-100)}}.file\:px-3{&::file-selector-button{padding-inline:calc(var(--spacing) * 3)}}.file\:px-4{&::file-selector-button{padding-inline:calc(var(--spacing) * 4)}}.file\:py-\[2px\]{&::file-selector-button{padding-block:2px}}.file\:py-\[3px\]{&::file-selector-button{padding-block:3px}}.file\:text-sm{&::file-selector-button{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}}.file\:shadow-xs{&::file-selector-button{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}}.placeholder\:text-neutral-400{&::placeholder{color:var(--color-neutral-400)}}.before\:absolute{&::before{content:var(--tw-content);position:absolute}}.before\:inset-0{&::before{content:var(--tw-content);inset:0}}.before\:-z-20{&::before{content:var(--tw-content);z-index:calc(20 * -1)}}.before\:bg-neutral-300{&::before{content:var(--tw-content);background-color:var(--color-neutral-300)}}.before\:content-\[\'\'\]{&::before{--tw-content:'';content:var(--tw-content)}}.before\:\[clip-path\:polygon\(16px_0\,100\%_0\,100\%_calc\(100\%_-_16px\)\,calc\(100\%_-_16px\)_100\%\,0_100\%\,0_16px\)\]{&::before{content:var(--tw-content);clip-path:polygon(16px 0,100% 0,100% calc(100% - 16px),calc(100% - 16px) 100%,0 100%,0 16px)}}.after\:absolute{&::after{content:var(--tw-content);position:absolute}}.after\:inset-\[1px\]{&::after{content:var(--tw-content);inset:1px}}.after\:-z-10{&::after{content:var(--tw-content);z-index:calc(10 * -1)}}.after\:bg-white{&::after{content:var(--tw-content);background-color:var(--color-white)}}.after\:content-\[\'\'\]{&::after{--tw-content:'';content:var(--tw-content)}}.after\:\[clip-path\:polygon\(15px_0\,100\%_0\,100\%_calc\(100\%_-_15px\)\,calc\(100\%_-_15px\)_100\%\,0_100\%\,0_15px\)\]{&::after{content:var(--tw-content);clip-path:polygon(15px 0,100% 0,100% calc(100% - 15px),calc(100% - 15px) 100%,0 100%,0 15px)}}.last\:border-r-0{&:last-child{border-right-style:var(--tw-border-style);border-right-width:0}}.last\:border-b-0{&:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}}.odd\:bg-white{&:nth-child(odd){background-color:var(--color-white)}}.even\:bg-neutral-100{&:nth-child(even){background-color:var(--color-neutral-100)}}.hover\:bg-green-100{&:hover{@media(hover:hover){background-color: var(--color-green-100);}}}.hover\:bg-green-800{&:hover{@media(hover:hover){background-color: var(--color-green-800);}}}.hover\:bg-green-950{&:hover{@media(hover:hover){background-color: var(--color-green-950);}}}.hover\:bg-neutral-100{&:hover{@media(hover:hover){background-color: var(--color-neutral-100);}}}.hover\:bg-neutral-200{&:hover{@media(hover:hover){background-color: var(--color-neutral-200);}}}.hover\:bg-neutral-300{&:hover{@media(hover:hover){background-color: var(--color-neutral-300);}}}.hover\:bg-neutral-50{&:hover{@media(hover:hover){background-color: var(--color-neutral-50);}}}.hover\:bg-neutral-500{&:hover{@media(hover:hover){background-color: var(--color-neutral-500);}}}.hover\:bg-neutral-800{&:hover{@media(hover:hover){background-color: var(--color-neutral-800);}}}.hover\:bg-orange-700{&:hover{@media(hover:hover){background-color: var(--color-orange-700);}}}.hover\:bg-primary-hover{&:hover{@media(hover:hover){background-color: var(--color-primary-hover);}}}.hover\:bg-red-700{&:hover{@media(hover:hover){background-color: var(--color-red-700);}}}.hover\:bg-red-800{&:hover{@media(hover:hover){background-color: var(--color-red-800);}}}.hover\:bg-red-950{&:hover{@media(hover:hover){background-color: var(--color-red-950);}}}.hover\:bg-sky-100{&:hover{@media(hover:hover){background-color: var(--color-sky-100);}}}.hover\:bg-sky-500{&:hover{@media(hover:hover){background-color: var(--color-sky-500);}}}.hover\:bg-sky-800{&:hover{@media(hover:hover){background-color: var(--color-sky-800);}}}.hover\:bg-sky-950{&:hover{@media(hover:hover){background-color: var(--color-sky-950);}}}.hover\:bg-white\/5{&:hover{@media(hover:hover){background-color: color-mix(in oklab,var(--color-white) 5%,transparent);}}}.hover\:bg-yellow-800{&:hover{@media(hover:hover){background-color: var(--color-yellow-800);}}}.hover\:text-neutral-600{&:hover{@media(hover:hover){color: var(--color-neutral-600);}}}.hover\:text-neutral-700{&:hover{@media(hover:hover){color: var(--color-neutral-700);}}}.hover\:text-neutral-800{&:hover{@media(hover:hover){color: var(--color-neutral-800);}}}.hover\:text-neutral-900{&:hover{@media(hover:hover){color: var(--color-neutral-900);}}}.hover\:text-sky-800{&:hover{@media(hover:hover){color: var(--color-sky-800);}}}.hover\:text-text-on-dark{&:hover{@media(hover:hover){color: var(--color-text-on-dark);}}}.hover\:text-white{&:hover{@media(hover:hover){color: var(--color-white);}}}.hover\:underline{&:hover{@media(hover:hover){text-decoration-line: underline;}}}.hover\:decoration-1{&:hover{@media(hover:hover){text-decoration-thickness: 1px;}}}.hover\:shadow-\[inset_0_0_0_2px_currentColor\]{&:hover{@media(hover:hover){--tw-shadow: inset 0 0 0 2px var(--tw-shadow-color,currentColor); box-shadow: var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);}}}.file\:hover\:bg-neutral-200{&::file-selector-button{&:hover{@media(hover:hover){background-color: var(--color-neutral-200);}}}}.focus\:border-primary{&:focus{border-color:var(--color-primary)}}.focus\:bg-neutral-100{&:focus{background-color:var(--color-neutral-100)}}.focus\:bg-red-50{&:focus{background-color:var(--color-red-50)}}.focus\:shadow-\[inset_0_0_0_2px_var\(--color-red-500\)\]{&:focus{--tw-shadow:inset 0 0 0 2px var(--tw-shadow-color, var(--color-red-500));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}}.focus\:outline-hidden{&:focus{--tw-outline-style:none;outline-style:none;@media(forced-colors:active){outline: 2px solid transparent; outline-offset: 2px;}}}.focus\:outline-2{&:focus{outline-style:var(--tw-outline-style);outline-width:2px}}.focus\:outline-offset-1{&:focus{outline-offset:1px}}.focus\:outline-green-500{&:focus{outline-color:var(--color-green-500)}}.focus\:outline-red-500{&:focus{outline-color:var(--color-red-500)}}.focus\:outline-sky-500{&:focus{outline-color:var(--color-sky-500)}}.focus-visible\:outline-2{&:focus-visible{outline-style:var(--tw-outline-style);outline-width:2px}}.focus-visible\:outline-offset-2{&:focus-visible{outline-offset:2px}}.focus-visible\:outline-current{&:focus-visible{outline-color:currentcolor}}.focus-visible\:outline-primary{&:focus-visible{outline-color:var(--color-primary)}}.active\:bg-neutral-200{&:active{background-color:var(--color-neutral-200)}}.enabled\:hover\:bg-neutral-50{&:enabled{&:hover{@media(hover:hover){background-color: var(--color-neutral-50);}}}}.enabled\:hover\:bg-neutral-900{&:enabled{&:hover{@media(hover:hover){background-color: var(--color-neutral-900);}}}}.enabled\:hover\:bg-red-700{&:enabled{&:hover{@media(hover:hover){background-color: var(--color-red-700);}}}}.disabled\:cursor-not-allowed{&:disabled{cursor:not-allowed}}.disabled\:bg-neutral-100{&:disabled{background-color:var(--color-neutral-100)}}.disabled\:bg-neutral-50{&:disabled{background-color:var(--color-neutral-50)}}.disabled\:text-neutral-300{&:disabled{color:var(--color-neutral-300)}}.disabled\:text-neutral-400{&:disabled{color:var(--color-neutral-400)}}.disabled\:opacity-40{&:disabled{opacity:40%}}.disabled\:opacity-50{&:disabled{opacity:50%}}.disabled\:hover\:bg-transparent{&:disabled{&:hover{@media(hover:hover){background-color: transparent;}}}}.sm\:block{@media(width >= 40rem){display: block;}}.sm\:flex{@media(width >= 40rem){display: flex;}}.sm\:grid-cols-2{@media(width >= 40rem){grid-template-columns: repeat(2,minmax(0,1fr));}}.sm\:grid-cols-3{@media(width >= 40rem){grid-template-columns: repeat(3,minmax(0,1fr));}}.md\:flex-initial{@media(width >= 48rem){flex: 0 auto;}}.md\:justify-start{@media(width >= 48rem){justify-content: flex-start;}}.lg\:col-span-10{@media(width >= 64rem){grid-column: span 10 / span 10;}}.lg\:col-span-2{@media(width >= 64rem){grid-column: span 2 / span 2;}}.lg\:col-span-5{@media(width >= 64rem){grid-column: span 5 / span 5;}}.lg\:col-span-7{@media(width >= 64rem){grid-column: span 7 / span 7;}}.lg\:block{@media(width >= 64rem){display: block;}}.lg\:h-full{@media(width >= 64rem){height: 100%;}}.lg\:grid-cols-12{@media(width >= 64rem){grid-template-columns: repeat(12,minmax(0,1fr));}}.lg\:self-start{@media(width >= 64rem){align-self: flex-start;}}.lg\:pr-8{@media(width >= 64rem){padding-right: calc(var(--spacing) * 8);}}.\[\&_\.ui-form\]\:m-0{& .ui-form{margin:0}}.\[\&_input\]\:cursor-text{& input{cursor:text}}.\[\&_td\]\:p-4{& td{padding:calc(var(--spacing) * 4)}}.\[\&_td\]\:px-2{& td{padding-inline:calc(var(--spacing) * 2)}}.\[\&_td\]\:py-0\.5{& td{padding-block:calc(var(--spacing) * .5)}}.\[\&_td\]\:py-1{& td{padding-block:var(--spacing)}}.\[\&_td\+td\]\:border-l{& td+td{border-left-style:var(--tw-border-style);border-left-width:1px}}.\[\&_td\+td\]\:border-neutral-300{& td+td{border-color:var(--color-neutral-300)}}.\[\&_th\]\:border-b{& th{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}}.\[\&_th\]\:border-neutral-300{& th{border-color:var(--color-neutral-300)}}.\[\&_tr\:hover\]\:\!bg-green-100{& tr:hover{background-color:var(--color-green-100)!important}}.\[\&_tr\:hover\]\:\!bg-neutral-200{& tr:hover{background-color:var(--color-neutral-200)!important}}.\[\&_tr\:hover\]\:\!bg-sky-100{& tr:hover{background-color:var(--color-sky-100)!important}}.\[\&_tr\:not\(\:last-child\)\]\:border-b{& tr:not(:last-child){border-bottom-style:var(--tw-border-style);border-bottom-width:1px}}.\[\&_tr\:not\(\:last-child\)\]\:border-neutral-300{& tr:not(:last-child){border-color:var(--color-neutral-300)}}.\[\&_tr\:nth-child\(even\)\]\:bg-neutral-100{& tr:nth-child(even){background-color:var(--color-neutral-100)}}}@property --tw-translate-x{syntax: "*"; + inherits: false; + initial-value: 0; +}@property --tw-translate-y{syntax: "*"; + inherits: false; + initial-value: 0; +}@property --tw-translate-z{syntax: "*"; + inherits: false; + initial-value: 0; +}@property --tw-scale-x{syntax: "*"; + inherits: false; + initial-value: 1; +}@property --tw-scale-y{syntax: "*"; + inherits: false; + initial-value: 1; +}@property --tw-scale-z{syntax: "*"; + inherits: false; + initial-value: 1; +}@property --tw-rotate-x{syntax: "*"; + inherits: false; +}@property --tw-rotate-y{syntax: "*"; + inherits: false; +}@property --tw-rotate-z{syntax: "*"; + inherits: false; +}@property --tw-skew-x{syntax: "*"; + inherits: false; +}@property --tw-skew-y{syntax: "*"; + inherits: false; +}@property --tw-space-y-reverse{syntax: "*"; + inherits: false; + initial-value: 0; +}@property --tw-border-style{syntax: "*"; + inherits: false; + initial-value: solid; +}@property --tw-leading{syntax: "*"; + inherits: false; +}@property --tw-font-weight{syntax: "*"; + inherits: false; +}@property --tw-tracking{syntax: "*"; + inherits: false; +}@property --tw-ordinal{syntax: "*"; + inherits: false; +}@property --tw-slashed-zero{syntax: "*"; + inherits: false; +}@property --tw-numeric-figure{syntax: "*"; + inherits: false; +}@property --tw-numeric-spacing{syntax: "*"; + inherits: false; +}@property --tw-numeric-fraction{syntax: "*"; + inherits: false; +}@property --tw-shadow{syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +}@property --tw-shadow-color{syntax: "*"; + inherits: false; +}@property --tw-shadow-alpha{syntax: ""; + inherits: false; + initial-value: 100%; +}@property --tw-inset-shadow{syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +}@property --tw-inset-shadow-color{syntax: "*"; + inherits: false; +}@property --tw-inset-shadow-alpha{syntax: ""; + inherits: false; + initial-value: 100%; +}@property --tw-ring-color{syntax: "*"; + inherits: false; +}@property --tw-ring-shadow{syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +}@property --tw-inset-ring-color{syntax: "*"; + inherits: false; +}@property --tw-inset-ring-shadow{syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +}@property --tw-ring-inset{syntax: "*"; + inherits: false; +}@property --tw-ring-offset-width{syntax: ""; + inherits: false; + initial-value: 0px; +}@property --tw-ring-offset-color{syntax: "*"; + inherits: false; + initial-value: #fff; +}@property --tw-ring-offset-shadow{syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +}@property --tw-duration{syntax: "*"; + inherits: false; +}@property --tw-ease{syntax: "*"; + inherits: false; +}@property --tw-content{syntax: "*"; + initial-value: ""; + inherits: false; +}@property --tw-outline-style{syntax: "*"; + inherits: false; + initial-value: solid; +} \ No newline at end of file diff --git a/go/cmd/examples/go-wasm-web/wwwroot/app.wasm b/go/cmd/examples/go-wasm-web/wwwroot/app.wasm new file mode 100755 index 00000000..345953fd Binary files /dev/null and b/go/cmd/examples/go-wasm-web/wwwroot/app.wasm differ diff --git a/go/cmd/examples/go-wasm-web/wwwroot/wasm_exec.js b/go/cmd/examples/go-wasm-web/wwwroot/wasm_exec.js new file mode 100644 index 00000000..d71af9e9 --- /dev/null +++ b/go/cmd/examples/go-wasm-web/wwwroot/wasm_exec.js @@ -0,0 +1,575 @@ +// Copyright 2018 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +"use strict"; + +(() => { + const enosys = () => { + const err = new Error("not implemented"); + err.code = "ENOSYS"; + return err; + }; + + if (!globalThis.fs) { + let outputBuf = ""; + globalThis.fs = { + constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1, O_DIRECTORY: -1 }, // unused + writeSync(fd, buf) { + outputBuf += decoder.decode(buf); + const nl = outputBuf.lastIndexOf("\n"); + if (nl != -1) { + console.log(outputBuf.substring(0, nl)); + outputBuf = outputBuf.substring(nl + 1); + } + return buf.length; + }, + write(fd, buf, offset, length, position, callback) { + if (offset !== 0 || length !== buf.length || position !== null) { + callback(enosys()); + return; + } + const n = this.writeSync(fd, buf); + callback(null, n); + }, + chmod(path, mode, callback) { callback(enosys()); }, + chown(path, uid, gid, callback) { callback(enosys()); }, + close(fd, callback) { callback(enosys()); }, + fchmod(fd, mode, callback) { callback(enosys()); }, + fchown(fd, uid, gid, callback) { callback(enosys()); }, + fstat(fd, callback) { callback(enosys()); }, + fsync(fd, callback) { callback(null); }, + ftruncate(fd, length, callback) { callback(enosys()); }, + lchown(path, uid, gid, callback) { callback(enosys()); }, + link(path, link, callback) { callback(enosys()); }, + lstat(path, callback) { callback(enosys()); }, + mkdir(path, perm, callback) { callback(enosys()); }, + open(path, flags, mode, callback) { callback(enosys()); }, + read(fd, buffer, offset, length, position, callback) { callback(enosys()); }, + readdir(path, callback) { callback(enosys()); }, + readlink(path, callback) { callback(enosys()); }, + rename(from, to, callback) { callback(enosys()); }, + rmdir(path, callback) { callback(enosys()); }, + stat(path, callback) { callback(enosys()); }, + symlink(path, link, callback) { callback(enosys()); }, + truncate(path, length, callback) { callback(enosys()); }, + unlink(path, callback) { callback(enosys()); }, + utimes(path, atime, mtime, callback) { callback(enosys()); }, + }; + } + + if (!globalThis.process) { + globalThis.process = { + getuid() { return -1; }, + getgid() { return -1; }, + geteuid() { return -1; }, + getegid() { return -1; }, + getgroups() { throw enosys(); }, + pid: -1, + ppid: -1, + umask() { throw enosys(); }, + cwd() { throw enosys(); }, + chdir() { throw enosys(); }, + } + } + + if (!globalThis.path) { + globalThis.path = { + resolve(...pathSegments) { + return pathSegments.join("/"); + } + } + } + + if (!globalThis.crypto) { + throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)"); + } + + if (!globalThis.performance) { + throw new Error("globalThis.performance is not available, polyfill required (performance.now only)"); + } + + if (!globalThis.TextEncoder) { + throw new Error("globalThis.TextEncoder is not available, polyfill required"); + } + + if (!globalThis.TextDecoder) { + throw new Error("globalThis.TextDecoder is not available, polyfill required"); + } + + const encoder = new TextEncoder("utf-8"); + const decoder = new TextDecoder("utf-8"); + + globalThis.Go = class { + constructor() { + this.argv = ["js"]; + this.env = {}; + this.exit = (code) => { + if (code !== 0) { + console.warn("exit code:", code); + } + }; + this._exitPromise = new Promise((resolve) => { + this._resolveExitPromise = resolve; + }); + this._pendingEvent = null; + this._scheduledTimeouts = new Map(); + this._nextCallbackTimeoutID = 1; + + const setInt64 = (addr, v) => { + this.mem.setUint32(addr + 0, v, true); + this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true); + } + + const setInt32 = (addr, v) => { + this.mem.setUint32(addr + 0, v, true); + } + + const getInt64 = (addr) => { + const low = this.mem.getUint32(addr + 0, true); + const high = this.mem.getInt32(addr + 4, true); + return low + high * 4294967296; + } + + const loadValue = (addr) => { + const f = this.mem.getFloat64(addr, true); + if (f === 0) { + return undefined; + } + if (!isNaN(f)) { + return f; + } + + const id = this.mem.getUint32(addr, true); + return this._values[id]; + } + + const storeValue = (addr, v) => { + const nanHead = 0x7FF80000; + + if (typeof v === "number" && v !== 0) { + if (isNaN(v)) { + this.mem.setUint32(addr + 4, nanHead, true); + this.mem.setUint32(addr, 0, true); + return; + } + this.mem.setFloat64(addr, v, true); + return; + } + + if (v === undefined) { + this.mem.setFloat64(addr, 0, true); + return; + } + + let id = this._ids.get(v); + if (id === undefined) { + id = this._idPool.pop(); + if (id === undefined) { + id = this._values.length; + } + this._values[id] = v; + this._goRefCounts[id] = 0; + this._ids.set(v, id); + } + this._goRefCounts[id]++; + let typeFlag = 0; + switch (typeof v) { + case "object": + if (v !== null) { + typeFlag = 1; + } + break; + case "string": + typeFlag = 2; + break; + case "symbol": + typeFlag = 3; + break; + case "function": + typeFlag = 4; + break; + } + this.mem.setUint32(addr + 4, nanHead | typeFlag, true); + this.mem.setUint32(addr, id, true); + } + + const loadSlice = (addr) => { + const array = getInt64(addr + 0); + const len = getInt64(addr + 8); + return new Uint8Array(this._inst.exports.mem.buffer, array, len); + } + + const loadSliceOfValues = (addr) => { + const array = getInt64(addr + 0); + const len = getInt64(addr + 8); + const a = new Array(len); + for (let i = 0; i < len; i++) { + a[i] = loadValue(array + i * 8); + } + return a; + } + + const loadString = (addr) => { + const saddr = getInt64(addr + 0); + const len = getInt64(addr + 8); + return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len)); + } + + const testCallExport = (a, b) => { + this._inst.exports.testExport0(); + return this._inst.exports.testExport(a, b); + } + + const timeOrigin = Date.now() - performance.now(); + this.importObject = { + _gotest: { + add: (a, b) => a + b, + callExport: testCallExport, + }, + gojs: { + // Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters) + // may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported + // function. A goroutine can switch to a new stack if the current stack is too small (see morestack function). + // This changes the SP, thus we have to update the SP used by the imported function. + + // func wasmExit(code int32) + "runtime.wasmExit": (sp) => { + sp >>>= 0; + const code = this.mem.getInt32(sp + 8, true); + this.exited = true; + delete this._inst; + delete this._values; + delete this._goRefCounts; + delete this._ids; + delete this._idPool; + this.exit(code); + }, + + // func wasmWrite(fd uintptr, p unsafe.Pointer, n int32) + "runtime.wasmWrite": (sp) => { + sp >>>= 0; + const fd = getInt64(sp + 8); + const p = getInt64(sp + 16); + const n = this.mem.getInt32(sp + 24, true); + fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n)); + }, + + // func resetMemoryDataView() + "runtime.resetMemoryDataView": (sp) => { + sp >>>= 0; + this.mem = new DataView(this._inst.exports.mem.buffer); + }, + + // func nanotime1() int64 + "runtime.nanotime1": (sp) => { + sp >>>= 0; + setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000); + }, + + // func walltime() (sec int64, nsec int32) + "runtime.walltime": (sp) => { + sp >>>= 0; + const msec = (new Date).getTime(); + setInt64(sp + 8, msec / 1000); + this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true); + }, + + // func scheduleTimeoutEvent(delay int64) int32 + "runtime.scheduleTimeoutEvent": (sp) => { + sp >>>= 0; + const id = this._nextCallbackTimeoutID; + this._nextCallbackTimeoutID++; + this._scheduledTimeouts.set(id, setTimeout( + () => { + this._resume(); + while (this._scheduledTimeouts.has(id)) { + // for some reason Go failed to register the timeout event, log and try again + // (temporary workaround for https://github.com/golang/go/issues/28975) + console.warn("scheduleTimeoutEvent: missed timeout event"); + this._resume(); + } + }, + getInt64(sp + 8), + )); + this.mem.setInt32(sp + 16, id, true); + }, + + // func clearTimeoutEvent(id int32) + "runtime.clearTimeoutEvent": (sp) => { + sp >>>= 0; + const id = this.mem.getInt32(sp + 8, true); + clearTimeout(this._scheduledTimeouts.get(id)); + this._scheduledTimeouts.delete(id); + }, + + // func getRandomData(r []byte) + "runtime.getRandomData": (sp) => { + sp >>>= 0; + crypto.getRandomValues(loadSlice(sp + 8)); + }, + + // func finalizeRef(v ref) + "syscall/js.finalizeRef": (sp) => { + sp >>>= 0; + const id = this.mem.getUint32(sp + 8, true); + this._goRefCounts[id]--; + if (this._goRefCounts[id] === 0) { + const v = this._values[id]; + this._values[id] = null; + this._ids.delete(v); + this._idPool.push(id); + } + }, + + // func stringVal(value string) ref + "syscall/js.stringVal": (sp) => { + sp >>>= 0; + storeValue(sp + 24, loadString(sp + 8)); + }, + + // func valueGet(v ref, p string) ref + "syscall/js.valueGet": (sp) => { + sp >>>= 0; + const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16)); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 32, result); + }, + + // func valueSet(v ref, p string, x ref) + "syscall/js.valueSet": (sp) => { + sp >>>= 0; + Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32)); + }, + + // func valueDelete(v ref, p string) + "syscall/js.valueDelete": (sp) => { + sp >>>= 0; + Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16)); + }, + + // func valueIndex(v ref, i int) ref + "syscall/js.valueIndex": (sp) => { + sp >>>= 0; + storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16))); + }, + + // valueSetIndex(v ref, i int, x ref) + "syscall/js.valueSetIndex": (sp) => { + sp >>>= 0; + Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24)); + }, + + // func valueCall(v ref, m string, args []ref) (ref, bool) + "syscall/js.valueCall": (sp) => { + sp >>>= 0; + try { + const v = loadValue(sp + 8); + const m = Reflect.get(v, loadString(sp + 16)); + const args = loadSliceOfValues(sp + 32); + const result = Reflect.apply(m, v, args); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 56, result); + this.mem.setUint8(sp + 64, 1); + } catch (err) { + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 56, err); + this.mem.setUint8(sp + 64, 0); + } + }, + + // func valueInvoke(v ref, args []ref) (ref, bool) + "syscall/js.valueInvoke": (sp) => { + sp >>>= 0; + try { + const v = loadValue(sp + 8); + const args = loadSliceOfValues(sp + 16); + const result = Reflect.apply(v, undefined, args); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, result); + this.mem.setUint8(sp + 48, 1); + } catch (err) { + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, err); + this.mem.setUint8(sp + 48, 0); + } + }, + + // func valueNew(v ref, args []ref) (ref, bool) + "syscall/js.valueNew": (sp) => { + sp >>>= 0; + try { + const v = loadValue(sp + 8); + const args = loadSliceOfValues(sp + 16); + const result = Reflect.construct(v, args); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, result); + this.mem.setUint8(sp + 48, 1); + } catch (err) { + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, err); + this.mem.setUint8(sp + 48, 0); + } + }, + + // func valueLength(v ref) int + "syscall/js.valueLength": (sp) => { + sp >>>= 0; + setInt64(sp + 16, parseInt(loadValue(sp + 8).length)); + }, + + // valuePrepareString(v ref) (ref, int) + "syscall/js.valuePrepareString": (sp) => { + sp >>>= 0; + const str = encoder.encode(String(loadValue(sp + 8))); + storeValue(sp + 16, str); + setInt64(sp + 24, str.length); + }, + + // valueLoadString(v ref, b []byte) + "syscall/js.valueLoadString": (sp) => { + sp >>>= 0; + const str = loadValue(sp + 8); + loadSlice(sp + 16).set(str); + }, + + // func valueInstanceOf(v ref, t ref) bool + "syscall/js.valueInstanceOf": (sp) => { + sp >>>= 0; + this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0); + }, + + // func copyBytesToGo(dst []byte, src ref) (int, bool) + "syscall/js.copyBytesToGo": (sp) => { + sp >>>= 0; + const dst = loadSlice(sp + 8); + const src = loadValue(sp + 32); + if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) { + this.mem.setUint8(sp + 48, 0); + return; + } + const toCopy = src.subarray(0, dst.length); + dst.set(toCopy); + setInt64(sp + 40, toCopy.length); + this.mem.setUint8(sp + 48, 1); + }, + + // func copyBytesToJS(dst ref, src []byte) (int, bool) + "syscall/js.copyBytesToJS": (sp) => { + sp >>>= 0; + const dst = loadValue(sp + 8); + const src = loadSlice(sp + 16); + if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) { + this.mem.setUint8(sp + 48, 0); + return; + } + const toCopy = src.subarray(0, dst.length); + dst.set(toCopy); + setInt64(sp + 40, toCopy.length); + this.mem.setUint8(sp + 48, 1); + }, + + "debug": (value) => { + console.log(value); + }, + } + }; + } + + async run(instance) { + if (!(instance instanceof WebAssembly.Instance)) { + throw new Error("Go.run: WebAssembly.Instance expected"); + } + this._inst = instance; + this.mem = new DataView(this._inst.exports.mem.buffer); + this._values = [ // JS values that Go currently has references to, indexed by reference id + NaN, + 0, + null, + true, + false, + globalThis, + this, + ]; + this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id + this._ids = new Map([ // mapping from JS values to reference ids + [0, 1], + [null, 2], + [true, 3], + [false, 4], + [globalThis, 5], + [this, 6], + ]); + this._idPool = []; // unused ids that have been garbage collected + this.exited = false; // whether the Go program has exited + + // Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory. + let offset = 4096; + + const strPtr = (str) => { + const ptr = offset; + const bytes = encoder.encode(str + "\0"); + new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes); + offset += bytes.length; + if (offset % 8 !== 0) { + offset += 8 - (offset % 8); + } + return ptr; + }; + + const argc = this.argv.length; + + const argvPtrs = []; + this.argv.forEach((arg) => { + argvPtrs.push(strPtr(arg)); + }); + argvPtrs.push(0); + + const keys = Object.keys(this.env).sort(); + keys.forEach((key) => { + argvPtrs.push(strPtr(`${key}=${this.env[key]}`)); + }); + argvPtrs.push(0); + + const argv = offset; + argvPtrs.forEach((ptr) => { + this.mem.setUint32(offset, ptr, true); + this.mem.setUint32(offset + 4, 0, true); + offset += 8; + }); + + // The linker guarantees global data starts from at least wasmMinDataAddr. + // Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr. + const wasmMinDataAddr = 4096 + 8192; + if (offset >= wasmMinDataAddr) { + throw new Error("total length of command line and environment variables exceeds limit"); + } + + this._inst.exports.run(argc, argv); + if (this.exited) { + this._resolveExitPromise(); + } + await this._exitPromise; + } + + _resume() { + if (this.exited) { + throw new Error("Go program has already exited"); + } + this._inst.exports.resume(); + if (this.exited) { + this._resolveExitPromise(); + } + } + + _makeFuncWrapper(id) { + const go = this; + return function () { + const event = { id: id, this: this, args: arguments }; + go._pendingEvent = event; + go._resume(); + return event.result; + }; + } + } +})(); diff --git a/go/cmd/examples/go-wasm-web/wwwroot/wasmboot.js b/go/cmd/examples/go-wasm-web/wwwroot/wasmboot.js new file mode 100644 index 00000000..b423eda5 --- /dev/null +++ b/go/cmd/examples/go-wasm-web/wwwroot/wasmboot.js @@ -0,0 +1,24 @@ +// bootstrap.js — boots the Go/Wasm client, and supports flash-free hot swaps. +// +// On first load the server has already rendered the page's HTML into #app and +// the wasm app hydrates it (see wasm/main.go). The dev server's livereload +// script hot-swaps a freshly built module WITHOUT a full page reload or a blank +// flash: it calls __gowasmPrepare() to fetch + compile the new module while the +// current page is still visible, then (in one synchronous step) __gowasmDispose() +// to tear down the old instance and start() to run the new one. +(function () { + // prepare fetches + compiles the module and returns a SYNCHRONOUS start() + // thunk. Separating the async work (network + compile) from start (which + // renders synchronously) is what lets a swap avoid an intermediate blank #app. + async function prepare() { + const go = new Go(); + // cache:no-store so a hot swap always fetches the freshly built bytes. + const resp = await fetch("/app.wasm", { cache: "no-store" }); + const result = await WebAssembly.instantiateStreaming(resp, go.importObject); + return function start() { go.run(result.instance); }; // runs main() (renders), then parks on select{} + } + async function boot() { (await prepare())(); } + window.__gowasmPrepare = prepare; + window.__gowasmBoot = boot; + boot(); +})(); diff --git a/go/cmd/twcss/main.go b/go/cmd/twcss/main.go new file mode 100644 index 00000000..868cbf9c --- /dev/null +++ b/go/cmd/twcss/main.go @@ -0,0 +1,45 @@ +// Command twcss compiles a Tailwind v4 stylesheet with kjol's native engine, +// scanning explicit content globs for utility candidates. Unlike the app bundler +// (which is wired to the frontend tree) it takes the entry, output, and content +// globs as flags/args, so it works for markup authored in any language — used by +// the go-wasm-web example, whose UI is written in Go. +// +// Usage (globs are relative to -base; pass "**" for a recursive walk): +// +// twcss -entry css/app.css -out wwwroot/app.css -base . 'webui/**/*.go' 'app/**/*.go' +package main + +import ( + "flag" + "fmt" + "os" + + "kjol/bundler" +) + +func main() { + entry := flag.String("entry", "", "path to the Tailwind entry stylesheet") + out := flag.String("out", "", "output CSS path") + base := flag.String("base", ".", "base dir the content globs are relative to") + flag.Parse() + + if *entry == "" || *out == "" { + fmt.Fprintln(os.Stderr, "twcss: -entry and -out are required") + os.Exit(2) + } + src, err := os.ReadFile(*entry) + if err != nil { + fmt.Fprintln(os.Stderr, "twcss:", err) + os.Exit(1) + } + css, err := bundler.CompileTailwind(string(src), *base, flag.Args()) + if err != nil { + fmt.Fprintln(os.Stderr, "twcss:", err) + os.Exit(1) + } + if err := os.WriteFile(*out, []byte(css), 0o644); err != nil { + fmt.Fprintln(os.Stderr, "twcss:", err) + os.Exit(1) + } + fmt.Printf("twcss: wrote %s (%d bytes)\n", *out, len(css)) +} diff --git a/go/cmd/wasmgen/main.go b/go/cmd/wasmgen/main.go new file mode 100644 index 00000000..c7bc93eb --- /dev/null +++ b/go/cmd/wasmgen/main.go @@ -0,0 +1,214 @@ +// Command wasmgen preprocesses component directives into glue code so that +// server-component calls look identical to client-component calls, and routes +// can opt into SSR with a tag. +// +// Directives (as doc comments on functions in the target package): +// +// //gowasm:page [static] [layout=] +// a page factory `func(Deps) func() *vdom.VNode`. +// `static` => the server SSRs that route; +// `layout=` => wrap it in a named //gowasm:layout. +// //gowasm:layout a layout `func(Deps, *vdom.VNode) *vdom.VNode`. +// //gowasm:server a server component `func() func() *vdom.VNode`. +// +// It emits three files in the package: +// - routes.gen.go (neutral) Routes(Deps) + StaticPaths + RouteLayout/LayoutFor +// - server.gen.go (native) init() registering each server component +// - client.gen.go (wasm) a client stub per server component that +// mounts it over /rsc +package main + +import ( + "fmt" + "go/ast" + "go/format" + "go/parser" + "go/token" + "os" + "path/filepath" + "sort" + "strings" + "text/template" +) + +type page struct { + Func string + Path string + Static bool + Layout string // name of the //gowasm:layout wrapper (empty => default) +} + +type layoutDecl struct { + Name string + Func string +} + +type genData struct { + Pages []page + Servers []string + Layouts []layoutDecl + DefaultLayout string // func name used when a page declares no layout +} + +func main() { + dir := "app" + if len(os.Args) > 1 { + dir = os.Args[1] + } + + fset := token.NewFileSet() + pkgs, err := parser.ParseDir(fset, dir, func(fi os.FileInfo) bool { + return !strings.HasSuffix(fi.Name(), ".gen.go") + }, parser.ParseComments) + if err != nil { + fmt.Fprintln(os.Stderr, "wasmgen: parse:", err) + os.Exit(1) + } + + var data genData + for _, pkg := range pkgs { + for _, file := range pkg.Files { + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Doc == nil { + continue + } + for _, c := range fn.Doc.List { + line := strings.TrimSpace(strings.TrimPrefix(c.Text, "//")) + switch { + case strings.HasPrefix(line, "gowasm:page "): + fields := strings.Fields(line[len("gowasm:page "):]) + if len(fields) == 0 { + continue + } + p := page{Func: fn.Name.Name, Path: fields[0]} + for _, f := range fields[1:] { + switch { + case f == "static": + p.Static = true + case strings.HasPrefix(f, "layout="): + p.Layout = strings.TrimPrefix(f, "layout=") + } + } + data.Pages = append(data.Pages, p) + case strings.HasPrefix(line, "gowasm:layout "): + fields := strings.Fields(line[len("gowasm:layout "):]) + if len(fields) == 0 { + continue + } + data.Layouts = append(data.Layouts, layoutDecl{Name: fields[0], Func: fn.Name.Name}) + case line == "gowasm:server": + data.Servers = append(data.Servers, fn.Name.Name) + } + } + } + } + } + sort.Slice(data.Pages, func(i, j int) bool { return data.Pages[i].Path < data.Pages[j].Path }) + sort.Strings(data.Servers) + sort.Slice(data.Layouts, func(i, j int) bool { return data.Layouts[i].Name < data.Layouts[j].Name }) + + // Default layout: prefer one literally named "app", else the first declared. + var defaultName string + if len(data.Layouts) > 0 { + data.DefaultLayout, defaultName = data.Layouts[0].Func, data.Layouts[0].Name + for _, l := range data.Layouts { + if l.Name == "app" { + data.DefaultLayout, defaultName = l.Func, l.Name + break + } + } + } + for i := range data.Pages { + if data.Pages[i].Layout == "" { + data.Pages[i].Layout = defaultName + } + } + + write(filepath.Join(dir, "routes.gen.go"), routesTmpl, data) + write(filepath.Join(dir, "server.gen.go"), serverTmpl, data) + write(filepath.Join(dir, "client.gen.go"), clientTmpl, data) + + fmt.Printf("wasmgen: %d page(s), %d server component(s)\n", len(data.Pages), len(data.Servers)) +} + +func write(path, tmpl string, data genData) { + var b strings.Builder + if err := template.Must(template.New("g").Parse(tmpl)).Execute(&b, data); err != nil { + panic(err) + } + src, err := format.Source([]byte(b.String())) + if err != nil { + fmt.Fprintf(os.Stderr, "wasmgen: format %s: %v\n---\n%s\n", path, err, b.String()) + os.Exit(1) + } + // Only write when changed, so re-running codegen doesn't churn mtimes (which + // would make the dev server's file watcher loop). + if existing, err := os.ReadFile(path); err == nil && string(existing) == string(src) { + return + } + if err := os.WriteFile(path, src, 0o644); err != nil { + panic(err) + } +} + +const routesTmpl = `// 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{ +{{range .Pages}} "{{.Path}}": {{.Func}}(d), +{{end}} } +} + +// StaticPaths are the routes the server pre-renders (SSR); others render client-side. +var StaticPaths = map[string]bool{ +{{range .Pages}}{{if .Static}} "{{.Path}}": true, +{{end}}{{end}}} + +// RouteLayout maps each route to the name of the layout that wraps it. +var RouteLayout = map[string]string{ +{{range .Pages}} "{{.Path}}": "{{.Layout}}", +{{end}}} + +// LayoutFor wraps a page's content in the layout declared for its route. +func LayoutFor(d Deps, path string, content *vdom.VNode) *vdom.VNode { +{{if .Layouts}} switch RouteLayout[path] { +{{range .Layouts}} case "{{.Name}}": + return {{.Func}}(d, content) +{{end}} } + return {{.DefaultLayout}}(d, content) +{{else}} return content +{{end}}} +` + +const serverTmpl = `// Code generated by wasmgen. DO NOT EDIT. + +//go:build !(js && wasm) + +package app + +import "kjol/rsc" + +func init() { +{{range .Servers}} rsc.Register("{{.}}", {{.}}) +{{end}}} +` + +const clientTmpl = `// Code generated by wasmgen. DO NOT EDIT. + +//go:build js && wasm + +package app + +import ( + "kjol/rsc" + "kjol/vdom" +) + +{{range .Servers}}// {{.}} is a generated client stub for the server component of the same name. +func {{.}}() func() *vdom.VNode { return rsc.Mount("{{.}}") } +{{end}}` diff --git a/go/httputil/fetch.go b/go/httputil/fetch.go new file mode 100644 index 00000000..94d459c3 --- /dev/null +++ b/go/httputil/fetch.go @@ -0,0 +1,66 @@ +package httputil + +import ( + "bytes" + "encoding/gob" + "encoding/json" + "errors" +) + +// Client-side fetch helpers — the counterparts to RespondGob / RespondJSON. In a +// gowasm app the server and the WebAssembly client share the same Go types, so +// the server encodes with RespondGob(data) and the client decodes straight back +// into that type with FetchGob — no JSON, no hand-written unmarshalling. These +// are neutral (they only touch encoding/*), so app code can call them while +// staying SSR-able; the actual HTTP transport is client-only and injected at +// startup by the wasm runtime (SetClientTransport), the inversion vdom.Schedule +// uses. + +// clientTransport does an HTTP GET returning the raw body; nil on the server. +var clientTransport func(url string) ([]byte, error) + +// SetClientTransport installs the HTTP GET used by FetchGob / FetchJSON. The +// wasm client runtime calls this at startup with wasmruntime.FetchBytes. +func SetClientTransport(get func(url string) ([]byte, error)) { clientTransport = get } + +func fetchInto[T any](url string, cb func(T, error), decode func([]byte, *T) error) { + go func() { + var out T + if clientTransport == nil { + cb(out, errors.New("httputil: no client transport installed (client-only)")) + return + } + body, err := clientTransport(url) + if err != nil { + cb(out, err) + return + } + if err := decode(body, &out); err != nil { + cb(out, err) + return + } + cb(out, nil) + }() +} + +// FetchGob GETs url and gob-decodes the body into T (pair with RespondGob), then +// calls cb(result, err) on a background goroutine — safe to call from a render +// or event handler; set the result into a signal in the callback. T is inferred +// from the callback: +// +// httputil.FetchGob("/api/quotes", func(qs []Quote, err error) { +// if err != nil { /* … */ } else { quotes.Set(qs) } +// }) +func FetchGob[T any](url string, cb func(T, error)) { + fetchInto(url, cb, func(b []byte, out *T) error { + return gob.NewDecoder(bytes.NewReader(b)).Decode(out) + }) +} + +// FetchJSON GETs url and json-decodes the body into T — for third-party JSON +// APIs (or your own RespondJSON). Same callback/goroutine shape as FetchGob. +func FetchJSON[T any](url string, cb func(T, error)) { + fetchInto(url, cb, func(b []byte, out *T) error { + return json.Unmarshal(b, out) + }) +} diff --git a/go/httputil/respond.go b/go/httputil/respond.go index 08cc4581..bcdc8736 100644 --- a/go/httputil/respond.go +++ b/go/httputil/respond.go @@ -1,6 +1,7 @@ package httputil import ( + "encoding/gob" "encoding/json" "net/http" ) @@ -11,6 +12,12 @@ func RespondJSON(w http.ResponseWriter, statusCode int, data any) { json.NewEncoder(w).Encode(data) } +func RespondGob(w http.ResponseWriter, statusCode int, data any) { + w.Header().Set("Content-Type", "application/gob") + w.WriteHeader(statusCode) + gob.NewEncoder(w).Encode(data) +} + func RespondError(w http.ResponseWriter, statusCode int, message string) { RespondJSON(w, statusCode, map[string]string{"error": message}) } diff --git a/go/rsc/client_wasm.go b/go/rsc/client_wasm.go new file mode 100644 index 00000000..920bed09 --- /dev/null +++ b/go/rsc/client_wasm.go @@ -0,0 +1,93 @@ +//go:build js && wasm + +package rsc + +import ( + "bytes" + "encoding/gob" + "syscall/js" + + "kjol/vdom" +) + +// Mount connects to a server component by name and returns a render function. +// The generated client stub for a //gowasm:server component just calls this, so +// the call site is identical to a client component. It POSTs {name, state, +// event} to /rsc and swaps (reconciles) the returned render into the DOM. State +// is opaque and round-trips through the client — the server keeps nothing. +func Mount(name string) func() *vdom.VNode { + tree := vdom.NewSignal[*vdom.VNode](nil) + var state [][]byte + var post func(kind string, nodeID int, event, value string) + + var convert func(*SNode) *vdom.VNode + convert = func(sn *SNode) *vdom.VNode { + if sn == nil { + return nil + } + if sn.Tag == "" { + return vdom.Text(sn.Text) + } + mods := make([]vdom.Mod, 0, len(sn.Attrs)+len(sn.Events)+len(sn.Kids)+1) + for k, v := range sn.Attrs { + mods = append(mods, vdom.Attr(k, v)) + } + if sn.HTML != "" { + mods = append(mods, vdom.Raw(sn.HTML)) + } + for _, ev := range sn.Events { + event, id := ev, sn.ID + mods = append(mods, vdom.OnEvent(ev, func(e vdom.Event) { post("event", id, event, e.Value()) })) + } + for _, k := range sn.Kids { + if c := convert(k); c != nil { + mods = append(mods, c) + } + } + return vdom.El(sn.Tag, mods...) + } + + onArr := js.FuncOf(func(this js.Value, args []js.Value) any { + arr := js.Global().Get("Uint8Array").New(args[0]) + b := make([]byte, arr.Get("length").Int()) + js.CopyBytesToGo(b, arr) + var resp Response + if gob.NewDecoder(bytes.NewReader(b)).Decode(&resp) == nil { + state = resp.State + tree.Set(convert(resp.Tree)) // re-render -> reconcile into the DOM + } + return nil + }) + onResp := js.FuncOf(func(this js.Value, args []js.Value) any { + args[0].Call("arrayBuffer").Call("then", onArr) + return nil + }) + + loc := js.Global().Get("location") + url := loc.Get("protocol").String() + "//" + loc.Get("host").String() + "/rsc" + + post = func(kind string, nodeID int, event, value string) { + var buf bytes.Buffer + if gob.NewEncoder(&buf).Encode(Request{Kind: kind, Name: name, State: state, NodeID: nodeID, Event: event, Value: value}) != nil { + return + } + body := js.Global().Get("Uint8Array").New(buf.Len()) + js.CopyBytesToJS(body, buf.Bytes()) + opts := js.Global().Get("Object").New() + opts.Set("method", "POST") + opts.Set("body", body) + js.Global().Call("fetch", url, opts).Call("then", onResp) + } + + started := false + return func() *vdom.VNode { + if !started { + started = true + post("mount", 0, "", "") + } + if t := tree.Get(); t != nil { + return t + } + return vdom.Div(vdom.Attr("class", "text-muted"), vdom.Text("loading server component…")) + } +} diff --git a/go/rsc/proto.go b/go/rsc/proto.go new file mode 100644 index 00000000..b3af8019 --- /dev/null +++ b/go/rsc/proto.go @@ -0,0 +1,38 @@ +// Package rsc runs server components with a stateless, React/Next-style +// request/response: the client POSTs the component name, its (opaque) signal +// state, and any triggered event; the server restores the state, applies the +// event, re-renders, and returns the new state + rendered tree, which the client +// reconciles ("swaps") into the DOM. No persistent connection, no server-held +// session — state round-trips through the client. +// +// This file holds the wire types (gob), shared by server and client. +package rsc + +// SNode is a serialized render node. Nodes with server handlers carry an ID and +// their event names; the client sends {name, state, ID, event} back so the +// server can find and invoke the handler. +type SNode struct { + ID int + Tag string + Text string + HTML string + Attrs map[string]string + Events []string + Kids []*SNode +} + +// Request is client -> server (every request is self-contained). +type Request struct { + Kind string // "mount" | "event" + Name string // component name + State [][]byte // opaque signal snapshot from the previous response + NodeID int // event: node that fired + Event string // event: event name + Value string // event: target value (inputs) +} + +// Response is server -> client. +type Response struct { + State [][]byte + Tree *SNode +} diff --git a/go/rsc/server.go b/go/rsc/server.go new file mode 100644 index 00000000..ea92f325 --- /dev/null +++ b/go/rsc/server.go @@ -0,0 +1,95 @@ +//go:build !(js && wasm) + +package rsc + +import ( + "encoding/gob" + "net/http" + "sort" + "sync" + + "kjol/vdom" +) + +// registry maps a server-component name to its factory (created by generated +// code — one Register per //gowasm:server component). The factory builds the +// component's signals + render closure; here it's re-run per request (stateless). +var registry = map[string]func() func() *vdom.VNode{} + +func Register(name string, factory func() func() *vdom.VNode) { registry[name] = factory } + +// renderMu serializes server renders because the vdom signal Collector is a +// process-global (see vdom.BeginCollect). Fine for this scale. +var renderMu sync.Mutex + +// Handler is the single /rsc endpoint. It restores the component's signals from +// the request, applies the event (if any), re-renders, and returns the new +// signal snapshot + tree. +func Handler(w http.ResponseWriter, r *http.Request) { + var req Request + if gob.NewDecoder(r.Body).Decode(&req) != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + factory := registry[req.Name] + if factory == nil { + http.NotFound(w, r) + return + } + + renderMu.Lock() + c := vdom.BeginCollect(req.State) // restore prior signal values (nil on mount) + render := factory() // signals created + restored here + vdom.EndCollect() + + if req.Kind == "event" { + // Reproduce the tree the client currently shows (same state) to find the + // handler by node id, then invoke it (mutating signals). + _, handlers := renderIDs(render()) + if hm := handlers[req.NodeID]; hm != nil { + if h := hm[req.Event]; h != nil { + h(serverEvent{value: req.Value}) + } + } + } + tree, _ := renderIDs(render()) // render the result (reflects the mutation) + resp := Response{State: c.Snapshot(), Tree: tree} + renderMu.Unlock() + + w.Header().Set("Content-Type", "application/octet-stream") + _ = gob.NewEncoder(w).Encode(resp) +} + +// renderIDs serializes a VNode tree to SNode, assigning a stable (traversal +// order) id to each node with handlers and building the handler table. +func renderIDs(root *vdom.VNode) (*SNode, map[int]map[string]func(vdom.Event)) { + handlers := map[int]map[string]func(vdom.Event){} + id := 0 + var walk func(*vdom.VNode) *SNode + walk = func(v *vdom.VNode) *SNode { + sn := &SNode{Tag: v.Tag, Text: v.Text, HTML: v.HTML, Attrs: v.Attrs} + if len(v.Events) > 0 { + id++ + sn.ID = id + hm := map[string]func(vdom.Event){} + for ev, h := range v.Events { + sn.Events = append(sn.Events, ev) + hm[ev] = h + } + sort.Strings(sn.Events) + handlers[id] = hm + } + if v.HTML == "" { + for _, k := range v.Children { + sn.Kids = append(sn.Kids, walk(k)) + } + } + return sn + } + return walk(root), handlers +} + +type serverEvent struct{ value string } + +func (e serverEvent) PreventDefault() {} +func (e serverEvent) Value() string { return e.value } diff --git a/go/vdom/events.go b/go/vdom/events.go new file mode 100644 index 00000000..8cf79818 --- /dev/null +++ b/go/vdom/events.go @@ -0,0 +1,17 @@ +package vdom + +// DOM event-name constants for On / OnEvent. +const ( + EVENT_CLICK = "click" + EVENT_DBLCLICK = "dblclick" + EVENT_INPUT = "input" + EVENT_CHANGE = "change" + EVENT_SUBMIT = "submit" + EVENT_KEYDOWN = "keydown" + EVENT_KEYUP = "keyup" + EVENT_FOCUS = "focus" + EVENT_BLUR = "blur" + EVENT_MOUSEDOWN = "mousedown" + EVENT_MOUSEUP = "mouseup" + EVENT_MOUSEMOVE = "mousemove" +) diff --git a/go/vdom/mode_native.go b/go/vdom/mode_native.go new file mode 100644 index 00000000..45e54520 --- /dev/null +++ b/go/vdom/mode_native.go @@ -0,0 +1,7 @@ +//go:build !(js && wasm) + +package vdom + +// IsClient is false on the server (native). Components use it to guard +// client-only effects (e.g. fetching) so they don't run during SSR. +const IsClient = false diff --git a/go/vdom/mode_wasm.go b/go/vdom/mode_wasm.go new file mode 100644 index 00000000..251abd65 --- /dev/null +++ b/go/vdom/mode_wasm.go @@ -0,0 +1,6 @@ +//go:build js && wasm + +package vdom + +// IsClient is true in the browser (wasm). +const IsClient = true diff --git a/go/vdom/signal.go b/go/vdom/signal.go new file mode 100644 index 00000000..293d99dc --- /dev/null +++ b/go/vdom/signal.go @@ -0,0 +1,81 @@ +package vdom + +import "encoding/json" + +// Schedule is installed by the wasm runtime at startup; a signal write +// triggers a re-render. On the server it is nil (renders are one-shot). +var Schedule func() + +// Signal holds state. On the client, writing it schedules a re-render. +type Signal[T any] struct{ v T } + +func NewSignal[T any](initial T) *Signal[T] { + s := &Signal[T]{v: initial} + if active != nil { + active.adopt(s) // server component round-trip: restore prior value, track for snapshot + } + return s +} + +func (s *Signal[T]) Get() T { return s.v } + +func (s *Signal[T]) Set(v T) { + s.v = v + if Schedule != nil { + Schedule() + } +} + +func (s *Signal[T]) Update(fn func(T) T) { s.Set(fn(s.v)) } + +func (s *Signal[T]) snapshot() []byte { b, _ := json.Marshal(s.v); return b } +func (s *Signal[T]) restoreFrom(b []byte) { _ = json.Unmarshal(b, &s.v) } + +// ---- signal-state round-trip for server components ---- +// +// A server component is stateless on the server: its signal values are +// serialized and round-tripped through the client. While a Collector is active +// (during a server-component render on the server), NewSignal restores each +// signal's value from the incoming snapshot by creation order (hook-order), and +// remembers it so the new values can be snapshotted back out. + +type signalState interface { + snapshot() []byte + restoreFrom([]byte) +} + +// Collector captures the signals created during a server render. +type Collector struct { + restore [][]byte + idx int + sigs []signalState +} + +var active *Collector + +func (c *Collector) adopt(s signalState) { + if c.idx < len(c.restore) { + s.restoreFrom(c.restore[c.idx]) + } + c.idx++ + c.sigs = append(c.sigs, s) +} + +// BeginCollect starts collecting signals, restoring them from `restore` (which +// may be nil for an initial mount). Call EndCollect when construction is done. +func BeginCollect(restore [][]byte) *Collector { + active = &Collector{restore: restore} + return active +} + +// EndCollect stops collecting (rendering/handlers may still read the signals). +func EndCollect() { active = nil } + +// Snapshot returns the current values of the collected signals, in order. +func (c *Collector) Snapshot() [][]byte { + out := make([][]byte, len(c.sigs)) + for i, s := range c.sigs { + out[i] = s.snapshot() + } + return out +} diff --git a/go/vdom/tags.go b/go/vdom/tags.go new file mode 100644 index 00000000..7be47b2d --- /dev/null +++ b/go/vdom/tags.go @@ -0,0 +1,30 @@ +package vdom + +// HTML tag helpers over El, for dot-import. +func Div(m ...Mod) *VNode { return El("div", m...) } +func Span(m ...Mod) *VNode { return El("span", m...) } +func P(m ...Mod) *VNode { return El("p", m...) } +func Section(m ...Mod) *VNode { return El("section", m...) } +func Nav(m ...Mod) *VNode { return El("nav", m...) } +func Header(m ...Mod) *VNode { return El("header", m...) } +func Main(m ...Mod) *VNode { return El("main", m...) } +func Footer(m ...Mod) *VNode { return El("footer", m...) } +func H1(m ...Mod) *VNode { return El("h1", m...) } +func H2(m ...Mod) *VNode { return El("h2", m...) } +func H3(m ...Mod) *VNode { return El("h3", m...) } +func Hr(m ...Mod) *VNode { return El("hr", m...) } +func A(m ...Mod) *VNode { return El("a", m...) } +func Strong(m ...Mod) *VNode { return El("strong", m...) } +func Em(m ...Mod) *VNode { return El("em", m...) } +func Small(m ...Mod) *VNode { return El("small", m...) } +func Code(m ...Mod) *VNode { return El("code", m...) } +func Label(m ...Mod) *VNode { return El("label", m...) } +func Ul(m ...Mod) *VNode { return El("ul", m...) } +func Li(m ...Mod) *VNode { return El("li", m...) } +func Form(m ...Mod) *VNode { return El("form", m...) } +func Input(m ...Mod) *VNode { return El("input", m...) } +func Button(m ...Mod) *VNode { return El("button", m...) } +func Table(m ...Mod) *VNode { return El("table", m...) } +func Tr(m ...Mod) *VNode { return El("tr", m...) } +func Td(m ...Mod) *VNode { return El("td", m...) } +func Img(m ...Mod) *VNode { return El("img", m...) } diff --git a/go/vdom/vnode.go b/go/vdom/vnode.go new file mode 100644 index 00000000..f9226d0b --- /dev/null +++ b/go/vdom/vnode.go @@ -0,0 +1,142 @@ +// Package vdom is a platform-neutral virtual DOM shared by the server (renders +// to an HTML string) and the client (reconciles into the real DOM). It compiles +// on BOTH native and js/wasm, so the same component code runs in both places — +// which is what makes server-side rendering + client hydration possible. +package vdom + +import ( + "html" + "sort" + "strings" +) + +// Event is a DOM event passed to handlers. The client provides a concrete +// implementation; on the server events are never invoked. +type Event interface { + PreventDefault() + Value() string // target.value (for inputs) +} + +// VNode is a virtual DOM node. Tag == "" is a text node (content in Text). HTML, +// if set on an element, is raw innerHTML (children ignored). +type VNode struct { + Tag string + Text string + HTML string + Attrs map[string]string + Props map[string]string + Events map[string]func(Event) + Children []*VNode + + // Runtime holds the wasm reconciler's per-node bookkeeping (DOM handle, + // listener wrappers). It's `any` so this package stays platform-neutral; it + // is nil on the server. + Runtime any +} + +// Mod configures a VNode while it is built. +type Mod interface{ apply(*VNode) } + +// El builds an element VNode. +func El(tag string, mods ...Mod) *VNode { + n := &VNode{Tag: tag, Attrs: map[string]string{}, Props: map[string]string{}, Events: map[string]func(Event){}} + for _, m := range mods { + m.apply(n) + } + return n +} + +// Text builds a text VNode. +func Text(s string) *VNode { return &VNode{Text: s} } + +func (n *VNode) apply(parent *VNode) { + if n != nil { // a nil child renders nothing (e.g. a closed Modal/Menu returns nil) + parent.Children = append(parent.Children, n) + } +} + +type attrMod struct{ k, v string } + +func (a attrMod) apply(n *VNode) { n.Attrs[a.k] = a.v } + +// Attr sets an HTML attribute. +func Attr(k, v string) Mod { return attrMod{k, v} } + +type propMod struct{ k, v string } + +func (p propMod) apply(n *VNode) { n.Props[p.k] = p.v } + +// Prop sets a live DOM property (e.g. an input's value). +func Prop(k, v string) Mod { return propMod{k, v} } + +type htmlMod struct{ html string } + +func (h htmlMod) apply(n *VNode) { n.HTML = h.html } + +// Raw sets inner HTML verbatim (children ignored) — e.g. a server-computed SVG. +func Raw(html string) Mod { return htmlMod{html} } + +type eventMod struct { + name string + h func(Event) +} + +func (e eventMod) apply(n *VNode) { n.Events[e.name] = e.h } + +// On registers an event handler that ignores the event object. +func On(event string, h func()) Mod { return eventMod{event, func(Event) { h() }} } + +// OnEvent registers an event handler that receives the Event. +func OnEvent(event string, h func(Event)) Mod { return eventMod{event, h} } + +// --- server-side HTML rendering (used for SSR; runs on any platform) --- + +var voidTags = map[string]bool{"br": true, "hr": true, "img": true, "input": true, "meta": true, "link": true} + +// RenderHTML serializes a VNode tree to HTML with NO extra whitespace, so the +// browser's parsed childNodes line up 1:1 with the VNode children on hydration. +func RenderHTML(n *VNode) string { + var b strings.Builder + writeNode(&b, n) + return b.String() +} + +func writeNode(b *strings.Builder, n *VNode) { + if n.Tag == "" { + b.WriteString(html.EscapeString(n.Text)) + return + } + b.WriteByte('<') + b.WriteString(n.Tag) + writeAttrs(b, n.Attrs) + writeAttrs(b, n.Props) // props like input value show up as attributes in SSR + b.WriteByte('>') + if voidTags[n.Tag] { + return + } + if n.HTML != "" { + b.WriteString(n.HTML) // raw + } else { + for _, c := range n.Children { + writeNode(b, c) + } + } + b.WriteString("') +} + +func writeAttrs(b *strings.Builder, m map[string]string) { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + b.WriteByte(' ') + b.WriteString(k) + b.WriteString(`="`) + b.WriteString(html.EscapeString(m[k])) + b.WriteByte('"') + } +} diff --git a/go/wasmdevserver/devserver.go b/go/wasmdevserver/devserver.go new file mode 100644 index 00000000..1825f615 --- /dev/null +++ b/go/wasmdevserver/devserver.go @@ -0,0 +1,376 @@ +// Package wasmdevserver is a reusable development server for gowasm apps: it serves +// the built web assets, renders routes server-side (SSR) at request time, hosts +// the /rsc server-component endpoint, and hot-swaps the freshly built wasm into +// the browser on change (no full reload, state preserved) — surfacing build +// failures as an in-page overlay. +// +// It never imports application code (per kjol's golden rule). The app injects +// everything specific to it through Config: how to build the wasm bundle +// (Build), how to render a route to HTML (Render), and how to wrap that HTML in +// a document (Document). The WebSocket hub and file watcher use only the +// standard library. +package wasmdevserver + +import ( + "crypto/sha1" + "encoding/base64" + "encoding/json" + "io" + "io/fs" + "log" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + "kjol/rsc" +) + +// Config wires an app into the dev server. Render and Document are called per +// request; Build is called for the initial build and on every source change. +type Config struct { + Addr string // listen address (default ":8085") + Dir string // static assets dir; Build writes app.wasm here (default "./wwwroot") + Watch bool // rebuild on change + hot reload + WatchDirs []string // source dirs to watch when Watch is set + Build func() ([]byte, error) // (re)build the wasm bundle; combined output on failure + Render func(path string) (inner string, ok bool) // SSR the #app inner HTML for a route (ok=false => client-rendered) + Document func(inner string) string // wrap #app inner HTML in a full HTML document + Handle func(mux *http.ServeMux) // optional: register extra routes (e.g. app API endpoints) +} + +// Serve builds once (in watch mode), wires the routes, and blocks serving. +func Serve(cfg Config) error { + if cfg.Addr == "" { + cfg.Addr = ":8085" + } + if cfg.Dir == "" { + cfg.Dir = "./wwwroot" + } + mux := http.NewServeMux() + h := newHub() + + if cfg.Watch { + if err := ensureShim(cfg.Dir); err != nil { + log.Printf("warning: could not stage wasm_exec.js: %v", err) + } + if cfg.Build != nil { + if out, err := cfg.Build(); err != nil { + log.Printf("initial build failed: %v\n%s", err, out) + h.setError(string(out)) // a browser opened now sees it via the overlay + } + } + mux.HandleFunc("/livereload", h.serveWS) + mux.HandleFunc("/livereload.js", serveClientJS) + go watchLoop(cfg, h) + log.Printf("hot reload enabled (watching %v)", cfg.WatchDirs) + } + + mux.HandleFunc("POST /rsc", rsc.Handler) // server components + if cfg.Handle != nil { + cfg.Handle(mux) // app-registered routes (API endpoints, etc.) + } + mux.HandleFunc("/", rootHandler(cfg)) + + log.Printf("serving %q on http://localhost%s", cfg.Dir, cfg.Addr) + return http.ListenAndServe(cfg.Addr, mux) +} + +// ---- serving: assets, and dynamic SSR for HTML routes ------------------- + +func rootHandler(cfg Config) http.HandlerFunc { + live := "" + if cfg.Watch { + live = `` + } + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + clean := filepath.Clean("/" + r.URL.Path) + fsPath := filepath.Join(cfg.Dir, clean) + if info, err := os.Stat(fsPath); err == nil && !info.IsDir() && clean != "/" { + if strings.HasSuffix(fsPath, ".wasm") { + w.Header().Set("Content-Type", "application/wasm") + } + http.ServeFile(w, r, fsPath) + return + } + inner := "" + if cfg.Render != nil { + inner, _ = cfg.Render(r.URL.Path) + } + html := inner + if cfg.Document != nil { + html = cfg.Document(inner) + } + if live != "" { + html = strings.Replace(html, "", live+"\n", 1) + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + io.WriteString(w, html) + } +} + +// ---- build + watch ------------------------------------------------------ + +func watchLoop(cfg Config, h *hub) { + prev := fingerprint(cfg.WatchDirs) + for { + time.Sleep(300 * time.Millisecond) + fp := fingerprint(cfg.WatchDirs) + if fp == prev { + continue + } + prev = fp + log.Println("change detected, rebuilding…") + h.broadcast(`{"type":"building"}`) + if cfg.Build == nil { + continue + } + if out, err := cfg.Build(); err != nil { + log.Printf("build failed: %v\n%s", err, out) + h.setError(string(out)) // push the compiler output to the browser overlay + continue + } + log.Println("rebuild ok — reloading clients") + h.clearError() + h.broadcast(`{"type":"reload"}`) + } +} + +// fingerprint changes whenever any .go file under dirs is modified. +func fingerprint(dirs []string) int64 { + var fp int64 + for _, d := range dirs { + filepath.WalkDir(d, func(path string, e fs.DirEntry, err error) error { + if err != nil || e.IsDir() || !strings.HasSuffix(path, ".go") { + return nil + } + if info, err := e.Info(); err == nil { + fp += info.ModTime().UnixNano() + info.Size() + } + return nil + }) + } + return fp +} + +func jsonStr(s string) string { b, _ := json.Marshal(s); return string(b) } + +// ensureShim copies Go's wasm_exec.js into dir if it isn't already there, so the +// server is self-sufficient without a separate build step first. +func ensureShim(dir string) error { + dst := filepath.Join(dir, "wasm_exec.js") + if _, err := os.Stat(dst); err == nil { + return nil + } + root, err := exec.Command("go", "env", "GOROOT").Output() + if err != nil { + return err + } + goroot := strings.TrimSpace(string(root)) + for _, p := range []string{ + filepath.Join(goroot, "lib", "wasm", "wasm_exec.js"), // Go >= 1.24 + filepath.Join(goroot, "misc", "wasm", "wasm_exec.js"), // Go <= 1.23 + } { + if b, err := os.ReadFile(p); err == nil { + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + return os.WriteFile(dst, b, 0o644) + } + } + return os.ErrNotExist +} + +// ---- injected livereload client ----------------------------------------- + +const clientJS = `// Injected by the dev server in watch mode. +(function () { + // Hot-swap the freshly built wasm in place — no full page reload and no blank + // flash. We fetch + compile the NEW module while the current page stays + // visible, then tear down the old instance and start the new one in the SAME + // task, so the browser never paints the intermediate empty #app. State is + // preserved: the old instance snapshots its signals on dispose; the new one + // restores them on boot. + async function hotSwap() { + if (!window.__gowasmPrepare) { location.reload(); return; } // fallback + var start; + try { start = await window.__gowasmPrepare(); } // network + compile; page still visible + catch (err) { console.error(err); location.reload(); return; } + hideOverlay(); + try { if (window.__gowasmDispose) window.__gowasmDispose(); } catch (err) { console.error(err); } + start(); // renders synchronously — no await between dispose and first paint + } + + // Full-screen overlay showing the Go compiler output when a build fails. The + // app underneath keeps running (and its state), so fixing the code and saving + // clears the overlay and hot-swaps without losing anything. + function ensureOverlay() { + var el = document.getElementById("__gowasm_error"); + if (!el) { + el = document.createElement("div"); + el.id = "__gowasm_error"; + el.style.cssText = "position:fixed;inset:0;z-index:2147483647;margin:0;padding:24px 28px;" + + "background:rgba(24,24,27,0.97);color:#e4e4e7;overflow:auto;" + + "font:13px/1.55 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;"; + document.body.appendChild(el); + } + return el; + } + function showOverlay(text) { + var el = ensureOverlay(); + el.textContent = ""; + var head = document.createElement("div"); + head.style.cssText = "color:#f87171;font-weight:700;font-size:15px;margin-bottom:14px;"; + head.textContent = "⚠ gowasm — build failed"; + var pre = document.createElement("pre"); + pre.style.cssText = "margin:0;white-space:pre-wrap;word-break:break-word;"; + pre.textContent = text || "(no compiler output)"; + el.appendChild(head); + el.appendChild(pre); + } + function hideOverlay() { + var el = document.getElementById("__gowasm_error"); + if (el && el.parentNode) { el.parentNode.removeChild(el); } + } + window.__gowasmErrorOverlay = { show: showOverlay, hide: hideOverlay }; + + function connect() { + var proto = location.protocol === "https:" ? "wss://" : "ws://"; + var ws = new WebSocket(proto + location.host + "/livereload"); + ws.onmessage = function (e) { + var msg = {}; + try { msg = JSON.parse(e.data); } catch (_) { return; } + if (msg.type === "reload") { hotSwap(); } // build ok: swap in place + else if (msg.type === "error") { showOverlay(msg.msg); } // build failed: show compiler output + else if (msg.type === "building") { console.log("[hot reload] rebuilding…"); } + }; + ws.onclose = function () { setTimeout(connect, 1000); }; // reconnect after reload/restart + ws.onerror = function () { try { ws.close(); } catch (_) {} }; + } + connect(); +})(); +` + +func serveClientJS(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/javascript; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + io.WriteString(w, clientJS) +} + +// ---- minimal WebSocket hub (stdlib only) -------------------------------- + +type hub struct { + mu sync.Mutex + clients map[*wsConn]struct{} + lastError string // most recent build-failure message (JSON), replayed to new clients +} + +func newHub() *hub { return &hub{clients: map[*wsConn]struct{}{}} } + +func (h *hub) add(c *wsConn) { h.mu.Lock(); h.clients[c] = struct{}{}; h.mu.Unlock() } +func (h *hub) remove(c *wsConn) { h.mu.Lock(); delete(h.clients, c); h.mu.Unlock() } + +// setError records the current build failure (so it survives to new clients) and +// pushes it to everyone connected. clearError is called on the next good build. +func (h *hub) setError(out string) { + msg := `{"type":"error","msg":` + jsonStr(out) + `}` + h.mu.Lock() + h.lastError = msg + h.mu.Unlock() + h.broadcast(msg) +} + +func (h *hub) clearError() { h.mu.Lock(); h.lastError = ""; h.mu.Unlock() } + +func (h *hub) errorMessage() string { h.mu.Lock(); defer h.mu.Unlock(); return h.lastError } + +func (h *hub) broadcast(msg string) { + h.mu.Lock() + defer h.mu.Unlock() + for c := range h.clients { + if err := c.sendText(msg); err != nil { + c.conn.Close() + delete(h.clients, c) + } + } +} + +// serveWS upgrades the request to a WebSocket and keeps the connection until the +// client disconnects. We only ever push server->client, so incoming frames are +// drained (which also lets us detect disconnects). +func (h *hub) serveWS(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(strings.ToLower(r.Header.Get("Upgrade")), "websocket") { + http.Error(w, "expected websocket upgrade", http.StatusBadRequest) + return + } + hj, ok := w.(http.Hijacker) + if !ok { + http.Error(w, "hijacking unsupported", http.StatusInternalServerError) + return + } + conn, brw, err := hj.Hijack() + if err != nil { + return + } + accept := acceptKey(r.Header.Get("Sec-WebSocket-Key")) + io.WriteString(brw, "HTTP/1.1 101 Switching Protocols\r\n"+ + "Upgrade: websocket\r\nConnection: Upgrade\r\n"+ + "Sec-WebSocket-Accept: "+accept+"\r\n\r\n") + if brw.Flush() != nil { + conn.Close() + return + } + + c := &wsConn{conn: conn} + h.add(c) + if msg := h.errorMessage(); msg != "" { + c.sendText(msg) // opened after a failed build => show the overlay right away + } + // Drain incoming bytes; return (and clean up) when the client goes away. + go func() { + io.Copy(io.Discard, brw) + conn.Close() + h.remove(c) + }() +} + +func acceptKey(key string) string { + h := sha1.New() + io.WriteString(h, key+"258EAFA5-E914-47DA-95CA-C5AB0DC85B11") + return base64.StdEncoding.EncodeToString(h.Sum(nil)) +} + +type wsConn struct { + mu sync.Mutex + conn net.Conn +} + +// sendText writes a single unmasked text frame (server frames are never masked). +func (c *wsConn) sendText(msg string) error { + c.mu.Lock() + defer c.mu.Unlock() + + payload := []byte(msg) + n := len(payload) + var header []byte + switch { + case n < 126: + header = []byte{0x81, byte(n)} + case n < 1<<16: + header = []byte{0x81, 126, byte(n >> 8), byte(n)} + default: + header = []byte{0x81, 127, + byte(n >> 56), byte(n >> 48), byte(n >> 40), byte(n >> 32), + byte(n >> 24), byte(n >> 16), byte(n >> 8), byte(n)} + } + if _, err := c.conn.Write(header); err != nil { + return err + } + _, err := c.conn.Write(payload) + return err +} diff --git a/go/wasmruntime/fetch.go b/go/wasmruntime/fetch.go new file mode 100644 index 00000000..a3d37a96 --- /dev/null +++ b/go/wasmruntime/fetch.go @@ -0,0 +1,149 @@ +//go:build js && wasm + +package wasmruntime + +import ( + "errors" + "fmt" + "strings" + "sync" + "syscall/js" +) + +// absURL resolves a relative path against the current origin (a browser resolves +// relative fetch URLs itself, but building the absolute URL keeps behavior +// uniform — and lets non-browser hosts, e.g. tests, fetch too). +func absURL(url string) string { + if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") { + return url + } + loc := js.Global().Get("location") + return loc.Get("protocol").String() + "//" + loc.Get("host").String() + url +} + +// Fetch performs an HTTP GET via the browser Fetch API and returns the response +// body as a string. It bridges a JS Promise into Go: it registers then/catch +// callbacks and blocks the calling goroutine on a channel until the request +// settles. +// +// Because it blocks, call it from a goroutine — never from a reactive callback +// or event handler — and write the result into signals when it returns, e.g.: +// +// go func() { +// body, err := Fetch(url) +// ... +// data.Set(...) // updates the UI reactively +// }() +func Fetch(url string) (string, error) { + var ( + once sync.Once + done = make(chan struct{}) + body string + ferr error + onResp, onText, onErr js.Func + ) + finish := func() { once.Do(func() { close(done) }) } + + onErr = js.FuncOf(func(this js.Value, args []js.Value) any { + msg := "fetch error" + if len(args) > 0 && args[0].Truthy() { + e := args[0] + if m := e.Get("message"); m.Truthy() { + msg = m.String() + } else { + msg = e.Call("toString").String() + } + } + ferr = errors.New(msg) + finish() + return nil + }) + + onText = js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) > 0 { + body = args[0].String() + } + finish() + return nil + }) + + onResp = js.FuncOf(func(this js.Value, args []js.Value) any { + resp := args[0] + if !resp.Get("ok").Bool() { + ferr = fmt.Errorf("HTTP %d", resp.Get("status").Int()) + finish() + return nil + } + // resp.text() -> Promise + resp.Call("text").Call("then", onText).Call("catch", onErr) + return nil + }) + + js.Global().Call("fetch", absURL(url)).Call("then", onResp).Call("catch", onErr) + + <-done + onResp.Release() + onText.Release() + onErr.Release() + return body, ferr +} + +// FetchBytes is like Fetch but returns the raw response body as bytes (via +// resp.arrayBuffer()), for binary payloads such as gob (Fetch's resp.text() +// would corrupt non-UTF-8 data). It blocks the calling goroutine the same way — +// call it from a goroutine and write the result into signals when it returns. +func FetchBytes(url string) ([]byte, error) { + var ( + once sync.Once + done = make(chan struct{}) + body []byte + ferr error + onResp, onBuf, onErr js.Func + ) + finish := func() { once.Do(func() { close(done) }) } + + onErr = js.FuncOf(func(this js.Value, args []js.Value) any { + msg := "fetch error" + if len(args) > 0 && args[0].Truthy() { + e := args[0] + if m := e.Get("message"); m.Truthy() { + msg = m.String() + } else { + msg = e.Call("toString").String() + } + } + ferr = errors.New(msg) + finish() + return nil + }) + + onBuf = js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) > 0 { + arr := js.Global().Get("Uint8Array").New(args[0]) + b := make([]byte, arr.Get("length").Int()) + js.CopyBytesToGo(b, arr) + body = b + } + finish() + return nil + }) + + onResp = js.FuncOf(func(this js.Value, args []js.Value) any { + resp := args[0] + if !resp.Get("ok").Bool() { + ferr = fmt.Errorf("HTTP %d", resp.Get("status").Int()) + finish() + return nil + } + resp.Call("arrayBuffer").Call("then", onBuf).Call("catch", onErr) + return nil + }) + + js.Global().Call("fetch", absURL(url)).Call("then", onResp).Call("catch", onErr) + + <-done + onResp.Release() + onBuf.Release() + onErr.Release() + return body, ferr +} diff --git a/go/wasmruntime/mount.go b/go/wasmruntime/mount.go new file mode 100644 index 00000000..ebcf773b --- /dev/null +++ b/go/wasmruntime/mount.go @@ -0,0 +1,86 @@ +//go:build js && wasm + +package wasmruntime + +import ( + "syscall/js" + + "kjol/vdom" +) + +var ( + rootRender func() + renderScheduled bool + disposed bool + flushFunc js.Func + flushInited bool +) + +// HasServerContent reports whether #app already holds server-rendered markup +// (so the client should Hydrate rather than build from scratch). +func HasServerContent() bool { + return document.Call("getElementById", "app").Get("firstChild").Truthy() +} + +// Run mounts a component fresh (client-side rendering). The component returns a +// VNode tree; any signal write re-renders and reconciles into the DOM. +func Run(component func() *vdom.VNode) { + vdom.Schedule = scheduleRender + root := document.Call("getElementById", "app") + var prev *vdom.VNode + rootRender = func() { + next := component() + patchChildren(root, one(prev), one(next)) + prev = next + } + rootRender() + finish(root) +} + +// Hydrate attaches to server-rendered DOM: it renders the same initial tree the +// server did, adopts the existing nodes (wiring events, no re-creation), then +// re-renders reactively from there. +func Hydrate(component func() *vdom.VNode) { + vdom.Schedule = scheduleRender + root := document.Call("getElementById", "app") + prev := component() + hydrateNode(root.Get("firstChild"), prev) + rootRender = func() { + next := component() + patchChildren(root, one(prev), one(next)) + prev = next + } + finish(root) +} + +func finish(root js.Value) { + // Allow a JS loader to tear this instance down before an in-place hot swap: + // snapshot signal state (so the next instance restores it), then clear #app. + js.Global().Set("__gowasmDispose", js.FuncOf(func(this js.Value, args []js.Value) any { + disposed = true + saveState() + root.Set("innerHTML", "") + return nil + })) + select {} // keep the runtime alive for event callbacks +} + +func scheduleRender() { + if disposed || rootRender == nil || renderScheduled { + return + } + renderScheduled = true + js.Global().Call("queueMicrotask", ensureFlush()) +} + +func ensureFlush() js.Func { + if !flushInited { + flushFunc = js.FuncOf(func(this js.Value, args []js.Value) any { + renderScheduled = false + rootRender() + return nil + }) + flushInited = true + } + return flushFunc +} diff --git a/go/wasmruntime/persist_wasm.go b/go/wasmruntime/persist_wasm.go new file mode 100644 index 00000000..dcea14ab --- /dev/null +++ b/go/wasmruntime/persist_wasm.go @@ -0,0 +1,68 @@ +//go:build js && wasm + +package wasmruntime + +import ( + "syscall/js" + + "kjol/vdom" +) + +// State preservation across an in-place hot swap (see the dev server's +// livereload script). A fresh wasm instance has fresh Go memory, so signal +// values would reset on every rebuild. To keep them, the outgoing instance +// snapshots its signals (by creation order, via a vdom.Collector) into +// sessionStorage on dispose, and the incoming instance restores them on boot. +// +// It's best-effort: if an edit adds/removes/reorders signals, the by-order match +// shifts and unmatched signals fall back to their initial value — the same +// hook-order caveat as server components. + +const stateKey = "__gowasm_hmr_state" + +var stateCollector *vdom.Collector + +// PreserveState registers the collector whose signals are snapshotted to +// sessionStorage when this instance is disposed for a hot swap. +func PreserveState(c *vdom.Collector) { stateCollector = c } + +// RestoreState returns the signal snapshot saved by a prior instance before a +// hot swap, or nil if there is none. It is one-shot: the entry is cleared so a +// manual page refresh starts from initial state. +func RestoreState() [][]byte { + ss := js.Global().Get("sessionStorage") + if !ss.Truthy() { + return nil + } + raw := ss.Call("getItem", stateKey) + if !raw.Truthy() { + return nil + } + ss.Call("removeItem", stateKey) + arr := js.Global().Get("JSON").Call("parse", raw) + if arr.Type() != js.TypeObject { + return nil + } + out := make([][]byte, arr.Length()) + for i := range out { + out[i] = []byte(arr.Index(i).String()) + } + return out +} + +// saveState writes the preserved collector's current signal values to +// sessionStorage. Called from the dispose hook before the DOM is torn down. +func saveState() { + if stateCollector == nil { + return + } + ss := js.Global().Get("sessionStorage") + if !ss.Truthy() { + return + } + arr := js.Global().Get("Array").New() + for _, b := range stateCollector.Snapshot() { + arr.Call("push", string(b)) + } + ss.Call("setItem", stateKey, js.Global().Get("JSON").Call("stringify", arr)) +} diff --git a/go/wasmruntime/reconcile.go b/go/wasmruntime/reconcile.go new file mode 100644 index 00000000..a66f714c --- /dev/null +++ b/go/wasmruntime/reconcile.go @@ -0,0 +1,235 @@ +//go:build js && wasm + +// Package wasmruntime is the wasm client runtime for the neutral vdom package: it +// reconciles vdom.VNode trees into the real DOM (fresh mount or hydration of +// server-rendered DOM), and drives re-renders when signals change. +package wasmruntime + +import ( + "syscall/js" + + "kjol/vdom" +) + +var document = js.Global().Get("document") + +// nodeRT is the per-node bookkeeping stored in VNode.Runtime (wasm only). +type nodeRT struct { + dom js.Value + jsFuncs map[string]js.Func + refs map[string]*handlerRef +} + +type handlerRef struct{ fn func(vdom.Event) } + +func rt(n *vdom.VNode) *nodeRT { + if n.Runtime == nil { + n.Runtime = &nodeRT{jsFuncs: map[string]js.Func{}, refs: map[string]*handlerRef{}} + } + return n.Runtime.(*nodeRT) +} + +// clientEvent adapts a DOM event to vdom.Event. +type clientEvent struct{ js js.Value } + +func (e clientEvent) PreventDefault() { e.js.Call("preventDefault") } + +func (e clientEvent) Value() string { + t := e.js.Get("target") + if !t.Truthy() { + return "" + } + v := t.Get("value") + if !v.Truthy() { + return "" // e.g. a button has no value + } + return v.String() +} + +func one(n *vdom.VNode) []*vdom.VNode { + if n == nil { + return nil + } + return []*vdom.VNode{n} +} + +// ---- fresh create + diff ---- + +func createDOM(n *vdom.VNode) js.Value { + if n.Tag == "" { + d := document.Call("createTextNode", n.Text) + rt(n).dom = d + return d + } + el := document.Call("createElement", n.Tag) + rt(n).dom = el + for k, v := range n.Attrs { + el.Call("setAttribute", k, v) + } + for k, v := range n.Props { + el.Set(k, v) + } + for name, h := range n.Events { + addListener(n, name, h) + } + if n.HTML != "" { + el.Set("innerHTML", n.HTML) + return el + } + for _, c := range n.Children { + el.Call("appendChild", createDOM(c)) + } + return el +} + +func patchChildren(parent js.Value, old, next []*vdom.VNode) { + n := max(len(old), len(next)) + for i := range n { + var o, x *vdom.VNode + if i < len(old) { + o = old[i] + } + if i < len(next) { + x = next[i] + } + patch(parent, o, x) + } +} + +func patch(parent js.Value, o, x *vdom.VNode) { + switch { + case o == nil && x == nil: + return + case o == nil: + parent.Call("appendChild", createDOM(x)) + case x == nil: + parent.Call("removeChild", rt(o).dom) + release(o) + case o.Tag != x.Tag: + parent.Call("replaceChild", createDOM(x), rt(o).dom) + release(o) + default: + x.Runtime = o.Runtime // adopt dom + listeners + dom := rt(x).dom + if !dom.Truthy() { + // o was a hydration hole (no adopted DOM node, e.g. a server/client + // markup mismatch). Recreate this node fresh instead of calling into an + // undefined DOM handle. + parent.Call("appendChild", createDOM(x)) + return + } + if x.Tag == "" { + if x.Text != o.Text { + dom.Set("nodeValue", x.Text) + } + return + } + updateAttrs(o, x) + updateProps(o, x) + updateEvents(o, x) + if x.HTML != "" { + if x.HTML != o.HTML { + dom.Set("innerHTML", x.HTML) + } + return + } + patchChildren(dom, o.Children, x.Children) + } +} + +func updateAttrs(o, x *vdom.VNode) { + dom := rt(x).dom + for k := range o.Attrs { + if _, ok := x.Attrs[k]; !ok { + dom.Call("removeAttribute", k) + } + } + for k, v := range x.Attrs { + if o.Attrs[k] != v { + dom.Call("setAttribute", k, v) + } + } +} + +func updateProps(o, x *vdom.VNode) { + dom := rt(x).dom + for k, v := range x.Props { + if o.Props[k] != v && dom.Get(k).String() != v { + dom.Set(k, v) + } + } +} + +func updateEvents(o, x *vdom.VNode) { + r := rt(x) // same nodeRT as o (adopted above) + for name, fn := range r.jsFuncs { + if _, ok := x.Events[name]; !ok { + r.dom.Call("removeEventListener", name, fn) + fn.Release() + delete(r.jsFuncs, name) + delete(r.refs, name) + } + } + for name, h := range x.Events { + if ref, ok := r.refs[name]; ok { + ref.fn = h + } else { + addListener(x, name, h) + } + } +} + +func addListener(n *vdom.VNode, name string, handler func(vdom.Event)) { + r := rt(n) + ref := &handlerRef{fn: handler} + fn := js.FuncOf(func(this js.Value, args []js.Value) any { + var ev js.Value + if len(args) > 0 { + ev = args[0] + } + ref.fn(clientEvent{js: ev}) + return nil + }) + r.dom.Call("addEventListener", name, fn) + r.jsFuncs[name] = fn + r.refs[name] = ref +} + +func release(n *vdom.VNode) { + if n.Runtime != nil { + for _, fn := range rt(n).jsFuncs { + fn.Release() + } + } + for _, c := range n.Children { + release(c) + } +} + +// ---- hydration: adopt server-rendered DOM instead of creating it ---- + +func hydrateNode(dom js.Value, n *vdom.VNode) { + if !dom.Truthy() { + return // structural mismatch; leave a hole (a later re-render will fix) + } + rt(n).dom = dom + if n.Tag == "" { + if dom.Get("nodeValue").String() != n.Text { + dom.Set("nodeValue", n.Text) + } + return + } + for name, h := range n.Events { + addListener(n, name, h) + } + for k, v := range n.Props { + dom.Set(k, v) + } + if n.HTML != "" { + return // trust server-rendered HTML + } + childNodes := dom.Get("childNodes") + for i, c := range n.Children { + hydrateNode(childNodes.Index(i), c) + } +} diff --git a/go/wasmruntime/router.go b/go/wasmruntime/router.go new file mode 100644 index 00000000..832a53df --- /dev/null +++ b/go/wasmruntime/router.go @@ -0,0 +1,38 @@ +//go:build js && wasm + +package wasmruntime + +import ( + "syscall/js" + + "kjol/vdom" +) + +// Router holds the current path in a signal, so reading Path() during render +// re-renders on navigation (and browser back/forward). +type Router struct { + path *vdom.Signal[string] +} + +func NewRouter() *Router { + r := &Router{path: vdom.NewSignal(currentPath())} + popstate := js.FuncOf(func(this js.Value, args []js.Value) any { + r.path.Set(currentPath()) + return nil + }) + js.Global().Call("addEventListener", "popstate", popstate) + return r +} + +func currentPath() string { + return js.Global().Get("location").Get("pathname").String() +} + +// Path returns the current route (reactive when read during render). +func (r *Router) Path() string { return r.path.Get() } + +// Navigate pushes a history entry and re-renders (client-side SPA navigation). +func (r *Router) Navigate(path string) { + js.Global().Get("history").Call("pushState", nil, "", path) + r.path.Set(path) +} diff --git a/go/wasmruntime/wasmruntime.go b/go/wasmruntime/wasmruntime.go new file mode 100644 index 00000000..e50234d2 --- /dev/null +++ b/go/wasmruntime/wasmruntime.go @@ -0,0 +1,7 @@ +//go:build !(js && wasm) + +// Package wasmruntime is the browser-side client runtime (reconciler, mount / +// hydrate, router, fetch, hot-reload state preservation). Its implementation +// compiles only under GOOS=js GOARCH=wasm; this placeholder keeps the package +// non-empty on other platforms so `go build ./...` / `go vet ./...` succeed. +package wasmruntime diff --git a/go/webui/README.md b/go/webui/README.md new file mode 100644 index 00000000..9be27d56 --- /dev/null +++ b/go/webui/README.md @@ -0,0 +1,57 @@ +# webui — Go/WebAssembly component kit + +A Go port of kjol's Solid.js component kit (`web/kit`) for the **gowasm** engine. +Components are neutral `*vdom.VNode` builders (SSR on the server, hydrate on the +client) styled with **Tailwind** utility classes. Import as `kjol/webui`. + +```go +import ui "kjol/webui" + +ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Save", OnClick: save}) +ui.Alert(ui.AlertGreen, "Done", vdom.Text("Saved.")) +ui.ToggleSwitch(on, func(v bool){ on = v }, "Notifications", "", false, "") +``` + +See the runnable **`/kit`** demo page in `cmd/examples/go-wasm-web` (app nav → "UI Kit"). + +## Conventions + +- A TSX `function Foo(props)` → `func Foo(p FooProps, children ...*vdom.VNode) *vdom.VNode` + (simple components take positional params). Reactive accessors collapse: pass plain + current values (read a signal at the call site); state changes come back via `OnChange` + callbacks (the whole tree re-renders and reconciles). +- Tailwind classes are copied verbatim so `kjol/cmd/twcss` (scanning these `.go` files) + emits the CSS. Custom tokens (`rounded-default`, `bg-primary`, `text-text-heading`, …) + come from the app's `@theme` block. +- Icons via `Icon(name, size, class)`; the registry ships a small default set, apps add + more with `RegisterIcon`. Unregistered names render an empty (correctly-sized) box. + +## Components + +- **Layout/content:** `Card*`, `BorderCard`, `CardHeader`, `PageHeader`, `PageContainer`, + `Breadcrumbs`, `Divider`, `CodeBox`, `Loader`, `Sidebar*`, `Icon*`. +- **Controls:** `Button`, `ButtonLink`, `SegmentedButtons`, `BackLink`, `Badge`, + `ToggleSwitch`, and the `Form*` family (`FormInput`, `FormTextarea`, `FormSelect`, + `FormLabel`, `FormFieldset`, `FormNumberInput`/`FormCurrencyInput`/`FormEmailInput`/… ). +- **Disclosure/nav:** `Alert`, `TabGroup`, `CrmTabGroup`, `Accordion`/`SingleAccordion`, + `Menu`*, `Submenu`, `EnvBadge`, `RemoteUpdateFlash`. +- **Overlays/floating:** `Modal`/`ConfirmModal`/`WizardModal`, `Toast*`, `Tooltip`/ + `HoverTooltip`, `Popover`/`HoverPopover`, `Floating*`. +- **Data:** `PrettyTable`, `AutoTable`, `CellGrid`, `ReactiveChart`, `Calendar`, + `DatePicker`, `FuzzyMatch*` (+ pure matchers), `Tutorial`. +- **Pure logic (no vdom):** `formatters.go` (`FormatPhoneNumber`, `FormatDate`, …) and + `validation.go` (`IsEmailValid`, `CreateValidation`, …). + +## Runtime limitations (what the TSX did that this can't) + +The neutral runtime has **no floating-ui, portals, refs, element measurement, focus +traps, or timers**. Components that relied on those are ported as **structure + +Tailwind + signal/event wiring**, with open/selection state lifted to props + +callbacks and positioning approximated with static Tailwind. Each such gap is marked +with a `// NOTE:` in the component's file. Concretely: dropdown/menu/tooltip/modal +positioning is static (not computed); outside-click / Escape / hover-delay dismissal, +auto-dismiss timers, and enter/exit animations are dropped (caller-driven); `AutoTable` +omits virtual scrolling, column resize/reorder, inline editing, and the formula engine; +`ReactiveChart` renders only its container (the example draws charts server-side with +go-chart); a few `Form*` controls that needed canvas/async (`FormSignaturePad`, +`FormAsyncCombobox`) are omitted. Everything compiles on native (SSR) and js/wasm. diff --git a/go/webui/accordion.go b/go/webui/accordion.go new file mode 100644 index 00000000..bfd9a817 --- /dev/null +++ b/go/webui/accordion.go @@ -0,0 +1,131 @@ +package webui + +import "kjol/vdom" + +// Port of web/kit/Accordion.tsx. +// +// NOTE: The TSX AccordionItem kept its open state in an internal signal (seeded +// by startOpen, synced from an optional isOpen prop via createEffect). Per the +// porting guide, selection state collapses to a plain value + callback: IsOpen +// is a plain bool and OnToggle fires on click (the caller flips its own state). +// The TSX startOpen prop collapses into the caller-supplied initial IsOpen / +// open[] / openIndex. + +const accordionRootCls = "ui-accordion border border-neutral-200 rounded-default overflow-hidden" +const accordionItemCls = "ui-accordion-item border-b border-neutral-200 last:border-b-0" +const accordionTriggerCls = "ui-accordion-trigger flex items-center justify-between w-full py-3 px-4 text-left font-medium text-neutral-900 bg-neutral-50 cursor-pointer border-none transition-colors hover:bg-neutral-100 active:bg-neutral-200 focus:outline-hidden disabled:text-neutral-400 disabled:cursor-not-allowed disabled:bg-neutral-50" +const accordionTitleCls = "ui-accordion-title flex-1" +const accordionContentCls = "ui-accordion-content px-4 pb-4 text-neutral-700" + +func accordionIconCls(open bool) string { + c := "ui-accordion-icon text-neutral-500 leading-none transition-transform duration-200" + if open { + c += " rotate-180" + } + return c +} + +// AccordionItemProps configures a single AccordionItem. IsOpen is the current +// open state; OnToggle fires when the (enabled) trigger is clicked. +type AccordionItemProps struct { + IsOpen bool + Disabled bool + Title string + OnToggle func() +} + +// AccordionItem is one collapsible row: a trigger button plus its content +// (children), shown when open and not disabled. +func AccordionItem(p AccordionItemProps, children ...*vdom.VNode) *vdom.VNode { + ariaExpanded := "false" + if p.IsOpen { + ariaExpanded = "true" + } + trigger := []vdom.Mod{ + vdom.Attr("type", "button"), + vdom.Attr("class", accordionTriggerCls), + vdom.Attr("aria-expanded", ariaExpanded), + vdom.On(vdom.EVENT_CLICK, func() { + if p.Disabled { + return + } + if p.OnToggle != nil { + p.OnToggle() + } + }), + vdom.El("span", vdom.Attr("class", accordionTitleCls), vdom.Text(p.Title)), + } + if p.Disabled { + trigger = append(trigger, vdom.Attr("disabled", "disabled")) + } else { + chev := "chevron-down" + if p.IsOpen { + chev = "chevron-up" + } + trigger = append(trigger, vdom.El("span", + vdom.Attr("class", accordionIconCls(p.IsOpen)), + Icon(chev, 18, ""), + )) + } + + mods := []vdom.Mod{vdom.Attr("class", accordionItemCls), vdom.El("button", trigger...)} + if p.IsOpen && !p.Disabled { + mods = append(mods, vdom.El("div", kids([]vdom.Mod{vdom.Attr("class", accordionContentCls)}, children)...)) + } + return vdom.El("div", mods...) +} + +// AccordionItemData is one entry for Accordion / SingleAccordion. +type AccordionItemData struct { + Title string + Content *vdom.VNode + Disabled bool +} + +// Accordion renders independently-collapsible items. open[i] is item i's open +// state (missing entries are closed); clicking item i calls onToggle(i) — the +// caller flips open[i]. +func Accordion(items []AccordionItemData, open []bool, onToggle func(int)) *vdom.VNode { + mods := []vdom.Mod{vdom.Attr("class", accordionRootCls)} + for i, item := range items { + idx := i + isOpen := i < len(open) && open[i] + mods = append(mods, AccordionItem(AccordionItemProps{ + IsOpen: isOpen, + Disabled: item.Disabled, + Title: item.Title, + OnToggle: func() { + if onToggle != nil { + onToggle(idx) + } + }, + }, item.Content)) + } + return vdom.El("div", mods...) +} + +// SingleAccordion renders items where at most one is open. openIndex is the open +// item (-1 = none); clicking an item calls onChange with the new open index — +// the clicked index, or -1 if that item was already open. +func SingleAccordion(items []AccordionItemData, openIndex int, onChange func(int)) *vdom.VNode { + mods := []vdom.Mod{vdom.Attr("class", accordionRootCls)} + for i, item := range items { + idx := i + mods = append(mods, AccordionItem(AccordionItemProps{ + IsOpen: openIndex == i, + Disabled: item.Disabled, + Title: item.Title, + OnToggle: func() { + if onChange == nil { + return + } + if openIndex == idx { + onChange(-1) + } else { + onChange(idx) + } + }, + }, item.Content)) + } + return vdom.El("div", mods...) +} diff --git a/go/webui/alerts.go b/go/webui/alerts.go new file mode 100644 index 00000000..d6bae590 --- /dev/null +++ b/go/webui/alerts.go @@ -0,0 +1,41 @@ +package webui + +import "kjol/vdom" + +// Port of web/kit/Alerts.tsx. The TSX exports one component per color +// (AlertWhite, AlertBlue, …); here that's Alert(color, header, children). + +const ( + AlertWhite = "white" + AlertGray = "gray" + AlertBlue = "blue" + AlertGreen = "green" + AlertRed = "red" + AlertYellow = "yellow" +) + +const alertBase = "p-4 rounded-default shadow-xs border" + +var alertColors = map[string]string{ + "white": "bg-white border-neutral-100", + "gray": "bg-neutral-50 border-neutral-200", + "blue": "bg-sky-50 border-sky-200", + "green": "bg-green-50 border-green-200", + "red": "bg-red-50 border-red-200", + "yellow": "bg-yellow-50 border-yellow-200", +} + +// Alert renders a colored callout. header is optional (rendered as a bold title +// above the body); children form the body. +func Alert(color, header string, children ...*vdom.VNode) *vdom.VNode { + cc := alertColors[color] + if cc == "" { + cc = alertColors["white"] + } + mods := []vdom.Mod{vdom.Attr("class", cx(alertBase, cc))} + if header != "" { + mods = append(mods, vdom.El("h3", vdom.Attr("class", "font-semibold mb-2"), vdom.Text(header))) + } + mods = append(mods, vdom.El("p", kids([]vdom.Mod{vdom.Attr("class", "text-sm")}, children)...)) + return vdom.El("div", mods...) +} diff --git a/go/webui/autotable.go b/go/webui/autotable.go new file mode 100644 index 00000000..59415afb --- /dev/null +++ b/go/webui/autotable.go @@ -0,0 +1,601 @@ +// Port of web/kit/AutoTable.tsx. +// +// NOTE: AutoTable.tsx is ~4200 LOC and heavily coupled to the browser. This is a +// faithful, COMPILING *core* — the column model, the header/body/pagination +// render shell, and the Tailwind styling — not a behavioral clone. The following +// substantial features of the TSX are INTENTIONALLY OUT OF SCOPE here because +// they depend on DOM measurement, refs, timers, portals, or floating-ui, none of +// which exist in the neutral vdom runtime: +// +// - Virtual scrolling / windowed row rendering (rows are rendered eagerly). +// - Column drag-to-reorder and drag-to-resize (getBoundingClientRect, refs, +// mousemove tracking) — replaced by a static per-column WidthClass. +// - Computed/pinned table sizing (pinnedTableWidth measurement) — replaced by +// table-fixed + static WidthClass. +// - Runtime-editable calculated columns and footer summary rows, the whole +// Excel-style formula engine (tokenize/compile/highlight), and their popover +// editors (Popover/Menu/floating-ui). +// - CSV and PDF export (pdf-lib / pdfjs-dist), and the export/customize popover. +// - Remote data fetching (authFetch), remoteFiltering, refreshSignal. +// - Search/filter toolbar, quick-date presets, column-visibility toggles, the +// search-aside card, and localStorage persistence of order/width/columns. +// - Accordion expand/collapse rows and row-highlight auto-paging. +// - Client-side sorting/filtering/pagination logic (compareRowsGeneric, +// processDataLocally): sort state and pagination are surfaced here as plain +// value props + callbacks; the caller owns the actual sort/page computation. +// +// What IS ported: the AutoTableColumn model (header text, alignment, width class, +// sortable + a cell render func), the AutoTable render shell (sticky header, +// body rows, loading skeletons, error/empty states, display-only pagination), +// the verbatim Tailwind class constants/maps, and sort/pagination as props + +// callbacks. Reactive accessors collapse to plain values per the port guide. +package webui + +import ( + "strconv" + + "kjol/vdom" +) + +// ColumnPosition is a cell/header alignment (mirrors the TSX 0|1|2 union). +type ColumnPosition int + +const ( + COL_POS_LEFT ColumnPosition = 0 + COL_POS_RIGHT ColumnPosition = 1 + COL_POS_CENTER ColumnPosition = 2 +) + +// AutoTableHeaderColor selects the header/body color scheme. +type AutoTableHeaderColor int + +const ( + AUTOTABLE_HEADER_COLOR_DEFAULT AutoTableHeaderColor = 0 + AUTOTABLE_HEADER_COLOR_BLUE AutoTableHeaderColor = 1 + AUTOTABLE_HEADER_COLOR_GREEN AutoTableHeaderColor = 2 + AUTOTABLE_HEADER_COLOR_GRAY AutoTableHeaderColor = 3 + AUTOTABLE_HEADER_COLOR_DARK_BLUE AutoTableHeaderColor = 4 +) + +// AutoTableSize selects header/body/pagination density. +type AutoTableSize int + +const ( + AUTOTABLE_SIZE_DEFAULT AutoTableSize = 0 + AUTOTABLE_SIZE_COMPACT AutoTableSize = 1 + AUTOTABLE_SIZE_SUPERCOMPACT AutoTableSize = 2 +) + +// -- Tailwind class maps (copied verbatim from AutoTable.tsx) --------------- + +// HEADER_COLOR_CLS is the background + text color per header color. +var HEADER_COLOR_CLS = map[AutoTableHeaderColor]string{ + AUTOTABLE_HEADER_COLOR_DEFAULT: "bg-neutral-50", + AUTOTABLE_HEADER_COLOR_BLUE: "bg-sky-700 text-white", + AUTOTABLE_HEADER_COLOR_GREEN: "bg-green-700 text-white", + AUTOTABLE_HEADER_COLOR_GRAY: "bg-neutral-600 text-white", + AUTOTABLE_HEADER_COLOR_DARK_BLUE: "bg-sky-900 text-white", +} + +// atHeaderSortHoverCls is the sortable-hover override per color. +var atHeaderSortHoverCls = map[AutoTableHeaderColor]string{ + AUTOTABLE_HEADER_COLOR_DEFAULT: "hover:bg-neutral-300", + AUTOTABLE_HEADER_COLOR_BLUE: "hover:bg-sky-500", + AUTOTABLE_HEADER_COLOR_GREEN: "hover:bg-green-800", + AUTOTABLE_HEADER_COLOR_GRAY: "hover:bg-neutral-500", + AUTOTABLE_HEADER_COLOR_DARK_BLUE: "hover:bg-sky-800", +} + +// HEADER_TEXT_CLS is the header text weight/case per color. +var HEADER_TEXT_CLS = map[AutoTableHeaderColor]string{ + AUTOTABLE_HEADER_COLOR_DEFAULT: "font-bold uppercase tracking-wider", + AUTOTABLE_HEADER_COLOR_BLUE: "font-semibold", + AUTOTABLE_HEADER_COLOR_GREEN: "font-semibold", + AUTOTABLE_HEADER_COLOR_GRAY: "font-semibold", + AUTOTABLE_HEADER_COLOR_DARK_BLUE: "font-semibold", +} + +// atHeaderSortIconCls is the sort-icon color (matches header text) per color. +var atHeaderSortIconCls = map[AutoTableHeaderColor]string{ + AUTOTABLE_HEADER_COLOR_DEFAULT: "text-black", + AUTOTABLE_HEADER_COLOR_BLUE: "text-white", + AUTOTABLE_HEADER_COLOR_GREEN: "text-white", + AUTOTABLE_HEADER_COLOR_GRAY: "text-white", + AUTOTABLE_HEADER_COLOR_DARK_BLUE: "text-white", +} + +// HEADER_PADDING_CLS is the th padding per table size. +var HEADER_PADDING_CLS = map[AutoTableSize]string{ + AUTOTABLE_SIZE_DEFAULT: "p-4 text-sm", + AUTOTABLE_SIZE_COMPACT: "py-1.5 px-2 text-sm", + AUTOTABLE_SIZE_SUPERCOMPACT: "py-1 px-2 text-xs", +} + +// BODY_PADDING_CLS is the body cell padding per table size (applied via [&_td]:). +var BODY_PADDING_CLS = map[AutoTableSize]string{ + AUTOTABLE_SIZE_DEFAULT: "text-sm [&_td]:p-4", + AUTOTABLE_SIZE_COMPACT: "text-sm [&_td]:py-1 [&_td]:px-2", + AUTOTABLE_SIZE_SUPERCOMPACT: "text-xs [&_td]:py-0.5 [&_td]:px-2", +} + +// atPaginationPaddingCls is the pagination bar padding per table size. +var atPaginationPaddingCls = map[AutoTableSize]string{ + AUTOTABLE_SIZE_DEFAULT: "py-3 px-4", + AUTOTABLE_SIZE_COMPACT: "py-1 px-4", + AUTOTABLE_SIZE_SUPERCOMPACT: "py-1 px-4", +} + +// atRowHoverCls is the body-row hover background per color (when hover is on). +var atRowHoverCls = map[AutoTableHeaderColor]string{ + AUTOTABLE_HEADER_COLOR_DEFAULT: "hover:bg-neutral-200", + AUTOTABLE_HEADER_COLOR_BLUE: "hover:bg-sky-100", + AUTOTABLE_HEADER_COLOR_GREEN: "hover:bg-green-100", + AUTOTABLE_HEADER_COLOR_GRAY: "hover:bg-neutral-200", + AUTOTABLE_HEADER_COLOR_DARK_BLUE: "hover:bg-sky-100", +} + +// POS_CLS is the text alignment per ColumnPosition. +var POS_CLS = map[ColumnPosition]string{ + COL_POS_LEFT: "text-left", + COL_POS_RIGHT: "text-right", + COL_POS_CENTER: "text-center", +} + +// HEADER_INNER_POS is the header inner flex direction per position. +var HEADER_INNER_POS = map[ColumnPosition]string{ + COL_POS_LEFT: "", + COL_POS_RIGHT: "flex-row-reverse", + COL_POS_CENTER: "justify-center", +} + +// -- Class string constants for parts that don't vary by config ------------ +const ( + TBL_CONTAINER = "relative flex flex-col w-full h-full bg-white rounded-default overflow-hidden" + TBL_WRAPPER = "overflow-x-auto w-full" + TBL_BASE = "min-w-full" + + HEADER_CONTENT = "transition-transform duration-150 ease-in-out" + HEADER_INNER_BASE = "flex justify-between gap-2 items-center" + + atSortIconWrap = "leading-none shrink-0 opacity-50" + + atSkeleton = "h-4 bg-neutral-200 rounded-default animate-pulse" + atErrorCell = "text-center text-red-600" + atEmptyCell = "text-center text-neutral-500" + + atPaginationBase = "flex justify-between items-center border-t border-neutral-300" + atPaginationInfo = "hidden sm:flex items-center text-sm text-neutral-500" + atPaginationControls = "flex items-center" + atPaginationLabel = "hidden sm:block text-sm text-neutral-500 mr-2" + atPaginationPage = "text-sm text-neutral-500 px-3" + atPaginationBtn = "p-1 min-h-9 text-sm font-normal leading-none bg-transparent border-0 cursor-pointer hover:bg-neutral-100 disabled:text-neutral-300 disabled:cursor-not-allowed disabled:hover:bg-transparent" + + // thead sticky classes are the STATIC replacement for the TSX's JS-driven + // header pinning (transform tracking on scroll). See file-level NOTE. + atTheadCls = "sticky top-0 z-10 [&_th]:border-b [&_th]:border-neutral-300" +) + +// AutoTableColumn describes one column: its header text, alignment, an optional +// static Tailwind width class (the static replacement for the TSX's measured/ +// resizable widths), whether it is sortable (and under which identifier), and a +// cell render func returning the full for a given row. If Cell is nil an +// empty aligned is rendered. +// +// NOTE: The TSX AutoTableColumn also carries csv/csvValue, sortType/sortValue, +// toggleable/hiddenByDefault, and a `calculated` spec — all tied to features that +// are out of scope here (export, local sort, toggles, calculated columns). +type AutoTableColumn struct { + DisplayName string + DisplayPosition ColumnPosition + WidthClass string // static Tailwind width, e.g. "w-32" (replaces measured sizing) + HeaderClasses string + Sortable bool + SortIdentifier string + Cell func(row any) *vdom.VNode +} + +// AutoTableOrderBy is the active sort (mirrors the TSX interface). +type AutoTableOrderBy struct { + Identifier string + Descending bool +} + +// AutoTablePagination is the display-only pagination state (mirrors the TSX +// interface; only the display fields are used by this core). +type AutoTablePagination struct { + CurrentPage int + TotalPages int + TotalItems int + MaxItemsPerPage int + ViewRangeLower int + ViewRangeUpper int +} + +// atConfig holds resolved AutoTable options. Defaults mirror the TSX opts memo. +type atConfig struct { + size AutoTableSize + color AutoTableHeaderColor + shadow bool + hover bool + alternate bool + headerBorderY bool + surroundingBorder bool + borderX bool + borderY bool + tableLayoutAuto bool + hidePagination bool + + loading bool + errorMsg string + emptyMessage string + + sortIdentifier string + sortDescending bool + onSort func(identifier string) + + pagination *AutoTablePagination + onPageChange func(page int) + onItemsPerPage func(n int) + + class string +} + +// AutoTableOption configures AutoTable (functional-options for the variadic opts). +type AutoTableOption func(*atConfig) + +// AutoTableWithSize sets the density (default / compact / supercompact). +func AutoTableWithSize(s AutoTableSize) AutoTableOption { + return func(c *atConfig) { c.size = s } +} + +// AutoTableWithColor sets the header/body color scheme. +func AutoTableWithColor(color AutoTableHeaderColor) AutoTableOption { + return func(c *atConfig) { c.color = color } +} + +// AutoTableWithHover enables per-row hover highlighting. +func AutoTableWithHover() AutoTableOption { return func(c *atConfig) { c.hover = true } } + +// AutoTableWithAlternate enables zebra striping on odd rows. +func AutoTableWithAlternate() AutoTableOption { return func(c *atConfig) { c.alternate = true } } + +// AutoTableWithShadow adds a drop shadow to the table container. +func AutoTableWithShadow() AutoTableOption { return func(c *atConfig) { c.shadow = true } } + +// AutoTableWithSurroundingBorder draws a border around the table container. +func AutoTableWithSurroundingBorder() AutoTableOption { + return func(c *atConfig) { c.surroundingBorder = true } +} + +// AutoTableWithHeaderBorderY adds vertical dividers between header cells. +func AutoTableWithHeaderBorderY() AutoTableOption { + return func(c *atConfig) { c.headerBorderY = true } +} + +// AutoTableWithBorderX draws horizontal dividers between body rows. +func AutoTableWithBorderX() AutoTableOption { return func(c *atConfig) { c.borderX = true } } + +// AutoTableWithBorderY draws vertical dividers between body cells. +func AutoTableWithBorderY() AutoTableOption { return func(c *atConfig) { c.borderY = true } } + +// AutoTableWithTableLayoutAuto uses auto table layout instead of table-fixed. +func AutoTableWithTableLayoutAuto() AutoTableOption { + return func(c *atConfig) { c.tableLayoutAuto = true } +} + +// AutoTableWithLoading renders skeleton placeholder rows instead of data. +func AutoTableWithLoading(loading bool) AutoTableOption { + return func(c *atConfig) { c.loading = loading } +} + +// AutoTableWithError renders a single error row with the given message. +func AutoTableWithError(msg string) AutoTableOption { + return func(c *atConfig) { c.errorMsg = msg } +} + +// AutoTableWithEmptyMessage overrides the "No entries found." empty-state text. +func AutoTableWithEmptyMessage(msg string) AutoTableOption { + return func(c *atConfig) { c.emptyMessage = msg } +} + +// AutoTableWithSort surfaces the active sort as a plain value + callback. onSort +// is invoked with a sortable column's SortIdentifier when its header is clicked; +// the caller owns the actual re-sorting (the TSX's local sort is out of scope). +func AutoTableWithSort(identifier string, descending bool, onSort func(identifier string)) AutoTableOption { + return func(c *atConfig) { + c.sortIdentifier = identifier + c.sortDescending = descending + c.onSort = onSort + } +} + +// AutoTableWithPagination surfaces display-only pagination state + callbacks. The +// caller owns the actual paging/query computation. +func AutoTableWithPagination(p *AutoTablePagination, onPageChange func(page int), onItemsPerPage func(n int)) AutoTableOption { + return func(c *atConfig) { + c.pagination = p + c.onPageChange = onPageChange + c.onItemsPerPage = onItemsPerPage + } +} + +// AutoTableWithHidePagination hides the pagination bar. +func AutoTableWithHidePagination() AutoTableOption { + return func(c *atConfig) { c.hidePagination = true } +} + +// AutoTableWithClass appends classes to the outermost wrapper. +func AutoTableWithClass(class string) AutoTableOption { + return func(c *atConfig) { c.class = class } +} + +// AutoTable renders the table shell for the given columns and rows. Sort state, +// loading, and pagination are plain value props supplied via opts (reactive +// accessors collapse). See the file-level NOTE for out-of-scope features. +func AutoTable(cols []AutoTableColumn, rows []any, opts ...AutoTableOption) *vdom.VNode { + cfg := &atConfig{} + for _, o := range opts { + o(cfg) + } + + tableCls := cx("border-collapse", TBL_BASE) + if !cfg.tableLayoutAuto { + tableCls = cx(tableCls, "table-fixed") + } + + containerCls := TBL_CONTAINER + if cfg.surroundingBorder { + containerCls = cx(containerCls, "border border-neutral-300") + } + if cfg.shadow { + containerCls = cx(containerCls, "shadow-sm") + } + + table := vdom.El("table", + vdom.Attr("class", tableCls), + atRenderHead(cols, cfg), + atRenderBody(cols, rows, cfg), + ) + + container := vdom.El("div", vdom.Attr("class", containerCls), + vdom.El("div", vdom.Attr("class", TBL_WRAPPER), table), + ) + if footer := atRenderPagination(cfg); footer != nil { + container.Children = append(container.Children, footer) + } + + return vdom.El("div", + vdom.Attr("class", cx("min-w-0 w-full max-w-full", cfg.class)), + container, + ) +} + +// atRenderHead builds the with one header . +func atRenderHead(cols []AutoTableColumn, cfg *atConfig) *vdom.VNode { + headerColor := HEADER_COLOR_CLS[cfg.color] + headerPadding := HEADER_PADDING_CLS[cfg.size] + + tr := vdom.El("tr") + if len(cols) == 0 { + tr.Children = append(tr.Children, + vdom.El("th", vdom.Attr("class", cx(headerPadding, headerColor)), vdom.Text(" "))) + } + for i, col := range cols { + tr.Children = append(tr.Children, atRenderHeaderCell(col, i, cfg, headerColor, headerPadding)) + } + return vdom.El("thead", vdom.Attr("class", atTheadCls), tr) +} + +// atRenderHeaderCell builds one . Drag/resize handles are out of scope. +func atRenderHeaderCell(col AutoTableColumn, displayIdx int, cfg *atConfig, headerColor, headerPadding string) *vdom.VNode { + pos := col.DisplayPosition + posCls := POS_CLS[pos] + + thCls := cx(headerPadding, headerColor, col.WidthClass, posCls) + if cfg.headerBorderY && displayIdx > 0 { + thCls = cx(thCls, "border-l border-l-neutral-300") + } + if col.Sortable { + thCls = cx(thCls, "cursor-pointer", atHeaderSortHoverCls[cfg.color]) + } + thCls = cx(thCls, col.HeaderClasses) + + mods := []vdom.Mod{vdom.Attr("class", thCls)} + if col.Sortable { + sortID := col.SortIdentifier + if sortID != "" && cfg.onSort != nil { + mods = append(mods, vdom.On(vdom.EVENT_CLICK, func() { cfg.onSort(sortID) })) + } + } + + // Inner content: label (grows) + sort caret. + inner := vdom.El("div", vdom.Attr("class", cx(HEADER_INNER_BASE, HEADER_INNER_POS[pos])), + vdom.El("div", vdom.Attr("class", cx("grow text-sm", HEADER_TEXT_CLS[cfg.color])), vdom.Text(col.DisplayName)), + ) + if col.Sortable { + iconWrap := vdom.El("div", vdom.Attr("class", cx(atSortIconWrap, "w-4 text-center", atHeaderSortIconCls[cfg.color]))) + if col.SortIdentifier != "" && cfg.sortIdentifier == col.SortIdentifier { + // caret-up/caret-down are not in the default icon registry, so they + // render as empty boxes until an app registers them (see Icons.go). + if cfg.sortDescending { + iconWrap.Children = append(iconWrap.Children, Icon("caret-down", 16, "")) + } else { + iconWrap.Children = append(iconWrap.Children, Icon("caret-up", 16, "")) + } + } + inner.Children = append(inner.Children, iconWrap) + } + + content := vdom.El("div", vdom.Attr("class", HEADER_CONTENT), inner) + mods = append(mods, content) + return vdom.El("th", mods...) +} + +// atRenderBody builds the with its loading / error / empty / data states. +func atRenderBody(cols []AutoTableColumn, rows []any, cfg *atConfig) *vdom.VNode { + bodyCls := BODY_PADDING_CLS[cfg.size] + if cfg.borderY { + bodyCls = cx(bodyCls, "[&_td+td]:border-l [&_td+td]:border-neutral-300") + } + + colspan := len(cols) + if colspan == 0 { + colspan = 1 + } + + tbody := vdom.El("tbody", vdom.Attr("class", bodyCls)) + + switch { + case cfg.loading: + // NOTE: the TSX randomizes each skeleton's width; a fixed width is used + // here (no measurement/randomness in the neutral runtime). + for r := 0; r < 5; r++ { + tr := vdom.El("tr") + if cfg.alternate && r%2 == 1 { + tr.Attrs["class"] = "bg-neutral-100" + } + for range cols { + tr.Children = append(tr.Children, vdom.El("td", + vdom.El("div", vdom.Attr("class", atSkeleton), vdom.Attr("style", "width: 70%")), + )) + } + tbody.Children = append(tbody.Children, tr) + } + case cfg.errorMsg != "": + tbody.Children = append(tbody.Children, vdom.El("tr", + vdom.El("td", vdom.Attr("colspan", strconv.Itoa(colspan)), vdom.Attr("class", atErrorCell), + vdom.Text("Error: "+cfg.errorMsg)), + )) + case len(cols) == 0: + tbody.Children = append(tbody.Children, vdom.El("tr", + vdom.El("td", vdom.Attr("colspan", strconv.Itoa(colspan)), vdom.Attr("class", atEmptyCell), + vdom.Text("No columns selected.")), + )) + case len(rows) == 0: + tbody.Children = append(tbody.Children, vdom.El("tr", + vdom.El("td", vdom.Attr("colspan", strconv.Itoa(colspan)), + vdom.Text(pick(cfg.emptyMessage, "No entries found."))), + )) + default: + hoverCls := "" + if cfg.hover { + hoverCls = atRowHoverCls[cfg.color] + } + for rowIdx, row := range rows { + isLast := rowIdx == len(rows)-1 + rowCls := "" + if cfg.alternate && rowIdx%2 == 1 { + rowCls = cx(rowCls, "bg-neutral-100") + } + rowCls = cx(rowCls, hoverCls) + if cfg.borderX && !isLast { + rowCls = cx(rowCls, "border-b border-neutral-300") + } + + tr := vdom.El("tr") + if rowCls != "" { + tr.Attrs["class"] = rowCls + } + for _, col := range cols { + tr.Children = append(tr.Children, atRenderCell(col, row)) + } + tbody.Children = append(tbody.Children, tr) + } + } + return tbody +} + +// atRenderCell renders a column's cell for a row. The cell func returns the full +// ; if nil, an empty aligned is produced. +func atRenderCell(col AutoTableColumn, row any) *vdom.VNode { + if col.Cell != nil { + if td := col.Cell(row); td != nil { + return td + } + } + return vdom.El("td", vdom.Attr("class", POS_CLS[col.DisplayPosition])) +} + +// atRenderPagination builds the display-only pagination bar, or nil when there is +// nothing to show. Page/items-per-page changes are surfaced via callbacks. +func atRenderPagination(cfg *atConfig) *vdom.VNode { + if cfg.pagination == nil || cfg.hidePagination { + return nil + } + p := cfg.pagination + + info := vdom.El("div", vdom.Attr("class", atPaginationInfo), + vdom.El("b", vdom.Attr("class", "leading-none"), Icon("list-ol", 16, "")), + vdom.El("span", vdom.Attr("class", "ml-3"), + vdom.Text(strconv.Itoa(p.ViewRangeLower)+"-"+strconv.Itoa(p.ViewRangeUpper)+" of "+strconv.Itoa(p.TotalItems))), + ) + + // NOTE: the TSX uses the ported FormSelect; a bare . onChange commits the parsed value on the native change event. +// +// NOTE: the TSX FormInput tracks an editing/draft state machine across +// focus/input/blur. Without persistent local signals that collapses to a single +// onchange commit; the input shows the formatted external value otherwise. +func datepickerInput(value, placeholder string, small bool, extra string, onChange func(string)) *vdom.VNode { + mods := []vdom.Mod{ + vdom.Attr("type", "text"), + vdom.Attr("class", datepickerInputCls(small, extra)), + vdom.Attr("placeholder", placeholder), + vdom.Prop("value", value), + } + if onChange != nil { + mods = append(mods, vdom.OnEvent(vdom.EVENT_CHANGE, func(e vdom.Event) { + onChange(datepickerParseInput(e.Value())) + })) + } + return vdom.El("div", vdom.Attr("class", "ui-form"), vdom.El("input", mods...)) +} + +func datepickerIconButton(onClick func()) *vdom.VNode { + return vdom.El("button", + vdom.Attr("type", "button"), + vdom.Attr("class", datePickerIconBtn), + vdom.Attr("aria-label", "Open calendar"), + vdom.On(vdom.EVENT_CLICK, onClick), + Icon("calendar", 16, "block leading-none"), + ) +} + +// DatePickerProps configures DatePicker and DateOfBirthPicker. It is controlled: +// Value is the "YYYY-MM-DD" selection, Open is the dropdown state, ViewMonth is +// the calendar's visible month — each paired with a callback. +// +// NOTE: the TSX owns open/editing/localValue/dropdownPos in internal signals and +// closes the dropdown via a document click listener. Outside-click handling, +// stopPropagation and computed positioning aren't representable in the neutral +// runtime, so open/selection/month are lifted to props + callbacks. +type DatePickerProps struct { + Value string // selected date "YYYY-MM-DD" + OnChange func(value string) // new selection (or "" when cleared) + Placeholder string // input placeholder + Small bool // compact input height + Clearable bool // show a clear (x) button when a value is set + Open bool // dropdown open state (controlled) + OnToggle func(open bool) // request to open/close the dropdown + ViewMonth time.Time // visible month of the dropdown calendar + OnNavigate func(month time.Time) // prev/next month requested in the dropdown +} + +// DatePicker renders a text field with a calendar dropdown (picker layout). +func DatePicker(p DatePickerProps) *vdom.VNode { + hasValue := p.Value != "" + + extra := "w-full pr-9" + if p.Clearable && hasValue { + extra = "w-full pr-14" + } + + fieldMods := []vdom.Mod{ + vdom.Attr("class", datePickerField), + vdom.On(vdom.EVENT_CLICK, func() { + if p.OnToggle != nil { + p.OnToggle(true) + } + }), + datepickerInput(datepickerFormatDisplay(p.Value), pick(p.Placeholder, "Select date"), p.Small, extra, p.OnChange), + } + if p.Clearable && hasValue { + fieldMods = append(fieldMods, vdom.El("button", + vdom.Attr("type", "button"), + vdom.Attr("class", datePickerClearBtn), + vdom.Attr("aria-label", "Clear date"), + vdom.On(vdom.EVENT_CLICK, func() { + if p.OnChange != nil { + p.OnChange("") + } + if p.OnToggle != nil { + p.OnToggle(false) + } + }), + Icon("xmark", 14, "block leading-none"), + )) + } + fieldMods = append(fieldMods, datepickerIconButton(func() { + if p.OnToggle != nil { + p.OnToggle(!p.Open) + } + })) + + wrapMods := []vdom.Mod{ + vdom.Attr("class", datePickerWrap), + vdom.El("div", fieldMods...), + } + + if p.Open { + cal := Calendar(CalendarProps{ + Selected: p.Value, + ViewMonth: p.ViewMonth, + Variant: CalendarVariantPicker, + OnSelect: func(key string) { + if p.OnChange != nil { + p.OnChange(key) + } + if p.OnToggle != nil { + p.OnToggle(false) + } + }, + OnNavigate: p.OnNavigate, + }) + wrapMods = append(wrapMods, vdom.El("div", + vdom.Attr("class", cx(datePickerDropdown, datePickerDropdownPos)), + cal, + )) + } + + return vdom.El("div", wrapMods...) +} + +// datepickerDOBCalendar renders the date-of-birth calendar: the picker grid with +// month/year attributes/handlers (identity, +// value, placeholder, password-manager opt-out, sizing hints, events). +func formCommonInputMods(p FormInputProps) []vdom.Mod { + mods := []vdom.Mod{ + vdom.Attr("type", pick(p.Type, "text")), + vdom.Prop("value", p.Value), + } + if p.Placeholder != "" { + mods = append(mods, vdom.Attr("placeholder", p.Placeholder)) + } + ac := p.Autocomplete + if ac == "" && p.PasswordManagerIgnore { + ac = "off" + } + if ac != "" { + mods = append(mods, vdom.Attr("autocomplete", ac)) + } + if p.InputMode != "" { + mods = append(mods, vdom.Attr("inputmode", p.InputMode)) + } + if p.PasswordManagerIgnore { + mods = append(mods, + vdom.Attr("data-1p-ignore", "true"), + vdom.Attr("data-lpignore", "other"), + vdom.Attr("data-form-type", "true"), + vdom.Attr("data-bwignore", "true"), + vdom.Attr("data-protonpass-ignore", "true"), + ) + } + if p.Disabled { + mods = append(mods, vdom.Attr("disabled", "disabled")) + } + if p.MaxLength != "" { + mods = append(mods, vdom.Attr("maxlength", p.MaxLength)) + } + if p.Min != "" { + mods = append(mods, vdom.Attr("min", p.Min)) + } + if p.Max != "" { + mods = append(mods, vdom.Attr("max", p.Max)) + } + if p.Step != "" { + mods = append(mods, vdom.Attr("step", p.Step)) + } + if p.Name != "" { + mods = append(mods, vdom.Attr("name", p.Name)) + } + if p.ID != "" { + mods = append(mods, vdom.Attr("id", p.ID)) + } + mods = append(mods, formEventMods(p)...) + return mods +} + +// -- FormInput / FormInputGroup ------------------------------------------------ + +// FormInput renders a single-line text wrapped in .ui-form, followed by +// error/success message spans. +func FormInput(p FormInputProps) *vdom.VNode { + class := formInputCls(p.Small, p.Error, p.Success, p.Class, p.OnDark) + inputMods := append([]vdom.Mod{vdom.Attr("class", class)}, formCommonInputMods(p)...) + inner := []vdom.Mod{vdom.Attr("class", "ui-form"), vdom.El("input", inputMods...)} + inner = append(inner, formValidationSpans(p.Error, p.Success)...) + return vdom.El("div", inner...) +} + +// FormInputGroup is a FormInput with a leading prefix cell (e.g. a "$" or an +// icon). The input's left corners are squared to butt against the prefix. +func FormInputGroup(p FormInputProps, prefix *vdom.VNode) *vdom.VNode { + pfxCls := formPrefixCls(p.Small, p.Error, p.OnDark) + inputCls := formInputCls(p.Small, p.Error, p.Success, cx("rounded-l-none", p.Class), p.OnDark) + inputMods := append([]vdom.Mod{vdom.Attr("class", inputCls)}, formCommonInputMods(p)...) + + pfxMods := []vdom.Mod{vdom.Attr("class", pfxCls)} + if prefix != nil { + pfxMods = append(pfxMods, prefix) + } + group := vdom.El("div", vdom.Attr("class", formInputGroupCls), + vdom.El("span", pfxMods...), + vdom.El("span", vdom.Attr("class", "grow flex"), vdom.El("input", inputMods...)), + ) + inner := []vdom.Mod{vdom.Attr("class", "ui-form"), group} + inner = append(inner, formValidationSpans(p.Error, p.Success)...) + return vdom.El("div", inner...) +} + +// -- specialized single-line inputs -------------------------------------------- +// +// NOTE: the TSX masks each keystroke by mutating input.value in the oninput +// handler. Here the cleaning runs on the emitted value: OnInput receives the +// sanitized string, so a controlled caller (Value <- signal <- OnInput) shows +// the masked value on the next render — the standard controlled-input flow. + +// FormNumberInput restricts input to digits (+ optional "." and leading "-"). +func FormNumberInput(p FormInputProps, integer, unsigned bool) *vdom.VNode { + user := p.OnInput + p.Type = "text" + p.InputMode = "decimal" + p.OnInput = func(v string) { + clean := formCleanNumber(v, integer, unsigned) + if user != nil { + user(clean) + } + } + return FormInput(p) +} + +// FormCurrencyInput accepts a decimal amount (max 2 fraction digits). Unless +// hideSymbol is set it is prefixed with "$". +func FormCurrencyInput(p FormInputProps, hideSymbol bool) *vdom.VNode { + user := p.OnInput + p.Type = "text" + p.InputMode = "decimal" + p.OnInput = func(v string) { + clean := formCleanCurrency(v) + if user != nil { + user(clean) + } + } + if hideSymbol { + return FormInput(p) + } + return FormInputGroup(p, vdom.Text("$")) +} + +// FormPercentInput accepts a decimal value, prefixed with "%". +func FormPercentInput(p FormInputProps) *vdom.VNode { + user := p.OnInput + p.Type = "text" + p.InputMode = "decimal" + p.OnInput = func(v string) { + clean := formCleanPercent(v) + if user != nil { + user(clean) + } + } + return FormInputGroup(p, vdom.Text("%")) +} + +// FormPhoneInput formats a US phone number as "(xxx) xxx-xxxx". +func FormPhoneInput(p FormInputProps) *vdom.VNode { + user := p.OnInput + p.Type = "text" + p.InputMode = "numeric" + if p.Placeholder == "" { + p.Placeholder = "(555) 555-5555" + } + p.OnInput = func(v string) { + clean := formCleanPhone(v) + if user != nil { + user(clean) + } + } + return FormInput(p) +} + +// FormPhoneInputWithIcon is FormPhoneInput with a leading icon (default "phone"). +func FormPhoneInputWithIcon(icon string, p FormInputProps) *vdom.VNode { + user := p.OnInput + p.Type = "text" + p.InputMode = "numeric" + if p.Placeholder == "" { + p.Placeholder = "(555) 555-5555" + } + p.OnInput = func(v string) { + clean := formCleanPhone(v) + if user != nil { + user(clean) + } + } + // NOTE: "phone" is not in the default icon registry; it renders an empty box + // until the app registers it (see webui.RegisterIcon). + return FormInputGroup(p, IconInline(pick(icon, "phone"), 16, "")) +} + +// FormEmailInput is a type=email input; showIcon prefixes an envelope icon. +func FormEmailInput(p FormInputProps, showIcon bool) *vdom.VNode { + p.Type = "email" + p.InputMode = "email" + if !showIcon { + return FormInput(p) + } + // NOTE: "envelope" is not in the default icon registry (renders empty box). + return FormInputGroup(p, IconInline("envelope", 16, "")) +} + +// FormURLInput is a URL input; showIcon prefixes a globe icon. +func FormURLInput(p FormInputProps, showIcon bool) *vdom.VNode { + p.Type = "text" + p.InputMode = "url" + if !showIcon { + return FormInput(p) + } + // NOTE: "globe" is not in the default icon registry (renders empty box). + return FormInputGroup(p, IconInline("globe", 16, "")) +} + +// FormZipCodeInput formats a US ZIP as "12345" or "12345-6789". +func FormZipCodeInput(p FormInputProps) *vdom.VNode { + user := p.OnInput + p.Type = "text" + p.InputMode = "numeric" + if p.Placeholder == "" { + p.Placeholder = "12345" + } + p.OnInput = func(v string) { + clean := formCleanZip(v) + if user != nil { + user(clean) + } + } + return FormInput(p) +} + +// FormCarNumberInput accepts up to 2 leading digits then up to 2 letters (e.g. "28A"). +func FormCarNumberInput(p FormInputProps) *vdom.VNode { + user := p.OnInput + p.Type = "text" + p.MaxLength = "4" + if p.Placeholder == "" { + p.Placeholder = "e.g. 28" + } + p.OnInput = func(v string) { + clean := formCleanCarNumber(v) + if user != nil { + user(clean) + } + } + return FormInput(p) +} + +// -- input-masking helpers (ported from the TSX oninput handlers) -------------- + +func formKeepChars(s string, keep func(r rune) bool) string { + var b strings.Builder + for _, r := range s { + if keep(r) { + b.WriteRune(r) + } + } + return b.String() +} + +func formDigits(s string) string { + return formKeepChars(s, func(r rune) bool { return r >= '0' && r <= '9' }) +} + +// formSlice is a bounds-safe s[a:b] (JS .slice/.substring semantics). +func formSlice(s string, a, b int) string { + if a < 0 { + a = 0 + } + if b > len(s) { + b = len(s) + } + if a > b { + a = b + } + return s[a:b] +} + +func formCleanNumber(v string, integer, unsigned bool) string { + v = formKeepChars(v, func(r rune) bool { + if r >= '0' && r <= '9' { + return true + } + if !integer && r == '.' { + return true + } + if !unsigned && r == '-' { + return true + } + return false + }) + if !unsigned { + neg := strings.HasPrefix(v, "-") + v = strings.ReplaceAll(v, "-", "") + if neg { + v = "-" + v + } + } + if !integer { + parts := strings.Split(v, ".") + if len(parts) > 2 { + v = parts[0] + "." + strings.Join(parts[1:], "") + } + } + return v +} + +func formCleanCurrency(v string) string { + v = formKeepChars(v, func(r rune) bool { return (r >= '0' && r <= '9') || r == '.' }) + parts := strings.Split(v, ".") + if len(parts) > 2 { + v = parts[0] + "." + strings.Join(parts[1:], "") + } + if len(parts) == 2 && len(parts[1]) > 2 { + v = parts[0] + "." + parts[1][:2] + } + return v +} + +func formCleanPercent(v string) string { + v = formKeepChars(v, func(r rune) bool { return (r >= '0' && r <= '9') || r == '.' }) + parts := strings.Split(v, ".") + if len(parts) > 2 { + v = parts[0] + "." + strings.Join(parts[1:], "") + } + return v +} + +func formCleanPhone(v string) string { + digits := formDigits(v) + if len(digits) > 10 { + digits = digits[:10] + } + out := "" + if len(digits) > 0 { + out = "(" + formSlice(digits, 0, 3) + } + if len(digits) > 3 { + out += ") " + formSlice(digits, 3, 6) + } + if len(digits) > 6 { + out += "-" + formSlice(digits, 6, 10) + } + return out +} + +func formCleanZip(v string) string { + d := formSlice(formDigits(v), 0, 9) + if len(d) <= 5 { + return d + } + return d[:5] + "-" + d[5:] +} + +func formCleanCarNumber(v string) string { + raw := formKeepChars(strings.ToUpper(v), func(r rune) bool { + return (r >= '0' && r <= '9') || (r >= 'A' && r <= 'Z') + }) + i := 0 + for i < len(raw) && i < 2 && raw[i] >= '0' && raw[i] <= '9' { + i++ + } + j := i + for j < len(raw) && j < i+2 && raw[j] >= 'A' && raw[j] <= 'Z' { + j++ + } + return raw[:i] + raw[i:j] +} + +// -- FormTextarea -------------------------------------------------------------- + +// FormTextareaProps configures FormTextarea. +type FormTextareaProps struct { + Value string + Placeholder string + Rows string + Name string + ID string + Disabled bool + Small bool + OnDark bool + Error string + Class string + OnInput func(string) + OnChange func(string) + OnBlur func() +} + +// FormTextarea renders a multi-line