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

@@ -2,10 +2,11 @@
A runnable example of kjol's **gowasm** engine: author UI **components in pure
Go**, compiled to **WebAssembly**, with **SSR + hydration**, **Next.js-style
server components**, layouts, and a **flash-free, state-preserving hot reload**.
server components**, layouts, the **`kjol/webui` component kit**, **Tailwind CSS**
(compiled by kjol's own engine), and a **flash-free, state-preserving hot reload**.
No custom markup, no JSX — just Go. The engine lives in top-level kjol packages
(`kjol/go/{vdom,wasmruntime,rsc,wasmdevserver}`); this directory is only the app
that consumes them.
(`kjol/go/{vdom,wasmruntime,rsc,wasmdevserver,webui}`); this directory is only the
app that consumes them.
## Run it
@@ -14,12 +15,16 @@ cd cmd/examples/go-wasm-web
go run ./server # codegen + SSR + hot reload at http://localhost:8085
```
Open http://localhost:8085. `/` and `/about` use the light **public** layout,
`/chart` and `/server` use the dark **app** layout. Edit any `.go` file and the
browser hot-swaps the new wasm **without a full reload or a flash**, preserving
page state; a build failure shows the Go compiler output as an overlay.
Open http://localhost:8085. `/` and `/about` use the light **public** layout;
`/chart`, `/server`, and **`/kit`** (a UI-kit "kitchen-sink" demo of the webui
components) use the dark **app** layout. Edit any `.go` file and the browser
hot-swaps the new wasm **without a full reload or a flash**, preserving page
state; a build failure shows the Go compiler output as an overlay.
(`build.sh` does a one-off build instead of running the dev server.)
Styling is **Tailwind**: the dev server (and `build.sh`) run `kjol/cmd/twcss`,
which scans the Go markup + the `webui` kit for utility classes and compiles
`css/app.css``wwwroot/app.css` with kjol's native Tailwind v4 engine. There is
**no Bootstrap and no hand-written CSS**. (`build.sh` does a one-off build.)
## This is a separate module
@@ -35,11 +40,13 @@ nested module; build it from this directory.
app/ the application — neutral, standalone functions (no central struct)
pages.go Deps + Shell + App/Public layouts + nav + Counter + pages
chart.go Chart page (go-chart, renders on both sides)
kit.go /kit — UI-kit demo page showcasing kjol/webui components
server_counter.go //gowasm:server component (server-only; clicks-over-time chart)
*.gen.go GENERATED by kjol/cmd/wasmgen (routes, layout dispatch, stubs)
css/app.css Tailwind entry (@import "tailwindcss" + @theme tokens)
wasm/ the js/wasm client entry point (main_native.go is a host stub)
server/ the dev-server main: injects Build/Render/Document into wasmdevserver
wwwroot/ bootstrap.js, bootstrap.min.css (+ generated wasm_exec.js, app.wasm)
wwwroot/ wasmboot.js (+ generated app.css, wasm_exec.js, app.wasm)
```
## How it maps onto the engine (top-level `kjol` packages)

View File

@@ -8,6 +8,7 @@ import (
chart "github.com/wcharczuk/go-chart/v2"
. "kjol/vdom"
ui "kjol/webui"
)
var chartLabels = []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}
@@ -64,17 +65,16 @@ func ChartPage(d Deps) func() *VNode {
data := NewSignal(fixedChartData())
return func() *VNode {
values := data.Get()
return Div(
H2(Attr("class", "h4 mb-3"), Text("Charts — go-chart (SSR + hydrate)")),
P(Attr("class", "text-secondary"),
Text("Rendered to SVG on the server, hydrated on the client; Shuffle re-renders client-side.")),
Button(Attr("class", "btn btn-primary mb-3"),
On(EVENT_CLICK, func() { data.Set(randomValues()) }), Text("Shuffle Data")),
Div(Attr("class", "row"),
Div(Attr("class", "col-12 col-lg-7 mb-3"),
Div(Attr("class", "border rounded p-2 bg-white overflow-auto"), Raw(barSVG(values)))),
Div(Attr("class", "col-12 col-lg-5 mb-3"),
Div(Attr("class", "border rounded p-2 bg-white overflow-auto"), Raw(pieSVG(values)))),
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.")),
),
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))),
),
)
}

