// Package app holds the kjol-website site's Go/WASM 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" 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: kjøl 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 { // The lockup names the LAYER you are standing in, not the site. On the front page // that is Kjøl itself; inside /wasm it is Kjøl Wasm Web; inside /c it is Kjøl C — // Wordmark, not Name, because up here "C" alone names a language rather than the thing // you are reading. A wordmark that says the same thing everywhere is one more thing the // reader has to keep track of himself. name, sub := "Kjøl", "a shared base layer" if l := CurrentLayer(d.Path()); l != nil { name, sub = l.Wordmark(), l.Sub } return A(Attr("class", "flex items-center gap-2.5 no-underline"), Attr("href", href), navigate(d, href), // text-white, not text-surface: the flag is the same in both themes, so the boat on // top of it has to be too. text-surface inverts to near-black in dark mode, which // would hide the boat against the navy cross. The flag itself carries a dark scrim // (see .flag-no) so this plain white boat reads without a shadow of its own. Span(Attr("class", "inline-flex h-8 w-8 items-center justify-center rounded-default flag-no text-white"), 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(name)), Span(Attr("class", "text-sm text-ink-faint"), Text(sub)), ), ) } // 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")), // The nav, the content and the footer are ONE column, and the way to get that is for // all three to be built the same way: gutter on the outside, measure on the inside. // //
… // // This used to be `mx-auto max-w-3xl px-4` on the nav's inner div — measure and gutter // on the SAME element. On a wide screen the gutter has nothing to do (the centring has // already pushed the box in much further), so all it did was inset the nav's contents // by another 16px: the wordmark sat a finger's width to the right of the headline // underneath it. Close enough to look like a mistake, far enough to see. // // The footer was worse — it was max-w-2xl, a different measure entirely. Nav(Attr("class", "site-nav border-b border-line"), Div(Attr("class", "px-4"), Div(Attr("class", "mx-auto flex max-w-3xl items-center gap-2 py-4"), wordmark(d, "/"), Div(Attr("class", "ml-auto flex items-center gap-1"), layersMenu(d), compositionsMenu(d), Ul(Attr("class", "flex items-center gap-1"), 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", "px-4 pb-14"), Div(Attr("class", "mx-auto max-w-3xl"), P(Attr("class", "text-sm text-ink-faint"), Text("Kjøl is a shared base layer, factored out of several applications so they stay in "+ "sync. It 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{"/wasm/components": 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")), Div(Attr("class", "ml-auto flex items-center gap-2"), layersMenu(d), compositionsMenu(d), Ul(Attr("class", "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. // sidebarNav is the sidebar's contents, which depend on WHICH LAYER you are reading. // // AppLayout is shared by every documentation page in this binary, and those pages are no // longer all about the same thing: /wasm/* documents the Go→WebAssembly engine, /c // documents the C base layer. A sidebar listing the engine's chapters while you are // reading about arenas would be worse than no sidebar at all. func sidebarNav(path string) []docsGroup { switch { case path == "/c" || strings.HasPrefix(path, "/c/"): return cNav() case path == "/go" || strings.HasPrefix(path, "/go/"): return goNav() default: return docsNav() } } 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 sidebarNav(d.Path()) { 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...) } // No icon: the sidebar is a list of words, and a glyph on every row is noise the reader has // to look past to read the label. The label is the navigation. (docsItem still carries an // Icon — it is used on the /docs index cards, where a larger tile earns one.) func sidebarLink(d Deps, it docsItem) *VNode { base, frag, isAnchor := strings.Cut(it.Path, "#") cls := "block rounded-default px-2 py-1.5 text-sm no-underline text-ink-soft hover:bg-surface-raised hover:text-ink" // A section link is NEVER "active", and that is deliberate. It cannot be: it would // have to know which section you had scrolled to, which means measuring all fifteen of // them on every scroll frame, and the only way to act on the answer is a signal write // — which re-renders this entire page. Sixty times a second, to move a highlight. // // (Marking them active by PAGE instead lights up all fifteen at once, which is worse // than no highlight: it tells you nothing and looks broken.) active := !isAnchor && d.Path() == it.Path if active { cls = "active block rounded-default px-2 py-1.5 text-sm no-underline bg-primary-subtle font-medium text-accent" } click := navigate(d, it.Path) if isAnchor { click = navigateAnchor(d, base, frag) } return A(Attr("class", cls), Attr("href", it.Path), click, 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 a column of plain text and two lists. // // It used to carry the Wasm Web engine's own highlights: the two-runtime demo, a list of // SSR/hydration/server-component features, the build transcript. All of it was true, and // none of it belonged HERE — the front page is kjøl's, and kjøl is not the Go/WebAssembly // engine any more than it is the C arena allocator. A reader landing on it should learn // what the thing IS, not be pitched one of its five parts. // // So the demo moved to /wasm, where it is the first thing that section shows, and the // front page says what is actually true of the whole: here are the languages, here are the // frameworks assembled out of them, go and read one. // //gowasm:page / static layout=public func HomePage(d Deps) func() *VNode { return func() *VNode { return Div(Attr("class", "mx-auto max-w-3xl"), H1(Attr("class", "text-3xl font-semibold tracking-tight text-text-heading"), Text("Kjøl")), P(Attr("class", "mt-3 leading-relaxed text-ink-soft"), Text("A shared base layer, factored out of several applications so they stay in sync. "+ "Kjøl is Norwegian for KEEL: the spine of a hull, the thing every other part is built onto.")), P(Attr("class", "mt-3 leading-relaxed text-ink-soft"), Text("It is not one library. It is a set of them, in several languages, and a couple of "+ "frameworks assembled out of those. Each one is documented here, and every page of that "+ "documentation runs the code it documents.")), // ---- layers: the languages ---- H2(Attr("class", "mt-12 text-lg font-semibold text-text-heading"), Text("Layers")), P(Attr("class", "mt-2 leading-relaxed text-ink-soft"), Text("What Kjøl is written in, and what it gives you in each. A layer is a directory of "+ "code you can use on its own — the Go base does not know the C one exists.")), layerGrid(Languages()), // ---- compositions: the frameworks ---- H2(Attr("class", "mt-12 text-lg font-semibold text-text-heading"), Text("Compositions")), P(Attr("class", "mt-2 leading-relaxed text-ink-soft"), Text("What the layers become when they are assembled into something that does a job. A "+ "composition is not another language: Kjøl Wasm Web is Go all the way down, and Kjøl JS "+ "Web is TypeScript compiled by a Go toolchain. These are the two you can click into.")), layerGrid(Compositions()), // ---- close ---- P(Attr("class", "mt-12 border-t border-line pt-6 leading-relaxed text-ink-soft"), Text("There is not a screenshot of a component anywhere on this site. Every example is the "+ "real thing, running — which is the only way a documentation page can tell you when it "+ "has gone stale. "), A(Attr("class", "text-accent underline underline-offset-4"), Attr("href", "/about"), navigate(d, "/about"), Text("Why this exists")), Text("."), ), ) } } // 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<") } // The real transcript. It is on the front page, so it is the first thing anybody copies — // which makes it the first thing to notice when it goes stale. const buildTranscript = `$ go run ./server -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) ==> bundling the Solid app -> wwwroot/bundle.min.{js,css} (TSX -> Solid -> esbuild) ==> copying Go's wasm_exec.js shim into wwwroot/ $ 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("Kjøl is a shared base layer, factored out of several applications so they stay in sync. "+ "(Kjøl 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, and it still is — that is Kjøl JS Web, and it "+ "is what those applications run today. Kjøl Wasm Web is the same kit written a second time "+ "in Go and compiled to WebAssembly: the same components, the same Tailwind, no JavaScript "+ "build at all. One language across the server and the browser, and a table you could share "+ "with a native app, because it is a Go function rather than a JSX file.")), P(Attr("class", "mt-4 leading-relaxed text-ink-soft"), Text("Neither of them is Kjøl. They are two compositions of it — two uses of the layers "+ "underneath, which are just directories of Go, TypeScript, C and Jai. The front page lists "+ "both, and does not argue for either.")), 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 /wasm/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"), ), ) } }`