diff --git a/CLAUDE.md b/CLAUDE.md
index 4fd6b132..a0b19e5f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -70,9 +70,21 @@ GOOS=js GOARCH=wasm go -C go test -exec="node testdata/domexec.js" ./wasmruntime
```
`webui` components that measure the page (Tooltip, Popover, Menu, Modal, DatePicker, Tutorial,
-AutoTable) are **controllers**: create them once alongside your signals, never inside a render
-closure. Floating panels share one positioning engine (`webui/position.go`, pure math, unit-tested
-natively) driven by the `Floating` controller (`webui/floating.go`).
+AutoTable, SignaturePad, AsyncCombobox) are **controllers**: create them once alongside your
+signals, never inside a render closure. Floating panels share one positioning engine
+(`webui/position.go`, pure math, unit-tested natively) driven by the `Floating` controller
+(`webui/floating.go`).
+
+**Theming / dark mode.** The kit is themed by **semantic tokens**, not by a `dark:` variant on
+every class: components say `bg-surface` / `border-line` / `text-ink` / `text-accent` and never
+name a colour, so a theme is ten CSS variables rather than four hundred class strings. The app
+must define them (see `webui.ThemeTokens` for the required set, and the example's `css/app.css`
+for a working pair) plus `@custom-variant dark (&:where(.dark, .dark *));` — the built-in `dark`
+variant is a `prefers-color-scheme` media query, which a site with its own switch cannot use.
+Only genuinely *coloured* things (an alert's red tint) carry `dark:` variants. `webui.Theme` is
+the controller (`Toggle`, `ThemeToggle`, `Init`); `webui.ThemeBootScript` goes in the document
+head **before** the stylesheet, or dark-mode users get a white flash until the wasm loads. It is
+the only JavaScript in a gowasm app.
### web/
diff --git a/go/cmd/examples/go-wasm-web/app/chart.go b/go/cmd/examples/go-wasm-web/app/chart.go
index 95e1e4fa..48985fca 100644
--- a/go/cmd/examples/go-wasm-web/app/chart.go
+++ b/go/cmd/examples/go-wasm-web/app/chart.go
@@ -63,19 +63,74 @@ func pieSVG(values []int) string {
//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.")),
+
+ return docPage("Rendering", "SSR & hydration",
+ "A static route is rendered to HTML by the server, so the page is complete before any "+
+ "WebAssembly has downloaded. The same component then runs in the browser, adopts the markup "+
+ "that is already there, and takes over. One function, two runtimes.",
+
+ docSection("the-directive", "Marking a route static",
+ prose("static on the page directive is what puts a route in the server's pre-render set. Leave "+
+ "it off and the route renders on the client only — which is the right choice when the page "+
+ "is behind a login, or its content depends on something only the browser knows."),
+ code("app/chart.go", chartSnippet),
+ note("Hydration adopts, it does not rebuild",
+ "The client renders the same tree the server did and walks the existing DOM alongside it, "+
+ "wiring event handlers to the nodes that are already on the page. If the two trees "+
+ "disagree, the CLIENT wins — a stale server binary should not be able to pin a wrong "+
+ "class onto the page forever."),
),
- 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))),
+
+ docSection("charts", "A worked example: charts",
+ prose("These charts are SVG produced by go-chart — a plain Go library that knows nothing about "+
+ "the browser. The server draws them and ships the markup inline; there is no chart "+
+ "JavaScript, and no canvas that has to wait for the client to boot before it shows anything."),
+ prose("Shuffle re-runs the same drawing code in the browser. The first render came from the "+
+ "server and the next one comes from WebAssembly, and the page cannot tell the difference."),
+
+ Div(Attr("class", "mt-4"),
+ ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Icon: "chart-column", Text: "Shuffle data",
+ OnClick: func() { data.Set(randomValues()) }}),
+ ),
+ Div(Attr("class", "mt-4 grid gap-4 lg:grid-cols-12"),
+ Div(Attr("class", "lg:col-span-7 rounded-default border border-line bg-surface p-3 shadow-xs overflow-auto"), Raw(barSVG(values))),
+ Div(Attr("class", "lg:col-span-5 rounded-default border border-line bg-surface p-3 shadow-xs overflow-auto"), Raw(pieSVG(values))),
+ ),
+
+ note("go-chart lives in the EXAMPLE, not in kjol",
+ "The engine is standard-library-only. This example is its own Go module precisely so a "+
+ "charting dependency it happens to want does not become a dependency of everyone who "+
+ "uses the framework."),
+ ),
+
+ docSection("api", "Reference",
+ apiTable(
+ apiRow{"//gowasm:page /path static", "Pre-render this route on the server, then hydrate it."},
+ apiRow{"vdom.RenderHTML", "Render a tree to an HTML string. This is what the server calls."},
+ apiRow{"wasmruntime.Hydrate", "Adopt server-rendered DOM instead of building it. The client's entry point for a static route."},
+ apiRow{"vdom.Raw", "Insert markup verbatim — how the server-drawn SVG gets in. The reconciler clears it correctly when the element is reused."},
+ ),
),
)
}
}
+
+const chartSnippet = `//gowasm:page /chart static layout=app
+func ChartPage(d Deps) func() *VNode {
+ data := NewSignal(fixedChartData())
+
+ return func() *VNode {
+ // go-chart draws an SVG string — on the server for the first paint,
+ // and in the browser for every render after that.
+ return Div(
+ ui.Button(ui.ButtonProps{
+ Text: "Shuffle data",
+ OnClick: func() { data.Set(randomValues()) },
+ }),
+ Div(Raw(barSVG(data.Get()))),
+ )
+ }
+}`
diff --git a/go/cmd/examples/go-wasm-web/app/data.go b/go/cmd/examples/go-wasm-web/app/data.go
index 45fa2311..4f2eac8c 100644
--- a/go/cmd/examples/go-wasm-web/app/data.go
+++ b/go/cmd/examples/go-wasm-web/app/data.go
@@ -74,43 +74,78 @@ func DataPage(d Deps) func() *VNode {
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.")),
- ),
+ return docPage("Rendering", "Data fetching",
+ "Fetching happens in the browser, so a server-rendered page ships its LOADING state and the "+
+ "client fills it in. Two shapes are shown here: gob against your own server, where the same "+
+ "Go type crosses the wire untranslated, and JSON against somebody else's API.",
- 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()) }}),
+ docSection("gob", "gob — the same Go type on both ends",
+ prose("Your server already speaks Go and so does your client, so there is no reason to translate "+
+ "through JSON in between. The handler answers with httputil.RespondGob([]Quote) and the "+
+ "client decodes straight back into []Quote — one type, declared once, with no tags and no "+
+ "hand-written unmarshalling to drift out of sync with it."),
+ code("app/data.go + server/main.go", gobSnippet),
+ demo("GET /api/quotes, decoded into []Quote",
+ quotesBody(qLoading.Get(), qErr.Get(), quotes.Get()),
+ ),
+ ),
+
+ docSection("json", "JSON — for everyone else's API",
+ prose("A third-party API does not speak gob, so httputil.FetchJSON decodes into a tagged struct "+
+ "the ordinary way. Enter a repository and the browser calls api.github.com directly."),
+ demo("GET api.github.com/repos/…, decoded into a tagged struct",
+ 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()),
+ ),
+ ),
+
+ docSection("ssr", "What the server renders",
+ prose("This route is static, so the server pre-renders it — but there is no fetch on the server: "+
+ "no transport is installed there, and inventing one would mean the server quietly making "+
+ "requests on the user's behalf. So a fetch started during SSR does nothing at all, the page "+
+ "renders its spinner, and the client runs the fetch for real once it has hydrated."),
+ note("A fetch that fails on the server is a bug in the framework, not in your page",
+ "An earlier version of this returned an error from SSR, and every static page that fetched "+
+ "anything rendered \"no client transport installed\" into its own HTML. Loading is the "+
+ "correct server-side answer to \"have you fetched this yet?\"."),
+ apiTable(
+ apiRow{"httputil.RespondGob", "Server: write a Go value as gob."},
+ apiRow{"httputil.FetchGob", "Client: decode a gob response into a Go value."},
+ apiRow{"httputil.FetchJSON", "Client: decode a JSON response into a tagged struct."},
+ apiRow{"httputil.SetClientTransport", "Override the transport — a base URL, auth headers. The runtime installs a fetch-based one for you."},
),
- repoBody(rLoading.Get(), rErr.Get(), repo.Get()),
),
)
}
}
+const gobSnippet = `// One type. Both ends. No tags, no JSON.
+type Quote struct {
+ Author string
+ Text string
+}
+
+// --- server ---
+mux.HandleFunc("GET /api/quotes", func(w http.ResponseWriter, r *http.Request) {
+ httputil.RespondGob(w, http.StatusOK, sampleQuotes()) // []Quote
+})
+
+// --- client ---
+httputil.FetchGob("/api/quotes", func(qs []Quote, err error) {
+ if err != nil { qErr.Set(err.Error()); return }
+ quotes.Set(qs) // []Quote
+})`
+
func quotesBody(loading bool, failed string, quotes []Quote) *VNode {
switch {
case failed != "":
@@ -121,8 +156,8 @@ func quotesBody(loading bool, failed string, quotes []Quote) *VNode {
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)),
+ P(Attr("class", "text-ink"), Text("“"+q.Text+"”")),
+ P(Attr("class", "mt-2 text-sm text-ink-muted"), Text("— "+q.Author)),
))
}
return row("grid gap-3 sm:grid-cols-2", cards...)
@@ -138,10 +173,10 @@ func repoBody(loading bool, failed string, r repoInfo) *VNode {
default:
return ui.BorderCard("",
row("flex items-center gap-2",
- Strong(Attr("class", "text-neutral-800"), Text(r.FullName)),
+ Strong(Attr("class", "text-ink"), 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)),
+ P(Attr("class", "mt-2 text-sm text-ink-soft"), Text(r.Description)),
)
}
}
diff --git a/go/cmd/examples/go-wasm-web/app/docs.go b/go/cmd/examples/go-wasm-web/app/docs.go
new file mode 100644
index 00000000..5a51f787
--- /dev/null
+++ b/go/cmd/examples/go-wasm-web/app/docs.go
@@ -0,0 +1,276 @@
+package app
+
+import (
+ . "kjol/vdom"
+ ui "kjol/webui"
+)
+
+// Documentation chrome.
+//
+// The app routes are the framework's documentation, so they are built from one small
+// vocabulary rather than each page inventing its own headings and spacing: a page has a
+// title and a lede, then sections; a section explains something in prose, shows the Go
+// that does it, and then RUNS that Go on the page you are reading. The last part is the
+// point — a docs page for a UI framework that only shows screenshots of its components
+// is a docs page that cannot tell you when it has gone stale.
+
+// docsNav is the sidebar: the sections of the documentation, in reading order.
+//
+// It is data, not markup, because it is consumed twice — once by the sidebar and once
+// by the /docs index, which lists the same pages as cards. Two hand-written copies of a
+// nav is two copies to forget to update.
+type docsGroup struct {
+ Title string
+ Items []docsItem
+}
+
+type docsItem struct {
+ Path string
+ Label string
+ Blurb string // shown on the /docs index; too long for the sidebar
+ Icon string
+}
+
+func docsNav() []docsGroup {
+ return []docsGroup{{
+ Title: "Introduction",
+ Items: []docsItem{
+ {Path: "/docs", Label: "Overview", Icon: "book-open",
+ Blurb: "What Kjol Web is, how a page becomes a WebAssembly binary, and what runs where."},
+ },
+ }, {
+ Title: "Rendering",
+ Items: []docsItem{
+ {Path: "/chart", Label: "SSR & hydration", Icon: "chart-column",
+ Blurb: "The same Go renders HTML on the server and takes over in the browser. Charts, server-drawn as SVG."},
+ {Path: "/server", Label: "Server components", Icon: "server",
+ Blurb: "Components whose state and code stay on the server. Calling one looks like calling any other."},
+ {Path: "/data", Label: "Data fetching", Icon: "cloud-arrow-down",
+ Blurb: "gob to your own server (Go types end to end, no JSON) and JSON to a third-party API."},
+ },
+ }, {
+ Title: "Components",
+ Items: []docsItem{
+ {Path: "/kit", Label: "UI kit", Icon: "squares",
+ Blurb: "Buttons, forms, tabs, alerts, cards — the kjol/webui components, written in Go."},
+ {Path: "/overlays", Label: "Overlays", Icon: "layers",
+ Blurb: "Tooltips, popovers, menus, modals: measured against the real viewport, flipped and shifted to fit."},
+ {Path: "/table", Label: "AutoTable", Icon: "table",
+ Blurb: "Filtering, sorting, column management, calculated columns, CSV and PDF export."},
+ },
+ }}
+}
+
+// ---- page scaffolding ---------------------------------------------------
+
+// docPage is the frame every documentation page shares: an eyebrow, a title, a lede,
+// and then its sections.
+func docPage(eyebrow, title, lede string, sections ...*VNode) *VNode {
+ mods := []Mod{Attr("class", "pb-16")}
+ mods = append(mods,
+ Div(Attr("class", "border-b border-line pb-6"),
+ P(Attr("class", "text-xs font-semibold uppercase tracking-widest text-accent"), Text(eyebrow)),
+ H1(Attr("class", "mt-2 text-3xl font-semibold tracking-tight text-text-heading"), Text(title)),
+ P(Attr("class", "mt-3 max-w-3xl text-ink-muted leading-relaxed"), Text(lede)),
+ ),
+ )
+ for _, s := range sections {
+ mods = append(mods, s)
+ }
+ return Div(mods...)
+}
+
+// docSection is a titled slab of the page. The id is what the "on this page" links and
+// the tour steps anchor to.
+func docSection(id, title string, body ...*VNode) *VNode {
+ mods := []Mod{Attr("id", id), Attr("class", "mt-12 scroll-mt-24")}
+ mods = append(mods,
+ H2(Attr("class", "text-xl font-semibold tracking-tight text-text-heading"), Text(title)),
+ )
+ for _, b := range body {
+ mods = append(mods, b)
+ }
+ return El("section", mods...)
+}
+
+// prose is a paragraph of explanation. Constrained to a reading measure: a line of body
+// text that runs the full width of a wide screen is genuinely harder to read, and the
+// demos beside it are allowed to be as wide as they like.
+func prose(text string) *VNode {
+ return P(Attr("class", "mt-3 max-w-3xl text-ink-soft leading-relaxed"), Text(text))
+}
+
+// ---- code ---------------------------------------------------------------
+
+// code is a Go snippet, captioned with where it comes from.
+//
+// The caption is a real file path in this example, not a decoration: every snippet on
+// these pages is copied from code that actually runs, and saying where from is what
+// lets you go and check.
+func code(caption, src string) *VNode { return codeLang(caption, "Go", src) }
+
+// codeLang is code() for a block that is not Go — a shell session, a formula. The label
+// in the corner says what you are looking at, and a shell command labelled "Go" is worse
+// than no label at all.
+//
+// Go blocks are syntax-highlighted (webui.HighlightGo); the others are shown verbatim.
+// A shell transcript put through a Go lexer comes out with `serving` painted as an
+// identifier and quotes as string literals — highlighting the wrong language is more
+// distracting than not highlighting at all.
+func codeLang(caption, lang, src string) *VNode {
+ var body *VNode
+ if lang == "Go" {
+ // Raw, not Text: HighlightGo returns HTML. It escapes every run of source on the
+ // way out, so the snippets that contain markup stay inert.
+ body = El("code", Raw(ui.HighlightGo(src)))
+ } else {
+ body = El("code", Text(src))
+ }
+
+ return Div(Attr("class", "mt-4 overflow-hidden rounded-default border border-neutral-800 bg-neutral-900"),
+ Div(Attr("class", "flex items-center gap-2 border-b border-neutral-800 px-4 py-2"),
+ Span(Attr("class", "text-xs font-medium text-ink-faint font-mono"), Text(caption)),
+ Span(Attr("class", "ml-auto rounded-full bg-white/5 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-ink-faint"), Text(lang)),
+ ),
+ Pre(Attr("class", "overflow-x-auto px-4 py-3 text-[13px] leading-relaxed text-neutral-100 font-mono"), body),
+ )
+}
+
+// ---- demos --------------------------------------------------------------
+
+// demo is the panel a section's example sits in, captioned with what it is showing.
+func demo(title string, body ...*VNode) *VNode {
+ mods := []Mod{Attr("class", "mt-4 rounded-default border border-line bg-surface shadow-xs")}
+ mods = append(mods,
+ Div(Attr("class", "border-b border-line px-4 py-2"),
+ Span(Attr("class", "text-xs text-ink-muted"), Text(title)),
+ ),
+ )
+ inner := []Mod{Attr("class", "p-4")}
+ for _, b := range body {
+ inner = append(inner, b)
+ }
+ mods = append(mods, Div(inner...))
+ return Div(mods...)
+}
+
+// note is an aside — a caveat, a gotcha, the reason something is the way it is.
+func note(title, body string) *VNode {
+ return Div(Attr("class", "mt-4 max-w-3xl rounded-default border border-primary-border bg-primary-subtle px-4 py-3"),
+ P(Attr("class", "text-sm font-semibold text-text-heading"), Text(title)),
+ P(Attr("class", "mt-1 text-sm text-ink-soft leading-relaxed"), Text(body)),
+ )
+}
+
+// ---- reference tables ---------------------------------------------------
+
+type apiRow struct{ Name, Desc string }
+
+// apiTable is the reference half of a page: the names, and what each one does.
+func apiTable(rows ...apiRow) *VNode {
+ body := make([]*VNode, 0, len(rows))
+ for _, r := range rows {
+ body = append(body, El("tr", Attr("class", "border-t border-line"),
+ El("td", Attr("class", "py-2 pr-4 align-top whitespace-nowrap"),
+ El("code", Attr("class", "rounded bg-surface-raised px-1.5 py-0.5 text-[13px] font-mono text-ink"), Text(r.Name))),
+ El("td", Attr("class", "py-2 text-sm text-ink-soft leading-relaxed"), Text(r.Desc)),
+ ))
+ }
+ rowMods := []Mod{}
+ for _, b := range body {
+ rowMods = append(rowMods, b)
+ }
+ return Div(Attr("class", "mt-4 max-w-5xl overflow-x-auto"),
+ El("table", Attr("class", "w-full border-collapse text-left"),
+ Tbody(rowMods...),
+ ),
+ )
+}
+
+// ---- the docs index -----------------------------------------------------
+
+//gowasm:page /docs static layout=app
+func DocsPage(d Deps) func() *VNode {
+ return func() *VNode {
+ var groups []*VNode
+ for _, g := range docsNav() {
+ grid := []Mod{Attr("class", "mt-3 grid gap-3 sm:grid-cols-2")}
+ for _, it := range g.Items {
+ if it.Path == "/docs" {
+ continue // don't list this page on itself
+ }
+ grid = append(grid, docsCard(d, it))
+ }
+ if len(grid) == 1 {
+ continue // the group held nothing but this page
+ }
+ groups = append(groups,
+ Div(Attr("class", "mt-10"),
+ H2(Attr("class", "text-sm font-semibold uppercase tracking-widest text-ink-faint"), Text(g.Title)),
+ Div(grid...),
+ ),
+ )
+ }
+
+ return docPage("Introduction", "Overview",
+ "Kjol Web is kjol's Go→WebAssembly UI engine. You write components as ordinary Go functions "+
+ "returning a virtual DOM; the server renders them to HTML and the same code hydrates them "+
+ "in the browser. There is no JavaScript build step, and the engine depends on nothing "+
+ "outside the standard library.",
+
+ docSection("what-runs-where", "What runs where",
+ prose("A page is Go, compiled twice. On the server it renders to an HTML string, so the first "+
+ "paint needs no WebAssembly at all. In the browser the same functions run again, adopt the "+
+ "markup that is already there, and from then on a signal write re-renders and reconciles into "+
+ "the live DOM."),
+ code("app/pages.go", ssrSnippet),
+ note("The host API is dual-build",
+ "Components measure the DOM — a tooltip has to know where its trigger is. Those calls are "+
+ "real under js/wasm and no-ops natively, which is what lets one component both SSR and "+
+ "position itself, without a branch in the component."),
+ ),
+
+ appendNodes(Div(Attr("class", "mt-14 border-t border-line pt-2")), groups...),
+ )
+ }
+}
+
+func docsCard(d Deps, it docsItem) *VNode {
+ return A(
+ Attr("class", "group block rounded-default border border-line bg-surface p-4 no-underline shadow-xs transition hover:border-primary-border hover:shadow-sm"),
+ Attr("href", it.Path), navigate(d, it.Path),
+ Div(Attr("class", "flex items-center gap-2"),
+ Span(Attr("class", "inline-flex h-7 w-7 items-center justify-center rounded-default bg-primary-subtle text-accent"),
+ ui.IconInline(it.Icon, 14, "")),
+ Span(Attr("class", "font-semibold text-text-heading"), Text(it.Label)),
+ Span(Attr("class", "ml-auto text-ink-faint transition group-hover:text-accent"), ui.IconInline("arrow-right", 12, "")),
+ ),
+ P(Attr("class", "mt-2 text-sm text-ink-muted leading-relaxed"), Text(it.Blurb)),
+ )
+}
+
+// appendNodes adds children to a node after the fact — the shape a few of these pages
+// need, where the section list is computed rather than written out.
+func appendNodes(parent *VNode, children ...*VNode) *VNode {
+ parent.Children = append(parent.Children, children...)
+ return parent
+}
+
+const ssrSnippet = `//gowasm:page /docs static layout=app
+func DocsPage(d Deps) func() *VNode {
+ count := NewSignal(0) // state lives in the closure
+
+ return func() *VNode { // the render: pure, called again on every change
+ return Div(Attr("class", "space-y-2"),
+ H1(Text("Overview")),
+ Button(
+ Attr("class", "btn"),
+ On(EVENT_CLICK, func() { count.Set(count.Get() + 1) }),
+ Text("clicked "+itoa(count.Get())+" times"),
+ ),
+ )
+ }
+}
+
+// static => the server pre-renders this route to HTML.
+// The same function then hydrates it in the browser.`
diff --git a/go/cmd/examples/go-wasm-web/app/icons_test.go b/go/cmd/examples/go-wasm-web/app/icons_test.go
new file mode 100644
index 00000000..361e0bc6
--- /dev/null
+++ b/go/cmd/examples/go-wasm-web/app/icons_test.go
@@ -0,0 +1,82 @@
+package app
+
+import (
+ "os"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strings"
+ "testing"
+
+ ui "kjol/webui"
+)
+
+// Every icon name this app names must actually resolve.
+//
+// An unregistered name renders an empty, correctly-sized box. That is the right thing
+// at runtime — a missing icon should not collapse the layout — but it means a typo is
+// invisible: the icon is simply absent, and nothing says why. Two of them (shapes,
+// layer-group, which the kit calls squares and layers) shipped in the sidebar looking
+// like blank squares before this test existed.
+//
+// It scans the SOURCE rather than a hand-kept list, so an icon added to a page tomorrow
+// is checked tomorrow, without anyone remembering to add it here.
+func TestEveryIconNameResolves(t *testing.T) {
+ // ui.Icon("x", …) / ui.IconInline("x", …), and the Icon: "x" field on the props
+ // structs (buttons, menu items, docs nav).
+ patterns := []*regexp.Regexp{
+ regexp.MustCompile(`Icon(?:Inline)?\("([a-z0-9-]+)"`),
+ regexp.MustCompile(`\bIcon:\s*"([a-z0-9-]+)"`),
+ }
+
+ files, err := filepath.Glob("*.go")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ used := map[string][]string{} // icon name -> files that ask for it
+ for _, f := range files {
+ if strings.HasSuffix(f, "_test.go") {
+ continue
+ }
+ src, err := os.ReadFile(f)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, re := range patterns {
+ for _, m := range re.FindAllStringSubmatch(string(src), -1) {
+ used[m[1]] = append(used[m[1]], f)
+ }
+ }
+ }
+
+ if len(used) == 0 {
+ t.Fatal("scanned the package and found no icon names at all — the patterns have gone stale")
+ }
+
+ names := make([]string, 0, len(used))
+ for n := range used {
+ names = append(names, n)
+ }
+ sort.Strings(names)
+
+ for _, n := range names {
+ if !ui.HasIcon(n) {
+ t.Errorf("icon %q is not registered (used in %s) — it will render as an empty box",
+ n, strings.Join(dedupe(used[n]), ", "))
+ }
+ }
+ t.Logf("checked %d icon names", len(names))
+}
+
+func dedupe(in []string) []string {
+ seen := map[string]bool{}
+ out := in[:0:0]
+ for _, s := range in {
+ if !seen[s] {
+ seen[s] = true
+ out = append(out, s)
+ }
+ }
+ return out
+}
diff --git a/go/cmd/examples/go-wasm-web/app/kit.go b/go/cmd/examples/go-wasm-web/app/kit.go
index 8992c488..2107f587 100644
--- a/go/cmd/examples/go-wasm-web/app/kit.go
+++ b/go/cmd/examples/go-wasm-web/app/kit.go
@@ -7,6 +7,14 @@ import (
ui "kjol/webui"
)
+// orElse is a fallback for an empty string.
+func orElse(s, fallback string) string {
+ if s == "" {
+ return fallback
+ }
+ return s
+}
+
// row is a flex/grid container helper (appends *VNode children as Mods).
func row(class string, children ...*VNode) *VNode {
mods := []Mod{Attr("class", class)}
@@ -16,19 +24,17 @@ func row(class string, children ...*VNode) *VNode {
return Div(mods...)
}
-// kitSection wraps a labeled demo block in a card.
+// kitSection is one labelled block of the gallery — a live demo panel, so that what you
+// are looking at is unmistakably the component running rather than a picture of it.
func kitSection(title string, body ...*VNode) *VNode {
- return ui.Card("",
- ui.CardHeader("", Text(title)),
- row("flex flex-col gap-4", body...),
- )
+ return demo(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)),
+ td("text-ink", Text(name)),
+ td("text-ink-soft", Text(plan)),
El("td", Attr("class", "px-3 py-2 text-sm text-right"), status),
)
}
@@ -67,14 +73,53 @@ func KitPage(d Deps) func() *VNode {
tip := ui.NewHoverTooltip(ui.PlacementTop, "")
skills := ui.NewMultiSelect(ui.DropdownOptions{})
+ // The controls the first port left out, now that the host API can carry them.
+ taxID := NewSignal("")
+ rate := NewSignal("")
+ signed := NewSignal("")
+ picked := NewSignal("")
+ tags := NewSignal([]string{"go"})
+
+ pad := ui.NewSignaturePad(ui.SignaturePadOptions{
+ OnChange: func(svg string) { signed.Set(svg) },
+ })
+ // The search is the caller's: the component knows how to debounce, order and render,
+ // and nothing at all about where options come from. Here it is a local slice; in an
+ // app it would be a fetch.
+ people := ui.NewAsyncCombobox(ui.AsyncComboboxOptions{
+ MinChars: 2,
+ Search: func(q string, done func([]ui.FormSelectOption)) {
+ var out []ui.FormSelectOption
+ for _, row := range employees() {
+ p, ok := row.(Employee)
+ if ok && strings.Contains(strings.ToLower(p.Name), strings.ToLower(q)) {
+ out = append(out, ui.FormSelectOption{Value: p.Email, Label: p.Name})
+ }
+ }
+ done(out)
+ },
+ })
+ tagPicker := ui.NewMultiSelectTrigger(ui.DropdownOptions{})
+
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).")),
+ return docPage("Components", "UI kit",
+ "kjol/webui is the component library: buttons, badges, forms, tabs, alerts, cards, tables. "+
+ "It is a Go port of the Solid.js kit the applications used before, styled with the same "+
+ "Tailwind utilities — so the two can be swapped for one another a screen at a time.",
+
+ docSection("using", "Using a component",
+ prose("Components are functions taking a props struct. There is no class hierarchy and nothing "+
+ "to register: a component is a value, so you can build one, store it, pass it around, and "+
+ "the compiler will tell you when you get it wrong."),
+ code("app/kit.go", kitSnippet),
+ note("Styling is Tailwind, compiled from your Go",
+ "The Tailwind engine scans .go files for class names, because that is where the markup is. "+
+ "There is no JavaScript build in this example at all — the CSS is compiled by a Go "+
+ "program from Go source."),
+ ),
+
+ docSection("gallery", "The gallery",
+ prose("Everything below is running. Click it."),
),
kitSection("Buttons",
@@ -124,9 +169,9 @@ func KitPage(d Deps) func() *VNode {
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)."))},
+ {Title: "Overview", Content: P(Attr("class", "pt-3 text-sm text-ink-soft"), Text("The overview panel."))},
+ {Title: "Details", Content: P(Attr("class", "pt-3 text-sm text-ink-soft"), Text("The details panel."))},
+ {Title: "Activity", Badge: 3, Content: P(Attr("class", "pt-3 text-sm text-ink-soft"), Text("The activity panel (3 new)."))},
},
ActiveIndex: tab.Get(),
OnTabChange: func(i int) { tab.Set(i) },
@@ -135,9 +180,9 @@ func KitPage(d Deps) func() *VNode {
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."))},
+ {Title: "What is Kjol Web?", Content: P(Attr("class", "text-sm text-ink-soft"), Text("kjol's Go→WebAssembly UI engine."))},
+ {Title: "Is it isomorphic?", Content: P(Attr("class", "text-sm text-ink-soft"), 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-ink-soft"), Text("Tailwind utility classes, compiled by kjol's native Tailwind engine."))},
}, acc.Get(), func(i int) { acc.Set(i) }),
),
@@ -166,11 +211,68 @@ func KitPage(d Deps) func() *VNode {
OnChange: func(v []string) { langs.Set(v) },
})),
),
- P(Attr("class", "text-xs text-neutral-500"),
+ P(Attr("class", "text-xs text-ink-muted"),
Text("Live: name=\""+name.Get()+"\" email=\""+email.Get()+"\" plan=\""+plan.Get()+
"\" languages="+strings.Join(langs.Get(), ","))),
),
+ kitSection("Masked inputs",
+ row("grid gap-4 sm:grid-cols-2",
+ row("flex flex-col gap-1", ui.FormLabel(ui.FormLabelProps{}, Text("Tax ID")),
+ // The mask is a pure function of the string, applied on every keystroke.
+ // It must be idempotent — it is fed its own output — or the field
+ // corrupts itself as you type.
+ ui.FormInput(ui.FormInputProps{
+ Value: taxID.Get(),
+ Placeholder: "12-3456789",
+ OnInput: func(v string) { taxID.Set(ui.MaskTaxID(v)) },
+ })),
+ row("flex flex-col gap-1", ui.FormLabel(ui.FormLabelProps{}, Text("Rate")),
+ ui.FormInput(ui.FormInputProps{
+ Value: rate.Get(),
+ Placeholder: "5.25",
+ OnInput: func(v string) { rate.Set(ui.MaskRate(v)) },
+ })),
+ ),
+ P(Attr("class", "text-xs text-ink-muted"),
+ Text("Type letters, extra dots, leading zeros — the mask takes what it can use.")),
+ ),
+
+ kitSection("Async combobox",
+ row("max-w-sm",
+ people.Render(ui.FormAsyncComboboxProps{
+ Placeholder: "Search people…",
+ OnSelect: func(o ui.FormSelectOption) { picked.Set(o.Label + " <" + o.Value + ">") },
+ }),
+ ),
+ P(Attr("class", "text-xs text-ink-muted"),
+ Text("Two characters before it asks; 200 ms after you stop typing. A response for a "+
+ "query you have already typed past is discarded rather than shown. Picked: "+
+ orElse(picked.Get(), "nothing yet"))),
+ ),
+
+ kitSection("Multi-select behind your own trigger",
+ tagPicker.Render(ui.FormMultiSelectTriggerProps{
+ Trigger: ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true,
+ Icon: "filter", Text: "Tags (" + itoa(len(tags.Get())) + ")"}),
+ Options: languageOptions(),
+ Value: tags.Get(),
+ Searchable: true,
+ ShowSelectAll: true,
+ OnChange: func(v []string) { tags.Set(v) },
+ }),
+ P(Attr("class", "text-xs text-ink-muted"),
+ Text("Same selection model as the field above; only the thing you click on differs.")),
+ ),
+
+ kitSection("Signature pad",
+ pad.Render(ui.SignaturePadProps{}),
+ P(Attr("class", "text-xs text-ink-muted"),
+ Text("Draw in it. It is an SVG, not a canvas — so the markup you are looking at IS the "+
+ "value the caller gets ("+itoa(len(signed.Get()))+" bytes), and a stored signature "+
+ "renders on the server.")),
+ ),
+
kitSection("Table",
ui.PrettyTable(
[]ui.PrettyTableColumn{
@@ -214,16 +316,40 @@ func KitPage(d Deps) func() *VNode {
modal.Render(ui.ModalProps{
Header: H3(Attr("class", "text-lg font-semibold text-text-heading"), Text("Example modal")),
},
- P(Attr("class", "text-neutral-600"),
+ P(Attr("class", "text-ink-soft"),
Text("Portaled to document.body, so it is not clipped by any ancestor. Escape closes "+
"the topmost modal; the backdrop click closes too.")),
),
),
- ui.Alert(ui.AlertGreen, "About this page",
- Text("Menus, tooltips, modals and dropdowns are now really measured: they are portaled to "+
- "document.body, positioned from getBoundingClientRect against the viewport, and they "+
- "flip and shift to stay on screen. Resize the window or scroll while one is open.")),
+ docSection("more", "Where to go next",
+ prose("The floating components on this page — the menu, the tooltip, the modal, the "+
+ "multi-select — are the shallow end. Overlays covers how they are positioned, and what "+
+ "happens when one would open off the edge of the screen."),
+ apiTable(
+ apiRow{"ui.Button / ui.Badge / ui.Alert", "The presentational set. Props structs, no state."},
+ apiRow{"ui.FormInput / FormSelect / FormCombobox", "Inputs. Value in, OnChange out — the caller owns the state."},
+ apiRow{"ui.NewMultiSelect", "A controller: checkboxed rows, pills that collapse to \"N items selected\" when they stop fitting."},
+ apiRow{"ui.Tabs / ui.Accordion / ui.Card", "Layout and disclosure."},
+ apiRow{"ui.RegisterIcon", "Add your own icons. The kit ships a small set; the app brings the rest."},
+ ),
+ ),
)
}
}
+
+const kitSnippet = `// A component is a function taking a props struct.
+ui.Button(ui.ButtonProps{
+ Color: ui.ButtonPrimary,
+ Icon: "check",
+ Text: "Save",
+ OnClick: func() { toaster.Success("Saved.") },
+})
+
+// Inputs are controlled: the caller owns the state.
+name := NewSignal("")
+
+ui.FormInput(ui.FormInputProps{
+ Value: name.Get(),
+ OnInput: func(v string) { name.Set(v) }, // a write re-renders
+})`
diff --git a/go/cmd/examples/go-wasm-web/app/landing_test.go b/go/cmd/examples/go-wasm-web/app/landing_test.go
new file mode 100644
index 00000000..7b15117c
--- /dev/null
+++ b/go/cmd/examples/go-wasm-web/app/landing_test.go
@@ -0,0 +1,120 @@
+package app
+
+import (
+ "strings"
+ "testing"
+
+ "kjol/vdom"
+)
+
+// The landing page's whole claim is that its two panes are ONE function: the live
+// component on the left, and the HTML string the server sends on the right. If they
+// could drift, the page would be a lie told in the most embarrassing possible place.
+//
+// So: render it, click the button the way the browser would, render again, and check
+// that BOTH panes moved. A pane rendered from a stale copy of the tree — or from a
+// second, hand-written one — fails here.
+func TestLandingPanesShareOneTree(t *testing.T) {
+ page := HomePage(Deps{Path: func() string { return "/" }})
+
+ html := vdom.RenderHTML(page())
+ if !strings.Contains(html, "clicked 0 times") {
+ t.Fatalf("the live pane did not render its initial state:\n%s", html)
+ }
+ // The right-hand pane is the ESCAPED HTML of the same tree, so the markup it shows
+ // appears in the page's own markup double-escaped: <div ...
+ if !strings.Contains(html, "<div class=") {
+ t.Fatal("the right-hand pane is not showing rendered HTML at all")
+ }
+
+ clickButton(t, page(), "Click me")
+
+ html = vdom.RenderHTML(page())
+ if strings.Count(html, "clicked 1 times") < 2 {
+ t.Errorf("after one click, %d panes say \"clicked 1 times\" — both should:\n%s",
+ strings.Count(html, "clicked 1 times"), html)
+ }
+}
+
+// The byte count under the right-hand pane is the length of the string actually shown,
+// not a number typed in by hand — so it has to move when the markup does.
+func TestLandingByteCountIsReal(t *testing.T) {
+ page := HomePage(Deps{Path: func() string { return "/" }})
+
+ before := byteCountLabel(t, vdom.RenderHTML(page()))
+ clickButton(t, page(), "Click me")
+ // "clicked 0 times" -> "clicked 1 times" is the same length, so click into double
+ // digits, where the markup genuinely grows by one byte.
+ for i := 0; i < 10; i++ {
+ clickButton(t, page(), "Click me")
+ }
+ after := byteCountLabel(t, vdom.RenderHTML(page()))
+
+ if before == after {
+ t.Errorf("the markup grew by a digit but the byte count did not move (%s) — it is not measuring the string", before)
+ }
+}
+
+// byteCountLabel pulls the "N bytes of HTML" caption out of the rendered page.
+func byteCountLabel(t *testing.T, html string) string {
+ t.Helper()
+ i := strings.Index(html, " bytes of HTML")
+ if i < 0 {
+ t.Fatal("no byte-count caption on the landing page")
+ }
+ start := strings.LastIndexByte(html[:i], '>') + 1
+ return html[start : i+len(" bytes of HTML")]
+}
+
+// clickButton finds a button by its label and fires its click handler.
+func clickButton(t *testing.T, n *vdom.VNode, label string) {
+ t.Helper()
+ if !findAndClickButton(n, label) {
+ t.Fatalf("no clickable button labelled %q on the page", label)
+ }
+}
+
+func findAndClickButton(n *vdom.VNode, label string) bool {
+ if n == nil {
+ return false
+ }
+ if n.Tag == "button" && strings.Contains(textOf(n), label) {
+ if h := n.Events[vdom.EVENT_CLICK]; h != nil {
+ h(clickEvent{})
+ return true
+ }
+ }
+ for _, c := range n.Children {
+ if findAndClickButton(c, label) {
+ return true
+ }
+ }
+ return false
+}
+
+func textOf(n *vdom.VNode) string {
+ if n.Tag == "" {
+ return n.Text
+ }
+ var b strings.Builder
+ for _, c := range n.Children {
+ b.WriteString(textOf(c))
+ }
+ return b.String()
+}
+
+// clickEvent is a vdom.Event with no DOM behind it — enough to invoke a handler.
+type clickEvent struct{}
+
+func (clickEvent) PreventDefault() {}
+func (clickEvent) StopPropagation() {}
+func (clickEvent) Value() string { return "" }
+func (clickEvent) Checked() bool { return false }
+func (clickEvent) Key() string { return "" }
+func (clickEvent) ClientX() int { return 0 }
+func (clickEvent) ClientY() int { return 0 }
+func (clickEvent) Target() any { return nil }
+func (clickEvent) SetData(_, _ string) {}
+func (clickEvent) GetData(string) string { return "" }
+
+var _ vdom.Event = clickEvent{}
diff --git a/go/cmd/examples/go-wasm-web/app/overlays.go b/go/cmd/examples/go-wasm-web/app/overlays.go
index 8f45d1bd..86666ad9 100644
--- a/go/cmd/examples/go-wasm-web/app/overlays.go
+++ b/go/cmd/examples/go-wasm-web/app/overlays.go
@@ -105,19 +105,34 @@ func OverlaysPage(d Deps) func() *VNode {
})
return func() *VNode {
- return Div(Attr("class", "space-y-8"),
- Div(
- H2(Attr("class", "text-2xl font-semibold tracking-tight text-text-heading"), Text("Overlays")),
- P(Attr("class", "mt-1 text-neutral-500"),
- Text("Every floating component, really measured: portaled to document.body, positioned from "+
- "getBoundingClientRect against the viewport, flipping and shifting to stay on screen. "+
- "Scroll or resize the window with one open.")),
- row("mt-3 flex gap-2", tour.StartButton(0, "", Text("Take the tour"))),
+ return docPage("Components", "Overlays",
+ "Tooltips, popovers, menus, modals and toasts — every one of them measured against the real "+
+ "viewport. A floating panel is portaled to document.body, positioned from its trigger's "+
+ "bounding box, and flipped or shifted when it would otherwise run off the screen.",
+
+ docSection("engine", "How a panel is placed",
+ prose("Positioning is a pure function: given the trigger's rectangle, the panel's size and the "+
+ "viewport, it returns coordinates. It is unit-tested natively, with no browser in sight, "+
+ "because none of it is about the browser — the browser only supplies the three rectangles."),
+ prose("The result is written to the element with SetStyle, NOT through a signal. A signal write "+
+ "re-renders the whole tree, and this runs on every scroll and resize frame; going through "+
+ "the vdom would rebuild the page sixty times a second to move one panel four pixels."),
+ code("webui/floating.go", floatingSnippet),
+ note("Controllers are built once",
+ "A floating component owns refs, timers and its open state. Build it alongside your signals, "+
+ "never inside the render closure — one built per frame can never stay open, because the "+
+ "thing holding \"open\" is thrown away and replaced before you can see it."),
+ row("mt-4 flex gap-2", tour.StartButton(0, "", Text("Take the tour"))),
),
// ---- tooltips ----
- El("div", Attr("id", "demo-tooltips"),
- kitSection("Tooltips",
+ docSection("demo-tooltips", "Tooltips",
+ prose("Hover, or focus — a tooltip that only answers to a mouse is a tooltip a keyboard user "+
+ "cannot read. Narrow the window and hover the Right one: it flips to the left, and its "+
+ "arrow follows it. Near an edge the panel shifts back on screen and the arrow slides to "+
+ "keep pointing at the trigger; in the original kit the arrow detached and pointed at "+
+ "nothing."),
+ demo("Placement, delay, and focus triggers",
row("flex flex-wrap items-center gap-3",
tipTop.Render(Span(Text("Above — the default")),
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Top"})),
@@ -128,34 +143,34 @@ func OverlaysPage(d Deps) func() *VNode {
tipFocus.Render(Span(Text("Shown on focus, not hover — tab to the field")),
ui.FormInput(ui.FormInputProps{Placeholder: "Focus me"})),
),
- P(Attr("class", "text-xs text-neutral-500"),
- Text("Drag the window narrow and hover the Right one: it flips to the left, and its arrow "+
- "follows. Near an edge the panel shifts back on screen and the arrow slides to keep "+
- "pointing at the trigger — the original kit's arrow detached here.")),
),
),
// ---- popovers ----
- El("div", Attr("id", "demo-popovers"),
- kitSection("Popovers",
+ docSection("demo-popovers", "Popovers",
+ prose("A popover closes on an outside click or on Escape — and only the TOPMOST one closes per "+
+ "press, so a dropdown inside a popover does not take the popover down with it. The hover "+
+ "variant keeps a bridge across the gap between trigger and panel, so the cursor can "+
+ "actually reach the thing it opened."),
+ demo("Click, alignment, and hover-with-a-bridge",
row("flex flex-wrap items-center gap-3",
pop.Trigger(ui.PopoverTriggerProps{},
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Click me"})),
pop.Content(ui.PopoverContentProps{Class: "w-64"},
- P(Attr("class", "text-sm text-neutral-600"),
+ P(Attr("class", "text-sm text-ink-soft"),
Text("Click outside, or press Escape, to close. Only the topmost floating closes per press.")),
),
popEnd.Trigger(ui.PopoverTriggerProps{},
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Aligned to my right edge"})),
popEnd.Content(ui.PopoverContentProps{Class: "w-56"},
- P(Attr("class", "text-sm text-neutral-600"), Text("Placement bottom-end.")),
+ P(Attr("class", "text-sm text-ink-soft"), Text("Placement bottom-end.")),
),
hoverPop.Trigger(ui.PopoverTriggerProps{},
ui.Button(ui.ButtonProps{Color: ui.ButtonBlue, Outline: true, Text: "Hover me, then reach the panel"})),
hoverPop.Content(ui.PopoverContentProps{Class: "w-64"},
- P(Attr("class", "text-sm text-neutral-600"),
+ P(Attr("class", "text-sm text-ink-soft"),
Text("Move the cursor across the gap and onto this panel — it stays open. "+
"Select this text to prove it.")),
),
@@ -164,8 +179,14 @@ func OverlaysPage(d Deps) func() *VNode {
),
// ---- menus ----
- El("div", Attr("id", "demo-menus"),
- kitSection("Menus & submenus",
+ docSection("demo-menus", "Menus & submenus",
+ prose("Opening one menu closes the other: a single-open manager keeps the page from filling up "+
+ "with panels nobody asked for. Submenus are exempt from it — they are Standalone — or a "+
+ "submenu would close the very menu it belongs to as it opened."),
+ prose("A submenu is portaled too, which is not a detail: the parent menu scrolls its own "+
+ "contents, and a submenu rendered inside it was clipped by that overflow the moment it "+
+ "was taller than its parent."),
+ demo("Items, icons, a submenu, and KeepOpen",
row("flex flex-wrap items-center gap-3",
menu.TriggerFunc(ui.MenuTriggerProps{Tag: "div"}, func(open bool) *VNode {
caret := " ▾"
@@ -204,60 +225,65 @@ func OverlaysPage(d Deps) func() *VNode {
hoverMenu.Item(ui.MenuItemProps{}, Text("Two")),
),
),
- P(Attr("class", "text-xs text-neutral-500"),
- Text("Opening one menu closes the other: a single-open manager, with submenus exempt "+
- "(Standalone), or a submenu would close its own parent.")),
),
),
// ---- date pickers ----
- kitSection("Date picker",
- row("grid gap-4 sm:grid-cols-2",
- row("flex flex-col gap-1",
- ui.FormLabel(ui.FormLabelProps{}, Text("Date (portaled, flips near the bottom)")),
- dp.Render(),
- ),
- row("flex flex-col gap-1",
- ui.FormLabel(ui.FormLabelProps{}, Text("Date of birth (inline, three selects)")),
- dob.Render(),
+ docSection("demo-dates", "Date picker",
+ prose("The field is typeable, not merely clickable. It parses loosely — 7/4/26, Jul 4 2026 and "+
+ "2026-07-04 all work — and commits what it understood on blur, so the calendar is an "+
+ "affordance rather than the only way in."),
+ demo("Picked: \""+picked.Get()+"\"",
+ row("grid gap-4 sm:grid-cols-2",
+ row("flex flex-col gap-1",
+ ui.FormLabel(ui.FormLabelProps{}, Text("Date (portaled, flips near the bottom)")),
+ dp.Render(),
+ ),
+ row("flex flex-col gap-1",
+ ui.FormLabel(ui.FormLabelProps{}, Text("Date of birth (inline, three selects)")),
+ dob.Render(),
+ ),
),
),
- P(Attr("class", "text-xs text-neutral-500"),
- Text("Picked: \""+picked.Get()+"\". Type into the field too — it parses loosely "+
- "(7/4/26, Jul 4 2026, 2026-07-04) and commits on blur.")),
),
// ---- modals ----
- kitSection("Modals",
- row("flex flex-wrap items-center gap-3",
- ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Open modal", OnClick: modal.Open}),
- ui.Button(ui.ButtonProps{Color: ui.ButtonRed, Outline: true, Text: "Delete something…", OnClick: confirm.Open}),
- ui.Button(ui.ButtonProps{Color: ui.ButtonBlue, Outline: true, Text: "Open wizard", OnClick: wizard.Open}),
- ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Open imperatively",
- OnClick: func() {
- // No component in the tree owns this one: OpenModal hands content
- // to the shared host rendered in the layout.
- ui.OpenModal(func() *VNode {
- return ui.ModalContent(ui.ModalContentProps{
- Header: H3(Attr("class", "text-lg font-semibold text-text-heading"), Text("Opened from anywhere")),
- },
- P(Attr("class", "text-neutral-600"),
- Text("This content was not rendered by any component — it was handed to "+
- "ModalHost (see AppLayout) by webui.OpenModal.")),
- )
- }, ui.ModalOptions{Size: ui.ModalSmall})
- }}),
+ docSection("demo-modals", "Modals",
+ prose("Portaled to document.body, so no ancestor's overflow:hidden or transform can clip them. "+
+ "Open the modal, then the nested one inside it, and press Escape twice: modals unwind one "+
+ "layer per press rather than all at once."),
+ prose("The last button opens a modal that no component in the tree owns — webui.OpenModal hands "+
+ "content to a shared host rendered once in the layout. That is what code far from the view "+
+ "needs: a confirmation raised from inside a save handler, say."),
+ demo("Deleted: "+strconv.FormatBool(deleted.Get()),
+ row("flex flex-wrap items-center gap-3",
+ ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Open modal", OnClick: modal.Open}),
+ ui.Button(ui.ButtonProps{Color: ui.ButtonRed, Outline: true, Text: "Delete something…", OnClick: confirm.Open}),
+ ui.Button(ui.ButtonProps{Color: ui.ButtonBlue, Outline: true, Text: "Open wizard", OnClick: wizard.Open}),
+ ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Open imperatively",
+ OnClick: func() {
+ // No component in the tree owns this one: OpenModal hands content
+ // to the shared host rendered in the layout.
+ ui.OpenModal(func() *VNode {
+ return ui.ModalContent(ui.ModalContentProps{
+ Header: H3(Attr("class", "text-lg font-semibold text-text-heading"), Text("Opened from anywhere")),
+ },
+ P(Attr("class", "text-ink-soft"),
+ Text("This content was not rendered by any component — it was handed to "+
+ "ModalHost (see AppLayout) by webui.OpenModal.")),
+ )
+ }, ui.ModalOptions{Size: ui.ModalSmall})
+ }}),
+ ),
),
- P(Attr("class", "text-xs text-neutral-500"),
- Text("Deleted: "+strconv.FormatBool(deleted.Get())+
- ". Open the modal, then the nested one inside it, and press Escape twice — "+
- "modals unwind one layer per press.")),
+ // The modals themselves. They portal to document.body, so where they sit in
+ // the tree makes no difference to where they appear.
modal.Render(ui.ModalProps{
Header: H3(Attr("class", "text-lg font-semibold text-text-heading"), Text("A modal")),
Footer: ui.Button(ui.ButtonProps{Color: ui.ButtonNeutral, Text: "Close", OnClick: modal.Close}),
},
- P(Attr("class", "text-neutral-600"),
+ P(Attr("class", "text-ink-soft"),
Text("Portaled to document.body, so no ancestor's overflow:hidden can clip it. It fades "+
"and scales in — a double requestAnimationFrame, because a single frame does not "+
"give the browser time to commit the initial style.")),
@@ -268,7 +294,7 @@ func OverlaysPage(d Deps) func() *VNode {
nested.Render(ui.ModalProps{
Header: H3(Attr("class", "text-lg font-semibold text-text-heading"), Text("Nested")),
},
- P(Attr("class", "text-neutral-600"), Text("Escape closes THIS one first, not the one behind it.")),
+ P(Attr("class", "text-ink-soft"), Text("Escape closes THIS one first, not the one behind it.")),
),
confirm.Confirm(ui.ConfirmModalProps{
Title: "Delete row",
@@ -306,7 +332,7 @@ func OverlaysPage(d Deps) func() *VNode {
Title: "Confirm",
Content: func(ctx ui.WizardStepContext) *VNode {
ctx.SetCanContinue(true)
- return P(Attr("class", "text-neutral-600"),
+ return P(Attr("class", "text-ink-soft"),
Text("All set for "+wizardName.Get()+". Finish to close."))
},
},
@@ -315,30 +341,42 @@ func OverlaysPage(d Deps) func() *VNode {
),
// ---- toasts ----
- kitSection("Toasts",
- row("flex flex-wrap items-center gap-2",
- ui.Button(ui.ButtonProps{Color: ui.ButtonGreen, Small: true, Text: "Success",
- OnClick: func() { toaster.Success("Saved.") }}),
- ui.Button(ui.ButtonProps{Color: ui.ButtonRed, Small: true, Text: "Error",
- OnClick: func() { toaster.Error("Something went wrong.") }}),
- ui.Button(ui.ButtonProps{Color: ui.ButtonBlue, Small: true, Text: "Info",
- OnClick: func() { toaster.Info("Just so you know.") }}),
- ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true, Text: "Sticky (no timer)",
- OnClick: func() {
- toaster.Push(ui.Toast{
- Message: "This one waits for you to dismiss it.",
- Type: ui.ToastWarning,
- Duration: ui.ToastSticky,
- })
- }}),
- ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true, Text: "Clear all",
- OnClick: toaster.Clear}),
+ docSection("demo-toasts", "Toasts",
+ prose("They dismiss themselves after five seconds. Watch the bar count down: it is one CSS "+
+ "transition, written straight at the element — not a re-render per frame, which is what a "+
+ "progress bar driven through a signal would cost you."),
+ prose("A sticky toast (Duration: ToastSticky) waits for the user instead. The menu items above "+
+ "raise toasts too, which is how you can see that an item really does close its own menu."),
+ demo("Push, dismiss, and a sticky one",
+ row("flex flex-wrap items-center gap-2",
+ ui.Button(ui.ButtonProps{Color: ui.ButtonGreen, Small: true, Text: "Success",
+ OnClick: func() { toaster.Success("Saved.") }}),
+ ui.Button(ui.ButtonProps{Color: ui.ButtonRed, Small: true, Text: "Error",
+ OnClick: func() { toaster.Error("Something went wrong.") }}),
+ ui.Button(ui.ButtonProps{Color: ui.ButtonBlue, Small: true, Text: "Info",
+ OnClick: func() { toaster.Info("Just so you know.") }}),
+ ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true, Text: "Sticky (no timer)",
+ OnClick: func() {
+ toaster.Push(ui.Toast{
+ Message: "This one waits for you to dismiss it.",
+ Type: ui.ToastWarning,
+ Duration: ui.ToastSticky,
+ })
+ }}),
+ ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true, Text: "Clear all",
+ OnClick: toaster.Clear}),
+ ),
+ ),
+ ),
+
+ docSection("overlay-api", "Reference",
+ apiTable(
+ apiRow{"NewFloating", "The positioning engine behind every panel: placement, offset, flip, shift, arrow."},
+ apiRow{"NewTooltip / NewPopover / NewMenu", "Controllers. Build once, outside the render."},
+ apiRow{"Standalone", "Exempts a panel from the single-open manager. A submenu needs it, or it closes its own parent."},
+ apiRow{"vdom.Portal", "Mounts children at document.body — the escape hatch from an ancestor's overflow:hidden."},
+ apiRow{"webui.OpenModal / ModalHost", "Open a modal from code that owns no component. Render the host once, in your layout."},
),
- P(Attr("class", "text-xs text-neutral-500"),
- Text("These auto-dismiss after 5 seconds — watch the bar count down; it is a CSS transition "+
- "driven straight at the DOM, not a re-render per frame. A sticky one (Duration: "+
- "ToastSticky) never leaves on its own. The menu items above raise toasts too, which is "+
- "how you can see that an item really does close its own menu.")),
),
// The toast container and the tutorial's overlay both render here; both are
@@ -348,3 +386,21 @@ func OverlaysPage(d Deps) func() *VNode {
)
}
}
+
+const floatingSnippet = `// Built ONCE — it owns refs, timers, and whether it is open.
+pop := ui.NewPopover(ui.PopoverOptions{
+ Placement: ui.PlacementBottomStart,
+ Offset: 8,
+})
+
+// ...and in the render:
+pop.Trigger(ui.PopoverTriggerProps{},
+ ui.Button(ui.ButtonProps{Text: "Click me"}),
+)
+pop.Content(ui.PopoverContentProps{Class: "w-64"},
+ P(Text("Outside click and Escape close me.")),
+)
+
+// The panel is portaled to document.body and positioned imperatively:
+// render invisible -> AfterRender -> measure -> ComputePosition -> SetStyle -> reveal
+// Never through a signal: this runs on every scroll frame.`
diff --git a/go/cmd/examples/go-wasm-web/app/pages.go b/go/cmd/examples/go-wasm-web/app/pages.go
index 1a4d989d..128a1003 100644
--- a/go/cmd/examples/go-wasm-web/app/pages.go
+++ b/go/cmd/examples/go-wasm-web/app/pages.go
@@ -13,8 +13,14 @@ package app
import (
"strconv"
+ "strings"
. "kjol/vdom"
+
+ // The host API is dual-build — real under js/wasm, no-ops natively — so neutral page
+ // code can measure the browser and still server-render. The landing page uses it for
+ // exactly one thing: reading the clock when hydration commits.
+ "kjol/wasmruntime"
ui "kjol/webui"
)
@@ -24,6 +30,14 @@ type Deps struct {
Navigate func(string)
}
+// Theme is the site-wide theme controller. One per site, created once — the switch in
+// the header and the class on have to be the same object, or the button and the
+// page disagree about what theme you are in.
+//
+// The client calls Theme.Init() after mounting (see wasm/main.go); on the server it is
+// inert, and the document's boot script has already put the right class on .
+var Theme = ui.NewTheme()
+
func itoa(n int) string { return strconv.Itoa(n) }
// Layout wraps a page's content with shared chrome (declared with //gowasm:layout,
@@ -44,31 +58,59 @@ func Shell(d Deps, routes map[string]func() *VNode) *VNode {
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+".")),
+ H2(Attr("class", "text-xl font-semibold text-ink mb-2"), Text("Page not found")),
+ P(Attr("class", "text-ink-muted"), Text("No route matches "+path+".")),
)
}
// --- layouts (Tailwind chrome) -------------------------------------------
+// wordmark is the brand lockup, shared by both layouts so they cannot drift.
+//
+// The boat is the point of the name: kjol is Norwegian for KEEL — the spine of a hull,
+// the thing every other part is built onto. Which is what this library is meant to be
+// for the applications that share it.
+func wordmark(d Deps, href string) *VNode {
+ return A(Attr("class", "flex items-center gap-2.5 no-underline"), Attr("href", href), navigate(d, href),
+ Span(Attr("class", "inline-flex h-8 w-8 items-center justify-center rounded-default bg-ink text-surface"),
+ ui.IconInline("sailboat", 17, "")),
+ Span(Attr("class", "flex items-baseline gap-1.5"),
+ Span(Attr("class", "text-lg font-semibold tracking-tight text-text-heading"), Text("Kjol Web")),
+ Span(Attr("class", "text-sm text-ink-faint"), Text("Go + WASM")),
+ ),
+ )
+}
+
+// PublicLayout is deliberately plain: a line of navigation, a column of content, a line
+// of footer. No hero, no glow, no full-bleed anything.
+//
+// The grid stays, faintly, because it is the one piece of decoration that is not trying
+// to sell you something — it is texture, and it costs nothing to read past.
+//
//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")),
+ return Div(Attr("class", "relative min-h-screen"),
+ // Behind everything, masked to fade out down the page. aria-hidden +
+ // pointer-events-none because it is decoration: not tabbable, not clickable, not
+ // read aloud.
+ Div(Attr("class", "pointer-events-none fixed inset-0 -z-10 bg-grid grid-fade"), Attr("aria-hidden", "true")),
+
+ Nav(Attr("class", "site-nav border-b border-line"),
+ Div(Attr("class", "mx-auto flex max-w-2xl items-center gap-2 px-4 py-4"),
+ wordmark(d, "/"),
Ul(Attr("class", "ml-auto flex items-center gap-1"),
- navItem(d, "/", "Home", false),
+ navItem(d, "/docs", "Docs", 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 →"))),
+ Li(Attr("class", "ml-1"), Theme.ThemeToggle(ui.ThemeToggleProps{Small: true})),
))),
- 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.")),
+
+ Main(Attr("class", "px-4 py-14"), content),
+
+ Footer(Attr("class", "mx-auto max-w-2xl px-4 pb-14"),
+ P(Attr("class", "text-sm text-ink-faint"),
+ Text("Kjol Web is part of kjol — a shared base layer. kjol is Norwegian for keel.")),
),
+ ui.ModalHost(),
)
}
@@ -77,30 +119,42 @@ func PublicLayout(d Deps, content *VNode) *VNode {
// column; prose pages still are.
var wideRoutes = map[string]bool{"/table": true}
+// AppLayout is the DOCUMENTATION shell: a sidebar of sections on the left, the page on
+// the right. The app routes are the framework's docs — each one explains a capability,
+// shows the Go that implements it, and then runs that Go on the page — so they are
+// framed like documentation rather than like a demo carousel.
+//
//gowasm:layout app
func AppLayout(d Deps, content *VNode) *VNode {
- width := "max-w-5xl"
+ // The content column is wide, and the PROSE inside it is what gets held to a reading
+ // measure (see prose()). Constraining the whole column to reading width instead left
+ // code blocks, demos and reference tables cramped into a third of the screen with a
+ // desert to the right of them — the text was comfortable and everything else paid
+ // for it.
+ width := "max-w-6xl"
if wideRoutes[d.Path()] {
- width = "max-w-[100rem]"
+ // The table's own chrome is the demo; a measure would hide the column management
+ // that is the whole point of it.
+ width = "max-w-none"
}
- return Div(Attr("class", "min-h-screen"),
- Nav(Attr("class", "app-nav border-b border-neutral-800 bg-neutral-900"),
- // The nav tracks the content's width, so the logo stays flush with the page
- // rather than floating in from the left on the wide routes.
- Div(Attr("class", "mx-auto flex "+width+" 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, "/table", "Table", true),
- navItem(d, "/overlays", "Overlays", true),
- navItem(d, "/kit", "UI Kit", true)),
- Ul(Attr("class", "ml-auto flex items-center"),
- navItem(d, "/", "Home", true)),
+ return Div(Attr("class", "min-h-screen bg-surface"),
+ Nav(Attr("class", "app-nav sticky top-0 z-20 border-b border-line bg-surface/90 backdrop-blur"),
+ Div(Attr("class", "mx-auto flex max-w-[110rem] items-center gap-3 px-6 py-3"),
+ wordmark(d, "/"),
+ Span(Attr("class", "rounded-full border border-line px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wider text-ink-faint"), Text("Docs")),
+ Ul(Attr("class", "ml-auto flex items-center gap-2"),
+ navItem(d, "/", "Home", false),
+ Li(Theme.ThemeToggle(ui.ThemeToggleProps{Small: true})),
+ ),
)),
- Main(Attr("class", "mx-auto "+width+" px-4 py-8"), content),
+
+ Div(Attr("class", "mx-auto flex max-w-[110rem] gap-8 px-6"),
+ docsSidebar(d),
+ Main(Attr("class", "min-w-0 flex-1 py-10"),
+ Div(Attr("class", width), content),
+ ),
+ ),
// The host for webui.OpenModal — content opened imperatively, by code that
// owns no component in the tree, is portaled out of here. Render it ONCE,
@@ -109,6 +163,39 @@ func AppLayout(d Deps, content *VNode) *VNode {
)
}
+// docsSidebar is the section list. Sticky, so it stays put while a long page scrolls —
+// on a documentation site the nav is how you know where you are, and a nav that scrolls
+// away leaves you nowhere.
+func docsSidebar(d Deps) *VNode {
+ mods := []Mod{Attr("class", "sticky top-[3.75rem] hidden h-[calc(100vh-3.75rem)] w-56 shrink-0 overflow-y-auto py-10 lg:block")}
+ for _, g := range docsNav() {
+ items := []Mod{Attr("class", "mt-2 space-y-0.5")}
+ for _, it := range g.Items {
+ items = append(items, Li(sidebarLink(d, it)))
+ }
+ mods = append(mods,
+ Div(Attr("class", "mb-6"),
+ P(Attr("class", "px-2 text-[11px] font-semibold uppercase tracking-widest text-ink-faint"), Text(g.Title)),
+ Ul(items...),
+ ),
+ )
+ }
+ return El("aside", mods...)
+}
+
+func sidebarLink(d Deps, it docsItem) *VNode {
+ cls := "flex items-center gap-2 rounded-default px-2 py-1.5 text-sm no-underline text-ink-soft hover:bg-surface-raised hover:text-ink"
+ iconCls := "text-ink-faint"
+ if d.Path() == it.Path {
+ cls = "active flex items-center gap-2 rounded-default px-2 py-1.5 text-sm no-underline bg-primary-subtle font-medium text-accent"
+ iconCls = "text-accent"
+ }
+ return A(Attr("class", cls), Attr("href", it.Path), navigate(d, it.Path),
+ ui.IconInline(it.Icon, 14, iconCls),
+ Text(it.Label),
+ )
+}
+
// 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
@@ -117,11 +204,11 @@ func navItem(d Deps, path, label string, dark bool) *VNode {
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"
+ cls = "rounded-default px-3 py-1.5 text-sm font-medium text-ink-faint 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"
+ cls = "active rounded-default px-3 py-1.5 text-sm font-medium bg-surface-raised text-ink"
default:
- cls = "rounded-default px-3 py-1.5 text-sm font-medium text-neutral-600 hover:bg-neutral-100 hover:text-neutral-900"
+ cls = "rounded-default px-3 py-1.5 text-sm font-medium text-ink-soft hover:bg-surface-raised hover:text-ink"
}
return Li(A(Attr("class", cls+" no-underline"), Attr("href", path), navigate(d, path), Text(label)))
}
@@ -139,8 +226,8 @@ func navigate(d Deps, path string) Mod {
// 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+": ")),
+ return Div(Attr("class", "counter flex items-center gap-3 rounded-default border border-line bg-surface px-4 py-3 shadow-xs"),
+ Span(Attr("class", "font-medium text-ink-soft"), 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 }) }}),
@@ -149,74 +236,268 @@ func Counter(label string, count *Signal[int]) *VNode {
)
}
+// ---- landing ------------------------------------------------------------
+
+// The landing page is one narrow column of plain text, a demo, and a list.
+//
+// It used to be a framework marketing page: an oversized headline, a hero glow, feature
+// cards in a grid, numbered chapters, a call to action repeated at both ends. All of it
+// was arguing. None of it was showing. A library this small does not need to argue — it
+// needs to say what it is, show that it works, and get out of the way, and a reader who
+// wants to be convinced can click into the docs and find every page running the code it
+// documents.
+//
+// What survives is the part that could not be faked: the same Go function rendered twice
+// at once, as live DOM and as the HTML string the server sends.
+//
//gowasm:page / static layout=public
func HomePage(d Deps) func() *VNode {
- a := NewSignal(0)
- b := NewSignal(0)
- dark := NewSignal(false)
+ clicks := NewSignal(0)
+
+ // The one measurement on the page: performance.now() when the client's first render
+ // commits. Zero until then — which is what the SERVER renders, and what the client
+ // renders on its first pass, so the two agree and hydration stays clean.
+ hydratedAt := NewSignal(0.0)
+ wasmruntime.AfterRender(func() {
+ if hydratedAt.Get() == 0 {
+ hydratedAt.Set(wasmruntime.Now())
+ }
+ })
+
+ // demoTree is called TWICE per render below — once for the DOM, once for the HTML.
+ // That is the point: the two panes cannot drift, because there is only one of them.
+ demoTree := func() *VNode {
+ return Div(Attr("class", "flex items-center gap-3"),
+ ui.Button(ui.ButtonProps{
+ Color: ui.ButtonPrimary, Text: "Click me",
+ OnClick: func() { clicks.Set(clicks.Get() + 1) },
+ }),
+ Span(Attr("class", "text-ink-soft"), Text("clicked "+itoa(clicks.Get())+" times")),
+ )
+ }
+
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")),
+ markup := RenderHTML(demoTree())
+
+ return Div(Attr("class", "mx-auto max-w-2xl"),
+ H1(Attr("class", "text-3xl font-semibold tracking-tight text-text-heading"),
+ Text("Kjol Web")),
+ P(Attr("class", "mt-3 leading-relaxed text-ink-soft"),
+ Text("A small library for writing web interfaces in Go. Components are ordinary functions "+
+ "returning a virtual DOM. The server renders them to HTML, and the same code compiles "+
+ "to WebAssembly and takes over in the browser.")),
+ P(Attr("class", "mt-3 leading-relaxed text-ink-soft"),
+ Text("There is no JavaScript build step, and nothing outside the standard library.")),
+
+ // ---- the demonstration ----
+ H2(Attr("class", "mt-12 text-lg font-semibold text-text-heading"), Text("One function, two runtimes")),
+ P(Attr("class", "mt-2 leading-relaxed text-ink-soft"),
+ Text("Below is a single Go function, shown twice. On the left it has been reconciled into "+
+ "the DOM and you can use it. On the right is the HTML the same function produces when "+
+ "the server renders it — the markup that reached your browser before any WebAssembly "+
+ "had loaded. Click the button; both move.")),
+
+ Div(Attr("class", "mt-5 border border-line sm:grid sm:grid-cols-2"),
+ Div(Attr("class", "border-b border-line sm:border-b-0 sm:border-r"),
+ paneLabel("in your browser"),
+ Div(Attr("class", "px-4 py-8"), demoTree()),
),
- Div(Attr("class", "mt-4"),
- ui.ToggleSwitch(dark.Get(), func(v bool) { dark.Set(v) }, "Dark mode", "Just a demo toggle", false, "")),
+ Div(
+ paneLabel(itoa(len(markup))+" bytes of HTML"),
+ Pre(Attr("class", "whitespace-pre-wrap px-4 py-4 font-mono text-[12px] leading-relaxed text-ink-muted"),
+ El("code", Text(prettyHTML(markup))),
+ ),
+ ),
+ ),
+ P(Attr("class", "mt-3 text-sm leading-relaxed text-ink-muted"),
+ Text("The right pane is not a picture of the source. It is vdom.RenderHTML, called on the "+
+ "very tree the left pane is showing, recomputed on every click.")),
+
+ // ---- what is in it ----
+ H2(Attr("class", "mt-12 text-lg font-semibold text-text-heading"), Text("What is in it")),
+ Ul(Attr("class", "mt-3 space-y-1.5 leading-relaxed text-ink-soft"),
+ item("Server-side rendering and client hydration, from one codebase."),
+ item("Server components: mark a function and its code and state stay on the server."),
+ item("A component kit — forms, tabs, modals, tooltips, toasts — with dark mode."),
+ item("A table with filtering, sorting, column management, formulas, and CSV and PDF export."),
+ item("Tailwind, compiled by a Go program that reads your Go."),
+ ),
+
+ // ---- building ----
+ H2(Attr("class", "mt-12 text-lg font-semibold text-text-heading"), Text("Building it")),
+ P(Attr("class", "mt-2 leading-relaxed text-ink-soft"),
+ Text("Two commands. The first produced the page you are reading; the second serves it and "+
+ "rebuilds on save.")),
+ codeLang("terminal", "sh", buildTranscript),
+
+ // ---- close ----
+ P(Attr("class", "mt-12 border-t border-line pt-6 leading-relaxed text-ink-soft"),
+ Text("Every page of the documentation runs the code it documents — there are no screenshots "+
+ "of components anywhere on this site. "),
+ A(Attr("class", "text-accent underline underline-offset-4"),
+ Attr("href", "/docs"), navigate(d, "/docs"), Text("Read the docs")),
+ Text(", or "),
+ A(Attr("class", "text-accent underline underline-offset-4"),
+ Attr("href", "/kit"), navigate(d, "/kit"), Text("look at the components")),
+ Text("."),
+ ),
+ P(Attr("class", "mt-4 text-sm text-ink-muted"),
+ Text(hydrationNote(hydratedAt.Get()))),
+ )
+ }
+}
+
+// item is one bullet.
+func item(text string) *VNode {
+ return Li(Attr("class", "flex gap-2.5"),
+ Span(Attr("class", "select-none text-ink-faint"), Text("—")),
+ Span(Text(text)),
+ )
+}
+
+// paneLabel captions one half of the two-runtime demo.
+func paneLabel(title string) *VNode {
+ return Div(Attr("class", "border-b border-line px-4 py-2"),
+ Span(Attr("class", "font-mono text-[11px] uppercase tracking-widest text-ink-faint"), Text(title)),
+ )
+}
+
+// hydrationNote is the page's one measurement, written as a sentence rather than
+// displayed on a dashboard. It is a fact about this page, not a boast about the library,
+// and it reads better as the former.
+func hydrationNote(ms float64) string {
+ if ms == 0 {
+ return "This page was rendered by Go on the server. WebAssembly is still loading."
+ }
+ return "This page was rendered by Go on the server; WebAssembly took over " +
+ strconv.FormatFloat(ms, 'f', 0, 64) + " ms later."
+}
+
+// prettyHTML puts each element of a rendered tree on its own line. The markup shown is
+// otherwise byte-for-byte what RenderHTML produced — long class lists and all, because
+// tidying them for the demo would make the pane a lie.
+func prettyHTML(s string) string {
+ return strings.ReplaceAll(s, "><", ">\n<")
+}
+
+const buildTranscript = `$ go run ./build
+==> generating directive glue (//gowasm:page, //gowasm:layout, //gowasm:server)
+==> compiling Tailwind CSS -> wwwroot/app.css
+==> compiling ./wasm -> wwwroot/app.wasm (GOOS=js GOARCH=wasm)
+
+$ go run ./server
+serving "./wwwroot" on http://localhost:8085`
+
+// ---- about --------------------------------------------------------------
+
+//gowasm:page /about static layout=public
+func AboutPage(d Deps) func() *VNode {
+ return func() *VNode {
+ return Div(Attr("class", "mx-auto max-w-3xl py-4"),
+ P(Attr("class", "text-xs font-semibold uppercase tracking-widest text-accent"), Text("About")),
+ H1(Attr("class", "mt-2 text-4xl font-semibold tracking-tight text-text-heading"), Text("Why this exists")),
+
+ P(Attr("class", "mt-6 text-lg leading-relaxed text-ink-soft"),
+ Text("Kjol Web is one part of kjol — a shared base layer factored out of several applications "+
+ "so they stay in sync. (Kjol is Norwegian for KEEL: the spine of a hull, the thing every "+
+ "other part is built onto.) The applications had drifted: the same table, the same forms, "+
+ "the same charts, each subtly different in each app, each fixed twice.")),
+
+ P(Attr("class", "mt-4 leading-relaxed text-ink-soft"),
+ Text("The UI kit began as Solid.js components. Kjol Web is the same kit, written in Go and "+
+ "compiled to WebAssembly — the same components, the same Tailwind, no JavaScript build. "+
+ "That means one language across the server and the browser, and a table you can share "+
+ "between a web app and a native one because it is a Go function, not a JSX file.")),
+
+ H2(Attr("class", "mt-12 text-2xl font-semibold tracking-tight text-text-heading"), Text("The rules it keeps")),
+ Div(Attr("class", "mt-6 space-y-4"),
+ principle("The framework never imports application code",
+ "Where kjol needs something app-specific, the app injects it — an interface, a registration "+
+ "call, a config struct. The dependency only ever points one way."),
+ principle("Standard library only",
+ "vdom, the reconciler, the component kit, the Tailwind compiler, the PDF writer: no "+
+ "third-party Go packages. A dependency in the engine is a dependency in every app that "+
+ "consumes it."),
+ principle("The same code on both sides",
+ "A component that cannot render on the server is a component that cannot be server-rendered. "+
+ "The browser APIs components need are dual-build: real under WebAssembly, no-ops "+
+ "natively — so one component measures the DOM and still SSRs."),
+ ),
+
+ Div(Attr("class", "mt-12 rounded-default border border-primary-border bg-primary-subtle p-5"),
+ P(Attr("class", "font-semibold text-text-heading"), Text("This page is the proof, not a claim about it")),
+ P(Attr("class", "mt-1 leading-relaxed text-ink-soft"),
+ Text("Its HTML was rendered by Go on the server, and the same Go is running in your browser "+
+ "now. View the source: the markup arrived complete.")),
),
)
}
}
-//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.")),
- )
- }
+func principle(title, body string) *VNode {
+ return Div(Attr("class", "border-l-2 border-line pl-4"),
+ H3(Attr("class", "font-semibold text-text-heading"), Text(title)),
+ P(Attr("class", "mt-1 leading-relaxed text-ink-soft"), Text(body)),
+ )
}
+// ---- server components --------------------------------------------------
+
//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(),
+ return docPage("Rendering", "Server components",
+ "A server component's code and state never reach the browser. Mark a function with "+
+ "//gowasm:server and the codegen replaces it, on the client, with a stub that renders it "+
+ "over an HTTP round-trip — so calling one looks exactly like calling any other component.",
+
+ docSection("declaring", "Declaring one",
+ prose("The directive is the whole API. The function stays an ordinary component: it takes "+
+ "whatever it needs, and returns a VNode tree."),
+ code("app/server_counter.go", serverSnippet),
+ note("Why the state stays put",
+ "The counter's value lives in a map on the server, keyed by instance. Nothing about it is "+
+ "shipped to the client — the browser holds an id and a rendered fragment, and every "+
+ "click asks the server what the next fragment should be."),
+ ),
+
+ docSection("try-it", "Try it",
+ prose("Each click below is a POST to /rsc. The server runs the component again and returns the "+
+ "new markup, which is merged into the DOM in place — the page is not reloaded and nothing "+
+ "else on it is re-rendered."),
+ demo("A counter whose state lives on the server", counter()),
+ ),
+
+ docSection("when", "When to reach for one",
+ prose("When the component needs something the browser must not have: a database handle, a "+
+ "secret, a large dataset you do not want to ship. The cost is a round-trip per interaction, "+
+ "so it is the wrong tool for anything that has to feel instant."),
+ apiTable(
+ apiRow{"//gowasm:server", "Marks a component as server-side. The codegen writes a client stub in its place."},
+ apiRow{"POST /rsc", "The endpoint the stub calls. Registered by the dev server; wire it into your own server with rsc.Handler."},
+ apiRow{"rsc.Handler", "The http.HandlerFunc that runs the component and returns its rendered fragment."},
+ ),
+ ),
)
}
}
+
+const serverSnippet = `//gowasm:server
+func ServerCounter() func() *VNode {
+ id := newInstanceID() // this state never leaves the server
+
+ return func() *VNode {
+ return Div(
+ Span(Text("count: "+itoa(counts[id]))),
+ Button(
+ On(EVENT_CLICK, func() { counts[id]++ }), // runs SERVER-side
+ Text("+1"),
+ ),
+ )
+ }
+}`
diff --git a/go/cmd/examples/go-wasm-web/app/routes.gen.go b/go/cmd/examples/go-wasm-web/app/routes.gen.go
index ac5a961a..7af6b876 100644
--- a/go/cmd/examples/go-wasm-web/app/routes.gen.go
+++ b/go/cmd/examples/go-wasm-web/app/routes.gen.go
@@ -10,6 +10,7 @@ func Routes(d Deps) map[string]func() *vdom.VNode {
"/about": AboutPage(d),
"/chart": ChartPage(d),
"/data": DataPage(d),
+ "/docs": DocsPage(d),
"/kit": KitPage(d),
"/overlays": OverlaysPage(d),
"/server": ServerPage(d),
@@ -23,6 +24,7 @@ var StaticPaths = map[string]bool{
"/about": true,
"/chart": true,
"/data": true,
+ "/docs": true,
"/table": true,
}
@@ -32,6 +34,7 @@ var RouteLayout = map[string]string{
"/about": "public",
"/chart": "app",
"/data": "app",
+ "/docs": "app",
"/kit": "app",
"/overlays": "app",
"/server": "app",
diff --git a/go/cmd/examples/go-wasm-web/app/server_counter.go b/go/cmd/examples/go-wasm-web/app/server_counter.go
index 236dc082..54c15b3d 100644
--- a/go/cmd/examples/go-wasm-web/app/server_counter.go
+++ b/go/cmd/examples/go-wasm-web/app/server_counter.go
@@ -34,17 +34,20 @@ func ServerCounter() func() *VNode {
count.Set(count.Get() + delta)
points.Set(append(points.Get(), clickPoint{T: time.Now().UnixMilli(), V: count.Get()}))
}
+ // No card of its own: the component draws bare content and lets the caller frame it.
+ // The docs page already puts it in a demo panel, and a card inside a card gives you
+ // two borders and two shadows around the same thing.
return func() *VNode {
- return ui.Card("",
+ return Div(
Div(Attr("class", "flex items-center gap-2 mb-3"),
- Span(Attr("class", "text-neutral-700"), Text("Server counter: ")),
+ Span(Attr("class", "text-ink-soft"), 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"),
+ Div(Attr("class", "rounded-default border border-line bg-surface p-2 overflow-auto"),
Raw(clickChartSVG(points.Get()))),
)
}
diff --git a/go/cmd/examples/go-wasm-web/app/table.go b/go/cmd/examples/go-wasm-web/app/table.go
index 22301e65..48911209 100644
--- a/go/cmd/examples/go-wasm-web/app/table.go
+++ b/go/cmd/examples/go-wasm-web/app/table.go
@@ -48,12 +48,12 @@ func tableColumns() []ui.AutoTableColumn {
Key: "name", DisplayName: "Name", Sortable: true, SortIdentifier: "Name",
CSV: true, CSVValue: func(r any) string { return emp(r).Name },
// No Toggleable: the name is what identifies a row, so it cannot be hidden.
- Cell: func(r any) *VNode { return ui.AutoTableTdLeft("text-neutral-800", Text(emp(r).Name)) },
+ Cell: func(r any) *VNode { return ui.AutoTableTdLeft("text-ink", Text(emp(r).Name)) },
},
{
Key: "email", DisplayName: "Email", Sortable: true, SortIdentifier: "Email",
Toggleable: true, CSV: true, CSVValue: func(r any) string { return emp(r).Email },
- Cell: func(r any) *VNode { return ui.AutoTableTdLeft("text-neutral-500", Text(emp(r).Email)) },
+ Cell: func(r any) *VNode { return ui.AutoTableTdLeft("text-ink-muted", Text(emp(r).Email)) },
},
{
Key: "team", DisplayName: "Team", Sortable: true, SortIdentifier: "Team",
@@ -162,7 +162,7 @@ func newEmployeeTable(highlight *Signal[string]) *ui.AutoTableState {
Accordion: true,
RowKey: func(r any) string { return emp(r).Email },
AccordionContent: func(r any) *VNode {
- return P(Attr("class", "px-4 py-2 text-sm text-neutral-600"), Text(emp(r).Note))
+ return P(Attr("class", "px-4 py-2 text-sm text-ink-soft"), Text(emp(r).Note))
},
Columns: ui.AutoTableColumnOptions{
@@ -202,19 +202,37 @@ func TablePage(d Deps) func() *VNode {
}
return ui.AutoTablePDFHeader{
Title: "Employees",
- Subtitle: "Exported from the gowasm example",
+ Subtitle: "Exported from the Kjol Web example",
ShowDate: true,
Orientation: orientation,
}
}
return func() *VNode {
- return Div(Attr("class", "space-y-6"),
- Div(
- H2(Attr("class", "text-2xl font-semibold tracking-tight text-text-heading"), Text("AutoTable")),
- P(Attr("class", "mt-1 text-neutral-500"),
- Text("Filtering, sorting, pagination, expandable rows and column management — all in Go. "+
- "Drag a header to reorder, drag its right edge to resize; both persist across reloads.")),
+ return docPage("Components", "AutoTable",
+ "A table that filters, sorts, pages, reorders, resizes, computes and exports — configured with "+
+ "a column list and a slice of rows. Everything a user changes about it is theirs and persists; "+
+ "everything it exports is what they filtered, not what happened to be on screen.",
+
+ docSection("defining", "Defining one",
+ prose("A column says how to read a field, how to sort it, and how to render it. The state object "+
+ "is a CONTROLLER: build it once, alongside your signals — never inside the render, which "+
+ "would hand it fresh refs and a fresh idea of which page it was on every frame."),
+ code("app/table.go", tableSnippet),
+ note("The server renders a skeleton, on purpose",
+ "The layout — column order, widths, what is hidden, the calculated columns — lives in the "+
+ "browser's localStorage, which the server cannot read. So the server ships a skeleton "+
+ "rather than the DEFAULT table: a user who had reordered their columns would otherwise "+
+ "watch them rearrange themselves the moment the WebAssembly booted."),
+ ),
+
+ docSection("try-it", "Try it",
+ prose("Search matches name or email. Sort by Salary and it parses the currency, so $980 sorts "+
+ "below $1,200.50. Unhide Rank and sort that: \"Item 2\" comes before \"Item 10\", because "+
+ "numbers inside text are compared as numbers. Drag a header to reorder it, drag its right "+
+ "edge to resize — reload the page and both are still where you left them."),
+ prose("Filter it, then export. You get every matching row across every page, in the column order "+
+ "you dragged them into, with the calculated columns computed per row."),
),
table.Render(
@@ -270,7 +288,7 @@ func TablePage(d Deps) func() *VNode {
// Highlight + auto-page-jump: Radia is on page 3 by default, and the table
// pages itself to wherever she actually is once filters and sorting move her.
- row("flex flex-wrap items-center gap-2",
+ row("mt-4 flex flex-wrap items-center gap-2",
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true,
Text: "Find Radia Perlman",
OnClick: func() { highlight.Set("radia@example.com") }}),
@@ -279,12 +297,59 @@ func TablePage(d Deps) func() *VNode {
OnClick: func() { highlight.Set("") }}),
),
- ui.Alert(ui.AlertBlue, "What to try",
- Text("Search (it matches name OR email); pick a status; select several teams. Sort by Salary — "+
- "it parses the currency, so $980 sorts below $1,200.50. Unhide Rank and sort it: 'Item 2' "+
- "comes before 'Item 10'. Click a row to expand it. Drag a header to reorder, drag its right "+
- "edge to resize — both survive a reload. Filter the table, then export: you get every "+
- "matching row, not just this page. 'Find Radia' jumps to whichever page she is on.")),
+ docSection("calculated", "Calculated columns",
+ prose("The toolbar's calculator builds new columns at runtime, in two modes. Basic picks a "+
+ "function and the columns it combines ACROSS each row — sum of Salary and Bonus, per "+
+ "person. Advanced writes a formula, with insert menus for columns, functions and constants: "+
+ "([Salary] + [Bonus]) * 12."),
+ prose("A summary row is the other axis: it aggregates ONE column DOWN the filtered rows and "+
+ "prints the result in the footer. Confusing the two is the classic bug here — a column that "+
+ "aggregates down shows every row the same number, and it looks plausible enough to ship."),
+ codeLang("formulas", "syntax", formulaSnippet),
+ note("Compiled as you type",
+ "The formula is parsed and evaluated against the real first row while you write it, so a "+
+ "typo shows up as an error under the box — not as a column of dashes discovered later."),
+ ),
+
+ docSection("export", "Export",
+ prose("CSV and PDF are written in Go, standard library only — the PDF writer builds its own "+
+ "xref table and embeds Helvetica metrics. Export takes the FILTERED rows, the VISIBLE "+
+ "columns, in the user's order, including whatever they calculated."),
+ apiTable(
+ apiRow{"NewAutoTableState", "Build the controller: the columns, and where to persist the layout."},
+ apiRow{".SetRows", "Hand it the data. It filters, sorts and pages from there."},
+ apiRow{".RestoreLayout", "Read the saved layout and reveal the table over its skeleton. Call it once, on the client."},
+ apiRow{".FilteredRows / .ExportColumns", "What the user selected, and what they can see — the inputs to any export."},
+ apiRow{"ExportCSV / ExportPDF", "Write the bytes. DownloadCSV / DownloadPDF / PrintPDF do it and hand them to the browser."},
+ ),
+ ),
)
}
}
+
+const tableSnippet = `// A controller: built ONCE, next to your signals — never inside the render.
+table := ui.NewAutoTableState([]ui.AutoTableColumn{
+ {DisplayName: "Name", SortIdentifier: "Name", Sortable: true,
+ Cell: func(r any) *VNode { return Text(r.(Employee).Name) }},
+ {DisplayName: "Salary", SortIdentifier: "Salary", Sortable: true,
+ SortType: ui.SortTypeNumeric, // parses the currency: $980 < $1,200.50
+ Cell: func(r any) *VNode { return Text(money(r.(Employee).Salary)) }},
+ {DisplayName: "Rank", HiddenByDefault: true},
+}, ui.AutoTableStateOptions{
+ PerPage: 5,
+ Columns: ui.AutoTableColumnOptions{
+ StorageKey: "employees", // order, widths, visibility — the user's, and persisted
+ },
+})
+
+table.SetRows(employees())`
+
+const formulaSnippet = `A COLUMN combines operands ACROSS one row:
+
+ sum[Salary, Bonus] -> 1200.50 + 150.00 = 1350.50 (per person)
+ ([Salary] + [Bonus]) * 12 -> the annualised figure
+ SUM({Salary:1:ROW()}) -> a running total, down the rows
+
+A SUMMARY ROW aggregates ONE column DOWN the filtered rows:
+
+ avg[Salary] -> one number, printed in the footer`
diff --git a/go/cmd/examples/go-wasm-web/css/app.css b/go/cmd/examples/go-wasm-web/css/app.css
index c0d7709d..03e343ef 100644
--- a/go/cmd/examples/go-wasm-web/css/app.css
+++ b/go/cmd/examples/go-wasm-web/css/app.css
@@ -54,13 +54,54 @@
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
+/* ---------------------------------------------------------------------------
+ Dark mode: `dark:` as a CLASS, not a media query.
+ ---------------------------------------------------------------------------
+ Tailwind's built-in dark variant follows the operating system. A site with its own
+ theme switch cannot use it: the OS says one thing, the switch says another, and the
+ media query wins — so the switch appears to do nothing.
+
+ This redefines it against a class on , which webui.Theme toggles. The OS is
+ still respected: it is the DEFAULT (see the boot script in server/main.go), just no
+ longer the last word.
+ --------------------------------------------------------------------------- */
+@custom-variant dark (&:where(.dark, .dark *));
+
/* App-side design tokens the webui kit references (Tailwind v4 @theme). Brand
- values live with the app; the kit stays generic. */
+ values live with the app; the kit stays generic.
+
+ The surface/line/ink tokens are the kit's THEME CONTRACT (see webui.ThemeTokens):
+ components say bg-surface / border-line / text-ink and never name a colour, so the
+ whole kit changes theme by changing these ten values rather than by carrying a dark:
+ variant on four hundred class strings. */
@theme {
--radius-default: 0.375rem;
- --color-primary: #4f46e5;
- --color-primary-hover: #4338ca;
+ /* Sky: a cool, neutral blue. The page is mostly prose, code and tables, and the
+ accent's job is to mark the few things you can act on — a saturated indigo or a
+ primary blue competes with the content for attention instead of directing it. */
+ --color-primary: #0284c7; /* sky-600 — accent FILLS; they carry white text */
+ --color-primary-hover: #0369a1; /* sky-700 */
+ --color-primary-subtle: #f0f9ff; /* sky-50 — tinted panels, badges, callouts */
+ --color-primary-border: #bae6fd; /* sky-200 */
+
+ /* accent is for TEXT and icons. It is a separate token from primary because the two
+ have opposite constraints: a fill must be dark enough for white text on top of it,
+ and accent text must be readable ON the surface. One value cannot be both, and in
+ dark mode they diverge completely. */
+ --color-accent: #0369a1; /* sky-700 */
+
+ /* Surfaces, lines, ink — the kit's theme contract. */
+ --color-surface: #ffffff;
+ --color-surface-muted: #fafafa;
+ --color-surface-raised: #f5f5f5;
+ --color-surface-strong: #e5e5e5;
+ --color-line: #e5e5e5;
+ --color-line-strong: #d4d4d4;
+ --color-ink: #171717;
+ --color-ink-soft: #525252;
+ --color-ink-muted: #737373;
+ --color-ink-faint: #a3a3a3;
--color-text-heading: #111827;
--color-text-on-dark: #f9fafb;
@@ -73,3 +114,86 @@
--font-sans: "Lora", ui-serif, Georgia, Cambria, "Times New Roman", serif;
--font-serif: "Lora", ui-serif, Georgia, Cambria, "Times New Roman", serif;
}
+
+/* ---------------------------------------------------------------------------
+ Grid background
+ ---------------------------------------------------------------------------
+ Plain CSS, not a utility: Tailwind's arbitrary-value syntax cannot carry a
+ background-image with commas in it without becoming unreadable, and this is a
+ single named thing rather than a composition of atoms. kjol's Tailwind engine
+ passes rules it does not recognise straight through, so this lands in the output
+ untouched.
+
+ The grid is drawn with two 1px gradients — a vertical set and a horizontal set —
+ tiled at --grid-size. It is deliberately faint: it should register as texture, not
+ as graph paper you have to read the page through.
+ --------------------------------------------------------------------------- */
+:root {
+ --grid-line: rgba(15, 23, 42, 0.055);
+}
+
+/* ---------------------------------------------------------------------------
+ The dark theme.
+ ---------------------------------------------------------------------------
+ Only the token VALUES change. Not one component knows this block exists — they ask
+ for bg-surface and text-ink, and here is where those come to mean something else.
+
+ This is a plain rule, not another @theme block: @theme generates utilities, and these
+ are overrides of utilities that already exist.
+
+ The surfaces are not pure black. Black gives a dark UI a hard, glaring edge against
+ white text and makes every border invisible; a very dark grey leaves room for the
+ raised surfaces and lines above it to actually be seen. --------------------------- */
+.dark {
+ --color-surface: #101013;
+ --color-surface-muted: #17171b;
+ --color-surface-raised: #1f1f24;
+ --color-surface-strong: #2c2c33;
+ --color-line: #2a2a30;
+ --color-line-strong: #3d3d45;
+ --color-ink: #f2f2f3;
+ --color-ink-soft: #c6c6cc;
+ --color-ink-muted: #9a9aa3;
+ --color-ink-faint: #71717a;
+
+ /* The fill stays put — white text has to remain readable on it — but accent TEXT has
+ to climb to stay readable on a near-black surface. This is exactly why they are two
+ tokens. */
+ --color-accent: #7dd3fc; /* sky-300 */
+ --color-primary-subtle: #0b2c3f;
+ --color-primary-border: #0e4966;
+
+ --color-text-heading: #f5f5f5;
+
+ /* The grid is drawn in ink, not in shadow, once the page is dark. */
+ --grid-line: rgba(226, 232, 240, 0.05);
+}
+
+/* The page's own background — painted before the app mounts, and behind it afterwards.
+ Without this, a dark app sits in a white window. */
+html {
+ background-color: var(--color-surface);
+ color: var(--color-ink);
+}
+
+.bg-grid {
+ background-image:
+ linear-gradient(to right, var(--grid-line) 1px, transparent 1px),
+ linear-gradient(to bottom, var(--grid-line) 1px, transparent 1px);
+ /* Written out rather than as var(--size) var(--size): the CSS minifier drops the
+ space between two adjacent var() calls, and while that is still legal CSS, a
+ background-size that depends on how a minifier tokenises is not worth the cleverness. */
+ background-size: 56px 56px;
+ background-position: center top;
+}
+
+/* Fades the grid out towards the bottom, so it frames the hero and then gets out of
+ the way of the content below rather than running under it the whole page. */
+.grid-fade {
+ -webkit-mask-image: linear-gradient(to bottom, #000, #000 35%, transparent 100%);
+ mask-image: linear-gradient(to bottom, #000, #000 35%, transparent 100%);
+}
+
+/* (The hero glow that used to live here went with the hero. A coloured wash behind an
+ oversized headline is the most recognisable gesture in framework marketing, and this
+ page is not making that argument any more.) */
diff --git a/go/cmd/examples/go-wasm-web/server/main.go b/go/cmd/examples/go-wasm-web/server/main.go
index 9ed27d72..1869a709 100644
--- a/go/cmd/examples/go-wasm-web/server/main.go
+++ b/go/cmd/examples/go-wasm-web/server/main.go
@@ -17,6 +17,7 @@ import (
"kjol/httputil"
"kjol/vdom"
"kjol/wasmdevserver"
+ "kjol/webui"
"gowasmweb/app"
"gowasmweb/buildsteps"
@@ -73,15 +74,23 @@ func render(path string) (string, bool) {
// 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 {
+ // The theme boot script comes FIRST — before the stylesheet, before any markup.
+ //
+ // The server cannot read localStorage, so it cannot know which theme to render. If
+ // the dark class were applied by the WebAssembly once it loads, a dark-mode user
+ // would be shown a white page for as long as the binary takes to download and then
+ // have it snatched away. This runs synchronously, before the first paint, so the
+ // first paint is already right. It is the only JavaScript in the project.
return `
-gowasm — a tiny Blazor-like engine
+Kjol Web — Go + WASM
+` + webui.ThemeBootScript + `
-
+
class for CellGrid headers (exported, matching
// the TSX GRID_HEADER_CLS).
-const GridHeaderCls = "border-b border-r border-neutral-300 bg-neutral-50 px-1.5 py-1.5 text-left text-xs font-bold uppercase text-black whitespace-nowrap last:border-r-0"
+const GridHeaderCls = "border-b border-r border-line-strong bg-surface-muted px-1.5 py-1.5 text-left text-xs font-bold uppercase text-ink whitespace-nowrap last:border-r-0"
var cgLeadingDigits = regexp.MustCompile(`^\d+`)
var cgLeadingFloat = regexp.MustCompile(`^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?`)
@@ -185,7 +185,7 @@ type SortableHeaderProps struct {
// column and calls OnSort(SortKey) on click.
func SortableHeader(p SortableHeaderProps) *vdom.VNode {
isActive := p.SortKey != "" && p.Current == p.SortKey
- cls := GridHeaderCls + " cursor-pointer select-none hover:bg-neutral-200"
+ cls := GridHeaderCls + " cursor-pointer select-none hover:bg-surface-strong"
if sz := cellGridColumnSize(p.Width, p.MinWidth); sz != "" {
cls += " " + sz
}
@@ -311,9 +311,9 @@ func CellGrid(p CellGridProps) *vdom.VNode {
if p.Dense {
rowHCls = "h-6"
}
- readonlyTdCls := "border-b border-r border-neutral-300 bg-black/5 px-2 text-neutral-700 align-middle " + rowHCls
- const editableTdCls = "border-b border-r border-neutral-300 p-0 relative align-middle"
- const inputCls = "absolute inset-0 w-full border-none outline-none bg-transparent px-2 placeholder:text-neutral-400 focus:bg-red-50 focus:shadow-[inset_0_0_0_2px_var(--color-red-500)]"
+ readonlyTdCls := "border-b border-r border-line-strong bg-black/5 px-2 text-ink-soft align-middle " + rowHCls
+ const editableTdCls = "border-b border-r border-line-strong p-0 relative align-middle"
+ const inputCls = "absolute inset-0 w-full border-none outline-none bg-transparent px-2 placeholder:text-ink-faint focus:bg-red-50 dark:bg-red-950 focus:shadow-[inset_0_0_0_2px_var(--color-red-500)]"
conflicts := cellGridConflictSets(p)
isConflict := func(field string, value any) bool {
@@ -417,7 +417,7 @@ func CellGrid(p CellGridProps) *vdom.VNode {
if inList {
tdCls += " relative"
if hasConflict {
- tdCls += " bg-amber-100"
+ tdCls += " bg-amber-100 dark:bg-amber-900"
}
}
im := col.InputMode
@@ -441,7 +441,7 @@ func CellGrid(p CellGridProps) *vdom.VNode {
)
mods := []vdom.Mod{vdom.Attr("class", tdCls), input}
if inList && hasConflict {
- mods = append(mods, vdom.Span(vdom.Attr("class", "pointer-events-none absolute right-0.5 top-1/2 -translate-y-1/2 text-amber-600"),
+ mods = append(mods, vdom.Span(vdom.Attr("class", "pointer-events-none absolute right-0.5 top-1/2 -translate-y-1/2 text-amber-600 dark:text-amber-400"),
vdom.Attr("title", "Duplicate value"),
Icon("triangle-exclamation", 12, ""),
))
@@ -461,7 +461,7 @@ func CellGrid(p CellGridProps) *vdom.VNode {
for _, col := range p.Columns {
cells = append(cells, renderCell(row, col))
}
- bodyRows = append(bodyRows, vdom.Tr(kids([]vdom.Mod{vdom.Attr("class", "odd:bg-white even:bg-neutral-100")}, cells)...))
+ bodyRows = append(bodyRows, vdom.Tr(kids([]vdom.Mod{vdom.Attr("class", "odd:bg-surface even:bg-surface-raised")}, cells)...))
}
tbody := vdom.Tbody(kids(nil, bodyRows)...)
@@ -469,7 +469,7 @@ func CellGrid(p CellGridProps) *vdom.VNode {
if p.Dense {
tableCls = "min-w-full w-max border-collapse text-xs"
}
- return vdom.Div(vdom.Attr("class", "relative max-w-full overflow-x-auto border border-neutral-300 rounded-default bg-white tabular-nums"),
+ return vdom.Div(vdom.Attr("class", "relative max-w-full overflow-x-auto border border-line-strong rounded-default bg-surface tabular-nums"),
vdom.Table(vdom.Attr("class", tableCls), thead, tbody),
)
}
diff --git a/go/webui/code.go b/go/webui/code.go
new file mode 100644
index 00000000..3845b8d1
--- /dev/null
+++ b/go/webui/code.go
@@ -0,0 +1,174 @@
+package webui
+
+import (
+ "html"
+ "strings"
+)
+
+// Go syntax highlighting, for the code samples a documentation page shows.
+//
+// It is a LEXER, not a parser: it classifies tokens and gives up gracefully on anything
+// it does not understand, because a highlighter that can fail to render is worse than
+// one that occasionally paints an identifier the wrong colour. Unterminated strings and
+// comments run to the end of the input rather than throwing.
+//
+// Output is HTML, and every run of source text passes through html.EscapeString on the
+// way out — the input is Go source, which is full of `<`, `>` and `&`, and one of the
+// snippets this is meant to display is literally a block of HTML.
+
+// A code block is dark in BOTH themes — a light code block on a light page is a
+// different kind of thing, and switching it with the theme means the snippet you were
+// reading changes colour under you. So these are fixed on-dark colours, not theme
+// tokens: the surface they sit on never changes.
+const (
+ goCommentClass = "text-neutral-400"
+ goStringClass = "text-emerald-300"
+ goKeywordClass = "text-sky-300"
+ goNumberClass = "text-amber-300"
+ goFuncClass = "text-violet-300"
+)
+
+var goKeywords = map[string]bool{
+ "break": true, "case": true, "chan": true, "const": true, "continue": true,
+ "default": true, "defer": true, "else": true, "fallthrough": true, "for": true,
+ "func": true, "go": true, "goto": true, "if": true, "import": true,
+ "interface": true, "map": true, "package": true, "range": true, "return": true,
+ "select": true, "struct": true, "switch": true, "type": true, "var": true,
+ // Not keywords to the Go spec — predeclared identifiers — but every editor colours
+ // them, and a reader looking for `nil` is looking for the same kind of thing.
+ "nil": true, "true": true, "false": true, "iota": true,
+ "string": true, "int": true, "int64": true, "float64": true, "bool": true,
+ "byte": true, "rune": true, "any": true, "error": true,
+}
+
+// HighlightGo turns Go source into HTML with the tokens wrapped in coloured spans.
+//
+// The result is meant for vdom.Raw inside a
: it contains no block elements and
+// preserves the source's whitespace exactly, so the
does the layout.
+func HighlightGo(src string) string {
+ var b strings.Builder
+ b.Grow(len(src) * 2)
+
+ i := 0
+ for i < len(src) {
+ c := src[i]
+
+ switch {
+ // Line comment — including the //gowasm: directives, which are the most
+ // important line in several of these snippets.
+ case c == '/' && i+1 < len(src) && src[i+1] == '/':
+ end := strings.IndexByte(src[i:], '\n')
+ if end < 0 {
+ end = len(src)
+ } else {
+ end += i
+ }
+ span(&b, goCommentClass, src[i:end])
+ i = end
+
+ // Block comment.
+ case c == '/' && i+1 < len(src) && src[i+1] == '*':
+ end := strings.Index(src[i+2:], "*/")
+ if end < 0 {
+ end = len(src)
+ } else {
+ end = i + 2 + end + 2
+ }
+ span(&b, goCommentClass, src[i:end])
+ i = end
+
+ // Interpreted string. Ends at the closing quote or the line's end — an
+ // unterminated string is a typo in a snippet, not a reason to paint the rest of
+ // the file green.
+ case c == '"':
+ i = quoted(&b, src, i, '"', true)
+
+ // Raw string: no escapes, and it may span lines.
+ case c == '`':
+ i = quoted(&b, src, i, '`', false)
+
+ // Rune literal.
+ case c == '\'':
+ i = quoted(&b, src, i, '\'', true)
+
+ case isDigit(c):
+ j := i
+ for j < len(src) && (isDigit(src[j]) || isHexish(src[j])) {
+ j++
+ }
+ span(&b, goNumberClass, src[i:j])
+ i = j
+
+ case isIdentStart(c):
+ j := i
+ for j < len(src) && isIdentPart(src[j]) {
+ j++
+ }
+ word := src[i:j]
+ switch {
+ case goKeywords[word]:
+ span(&b, goKeywordClass, word)
+ case callAhead(src, j):
+ // An identifier immediately followed by "(" is being called (or is a type
+ // being converted to). Colouring it is what makes the shape of a snippet
+ // readable at a glance.
+ span(&b, goFuncClass, word)
+ default:
+ b.WriteString(html.EscapeString(word))
+ }
+ i = j
+
+ default:
+ b.WriteString(html.EscapeString(string(c)))
+ i++
+ }
+ }
+ return b.String()
+}
+
+// quoted consumes a quoted literal starting at i and writes it as a string span.
+// escapes reports whether a backslash escapes the next byte (false for raw strings).
+func quoted(b *strings.Builder, src string, i int, quote byte, escapes bool) int {
+ j := i + 1
+ for j < len(src) {
+ if escapes && src[j] == '\\' && j+1 < len(src) {
+ j += 2
+ continue
+ }
+ if src[j] == quote {
+ j++
+ break
+ }
+ if escapes && src[j] == '\n' {
+ break // unterminated: stop at the line end rather than eating the file
+ }
+ j++
+ }
+ span(b, goStringClass, src[i:j])
+ return j
+}
+
+// callAhead reports whether the next non-space byte at or after i is an opening paren.
+func callAhead(src string, i int) bool {
+ for i < len(src) && (src[i] == ' ' || src[i] == '\t') {
+ i++
+ }
+ return i < len(src) && src[i] == '('
+}
+
+func span(b *strings.Builder, class, text string) {
+ b.WriteString(``)
+ b.WriteString(html.EscapeString(text))
+ b.WriteString(``)
+}
+
+// isDigit already exists in the package (autotable.go) — reused rather than shadowed.
+
+func isHexish(c byte) bool {
+ return c == '.' || c == 'x' || c == 'X' || c == '_' ||
+ (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
+}
+func isIdentStart(c byte) bool { return c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') }
+func isIdentPart(c byte) bool { return isIdentStart(c) || isDigit(c) }
diff --git a/go/webui/code_test.go b/go/webui/code_test.go
new file mode 100644
index 00000000..415c6c1a
--- /dev/null
+++ b/go/webui/code_test.go
@@ -0,0 +1,99 @@
+package webui
+
+import (
+ "strings"
+ "testing"
+)
+
+// The highlighter emits HTML, and its input is Go source — which is full of <, > and &.
+// Anything that reaches the page unescaped is markup injection into your own docs page:
+// a snippet containing `
` would render a div.
+func TestHighlightGoEscapes(t *testing.T) {
+ got := HighlightGo(`s := "
" // a & b`)
+
+ if strings.Contains(got, "
` in the SOURCE reached the output as markup:\n%s", got)
+ }
+ if !strings.Contains(got, "<div") {
+ t.Errorf("the angle bracket was not escaped:\n%s", got)
+ }
+ if !strings.Contains(got, "&") {
+ t.Errorf("the ampersand was not escaped:\n%s", got)
+ }
+}
+
+func TestHighlightGoClassifies(t *testing.T) {
+ got := HighlightGo("func main() { x := 42 // note\n}")
+
+ for _, want := range []struct{ what, class, text string }{
+ {"keyword", goKeywordClass, "func"},
+ {"call", goFuncClass, "main"},
+ {"number", goNumberClass, "42"},
+ {"comment", goCommentClass, "// note"},
+ } {
+ if !strings.Contains(got, ``+want.text+``) {
+ t.Errorf("%s %q was not highlighted:\n%s", want.what, want.text, got)
+ }
+ }
+}
+
+// The //gowasm: directives are the most important line in half these snippets. They are
+// comments, and must survive as such.
+func TestHighlightGoKeepsDirectives(t *testing.T) {
+ got := HighlightGo("//gowasm:page / static layout=public\nfunc HomePage() {}")
+ if !strings.Contains(got, `//gowasm:page / static layout=public`) {
+ t.Errorf("the directive was not kept whole as a comment:\n%s", got)
+ }
+}
+
+// A lexer that can hang or eat the rest of the file on malformed input would take the
+// whole page down with it. Unterminated literals stop; they do not run away.
+func TestHighlightGoSurvivesMalformedInput(t *testing.T) {
+ for _, src := range []string{
+ `x := "unterminated`,
+ "y := `unterminated raw",
+ "/* unterminated block",
+ `z := '`,
+ "",
+ } {
+ got := HighlightGo(src)
+ // The text must all still be there — mangling is not an acceptable failure mode
+ // either. Compare on the visible characters, ignoring the spans.
+ if plain := stripTags(got); plain != src {
+ t.Errorf("input %q came out as %q", src, plain)
+ }
+ }
+}
+
+// Nothing is dropped: every byte of the source is still on the page, in order.
+func TestHighlightGoIsLossless(t *testing.T) {
+ src := "package app\n\nimport \"strings\"\n\nfunc f(n int) string {\n\treturn strings.Repeat(\"x\", n) // pad\n}\n"
+ if plain := stripTags(HighlightGo(src)); plain != src {
+ t.Errorf("the highlighter changed the source.\n got: %q\nwant: %q", plain, src)
+ }
+}
+
+// stripTags removes the spans and unescapes, recovering the original source.
+func stripTags(s string) string {
+ var b strings.Builder
+ for i := 0; i < len(s); {
+ if s[i] == '<' {
+ j := strings.IndexByte(s[i:], '>')
+ if j < 0 {
+ break
+ }
+ i += j + 1
+ continue
+ }
+ b.WriteByte(s[i])
+ i++
+ }
+ out := b.String()
+ // Reverse html.EscapeString, innermost last.
+ out = strings.ReplaceAll(out, "<", "<")
+ out = strings.ReplaceAll(out, ">", ">")
+ out = strings.ReplaceAll(out, """, `"`)
+ out = strings.ReplaceAll(out, "'", "'")
+ out = strings.ReplaceAll(out, "&", "&")
+ return out
+}
diff --git a/go/webui/crmtabs.go b/go/webui/crmtabs.go
index 209e621c..1b9852eb 100644
--- a/go/webui/crmtabs.go
+++ b/go/webui/crmtabs.go
@@ -49,9 +49,9 @@ func crmTabsPanels(p CrmTabGroupProps) []*vdom.VNode {
// --- CrmTabGroup: boxed top-accent tabs -------------------------------------
const crmTabRow = "flex w-full overflow-x-auto text-sm"
-const crmTabBase = "flex items-center gap-1.5 cursor-pointer p-4 font-medium border-neutral-300 transition-colors"
-const crmTabInactive = "border-b text-neutral-500 hover:text-neutral-800"
-const crmTabActive = "border-x border-t-2 border-t-sky-700 text-primary"
+const crmTabBase = "flex items-center gap-1.5 cursor-pointer p-4 font-medium border-line-strong transition-colors"
+const crmTabInactive = "border-b text-ink-muted hover:text-ink"
+const crmTabActive = "border-x border-t-2 border-t-sky-700 text-accent"
const crmTabBadge = "inline-flex items-center justify-center min-w-5 h-5 px-1 text-xs font-semibold bg-primary text-white rounded-full"
// CrmTabGroup renders boxed tabs with a sky-blue top accent; content sits flat
@@ -79,7 +79,7 @@ func CrmTabGroup(p CrmTabGroupProps) *vdom.VNode {
}
row = append(row, vdom.Button(btn...))
}
- row = append(row, vdom.Div(vdom.Attr("class", "flex-1 border-b border-neutral-300")))
+ row = append(row, vdom.Div(vdom.Attr("class", "flex-1 border-b border-line-strong")))
return vdom.Div(vdom.Attr("class", "w-full"),
vdom.Div(row...),
@@ -89,12 +89,12 @@ func CrmTabGroup(p CrmTabGroupProps) *vdom.VNode {
// --- CrmSubTabGroup: segmented control --------------------------------------
-const crmSubTabWrap = "flex pb-3 border-b border-neutral-300 overflow-x-auto"
-const crmSubTabGroup = "inline-flex items-stretch rounded-md border border-neutral-300 overflow-hidden text-sm select-none"
+const crmSubTabWrap = "flex pb-3 border-b border-line-strong overflow-x-auto"
+const crmSubTabGroup = "inline-flex items-stretch rounded-md border border-line-strong overflow-hidden text-sm select-none"
const crmSubTabBase = "flex items-center gap-1.5 py-1 px-3 cursor-pointer font-medium whitespace-nowrap transition-colors"
-const crmSubTabDivider = "border-l border-neutral-300"
+const crmSubTabDivider = "border-l border-line-strong"
const crmSubTabActive = "bg-neutral-500 text-white"
-const crmSubTabInactive = "bg-white text-neutral-600 hover:bg-neutral-100 hover:text-neutral-900"
+const crmSubTabInactive = "bg-surface text-ink-soft hover:bg-surface-raised hover:text-ink"
const crmSubTabBadge = "inline-flex items-center justify-center min-w-5 h-5 px-1 text-xs font-semibold bg-black/10 text-current rounded-full"
// CrmSubTabGroup renders a left-aligned segmented control (interlocking
diff --git a/go/webui/datepicker.go b/go/webui/datepicker.go
index dd8ea0ca..dbaf016f 100644
--- a/go/webui/datepicker.go
+++ b/go/webui/datepicker.go
@@ -30,7 +30,7 @@ import (
const datePickerWrap = "relative w-full min-w-0"
const datePickerField = "relative w-full min-w-0 cursor-pointer [&_.ui-form]:m-0 [&_input]:cursor-text"
-const datePickerDropdown = "bg-white border border-neutral-200 rounded-default shadow-[0_4px_12px_rgba(0,0,0,0.15)] p-1 min-w-[16rem]"
+const datePickerDropdown = "bg-surface border border-line rounded-default shadow-[0_4px_12px_rgba(0,0,0,0.15)] p-1 min-w-[16rem]"
const datePickerIconBtn = "absolute inset-y-0 right-0 z-[1] flex items-center justify-center bg-transparent border-0 px-2 cursor-pointer text-text-muted leading-none hover:text-text-body pointer-events-auto"
const datePickerClearBtn = "absolute inset-y-0 right-8 z-[1] flex items-center justify-center bg-transparent border-0 px-1.5 cursor-pointer text-text-muted leading-none hover:text-text-body pointer-events-auto"
diff --git a/go/webui/floating.go b/go/webui/floating.go
index 4baaf11e..07a83716 100644
--- a/go/webui/floating.go
+++ b/go/webui/floating.go
@@ -403,6 +403,13 @@ type FloatingTriggerProps struct {
// OnClick runs in addition to the toggle (which is suppressed when the trigger
// opens on hover).
OnClick func()
+
+ // NoToggle anchors the panel to this element without making it a switch.
+ //
+ // A text field is not a switch: the panel opens because you typed, and closes
+ // because you chose something. Toggling on click means clicking back into the field
+ // to fix a typo dismisses the results you were reading.
+ NoToggle bool
}
// Trigger renders the element the panel is anchored to.
@@ -436,6 +443,10 @@ func (f *Floating) Trigger(p FloatingTriggerProps, children ...*vdom.VNode) *vdo
if p.OnClick != nil {
mods = append(mods, vdom.On(vdom.EVENT_CLICK, p.OnClick))
}
+ } else if p.NoToggle {
+ if p.OnClick != nil {
+ mods = append(mods, vdom.On(vdom.EVENT_CLICK, p.OnClick))
+ }
} else {
mods = append(mods, vdom.On(vdom.EVENT_CLICK, func() {
if p.OnClick != nil {
diff --git a/go/webui/forms.go b/go/webui/forms.go
index 516c5694..166b5ead 100644
--- a/go/webui/forms.go
+++ b/go/webui/forms.go
@@ -23,28 +23,28 @@ import (
// -- shared Tailwind class strings (verbatim from Forms.tsx) -------------------
-const formInputBase = "bg-white block w-full border rounded-default shadow-xs text-sm focus:outline-2 focus:outline-offset-1 disabled:bg-neutral-100 disabled:cursor-not-allowed"
+const formInputBase = "bg-surface block w-full border rounded-default shadow-xs text-sm focus:outline-2 focus:outline-offset-1 disabled:bg-surface-raised disabled:cursor-not-allowed"
const formInputBaseDark = "bg-dark text-text-on-dark placeholder:text-text-on-dark-faint block w-full border rounded-default shadow-xs text-sm focus:outline-2 focus:outline-offset-1 disabled:opacity-50 disabled:cursor-not-allowed"
-const formErrorCls = "block text-red-600 text-xs mt-1"
+const formErrorCls = "block text-red-600 dark:text-red-400 text-xs mt-1"
const formSuccessCls = "block text-green-600 text-xs mt-1"
const formInputGroupCls = "flex flex-row items-stretch w-full text-sm"
-const formTriggerBase = "bg-white border border-neutral-300 rounded-default shadow-xs text-sm w-full text-left flex items-center justify-between gap-2 cursor-pointer disabled:bg-neutral-100 disabled:cursor-not-allowed"
+const formTriggerBase = "bg-surface border border-line-strong rounded-default shadow-xs text-sm w-full text-left flex items-center justify-between gap-2 cursor-pointer disabled:bg-surface-raised disabled:cursor-not-allowed"
const formTriggerBaseDark = "bg-dark-raised border border-border-on-dark text-text-on-dark rounded-default shadow-xs text-sm w-full text-left flex items-center justify-between gap-2 cursor-pointer hover:border-border-on-dark-hover disabled:opacity-50 disabled:cursor-not-allowed"
-const formDropdown = "bg-white border border-neutral-300 rounded-default shadow-lg max-h-60 overflow-auto"
+const formDropdown = "bg-surface border border-line-strong rounded-default shadow-lg max-h-60 overflow-auto"
const formDropdownDark = "bg-dark-raised border border-border-on-dark rounded-default shadow-lg max-h-60 overflow-auto"
-const formDropdownSearchWrap = "sticky top-0 bg-white border-b border-neutral-200 p-2"
+const formDropdownSearchWrap = "sticky top-0 bg-surface border-b border-line p-2"
const formDropdownSearchWrapDark = "sticky top-0 bg-dark-raised border-b border-border-on-dark p-2"
-const formDropdownSearchInput = "w-full bg-white border border-neutral-300 rounded-default shadow-xs text-sm p-1 focus:outline-2 focus:outline-sky-500 focus:outline-offset-1"
+const formDropdownSearchInput = "w-full bg-surface border border-line-strong rounded-default shadow-xs text-sm p-1 focus:outline-2 focus:outline-sky-500 focus:outline-offset-1"
const formDropdownSearchInputDark = "w-full bg-dark border border-border-on-dark text-text-on-dark placeholder:text-text-on-dark-faint rounded-default shadow-xs text-sm p-1 focus:outline-2 focus:outline-sky-500 focus:outline-offset-1"
-const formDropdownOption = "w-full text-left p-2 text-sm cursor-pointer flex items-center gap-2 bg-transparent border-none hover:bg-neutral-100 disabled:text-neutral-400 disabled:cursor-not-allowed whitespace-nowrap"
+const formDropdownOption = "w-full text-left p-2 text-sm cursor-pointer flex items-center gap-2 bg-transparent border-none hover:bg-surface-raised disabled:text-ink-faint disabled:cursor-not-allowed whitespace-nowrap"
const formDropdownOptionDark = "w-full text-left p-2 text-sm cursor-pointer flex items-center gap-2 bg-transparent border-none text-text-on-dark hover:bg-white/5 disabled:text-text-on-dark-faint disabled:cursor-not-allowed whitespace-nowrap"
-const formDropdownNoResults = "p-2 text-sm text-neutral-500 text-center"
+const formDropdownNoResults = "p-2 text-sm text-ink-muted text-center"
const formDropdownNoResultsDark = "p-2 text-sm text-text-on-dark-muted text-center"
-const formSelectAllWrap = "border-b border-neutral-200"
-const formSelectAllBtn = "w-full text-left p-2 text-sm cursor-pointer text-neutral-600 font-medium bg-transparent border-none hover:bg-neutral-100"
+const formSelectAllWrap = "border-b border-line"
+const formSelectAllBtn = "w-full text-left p-2 text-sm cursor-pointer text-ink-soft font-medium bg-transparent border-none hover:bg-surface-raised"
// -- shared class builders -----------------------------------------------------
@@ -59,7 +59,7 @@ func formControlH(small bool) string {
// formFieldBorder picks the border/focus-outline color: error > success > normal.
// error/success are the message strings; non-empty means "present" (truthy).
func formFieldBorder(errMsg, successMsg string, onDark bool) string {
- borderNormal := "border-neutral-300 focus:outline-sky-500"
+ borderNormal := "border-line-strong focus:outline-sky-500"
if onDark {
borderNormal = "border-border-on-dark focus:outline-sky-500"
}
@@ -100,8 +100,8 @@ func formTextareaCls(small bool, errMsg, extra string, onDark bool) string {
}
func formPrefixCls(small bool, errMsg string, onDark bool) string {
- base := "bg-neutral-100 border shadow-xs border-r-0 flex items-center shrink-0 rounded-l-default"
- borderNormal := "border-neutral-300"
+ base := "bg-surface-raised border shadow-xs border-r-0 flex items-center shrink-0 rounded-l-default"
+ borderNormal := "border-line-strong"
if onDark {
base = "bg-dark-raised text-text-on-dark-muted border shadow-xs border-r-0 flex items-center shrink-0 rounded-l-default"
borderNormal = "border-border-on-dark"
@@ -662,7 +662,7 @@ func FormLabel(p FormLabelProps, children ...*vdom.VNode) *vdom.VNode {
if p.Inline {
display = "inline"
}
- color := "text-neutral-700"
+ color := "text-ink-soft"
if p.OnDark {
color = "text-text-on-dark"
}
@@ -685,8 +685,8 @@ func FormFileInput(p FormInputProps) *vdom.VNode {
filePad = "file:py-[2px] file:px-3"
}
class := cx(formInputBase,
- "p-1 border-neutral-300 focus:outline-sky-500 cursor-pointer",
- "file:ml-1 file:mr-2 file:bg-neutral-100 file:border file:border-neutral-300 file:rounded-default file:shadow-xs file:text-sm file:cursor-pointer file:hover:bg-neutral-200",
+ "p-1 border-line-strong focus:outline-sky-500 cursor-pointer",
+ "file:ml-1 file:mr-2 file:bg-surface-raised file:border file:border-line-strong file:rounded-default file:shadow-xs file:text-sm file:cursor-pointer file:hover:bg-surface-strong",
filePad, p.Class)
mods := []vdom.Mod{
vdom.Attr("type", "file"),
@@ -717,8 +717,8 @@ func FormSpacer() *vdom.VNode { return vdom.Div(vdom.Attr("class", "mb-3")) }
// FormFieldset wraps children in a bordered