View File

@@ -0,0 +1,186 @@
package app
import (
. "kjol/vdom"
ui "kjol/webui"
)
// row is a flex/grid container helper (appends *VNode children as Mods).
func row(class string, children ...*VNode) *VNode {
mods := []Mod{Attr("class", class)}
for _, c := range children {
mods = append(mods, c)
}
return Div(mods...)
}
// kitSection wraps a labeled demo block in a card.
func kitSection(title string, body ...*VNode) *VNode {
return ui.Card("",
ui.CardHeader("", Text(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)),
El("td", Attr("class", "px-3 py-2 text-sm text-right"), status),
)
}
//gowasm:page /kit layout=app
func KitPage(d Deps) func() *VNode {
// Interactive demos own their state via signals (a write re-renders).
tab := NewSignal(0)
acc := NewSignal(0)
notify := NewSignal(true)
span := NewSignal("week")
modalOpen := NewSignal(false)
menuOpen := NewSignal(false)
name := NewSignal("")
email := NewSignal("")
plan := NewSignal("pro")
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).")),
),
kitSection("Buttons",
row("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, Text: "Red"}),
ui.Button(ui.ButtonProps{Color: ui.ButtonBlue, Text: "Blue"}),
ui.Button(ui.ButtonProps{Color: ui.ButtonNeutral, Text: "Neutral"}),
),
row("flex flex-wrap items-center gap-2",
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Outline: true, Text: "Outline"}),
ui.Button(ui.ButtonProps{Color: ui.ButtonRed, Outline: true, Text: "Danger"}),
ui.Button(ui.ButtonProps{Color: ui.ButtonGreen, Small: true, Icon: "check", Text: "Small + icon"}),
ui.Button(ui.ButtonProps{Color: ui.ButtonNeutral, Icon: "plus"}),
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Disabled", Disabled: true}),
),
),
kitSection("Badges",
row("flex flex-wrap items-center gap-2",
ui.Badge(ui.BadgeProps{Color: ui.BadgeGreen}, Text("active")),
ui.Badge(ui.BadgeProps{Color: ui.BadgeRed}, Text("failed")),
ui.Badge(ui.BadgeProps{Color: ui.BadgeBlue}, Text("info")),
ui.Badge(ui.BadgeProps{Color: ui.BadgeAmber, Pill: true}, Text("pending")),
ui.Badge(ui.BadgeProps{Color: ui.BadgeNeutral}, Text("default")),
ui.Badge(ui.BadgeProps{Color: ui.BadgeMuted}, Text("muted")),
),
),
kitSection("Alerts",
ui.Alert(ui.AlertBlue, "Heads up", Text("An informational message with a header.")),
ui.Alert(ui.AlertGreen, "", Text("A success alert without a header.")),
ui.Alert(ui.AlertYellow, "Warning", Text("Something needs your attention.")),
ui.Alert(ui.AlertRed, "Error", Text("Something went wrong.")),
),
kitSection("Toggles & segmented control",
ui.ToggleSwitch(notify.Get(), func(v bool) { notify.Set(v) }, "Email notifications", "Send me product updates", false, ""),
ui.SegmentedButtons([]ui.SegmentedButtonOption{
{Value: "day", Label: "Day"},
{Value: "week", Label: "Week"},
{Value: "month", Label: "Month"},
}, span.Get(), func(v string) { span.Set(v) }, false, "max-w-xs"),
),
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)."))},
},
ActiveIndex: tab.Get(),
OnTabChange: func(i int) { tab.Set(i) },
}),
),
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."))},
}, acc.Get(), func(i int) { acc.Set(i) }),
),
kitSection("Forms",
row("grid gap-4 sm:grid-cols-3",
row("flex flex-col gap-1", ui.FormLabel(ui.FormLabelProps{}, Text("Name")),
ui.FormInput(ui.FormInputProps{Value: name.Get(), Placeholder: "Ada Lovelace", OnInput: func(v string) { name.Set(v) }})),
row("flex flex-col gap-1", ui.FormLabel(ui.FormLabelProps{}, Text("Email")),
ui.FormEmailInput(ui.FormInputProps{Value: email.Get(), Placeholder: "ada@example.com", OnInput: func(v string) { email.Set(v) }}, true)),
row("flex flex-col gap-1", ui.FormLabel(ui.FormLabelProps{}, Text("Plan")),
ui.FormSelect(ui.FormSelectProps{Value: plan.Get(), OnChange: func(v string) { plan.Set(v) }},
ui.FormOption("free", "Free", false),
ui.FormOption("pro", "Pro", false),
ui.FormOption("enterprise", "Enterprise", false))),
),
P(Attr("class", "text-xs text-neutral-500"),
Text("Live: name=\""+name.Get()+"\" email=\""+email.Get()+"\" plan=\""+plan.Get()+"\"")),
),
kitSection("Table",
ui.PrettyTable(
[]ui.PrettyTableColumn{
{DisplayName: "Name"},
{DisplayName: "Plan"},
{DisplayName: "Status", DisplayPosition: ui.PrettyTableColRight},
},
ui.PrettyTableOptions{Hover: true, Alternate: true, SurroundingBorder: true, HeaderBorderY: true},
ptRow("Ada Lovelace", "Pro", ui.Badge(ui.BadgeProps{Color: ui.BadgeGreen}, Text("active"))),
ptRow("Alan Turing", "Free", ui.Badge(ui.BadgeProps{Color: ui.BadgeNeutral}, Text("trial"))),
ptRow("Grace Hopper", "Enterprise", ui.Badge(ui.BadgeProps{Color: ui.BadgeBlue}, Text("invited"))),
),
),
kitSection("Overlays (interactive)",
row("flex flex-wrap items-center gap-4",
// Modal, toggled by a signal.
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Open modal", OnClick: func() { modalOpen.Set(true) }}),
// Menu, toggled by a signal.
ui.Menu("relative inline-block",
ui.MenuTrigger(func() { menuOpen.Set(!menuOpen.Get()) }, "",
ui.Button(ui.ButtonProps{Color: ui.ButtonWhite, Text: "Menu ▾"})),
ui.MenuContent(menuOpen.Get(), ui.MenuPlacementBottomStart, "",
ui.MenuItem(ui.MenuItemProps{Icon: "check", OnClick: func() { menuOpen.Set(false) }}, Text("Profile")),
ui.MenuItem(ui.MenuItemProps{OnClick: func() { menuOpen.Set(false) }}, Text("Settings")),
ui.MenuDivider(""),
ui.MenuItem(ui.MenuItemProps{OnClick: func() { menuOpen.Set(false) }}, Text("Sign out")),
),
),
// Tooltip (pure CSS hover).
ui.HoverTooltip(Span(Text("A CSS-only tooltip")), "top", "",
ui.Button(ui.ButtonProps{Color: ui.ButtonNeutral, Outline: true, Text: "Hover me"})),
),
ui.Modal(ui.ModalProps{
IsOpen: modalOpen.Get(),
OnClose: func() { modalOpen.Set(false) },
Size: ui.ModalMedium,
Header: H3(Attr("class", "text-lg font-semibold text-text-heading"), Text("Example modal")),
},
P(Attr("class", "text-neutral-600"), Text("This modal is toggled by a signal; the backdrop and close button round-trip through OnClose.")),
),
),
ui.Alert(ui.AlertGray, "About this page",
Text("Floating/positioned components (menus, tooltips, modals, dropdowns) are ported as "+
"structure + Tailwind + signal/event wiring — the neutral runtime has no floating-ui, "+
"portals, or element measurement, so positioning is approximated with static classes.")),
)
}
}

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

