// Package app holds the go-wasm-web example's pages and components as // standalone, platform-neutral functions (SSR on the server, hydrate on the // client). UI is built from the kjol webui kit + Tailwind utility classes. // // Directives (processed by kjol/cmd/wasmgen at build time): // // //gowasm:page [static] [layout=] a route (static => SSR'd) // //gowasm:layout a func(Deps, *VNode) *VNode wrapper // //gowasm:server (see server_counter.go) a server component package app //go:generate go run kjol/cmd/wasmgen . import ( "strconv" "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" ) // Deps are the client-only capabilities, injected so pages stay neutral. type Deps struct { Path func() string Navigate func(string) } // Theme is the site-wide theme controller. One per site, created once — the switch in // the header and the class on have to be the same object, or the button and the // page disagree about what theme you are in. // // The client calls Theme.Init() after mounting (see wasm/main.go); on the server it is // inert, and the document's boot script has already put the right class on . var Theme = ui.NewTheme() func itoa(n int) string { return strconv.Itoa(n) } // Layout wraps a page's content with shared chrome (declared with //gowasm:layout, // selected per route via `layout=`; the generated LayoutFor dispatches by name). type Layout func(d Deps, content *VNode) *VNode // Shell renders the current route's page inside its declared layout. func Shell(d Deps, routes map[string]func() *VNode) *VNode { path := d.Path() var content *VNode if page := routes[path]; page != nil { content = page() } else { content = notFound(path) } return LayoutFor(d, path, content) } func notFound(path string) *VNode { return Div(Attr("class", "py-10"), H2(Attr("class", "text-xl font-semibold text-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(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, "/docs", "Docs", false), navItem(d, "/about", "About", false), Li(Attr("class", "ml-1"), Theme.ThemeToggle(ui.ThemeToggleProps{Small: true})), ))), 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(), ) } // wideRoutes get a roomier container. A table with a dozen columns, a drag handle // and three calculated columns has no business being squeezed into a reading-width // 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 { // 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()] { // 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 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})), ), )), 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, // near the root. It is an empty portal when nothing is open. ui.ModalHost(), ) } // 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 var cls string switch { case dark && active: cls = "active rounded-default px-3 py-1.5 text-sm font-medium bg-white/10 text-white" case dark: cls = "rounded-default px-3 py-1.5 text-sm font-medium text-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-surface-raised text-ink" default: 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))) } // navigate intercepts a link click for client-side SPA navigation (Navigate is // nil on the server, so the anchor falls back to a normal navigation). func navigate(d Deps, path string) Mod { return OnEvent(EVENT_CLICK, func(e Event) { if d.Navigate != nil { e.PreventDefault() d.Navigate(path) } }) } // Counter is a presentational client component; state is owned by the caller. func Counter(label string, count *Signal[int]) *VNode { return Div(Attr("class", "counter flex items-center gap-3 rounded-default border border-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 }) }}), ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Small: true, Text: "+", OnClick: func() { count.Update(func(v int) int { return v + 1 }) }}), ), ) } // ---- 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 { 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 { 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( 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.")), ), ) } } 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 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"), ), ) } }`