Add landing page for kjol, documentation

This commit is contained in:
2026-07-13 16:51:21 -04:00
parent 5230bd6702
commit fec8ef4a3e
54 changed files with 3529 additions and 547 deletions

View File

@@ -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()))),
)
}
}`

View File

@@ -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)),
)
}
}

View File

@@ -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.`

View File

@@ -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
}

View File

@@ -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
})`

View File

@@ -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: &lt;div ...
if !strings.Contains(html, "&lt;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{}

View File

@@ -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.`

View File

@@ -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 <html> 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 <html>.
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"),
),
)
}
}`

View File

@@ -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",

View File

@@ -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()))),
)
}

View File

@@ -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`

View File

@@ -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 <html>, 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.) */

View File

@@ -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 <div id="app"> and the markup, so hydration's childNodes line up. The
// dev server injects the livereload script before </body> 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 `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>gowasm — a tiny Blazor-like engine</title>
<title>Kjol Web — Go + WASM</title>
` + webui.ThemeBootScript + `
<link rel="stylesheet" href="/app.css" />
</head>
<body class="bg-neutral-50 text-neutral-900 antialiased">
<body class="bg-surface text-ink antialiased">
<div id="app">` + inner + `</div>
<script src="/wasm_exec.js"></script>
<script src="/wasmboot.js"></script>

View File

@@ -32,4 +32,10 @@ func main() {
} else {
wasmruntime.Run(render) // client-rendered route
}
// Adopt the theme AFTER mounting. The class is already on <html> — the document's
// boot script put it there before the first paint — so this is not what makes the
// page dark; it is what makes the SWITCH know which way it is pointing, and what
// keeps the page following the OS if the user never touched the switch.
app.Theme.Init()
}

File diff suppressed because one or more lines are too long