View File

@@ -9,6 +9,7 @@ func Routes(d Deps) map[string]func() *vdom.VNode {
"/": HomePage(d),
"/about": AboutPage(d),
"/chart": ChartPage(d),
"/kit": KitPage(d),
"/server": ServerPage(d),
}
}
@@ -25,6 +26,7 @@ var RouteLayout = map[string]string{
"/": "public",
"/about": "public",
"/chart": "app",
"/kit": "app",
"/server": "app",
}

View File

@@ -10,6 +10,7 @@ import (
chart "github.com/wcharczuk/go-chart/v2"
. "kjol/vdom"
ui "kjol/webui"
)
// ServerCounter is a SERVER component — note it's written exactly like a client
@@ -34,19 +35,17 @@ func ServerCounter() func() *VNode {
points.Set(append(points.Get(), clickPoint{T: time.Now().UnixMilli(), V: count.Get()}))
}
return func() *VNode {
return Div(Attr("class", "card"),
Div(Attr("class", "card-body"),
Div(Attr("class", "d-flex align-items-center gap-2 mb-2"),
Span(Text("Server counter: ")),
Strong(Attr("class", "badge text-bg-success fs-6"), Text(strconv.Itoa(count.Get()))),
Button(Attr("class", "btn btn-sm btn-outline-secondary"),
On(EVENT_CLICK, func() { bump(-1) }), Text("")),
Button(Attr("class", "btn btn-sm btn-success"),
On(EVENT_CLICK, func() { bump(1) }), Text("+")),
return ui.Card("",
Div(Attr("class", "flex items-center gap-2 mb-3"),
Span(Attr("class", "text-neutral-700"), 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", "border rounded p-2 bg-white overflow-auto"),
Raw(clickChartSVG(points.Get()))),
),
Div(Attr("class", "rounded-default border border-neutral-200 bg-white p-2 overflow-auto"),
Raw(clickChartSVG(points.Get()))),
)
}
}

View File

@@ -1,24 +1,36 @@
#!/usr/bin/env bash
# Pre-compile step: run the directive codegen, build the Go client to WebAssembly,
# and stage the JS shim. Run the dev server instead (go run ./server) for hot
# reload; this script is for a one-off/production-style build. Run from anywhere.
# Pre-compile step: directive codegen, Tailwind CSS, WebAssembly build, JS shim.
# Run the dev server instead (go run ./server) for hot reload; this is for a
# one-off/production-style build. Run from anywhere.
set -euo pipefail
cd "$(dirname "$0")"
KJOL_GO="$(cd ../../.. && pwd)"
echo "==> Generating directive glue (//gowasm:page, //gowasm:layout, //gowasm:server)"
go run kjol/cmd/wasmgen ./app
echo "==> Compiling Tailwind CSS -> wwwroot/app.css (scanning webui + Go markup)"
# Run twcss from the kjol module root so the Tailwind engine's deps resolve there
# (not in this example module — keeps its go.mod lean).
( cd "$KJOL_GO" && go run ./cmd/twcss \
-entry cmd/examples/go-wasm-web/css/app.css \
-out cmd/examples/go-wasm-web/wwwroot/app.css \
-base . \
'webui/**/*.go' \
'cmd/examples/go-wasm-web/app/**/*.go' \
'cmd/examples/go-wasm-web/server/**/*.go' )
echo "==> Compiling ./wasm to wwwroot/app.wasm (GOOS=js GOARCH=wasm)"
GOOS=js GOARCH=wasm go build -o wwwroot/app.wasm ./wasm
echo "==> Copying Go's wasm_exec.js shim into wwwroot/"
GOROOT="$(go env GOROOT)"
if [ -f "$GOROOT/lib/wasm/wasm_exec.js" ]; then
cp "$GOROOT/lib/wasm/wasm_exec.js" wwwroot/wasm_exec.js # Go >= 1.24
else
cp "$GOROOT/misc/wasm/wasm_exec.js" wwwroot/wasm_exec.js # Go <= 1.23
fi
shim="$GOROOT/lib/wasm/wasm_exec.js" # Go >= 1.24
[ -f "$shim" ] || shim="$GOROOT/misc/wasm/wasm_exec.js" # Go <= 1.23
rm -f wwwroot/wasm_exec.js # GOROOT copy is read-only; remove before overwriting
cp "$shim" wwwroot/wasm_exec.js
chmod u+w wwwroot/wasm_exec.js
echo "==> Done. Run the server with: go run ./server"
echo " then open http://localhost:8085"

View File

@@ -0,0 +1,14 @@
@import "tailwindcss";
/* App-side design tokens the webui kit references (Tailwind v4 @theme). Brand
values live with the app; the kit stays generic. */
@theme {
--radius-default: 0.375rem;
--color-primary: #4f46e5;
--color-primary-hover: #4338ca;
--color-text-heading: #111827;
--color-text-on-dark: #f9fafb;
--color-text-on-dark-muted: #9ca3af;
}

View File

@@ -31,7 +31,7 @@ func main() {
Addr: *addr,
Dir: "./wwwroot",
Watch: *watch,
WatchDirs: []string{"app", "wasm", "../../../vdom", "../../../wasmruntime", "../../../rsc"}, // example + kjol engine
WatchDirs: []string{"app", "wasm", "css", "../../../webui", "../../../vdom", "../../../wasmruntime", "../../../rsc"}, // example + kjol engine + kit
Build: buildWasm,
Render: render,
Document: document,
@@ -59,23 +59,37 @@ func document(inner string) string {
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>gowasm — a tiny Blazor-like engine</title>
<link rel="stylesheet" href="/bootstrap.min.css" />
<link rel="stylesheet" href="/app.css" />
</head>
<body>
<body class="bg-neutral-50 text-neutral-900 antialiased">
<div id="app">` + inner + `</div>
<script src="/wasm_exec.js"></script>
<script src="/bootstrap.js"></script>
<script src="/wasmboot.js"></script>
</body>
</html>`
}
// buildWasm runs the directive codegen (kjol/cmd/wasmgen), then compiles
// ./wasm to wwwroot/app.wasm. Returned combined output is shown in the browser
// overlay on failure.
// buildWasm runs the directive codegen (kjol/cmd/wasmgen), compiles the Tailwind
// CSS (kjol/cmd/twcss, scanning the Go markup + webui kit), then compiles ./wasm
// to wwwroot/app.wasm. Returned combined output is shown in the browser overlay
// on failure.
func buildWasm() ([]byte, error) {
if out, err := exec.Command("go", "run", "kjol/cmd/wasmgen", "./app").CombinedOutput(); err != nil {
return out, err
}
// Compile Tailwind from the kjol module root (so the engine's deps resolve),
// scanning the webui kit + this example's Go markup for utility candidates.
tw := exec.Command("go", "run", "./cmd/twcss",
"-entry", "cmd/examples/go-wasm-web/css/app.css",
"-out", "cmd/examples/go-wasm-web/wwwroot/app.css",
"-base", ".",
"webui/**/*.go",
"cmd/examples/go-wasm-web/app/**/*.go",
"cmd/examples/go-wasm-web/server/**/*.go")
tw.Dir = "../../.." // kjol/go
if out, err := tw.CombinedOutput(); err != nil {
return out, err
}
cmd := exec.Command("go", "build", "-o", filepath.Join("wwwroot", "app.wasm"), "./wasm")
cmd.Env = append(os.Environ(), "GOOS=js", "GOARCH=wasm")
return cmd.CombinedOutput()

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

45
go/cmd/twcss/main.go Normal file
View File

@@ -0,0 +1,45 @@
// Command twcss compiles a Tailwind v4 stylesheet with kjol's native engine,
// scanning explicit content globs for utility candidates. Unlike the app bundler
// (which is wired to the frontend tree) it takes the entry, output, and content
// globs as flags/args, so it works for markup authored in any language — used by
// the go-wasm-web example, whose UI is written in Go.
//
// Usage (globs are relative to -base; pass "**" for a recursive walk):
//
// twcss -entry css/app.css -out wwwroot/app.css -base . 'webui/**/*.go' 'app/**/*.go'
package main
import (
"flag"
"fmt"
"os"
"kjol/bundler"
)
func main() {
entry := flag.String("entry", "", "path to the Tailwind entry stylesheet")
out := flag.String("out", "", "output CSS path")
base := flag.String("base", ".", "base dir the content globs are relative to")
flag.Parse()
if *entry == "" || *out == "" {
fmt.Fprintln(os.Stderr, "twcss: -entry and -out are required")
os.Exit(2)
}
src, err := os.ReadFile(*entry)
if err != nil {
fmt.Fprintln(os.Stderr, "twcss:", err)
os.Exit(1)
}
css, err := bundler.CompileTailwind(string(src), *base, flag.Args())
if err != nil {
fmt.Fprintln(os.Stderr, "twcss:", err)
os.Exit(1)
}
if err := os.WriteFile(*out, []byte(css), 0o644); err != nil {
fmt.Fprintln(os.Stderr, "twcss:", err)
os.Exit(1)
}
fmt.Printf("twcss: wrote %s (%d bytes)\n", *out, len(css))
}