initial port of the UI kit

This commit is contained in:
2026-07-13 01:58:29 -04:00
parent 261a7f2b4d
commit b02df48a66
51 changed files with 7507 additions and 154 deletions

View File

@@ -1,22 +1,12 @@
// Package app holds the application's pages and components as standalone
// functions (no central App struct). It is platform-neutral, so the SAME code
// renders on the server (SSR) and hydrates on the client.
// 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 cmd/wasmgen at build time):
// Directives (processed by kjol/cmd/wasmgen at build time):
//
// //gowasm:page <path> [static] [layout=<name>]
// marks a page factory as a route. `static`
// pre-renders it on the server (SSR);
// `layout=<name>` wraps it in a //gowasm:layout.
// //gowasm:layout <name> marks a func(Deps, *VNode) *VNode as a named
// layout that wraps a page's content.
// //gowasm:server (see server_counter.go) marks a component
// that runs on the server; the generated
// client stub makes calling it identical to
// calling any other component.
//
// The Routes() map, StaticPaths set, and RouteLayout/LayoutFor dispatch are all
// generated from these directives.
// //gowasm:page <path> [static] [layout=<name>] a route (static => SSR'd)
// //gowasm:layout <name> a func(Deps, *VNode) *VNode wrapper
// //gowasm:server (see server_counter.go) a server component
package app
//go:generate go run kjol/cmd/wasmgen .
@@ -25,9 +15,10 @@ import (
"strconv"
. "kjol/vdom"
ui "kjol/webui"
)
// Deps are the client-only capabilities, injected so the pages stay neutral.
// Deps are the client-only capabilities, injected so pages stay neutral.
type Deps struct {
Path func() string
Navigate func(string)
@@ -35,13 +26,11 @@ type Deps struct {
func itoa(n int) string { return strconv.Itoa(n) }
// Layout wraps a page's rendered content with shared chrome (nav, footer, …).
// Layouts are declared with //gowasm:layout and selected per route via a page's
// `layout=` directive; the generated LayoutFor dispatches by name.
// 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. `routes` is
// the generated route table; `d.Path()` selects both the page and its layout.
// 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
@@ -54,70 +43,71 @@ func Shell(d Deps, routes map[string]func() *VNode) *VNode {
}
func notFound(path string) *VNode {
return Div(
H2(Attr("class", "h4 mb-3"), Text("Page not found")),
P(Attr("class", "text-secondary"), Text("No route matches "+path+".")),
return Div(Attr("class", "py-10"),
H2(Attr("class", "text-xl font-semibold text-neutral-800 mb-2"), Text("Page not found")),
P(Attr("class", "text-neutral-500"), Text("No route matches "+path+".")),
)
}
// --- layouts (selected per route via `layout=` in //gowasm:page) ---------
// --- layouts (Tailwind chrome) -------------------------------------------
// PublicLayout is the chrome for public/marketing pages: a light navbar with a
// call-to-action into the app, and a footer. The func(Deps, *VNode) *VNode shape
// is what //gowasm:layout expects.
//
//gowasm:layout public
func PublicLayout(d Deps, content *VNode) *VNode {
return Div(
Nav(Attr("class", "navbar navbar-expand bg-light border-bottom mb-4"),
Div(Attr("class", "container"),
A(Attr("class", "navbar-brand fw-bold"), Attr("href", "/"), navigate(d, "/"), Text("gowasm")),
Ul(Attr("class", "navbar-nav ms-auto align-items-center"),
navItem(d, "/", "Home"),
navItem(d, "/about", "About"),
Li(Attr("class", "nav-item ms-2"),
A(Attr("class", "btn btn-sm btn-primary"), Attr("href", "/chart"),
navigate(d, "/chart"), Text("Open app →"))),
Nav(Attr("class", "site-nav sticky top-0 z-10 border-b border-neutral-200 bg-white"),
Div(Attr("class", "mx-auto flex max-w-5xl items-center gap-2 px-4 py-3"),
A(Attr("class", "text-lg font-semibold tracking-tight text-text-heading no-underline"), Attr("href", "/"), navigate(d, "/"), Text("gowasm")),
Ul(Attr("class", "ml-auto flex items-center gap-1"),
navItem(d, "/", "Home", false),
navItem(d, "/about", "About", false),
Li(Attr("class", "ml-2"),
A(Attr("class", "inline-flex items-center gap-1 rounded-default bg-primary px-3 py-1.5 text-sm font-medium text-white no-underline hover:bg-primary-hover"),
Attr("href", "/chart"), navigate(d, "/chart"), Text("Open app →"))),
))),
Main(Attr("class", "container"),
Main(Attr("class", "mx-auto max-w-5xl px-4 py-8"),
content,
Footer(Attr("class", "text-secondary small border-top mt-5 pt-3"),
Text("gowasm public site — a tiny Blazor-like engine in Go.")),
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.")),
),
)
}
// AppLayout is the chrome for the application itself: a dark app navbar listing
// the app's sections, plus a link back to the public site.
//
//gowasm:layout app
func AppLayout(d Deps, content *VNode) *VNode {
return Div(
Nav(Attr("class", "navbar navbar-expand navbar-dark bg-dark mb-4"),
Div(Attr("class", "container"),
A(Attr("class", "navbar-brand fw-bold"), Attr("href", "/chart"), navigate(d, "/chart"), Text("gowasm · app")),
Ul(Attr("class", "navbar-nav me-auto"),
navItem(d, "/chart", "Chart"),
navItem(d, "/server", "Server")),
Ul(Attr("class", "navbar-nav"),
navItem(d, "/", "Home")),
return Div(Attr("class", "min-h-screen"),
Nav(Attr("class", "app-nav border-b border-neutral-800 bg-neutral-900"),
Div(Attr("class", "mx-auto flex max-w-5xl items-center gap-2 px-4 py-3"),
A(Attr("class", "text-lg font-semibold tracking-tight text-text-on-dark no-underline"), Attr("href", "/chart"), navigate(d, "/chart"), Text("gowasm · app")),
Ul(Attr("class", "ml-4 flex items-center gap-1"),
navItem(d, "/chart", "Chart", true),
navItem(d, "/server", "Server", true),
navItem(d, "/kit", "UI Kit", true)),
Ul(Attr("class", "ml-auto flex items-center"),
navItem(d, "/", "Home", true)),
)),
Main(Attr("class", "container"), content),
Main(Attr("class", "mx-auto max-w-5xl px-4 py-8"), content),
)
}
// navItem is a nav link that carries an active state on the current route.
func navItem(d Deps, path, label string) *VNode {
cls := "nav-link"
if d.Path() == path {
cls += " active"
// navItem is a nav link with an active state; dark switches to on-dark colors.
func navItem(d Deps, path, label string, dark bool) *VNode {
active := d.Path() == path
var cls string
switch {
case dark && active:
cls = "active rounded-default px-3 py-1.5 text-sm font-medium bg-white/10 text-white"
case dark:
cls = "rounded-default px-3 py-1.5 text-sm font-medium text-neutral-300 hover:bg-white/5 hover:text-white"
case active:
cls = "active rounded-default px-3 py-1.5 text-sm font-medium bg-neutral-100 text-neutral-900"
default:
cls = "rounded-default px-3 py-1.5 text-sm font-medium text-neutral-600 hover:bg-neutral-100 hover:text-neutral-900"
}
return Li(Attr("class", "nav-item"),
A(Attr("class", cls), Attr("href", path), navigate(d, path), Text(label)))
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. On the server
// Navigate is nil, so the anchor falls back to a normal navigation.
// 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 {
@@ -129,31 +119,48 @@ 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 card mb-2"),
Div(Attr("class", "card-body py-2 d-flex align-items-center"),
Span(Attr("class", "me-2 fw-semibold"), Text(label+": ")),
Strong(Attr("class", "badge text-bg-primary me-2"), Text(itoa(count.Get()))),
Div(Attr("class", "btn-group btn-group-sm ms-auto"),
Button(Attr("class", "btn btn-outline-secondary"),
On(EVENT_CLICK, func() { count.Update(func(v int) int { return v - 1 }) }), Text("")),
Button(Attr("class", "btn btn-outline-primary"),
On(EVENT_CLICK, func() { count.Update(func(v int) int { return v + 1 }) }), Text("+")),
)))
return Div(Attr("class", "counter flex items-center gap-3 rounded-default border border-neutral-200 bg-white px-4 py-3 shadow-xs"),
Span(Attr("class", "font-medium text-neutral-700"), Text(label+": ")),
Strong(Attr("class", "badge inline-flex min-w-8 items-center justify-center rounded-full bg-primary px-2.5 py-0.5 text-sm font-semibold text-white"), Text(itoa(count.Get()))),
Div(Attr("class", "ml-auto flex gap-1"),
ui.Button(ui.ButtonProps{Color: ui.ButtonSecondary, Small: true, Text: "", OnClick: func() { count.Update(func(v int) int { return v - 1 }) }}),
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Small: true, Text: "+", OnClick: func() { count.Update(func(v int) int { return v + 1 }) }}),
),
)
}
//gowasm:page / static layout=public
func HomePage(d Deps) func() *VNode {
a := NewSignal(0)
b := NewSignal(0)
dark := NewSignal(false)
return func() *VNode {
return Div(
H2(Attr("class", "h4 mb-3"), Text("Home — component composition")),
P(Attr("class", "text-secondary"), Text("Two counters; the total is derived across them. Server-rendered, then hydrated.")),
Counter("Apples", a),
Counter("Bananas", b),
Div(Attr("class", "alert alert-info d-flex justify-content-between align-items-center mt-3"),
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", "fs-5"), Text(itoa(a.Get()+b.Get())))),
Strong(Attr("class", "font-semibold"), Text(itoa(a.Get()+b.Get()))),
),
ui.Card("",
ui.CardHeader("", Text("webui kit")),
Div(Attr("class", "flex flex-wrap items-center gap-2"),
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Primary"}),
ui.Button(ui.ButtonProps{Color: ui.ButtonGreen, Text: "Green"}),
ui.Button(ui.ButtonProps{Color: ui.ButtonRed, Outline: true, Text: "Danger"}),
ui.Button(ui.ButtonProps{Color: ui.ButtonNeutral, Small: true, Icon: "check", Text: "Small"}),
ui.Badge(ui.BadgeProps{Color: ui.BadgeGreen}, Text("active")),
ui.Badge(ui.BadgeProps{Color: ui.BadgeAmber, Pill: true}, Text("pending")),
),
Div(Attr("class", "mt-4"),
ui.ToggleSwitch(dark.Get(), func(v bool) { dark.Set(v) }, "Dark mode", "Just a demo toggle", false, "")),
),
)
}
}
@@ -161,32 +168,34 @@ func HomePage(d Deps) func() *VNode {
//gowasm:page /about static layout=public
func AboutPage(d Deps) func() *VNode {
return func() *VNode {
return Div(
H2(Attr("class", "h4 mb-3"), Text("About")),
P(Attr("class", "lead"),
Text("Components are standalone 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.")),
return Div(Attr("class", "space-y-6"),
H2(Attr("class", "text-2xl font-semibold tracking-tight text-text-heading"), Text("About")),
ui.Card("",
P(Attr("class", "text-neutral-600 leading-relaxed"),
Text("Components are standalone Go functions; calling a server component looks "+
"identical to calling a client one — the //gowasm:server directive and the "+
"build-time codegen wire up the round-trip. Static routes are SSR'd; the rest "+
"render on the client. The UI kit (kjol/webui) is a Go port of the Solid.js "+
"component kit, styled with Tailwind.")),
),
ui.Alert(ui.AlertGreen, "Neutral + isomorphic",
Text("This page's markup runs on the server (SSR) and hydrates on the client from the same Go code.")),
)
}
}
//gowasm:page /server layout=app
func ServerPage(d Deps) func() *VNode {
// ServerCounter is a server component — but 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. (Not `static`: the
// client renders the stub, which round-trips, so there's nothing stable to
// pre-render + hydrate.)
// 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(
H2(Attr("class", "h4 mb-3"), Text("Server component")),
P(Attr("class", "text-secondary"),
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. The call site is identical to a "+
"client component.")),
"and the returned render merges into the DOM.")),
counter(),
)
}