Add landing page for kjol, documentation
This commit is contained in:
@@ -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()))),
|
||||
)
|
||||
}
|
||||
}`
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
276
go/cmd/examples/go-wasm-web/app/docs.go
Normal file
276
go/cmd/examples/go-wasm-web/app/docs.go
Normal 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.`
|
||||
82
go/cmd/examples/go-wasm-web/app/icons_test.go
Normal file
82
go/cmd/examples/go-wasm-web/app/icons_test.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
})`
|
||||
|
||||
120
go/cmd/examples/go-wasm-web/app/landing_test.go
Normal file
120
go/cmd/examples/go-wasm-web/app/landing_test.go
Normal 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: <div ...
|
||||
if !strings.Contains(html, "<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{}
|
||||
@@ -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.`
|
||||
|
||||
@@ -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"),
|
||||
),
|
||||
)
|
||||
}
|
||||
}`
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()))),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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.) */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user