Add landing page for kjol, documentation

This commit is contained in:
2026-07-13 16:51:21 -04:00
parent 5230bd6702
commit fec8ef4a3e
54 changed files with 3529 additions and 547 deletions

View File

@@ -70,9 +70,21 @@ GOOS=js GOARCH=wasm go -C go test -exec="node testdata/domexec.js" ./wasmruntime
```
`webui` components that measure the page (Tooltip, Popover, Menu, Modal, DatePicker, Tutorial,
AutoTable) are **controllers**: create them once alongside your signals, never inside a render
closure. Floating panels share one positioning engine (`webui/position.go`, pure math, unit-tested
natively) driven by the `Floating` controller (`webui/floating.go`).
AutoTable, SignaturePad, AsyncCombobox) are **controllers**: create them once alongside your
signals, never inside a render closure. Floating panels share one positioning engine
(`webui/position.go`, pure math, unit-tested natively) driven by the `Floating` controller
(`webui/floating.go`).
**Theming / dark mode.** The kit is themed by **semantic tokens**, not by a `dark:` variant on
every class: components say `bg-surface` / `border-line` / `text-ink` / `text-accent` and never
name a colour, so a theme is ten CSS variables rather than four hundred class strings. The app
must define them (see `webui.ThemeTokens` for the required set, and the example's `css/app.css`
for a working pair) plus `@custom-variant dark (&:where(.dark, .dark *));` — the built-in `dark`
variant is a `prefers-color-scheme` media query, which a site with its own switch cannot use.
Only genuinely *coloured* things (an alert's red tint) carry `dark:` variants. `webui.Theme` is
the controller (`Toggle`, `ThemeToggle`, `Init`); `webui.ThemeBootScript` goes in the document
head **before** the stylesheet, or dark-mode users get a white flash until the wasm loads. It is
the only JavaScript in a gowasm app.
### web/

View File

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

View File

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

View 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.`

View 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
}

View File

@@ -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
})`

View 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: &lt;div ...
if !strings.Contains(html, "&lt;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{}

View File

@@ -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.`

View File

@@ -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"),
),
)
}
}`

View File

@@ -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",

View File

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

View File

@@ -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`

View File

@@ -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.) */

View File

@@ -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>

View File

@@ -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

View File

@@ -0,0 +1,52 @@
package tw
import (
"strings"
"testing"
)
// @custom-variant is how a project redefines `dark:` as a CLASS toggle. The built-in
// dark variant is a prefers-color-scheme media query, which a site with its own theme
// switch cannot use: the OS says one thing and the switch says another, and the media
// query wins.
func TestCustomVariantDarkClass(t *testing.T) {
css, _, err := Compile(
"@import \"tailwindcss\";\n@custom-variant dark (&:where(.dark, .dark *));\n",
".", []string{"dark:bg-black", "bg-white"})
if err != nil {
t.Fatal(err)
}
if strings.Contains(css, "prefers-color-scheme") {
t.Error("dark: still compiled to a media query — the @custom-variant was ignored")
}
if !strings.Contains(css, ".dark") {
t.Errorf("dark: did not compile to a class selector:\n%s", css)
}
}
// The shorthand's selector may itself contain commas — &:where(.dark, .dark *) is ONE
// selector, and splitting it on that comma yields two broken halves.
func TestCustomVariantKeepsNestedCommas(t *testing.T) {
css, _, err := Compile(
"@import \"tailwindcss\";\n@custom-variant dark (&:where(.dark, .dark *));\n",
".", []string{"dark:bg-black"})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(css, ".dark *") {
t.Errorf("the descendant half of the selector was lost:\n%s", css)
}
}
// The block form, with an explicit @slot.
func TestCustomVariantBlockForm(t *testing.T) {
css, _, err := Compile(
"@import \"tailwindcss\";\n@custom-variant tall {\n @media (min-height: 800px) { @slot; }\n}\n",
".", []string{"tall:bg-black"})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(css, "min-height: 800px") && !strings.Contains(css, "min-height:800px") {
t.Errorf("the block-form variant did not compile:\n%s", css)
}
}

View File

@@ -1261,7 +1261,8 @@ func compileCandidates(rawCandidates []string, ds *DesignSystem, onInvalid func(
// the candidate list.
//
// @INCOMPLETE Only static @utility blocks are supported (no functional
// @utility/--value()); @custom-variant is not yet wired. -mta
// @utility/--value()). @custom-variant IS wired (both the shorthand and block
// forms) — see parseCustomVariant. -mta
//go:embed tw_theme.css
var defaultThemeCSS string
@@ -1304,6 +1305,7 @@ func twCompile(input, baseDir string, candidates []string) (string, int, error)
var keyframes []*AstNode
var passthrough []*AstNode
var customUtilities []*AstNode
var customVariants []*AstNode
hasPreflight := false
hasUtilities := false
@@ -1359,6 +1361,8 @@ func twCompile(input, baseDir string, candidates []string) (string, int, error)
processTheme(node)
case node.Kind == nAtRule && node.Name == "@utility":
customUtilities = append(customUtilities, node)
case node.Kind == nAtRule && node.Name == "@custom-variant":
customVariants = append(customVariants, node)
default:
passthrough = append(passthrough, node)
}
@@ -1373,6 +1377,20 @@ func twCompile(input, baseDir string, candidates []string) (string, int, error)
ds := buildDesignSystem(theme)
// Register @custom-variant blocks. This is how a project defines `dark:` as a CLASS
// toggle rather than a media query — the built-in dark variant follows the OS, which
// a site with a theme switch cannot use:
//
// @custom-variant dark (&:where(.dark, .dark *));
//
// Both of Tailwind's forms are accepted: the shorthand above, and the block form
// with an explicit @slot.
for _, cv := range customVariants {
if name, body, ok := parseCustomVariant(cv); ok {
ds.variants.fromAst(name, body, ds)
}
}
// Register @utility blocks as static utilities.
for _, u := range customUtilities {
name := strings.TrimSpace(u.Params)
@@ -8813,6 +8831,95 @@ func (v *Variants) compare(a, z *Variant) int {
return 1
}
// parseCustomVariant reads an @custom-variant at-rule into a name and the AST body that
// fromAst expects (a body whose rules contain an @slot where the utility goes).
//
// Two forms, both from Tailwind:
//
// @custom-variant dark (&:where(.dark, .dark *)); // shorthand
//
// @custom-variant dark { // block, explicit slot
// &:where(.dark, .dark *) { @slot; }
// }
//
// In the shorthand, a parenthesised selector starting with '@' is an at-rule
// (`@custom-variant any-hover (@media (any-hover: hover))`), and anything else is a
// selector. Several may be given, comma-separated at the top level.
func parseCustomVariant(node *AstNode) (name string, body []*AstNode, ok bool) {
params := strings.TrimSpace(node.Params)
if params == "" {
return "", nil, false
}
// The name is the first token; whatever follows is the shorthand's parenthesised part.
i := strings.IndexAny(params, " \t(")
if i < 0 {
// No shorthand: it must be the block form, which carries its own @slot.
if len(node.Nodes) == 0 {
return "", nil, false
}
return params, node.Nodes, true
}
name = strings.TrimSpace(params[:i])
rest := strings.TrimSpace(params[i:])
if rest == "" {
if len(node.Nodes) == 0 {
return "", nil, false
}
return name, node.Nodes, true
}
if !strings.HasPrefix(rest, "(") || !strings.HasSuffix(rest, ")") {
return "", nil, false
}
inner := strings.TrimSpace(rest[1 : len(rest)-1])
if inner == "" {
return "", nil, false
}
for _, sel := range splitTopLevel(inner, ',') {
sel = strings.TrimSpace(sel)
if sel == "" {
continue
}
slot := atRule("@slot", "")
if strings.HasPrefix(sel, "@") {
// "@media (any-hover: hover)" -> name "@media", params "(any-hover: hover)"
at, params, _ := strings.Cut(sel, " ")
body = append(body, atRule(at, strings.TrimSpace(params), slot))
continue
}
body = append(body, styleRule(sel, slot))
}
if len(body) == 0 {
return "", nil, false
}
return name, body, true
}
// splitTopLevel splits on sep, ignoring separators nested inside brackets — a selector
// list like `&:where(.dark, .dark *)` is ONE selector, and splitting it on its inner
// comma would produce two broken halves.
func splitTopLevel(s string, sep byte) []string {
var parts []string
depth := 0
start := 0
for i := 0; i < len(s); i++ {
switch s[i] {
case '(', '[':
depth++
case ')', ']':
depth--
case sep:
if depth == 0 {
parts = append(parts, s[start:i])
start = i + 1
}
}
}
return append(parts, s[start:])
}
// fromAst registers a variant whose body comes from CSS (@custom-variant).
func (v *Variants) fromAst(name string, ast []*AstNode, ds *DesignSystem) {
var selectors []string

View File

@@ -17,9 +17,18 @@ const (
EVENT_MOUSEENTER = "mouseenter"
EVENT_MOUSELEAVE = "mouseleave"
EVENT_CONTEXTMENU = "contextmenu"
EVENT_SCROLL = "scroll"
EVENT_RESIZE = "resize"
EVENT_WHEEL = "wheel"
// Pointer events unify mouse, touch and pen behind one set of handlers that all
// carry clientX/clientY. A component that wants to be drawn on — the signature pad —
// needs exactly one code path, not a mouse one and a touch one that drift apart.
EVENT_POINTERDOWN = "pointerdown"
EVENT_POINTERMOVE = "pointermove"
EVENT_POINTERUP = "pointerup"
EVENT_POINTERCANCEL = "pointercancel"
EVENT_SCROLL = "scroll"
EVENT_RESIZE = "resize"
EVENT_WHEEL = "wheel"
// focusin/focusout BUBBLE; focus/blur do not. Anything that needs to know a
// subtree gained focus (a tooltip staying open while a child is focused) must

View File

@@ -13,6 +13,20 @@ func Measure(*vdom.Ref) Rect { return Rect{} }
func Viewport() Size { return Size{} }
func CSSVarPx(string) float64 { return 0 }
// Now is 0 on the server: there is no page load to measure from. A component that
// reports a timing therefore SSRs an empty reading and fills it in once the client has
// taken over — which is the honest answer, since the number IS a property of the client.
func Now() float64 { return 0 }
// The theme is a property of the BROWSER — the OS setting and a localStorage choice,
// neither of which the server can see. So the server always renders the light theme, and
// the document's boot script (webui.ThemeBootScript) applies the dark class before the
// first paint. Nothing here can help with that, and pretending otherwise would just move
// the flash somewhere harder to find.
func PrefersDark() bool { return false }
func OnMediaChange(string, func(bool)) Unsub { return func() {} }
func SetRootClass(string, bool) {}
func RAF(func()) int { return 0 }
func CancelRAF(int) {}
func AfterRender(func()) {}

View File

@@ -67,8 +67,63 @@ func CSSVarPx(name string) float64 {
return parseCSSLength(raw, style.Get("fontSize").String())
}
// ---- theme ----
// PrefersDark reports whether the OS asks for a dark colour scheme.
func PrefersDark() bool {
mq := window.Call("matchMedia", "(prefers-color-scheme: dark)")
return mq.Truthy() && mq.Get("matches").Bool()
}
// OnMediaChange watches a media query. The theme controller uses it to follow the OS
// while the user has not overridden it — a preference changed in the system settings
// should reach an open tab, not wait for a reload.
func OnMediaChange(query string, fn func(matches bool)) Unsub {
mq := window.Call("matchMedia", query)
if !mq.Truthy() {
return func() {}
}
cb := js.FuncOf(func(_ js.Value, args []js.Value) any {
matches := false
if len(args) > 0 {
matches = args[0].Get("matches").Bool()
}
fn(matches)
return nil
})
mq.Call("addEventListener", "change", cb)
return func() {
mq.Call("removeEventListener", "change", cb)
cb.Release()
}
}
// SetRootClass adds or removes a class on <html>.
//
// The THEME lives there rather than on the app's root element because the page's own
// background — the thing behind everything, painted before the app mounts — is styled
// from <html>. A dark class on #app leaves a white margin around a dark page.
func SetRootClass(class string, on bool) {
list := document.Get("documentElement").Get("classList")
if on {
list.Call("add", class)
return
}
list.Call("remove", class)
}
// ---- frame timing + the post-render hook ----
// Now is performance.now(): milliseconds since the page began navigating, with
// sub-millisecond resolution.
//
// It is measured from the same origin the browser uses for its own timings, so a
// reading taken when the first render commits IS the time it took this app to become
// interactive — not an interval the app started and stopped itself.
func Now() float64 {
return window.Get("performance").Call("now").Float()
}
func RAF(fn func()) int {
var cb js.Func
cb = js.FuncOf(func(js.Value, []js.Value) any {

View File

@@ -11,14 +11,14 @@ import "kjol/vdom"
// The TSX startOpen prop collapses into the caller-supplied initial IsOpen /
// open[] / openIndex.
const accordionRootCls = "ui-accordion border border-neutral-200 rounded-default overflow-hidden"
const accordionItemCls = "ui-accordion-item border-b border-neutral-200 last:border-b-0"
const accordionTriggerCls = "ui-accordion-trigger flex items-center justify-between w-full py-3 px-4 text-left font-medium text-neutral-900 bg-neutral-50 cursor-pointer border-none transition-colors hover:bg-neutral-100 active:bg-neutral-200 focus:outline-hidden disabled:text-neutral-400 disabled:cursor-not-allowed disabled:bg-neutral-50"
const accordionRootCls = "ui-accordion border border-line rounded-default overflow-hidden"
const accordionItemCls = "ui-accordion-item border-b border-line last:border-b-0"
const accordionTriggerCls = "ui-accordion-trigger flex items-center justify-between w-full py-3 px-4 text-left font-medium text-ink bg-surface-muted cursor-pointer border-none transition-colors hover:bg-surface-raised active:bg-surface-strong focus:outline-hidden disabled:text-ink-faint disabled:cursor-not-allowed disabled:bg-surface-muted"
const accordionTitleCls = "ui-accordion-title flex-1"
const accordionContentCls = "ui-accordion-content px-4 pb-4 text-neutral-700"
const accordionContentCls = "ui-accordion-content px-4 pb-4 text-ink-soft"
func accordionIconCls(open bool) string {
c := "ui-accordion-icon text-neutral-500 leading-none transition-transform duration-200"
c := "ui-accordion-icon text-ink-muted leading-none transition-transform duration-200"
if open {
c += " rotate-180"
}

View File

@@ -17,12 +17,12 @@ const (
const alertBase = "p-4 rounded-default shadow-xs border"
var alertColors = map[string]string{
"white": "bg-white border-neutral-100",
"gray": "bg-neutral-50 border-neutral-200",
"blue": "bg-sky-50 border-sky-200",
"green": "bg-green-50 border-green-200",
"red": "bg-red-50 border-red-200",
"yellow": "bg-yellow-50 border-yellow-200",
"white": "bg-surface border-line",
"gray": "bg-surface-muted border-line",
"blue": "bg-sky-50 dark:bg-sky-950 border-sky-200 dark:border-sky-900",
"green": "bg-green-50 dark:bg-green-950 border-green-200 dark:border-green-900",
"red": "bg-red-50 dark:bg-red-950 border-red-200 dark:border-red-900",
"yellow": "bg-yellow-50 dark:bg-yellow-950 border-yellow-200 dark:border-yellow-900",
}
// Alert renders a colored callout. header is optional (rendered as a bold title

View File

@@ -80,7 +80,7 @@ const (
// HEADER_COLOR_CLS is the background + text color per header color.
var HEADER_COLOR_CLS = map[AutoTableHeaderColor]string{
AUTOTABLE_HEADER_COLOR_DEFAULT: "bg-neutral-50",
AUTOTABLE_HEADER_COLOR_DEFAULT: "bg-surface-muted",
AUTOTABLE_HEADER_COLOR_BLUE: "bg-sky-700 text-white",
AUTOTABLE_HEADER_COLOR_GREEN: "bg-green-700 text-white",
AUTOTABLE_HEADER_COLOR_GRAY: "bg-neutral-600 text-white",
@@ -89,7 +89,7 @@ var HEADER_COLOR_CLS = map[AutoTableHeaderColor]string{
// atHeaderSortHoverCls is the sortable-hover override per color.
var atHeaderSortHoverCls = map[AutoTableHeaderColor]string{
AUTOTABLE_HEADER_COLOR_DEFAULT: "hover:bg-neutral-300",
AUTOTABLE_HEADER_COLOR_DEFAULT: "hover:bg-surface-strong",
AUTOTABLE_HEADER_COLOR_BLUE: "hover:bg-sky-500",
AUTOTABLE_HEADER_COLOR_GREEN: "hover:bg-green-800",
AUTOTABLE_HEADER_COLOR_GRAY: "hover:bg-neutral-500",
@@ -107,7 +107,7 @@ var HEADER_TEXT_CLS = map[AutoTableHeaderColor]string{
// atHeaderSortIconCls is the sort-icon color (matches header text) per color.
var atHeaderSortIconCls = map[AutoTableHeaderColor]string{
AUTOTABLE_HEADER_COLOR_DEFAULT: "text-black",
AUTOTABLE_HEADER_COLOR_DEFAULT: "text-ink",
AUTOTABLE_HEADER_COLOR_BLUE: "text-white",
AUTOTABLE_HEADER_COLOR_GREEN: "text-white",
AUTOTABLE_HEADER_COLOR_GRAY: "text-white",
@@ -137,11 +137,11 @@ var atPaginationPaddingCls = map[AutoTableSize]string{
// atRowHoverCls is the body-row hover background per color (when hover is on).
var atRowHoverCls = map[AutoTableHeaderColor]string{
AUTOTABLE_HEADER_COLOR_DEFAULT: "hover:bg-neutral-200",
AUTOTABLE_HEADER_COLOR_BLUE: "hover:bg-sky-100",
AUTOTABLE_HEADER_COLOR_GREEN: "hover:bg-green-100",
AUTOTABLE_HEADER_COLOR_GRAY: "hover:bg-neutral-200",
AUTOTABLE_HEADER_COLOR_DARK_BLUE: "hover:bg-sky-100",
AUTOTABLE_HEADER_COLOR_DEFAULT: "hover:bg-surface-strong",
AUTOTABLE_HEADER_COLOR_BLUE: "hover:bg-sky-100 dark:bg-sky-900",
AUTOTABLE_HEADER_COLOR_GREEN: "hover:bg-green-100 dark:bg-green-900",
AUTOTABLE_HEADER_COLOR_GRAY: "hover:bg-surface-strong",
AUTOTABLE_HEADER_COLOR_DARK_BLUE: "hover:bg-sky-100 dark:bg-sky-900",
}
// POS_CLS is the text alignment per ColumnPosition.
@@ -160,7 +160,7 @@ var HEADER_INNER_POS = map[ColumnPosition]string{
// -- Class string constants for parts that don't vary by config ------------
const (
TBL_CONTAINER = "relative flex flex-col w-full h-full bg-white rounded-default overflow-hidden"
TBL_CONTAINER = "relative flex flex-col w-full h-full bg-surface rounded-default overflow-hidden"
TBL_WRAPPER = "overflow-x-auto w-full"
TBL_BASE = "min-w-full"
@@ -169,20 +169,20 @@ const (
atSortIconWrap = "leading-none shrink-0 opacity-50"
atSkeleton = "h-4 bg-neutral-200 rounded-default animate-pulse"
atErrorCell = "text-center text-red-600"
atEmptyCell = "text-center text-neutral-500"
atSkeleton = "h-4 bg-surface-strong rounded-default animate-pulse"
atErrorCell = "text-center text-red-600 dark:text-red-400"
atEmptyCell = "text-center text-ink-muted"
atPaginationBase = "flex justify-between items-center border-t border-neutral-300"
atPaginationInfo = "hidden sm:flex items-center text-sm text-neutral-500"
atPaginationBase = "flex justify-between items-center border-t border-line-strong"
atPaginationInfo = "hidden sm:flex items-center text-sm text-ink-muted"
atPaginationControls = "flex items-center"
atPaginationLabel = "hidden sm:block text-sm text-neutral-500 mr-2"
atPaginationPage = "text-sm text-neutral-500 px-3"
atPaginationBtn = "p-1 min-h-9 text-sm font-normal leading-none bg-transparent border-0 cursor-pointer hover:bg-neutral-100 disabled:text-neutral-300 disabled:cursor-not-allowed disabled:hover:bg-transparent"
atPaginationLabel = "hidden sm:block text-sm text-ink-muted mr-2"
atPaginationPage = "text-sm text-ink-muted px-3"
atPaginationBtn = "p-1 min-h-9 text-sm font-normal leading-none bg-transparent border-0 cursor-pointer hover:bg-surface-raised disabled:text-ink-faint disabled:cursor-not-allowed disabled:hover:bg-transparent"
// thead sticky classes are the STATIC replacement for the TSX's JS-driven
// header pinning (transform tracking on scroll). See file-level NOTE.
atTheadCls = "sticky top-0 z-10 [&_th]:border-b [&_th]:border-neutral-300"
atTheadCls = "sticky top-0 z-10 [&_th]:border-b [&_th]:border-line-strong"
)
// AutoTableColumn describes one column: its header, alignment, width, whether it
@@ -475,7 +475,7 @@ func AutoTable(cols []AutoTableColumn, rows []any, opts ...AutoTableOption) *vdo
containerCls := TBL_CONTAINER
if cfg.surroundingBorder {
containerCls = cx(containerCls, "border border-neutral-300")
containerCls = cx(containerCls, "border border-line-strong")
}
if cfg.shadow {
containerCls = cx(containerCls, "shadow-sm")
@@ -754,7 +754,7 @@ func atResizeHandle(key string, h *atColumnHooks) *vdom.VNode {
func atRenderBody(cols []AutoTableColumn, rows []any, cfg *atConfig) *vdom.VNode {
bodyCls := BODY_PADDING_CLS[cfg.size]
if cfg.borderY {
bodyCls = cx(bodyCls, "[&_td+td]:border-l [&_td+td]:border-neutral-300")
bodyCls = cx(bodyCls, "[&_td+td]:border-l [&_td+td]:border-line-strong")
}
colspan := atTotalColumns(cols, cfg)
@@ -768,7 +768,7 @@ func atRenderBody(cols []AutoTableColumn, rows []any, cfg *atConfig) *vdom.VNode
for r := 0; r < 5; r++ {
tr := vdom.Tr()
if cfg.alternate && r%2 == 1 {
tr.Attrs["class"] = "bg-neutral-100"
tr.Attrs["class"] = "bg-surface-raised"
}
if cfg.accordion {
tr.Children = append(tr.Children, vdom.Td(vdom.Attr("class", AUTOTABLE_ACCORDION_CELL)))
@@ -799,11 +799,11 @@ func atRenderBody(cols []AutoTableColumn, rows []any, cfg *atConfig) *vdom.VNode
isLast := rowIdx == len(rows)-1
rowCls := ""
if cfg.alternate && rowIdx%2 == 1 {
rowCls = cx(rowCls, "bg-neutral-100")
rowCls = cx(rowCls, "bg-surface-raised")
}
rowCls = cx(rowCls, hoverCls)
if cfg.borderX && !isLast {
rowCls = cx(rowCls, "border-b border-neutral-300")
rowCls = cx(rowCls, "border-b border-line-strong")
}
if cfg.highlight != nil && cfg.highlight(row) {
rowCls = cx(rowCls, AUTOTABLE_HIGHLIGHT_ROW)
@@ -2182,14 +2182,14 @@ func (s *AutoTableState) FiltersToggle() *vdom.VNode {
const (
AUTOTABLE_SEARCH_FIELDS = "flex flex-wrap items-end gap-2"
AUTOTABLE_SEARCH_FIELD = "flex flex-col gap-1 min-w-0 grow sm:grow-0 sm:w-48"
AUTOTABLE_SEARCH_SELECT = "w-full rounded-default border border-neutral-300 bg-white px-3 py-2 text-sm"
AUTOTABLE_FILTERS_TOGGLE = "sm:hidden inline-flex items-center gap-2 rounded-default border border-neutral-300 px-3 py-2 text-sm"
AUTOTABLE_SEARCH_SELECT = "w-full rounded-default border border-line-strong bg-surface px-3 py-2 text-sm"
AUTOTABLE_FILTERS_TOGGLE = "sm:hidden inline-flex items-center gap-2 rounded-default border border-line-strong px-3 py-2 text-sm"
AUTOTABLE_FILTERS_BADGE = "ml-1 inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-sky-600 px-1.5 text-xs font-semibold text-white"
AUTOTABLE_TOOLBAR = "flex flex-wrap items-end justify-between gap-3 pb-3"
AUTOTABLE_ASIDE = "w-full sm:w-64 shrink-0 rounded-default border border-neutral-300 bg-white p-3"
AUTOTABLE_ASIDE = "w-full sm:w-64 shrink-0 rounded-default border border-line-strong bg-surface p-3"
AUTOTABLE_ACCORDION_CELL = "w-10 text-center"
AUTOTABLE_ACCORDION_ROW = "bg-neutral-50"
AUTOTABLE_HIGHLIGHT_ROW = "bg-amber-100! shadow-[inset_3px_0_0_0_theme(colors.amber.500)]"
AUTOTABLE_ACCORDION_ROW = "bg-surface-muted"
AUTOTABLE_HIGHLIGHT_ROW = "bg-amber-100 dark:bg-amber-900! shadow-[inset_3px_0_0_0_theme(colors.amber.500)]"
)
// ==========================================================================
@@ -2602,7 +2602,7 @@ func (s *AutoTableState) pendingSkeleton() *vdom.VNode {
vdom.Attr("aria-label", "Loading table"),
vdom.Div(
kids([]vdom.Mod{
vdom.Attr("class", cx(TBL_CONTAINER, "border border-neutral-300 p-4 flex flex-col gap-3")),
vdom.Attr("class", cx(TBL_CONTAINER, "border border-line-strong p-4 flex flex-col gap-3")),
}, bars)...,
),
)
@@ -2718,6 +2718,7 @@ func (s *AutoTableState) ColumnPicker() *vdom.VNode {
if len(options) == 0 {
return nil
}
total := len(options)
return s.multiSelect("__columns__").Render(FormMultiSelectProps{
Options: options,
Value: selected,
@@ -2725,6 +2726,11 @@ func (s *AutoTableState) ColumnPicker() *vdom.VNode {
Searchable: true,
ShowSelectAll: true,
FieldWidth: "w-52",
// Not "5 items selected": this picker is about COLUMNS, and the count is only
// meaningful next to how many there are to choose from.
CollapsedLabel: func(n int) string {
return strconv.Itoa(n) + " of " + strconv.Itoa(total) + " columns"
},
OnChange: func(visible []string) {
show := map[string]bool{}
for _, k := range visible {
@@ -2779,7 +2785,7 @@ func (s *AutoTableState) ResetMenu() *vdom.VNode {
}
if len(items) == 0 {
items = append(items, vdom.Div(vdom.Attr("class", "px-3 py-2 text-sm text-neutral-500"),
items = append(items, vdom.Div(vdom.Attr("class", "px-3 py-2 text-sm text-ink-muted"),
vdom.Text("Nothing to reset"),
))
} else {
@@ -3091,7 +3097,7 @@ func (s *AutoTableState) summaryFoot(ctx *CalcContext, colCount int) *vdom.VNode
}
// AUTOTABLE_TFOOT styles the summary footer.
const AUTOTABLE_TFOOT = "border-t-2 border-neutral-300 bg-neutral-50 [&_td]:p-3"
const AUTOTABLE_TFOOT = "border-t-2 border-line-strong bg-surface-muted [&_td]:p-3"
// ---- the calculated-column editor ----
@@ -3127,16 +3133,16 @@ func isBinaryCalcFn(fn CalculatedFunction) bool {
// their metrics line up to the pixel; if they ever drift, the caret stops landing on
// the glyph it appears to be on.
const (
CALC_ADD_TRIGGER_CLS = "inline-flex items-center justify-center gap-2 cursor-pointer text-sm font-normal rounded-default transition shadow-xs bg-neutral-50 text-black border border-neutral-300 hover:bg-neutral-100 py-1 px-3"
CALC_ADD_TRIGGER_CLS = "inline-flex items-center justify-center gap-2 cursor-pointer text-sm font-normal rounded-default transition shadow-xs bg-surface-muted text-ink border border-line-strong hover:bg-surface-raised py-1 px-3"
FORMULA_EDIT_BASE = "block w-full h-[30px] font-mono text-sm p-1 rounded-default box-border whitespace-pre"
FORMULA_OVERLAY_CLS = FORMULA_EDIT_BASE + " absolute inset-0 overflow-hidden pointer-events-none border border-transparent text-neutral-800"
FORMULA_TEXTAREA_CLS = FORMULA_EDIT_BASE + " relative bg-transparent text-transparent caret-neutral-800 resize-none overflow-x-auto overflow-y-hidden shadow-xs border border-neutral-300 focus:border-sky-500 outline-hidden"
FORMULA_OVERLAY_CLS = FORMULA_EDIT_BASE + " absolute inset-0 overflow-hidden pointer-events-none border border-transparent text-ink"
FORMULA_TEXTAREA_CLS = FORMULA_EDIT_BASE + " relative bg-transparent text-transparent caret-neutral-800 resize-none overflow-x-auto overflow-y-hidden shadow-xs border border-line-strong focus:border-sky-500 outline-hidden"
formulaPlaceholder = `<span class="text-neutral-400">e.g. [Revenue] / SUM({Revenue}) * 100</span>`
formulaPlaceholder = `<span class="text-ink-faint">e.g. [Revenue] / SUM({Revenue}) * 100</span>`
calcMenuTrigger = "inline-flex items-center gap-1 rounded-default border border-neutral-300 bg-neutral-50 px-2 py-1 text-xs leading-none text-neutral-700 cursor-pointer hover:bg-neutral-100"
calcChooserItem = "w-full text-left px-2 py-2 rounded-default hover:bg-neutral-100 cursor-pointer border-0 bg-transparent flex items-start gap-2.5"
calcMenuTrigger = "inline-flex items-center gap-1 rounded-default border border-line-strong bg-surface-muted px-2 py-1 text-xs leading-none text-ink-soft cursor-pointer hover:bg-surface-raised"
calcChooserItem = "w-full text-left px-2 py-2 rounded-default hover:bg-surface-raised cursor-pointer border-0 bg-transparent flex items-start gap-2.5"
)
// HighlightFormula renders a formula as HTML with syntax colouring: cell refs
@@ -3145,6 +3151,20 @@ const (
// Purely visual, and deliberately LEXICAL rather than a real parse — it has to
// colour a half-typed formula that does not compile yet, which is exactly when the
// colours are worth the most.
// The formula highlighter's token colours.
//
// Named, rather than written inline, because they are asserted on in tests — and a
// colour spelled out in two places is a colour that gets changed in one. (It already
// happened: adding the dark: variants broke every highlighter test, none of which cared
// about the colour, all of which had a copy of it.)
const (
fmlCellClass = "text-sky-600 dark:text-sky-400" // [Cell]
fmlColumnClass = "text-violet-600 dark:text-violet-400" // {Column}
fmlNumberClass = "text-amber-600 dark:text-amber-400" // 42, PI
fmlFuncClass = "text-emerald-700 dark:text-emerald-400 font-semibold" // SUM(
fmlOpClass = "text-ink-faint" // + - * /
)
func HighlightFormula(src string) string {
var b strings.Builder
i := 0
@@ -3159,7 +3179,7 @@ func HighlightFormula(src string) string {
if j >= 0 {
end = i + j + 1
}
b.WriteString(`<span class="text-sky-600">`)
b.WriteString(`<span class="` + fmlCellClass + `">`)
b.WriteString(html.EscapeString(src[i:end]))
b.WriteString(`</span>`)
i = end
@@ -3176,7 +3196,7 @@ func HighlightFormula(src string) string {
}
j++
}
b.WriteString(`<span class="text-violet-600">`)
b.WriteString(`<span class="` + fmlColumnClass + `">`)
b.WriteString(html.EscapeString(src[i:j]))
b.WriteString(`</span>`)
i = j
@@ -3186,7 +3206,7 @@ func HighlightFormula(src string) string {
for j < len(src) && (isDigit(src[j]) || src[j] == '.') {
j++
}
b.WriteString(`<span class="text-amber-600">`)
b.WriteString(`<span class="` + fmlNumberClass + `">`)
b.WriteString(html.EscapeString(src[i:j]))
b.WriteString(`</span>`)
i = j
@@ -3205,11 +3225,11 @@ func HighlightFormula(src string) string {
}
switch {
case k < len(src) && src[k] == '(':
b.WriteString(`<span class="text-emerald-700 font-semibold">`)
b.WriteString(`<span class="` + fmlFuncClass + `">`)
b.WriteString(html.EscapeString(word))
b.WriteString(`</span>`)
case isFormulaConstant(word):
b.WriteString(`<span class="text-amber-600">`)
b.WriteString(`<span class="` + fmlNumberClass + `">`)
b.WriteString(html.EscapeString(word))
b.WriteString(`</span>`)
default:
@@ -3218,7 +3238,7 @@ func HighlightFormula(src string) string {
i = j
case strings.IndexByte("+-*/^%=<>(),:", c) >= 0:
b.WriteString(`<span class="text-neutral-400">`)
b.WriteString(`<span class="` + fmlOpClass + `">`)
b.WriteString(html.EscapeString(string(c)))
b.WriteString(`</span>`)
i++
@@ -3590,7 +3610,7 @@ func (s *AutoTableState) calcChooser(e *calcEditor) *vdom.VNode {
))
}
if len(existing) > 0 {
children = append(children, vdom.Div(kids([]vdom.Mod{vdom.Attr("class", "flex flex-col gap-1 border-b border-neutral-200 pb-2 mb-1")}, existing)...))
children = append(children, vdom.Div(kids([]vdom.Mod{vdom.Attr("class", "flex flex-col gap-1 border-b border-line pb-2 mb-1")}, existing)...))
}
children = append(children,
@@ -3607,19 +3627,19 @@ func calcChooserButton(icon, title, subtitle string, onClick func()) *vdom.VNode
return vdom.Button(vdom.Attr("type", "button"),
vdom.Attr("class", calcChooserItem),
vdom.On(vdom.EVENT_CLICK, onClick),
Icon(icon, 16, "mt-0.5 text-neutral-500"),
Icon(icon, 16, "mt-0.5 text-ink-muted"),
vdom.Span(vdom.Attr("class", "flex flex-col"),
vdom.Span(vdom.Attr("class", "text-sm font-medium text-neutral-800"), vdom.Text(title)),
vdom.Span(vdom.Attr("class", "text-xs text-neutral-500"), vdom.Text(subtitle)),
vdom.Span(vdom.Attr("class", "text-sm font-medium text-ink"), vdom.Text(title)),
vdom.Span(vdom.Attr("class", "text-xs text-ink-muted"), vdom.Text(subtitle)),
),
)
}
func calcListRow(title, subtitle string, onEdit, onDelete func()) *vdom.VNode {
return vdom.Div(vdom.Attr("class", "flex items-center gap-2 rounded-default px-2 py-1 hover:bg-neutral-50"),
return vdom.Div(vdom.Attr("class", "flex items-center gap-2 rounded-default px-2 py-1 hover:bg-surface-muted"),
vdom.Div(vdom.Attr("class", "min-w-0 grow"),
vdom.Div(vdom.Attr("class", "truncate text-sm font-medium text-neutral-800"), vdom.Text(title)),
vdom.Div(vdom.Attr("class", "truncate font-mono text-xs text-neutral-500"), vdom.Text(subtitle)),
vdom.Div(vdom.Attr("class", "truncate text-sm font-medium text-ink"), vdom.Text(title)),
vdom.Div(vdom.Attr("class", "truncate font-mono text-xs text-ink-muted"), vdom.Text(subtitle)),
),
Button(ButtonProps{Color: ButtonLightNeutral, Small: true, Icon: "pencil", Title: "Edit", OnClick: onEdit}),
Button(ButtonProps{Color: ButtonLightNeutral, Small: true, Icon: "trash", Title: "Delete", OnClick: onDelete}),
@@ -3712,14 +3732,14 @@ func (s *AutoTableState) calcForm(e *calcEditor, summary bool) *vdom.VNode {
title = "Edit " + strings.ToLower(title)
}
header := vdom.Div(vdom.Attr("class", "flex items-center gap-2 border-b border-neutral-200 px-3 py-2"),
header := vdom.Div(vdom.Attr("class", "flex items-center gap-2 border-b border-line px-3 py-2"),
vdom.Button(vdom.Attr("type", "button"),
vdom.Attr("class", "shrink-0 cursor-pointer rounded-default p-1 text-neutral-500 hover:bg-neutral-100 border-0 bg-transparent"),
vdom.Attr("class", "shrink-0 cursor-pointer rounded-default p-1 text-ink-muted hover:bg-surface-raised border-0 bg-transparent"),
vdom.Attr("aria-label", "Back"),
vdom.On(vdom.EVENT_CLICK, e.reset),
Icon("chevron-left", 14, ""),
),
vdom.Span(vdom.Attr("class", "text-sm font-medium text-neutral-800"), vdom.Text(title)),
vdom.Span(vdom.Attr("class", "text-sm font-medium text-ink"), vdom.Text(title)),
// Basic vs Advanced.
vdom.Div(vdom.Attr("class", "ml-auto"),
SegmentedButtons([]SegmentedButtonOption{
@@ -3773,7 +3793,7 @@ func (s *AutoTableState) calcForm(e *calcEditor, summary bool) *vdom.VNode {
}
if msg := e.errorMsg.Get(); msg != "" {
fields = append(fields, vdom.P(vdom.Attr("class", "text-xs text-red-600"), vdom.Text(msg)))
fields = append(fields, vdom.P(vdom.Attr("class", "text-xs text-red-600 dark:text-red-400"), vdom.Text(msg)))
}
saveLabel := "Add"
@@ -3877,7 +3897,7 @@ func (s *AutoTableState) calcBasicEditor(e *calcEditor, summary bool) *vdom.VNod
})))
}
children = append(children, vdom.P(vdom.Attr("class", "text-xs text-neutral-500"),
children = append(children, vdom.P(vdom.Attr("class", "text-xs text-ink-muted"),
vdom.Text(calcBasicHint(e, summary))))
return vdom.Div(kids([]vdom.Mod{vdom.Attr("class", "flex flex-col gap-2")}, children)...)
@@ -3908,12 +3928,12 @@ func (s *AutoTableState) calcAdvancedEditor(e *calcEditor, summary bool) *vdom.V
label := o.Label
colItems = append(colItems,
e.colMenu.Item(MenuItemProps{OnClick: func() { e.insertAtCaret("[" + label + "]") }},
vdom.Span(vdom.Attr("class", "font-mono text-sky-600"), vdom.Text("["+label+"]")),
vdom.Span(vdom.Attr("class", "ml-2 text-xs text-neutral-500"), vdom.Text("this row")),
vdom.Span(vdom.Attr("class", "font-mono text-sky-600 dark:text-sky-400"), vdom.Text("["+label+"]")),
vdom.Span(vdom.Attr("class", "ml-2 text-xs text-ink-muted"), vdom.Text("this row")),
),
e.colMenu.Item(MenuItemProps{OnClick: func() { e.insertAtCaret("{" + label + "}") }},
vdom.Span(vdom.Attr("class", "font-mono text-violet-600"), vdom.Text("{"+label+"}")),
vdom.Span(vdom.Attr("class", "ml-2 text-xs text-neutral-500"), vdom.Text("whole column")),
vdom.Span(vdom.Attr("class", "font-mono text-violet-600 dark:text-violet-400"), vdom.Text("{"+label+"}")),
vdom.Span(vdom.Attr("class", "ml-2 text-xs text-ink-muted"), vdom.Text("whole column")),
),
)
}
@@ -3925,7 +3945,7 @@ func (s *AutoTableState) calcAdvancedEditor(e *calcEditor, summary bool) *vdom.V
groups := filterFormulaGroups(e.fnSearch.Get())
if len(groups) == 0 {
fnItems = append(fnItems, vdom.Div(
vdom.Attr("class", "px-2 py-3 text-center text-xs text-neutral-500"),
vdom.Attr("class", "px-2 py-3 text-center text-xs text-ink-muted"),
vdom.Text("No functions match"),
))
}
@@ -3940,7 +3960,7 @@ func (s *AutoTableState) calcAdvancedEditor(e *calcEditor, summary bool) *vdom.V
// The signature is syntax-highlighted with the same highlighter the
// formula box uses, so the menu and the editor speak one language.
vdom.Span(vdom.Attr("class", "font-mono text-xs"), vdom.Raw(HighlightFormula(f.Sig))),
vdom.Span(vdom.Attr("class", "text-xs text-neutral-500"), vdom.Text(f.Desc)),
vdom.Span(vdom.Attr("class", "text-xs text-ink-muted"), vdom.Text(f.Desc)),
),
))
}
@@ -3953,8 +3973,8 @@ func (s *AutoTableState) calcAdvancedEditor(e *calcEditor, summary bool) *vdom.V
OnClick: func() { e.insertAtCaret(cc.Name) },
},
vdom.Div(vdom.Attr("class", "flex flex-col items-start gap-0.5"),
vdom.Span(vdom.Attr("class", "font-mono text-xs text-amber-600"), vdom.Text(cc.Name)),
vdom.Span(vdom.Attr("class", "text-xs text-neutral-500"), vdom.Text(cc.Desc)),
vdom.Span(vdom.Attr("class", "font-mono text-xs text-amber-600 dark:text-amber-400"), vdom.Text(cc.Name)),
vdom.Span(vdom.Attr("class", "text-xs text-ink-muted"), vdom.Text(cc.Desc)),
),
))
}
@@ -4008,7 +4028,7 @@ func (s *AutoTableState) calcAdvancedEditor(e *calcEditor, summary bool) *vdom.V
),
)
help := vdom.P(vdom.Attr("class", "text-xs text-neutral-500"),
help := vdom.P(vdom.Attr("class", "text-xs text-ink-muted"),
vdom.Text("[Column] is this row's cell · {Column} is the whole column · {Column:1:ROW()} is a running total."))
children := []*vdom.VNode{menus, editor, help}
@@ -4024,11 +4044,11 @@ func (s *AutoTableState) calcAdvancedEditor(e *calcEditor, summary bool) *vdom.V
func (e *calcEditor) fnSearchBox() *vdom.VNode {
wasmruntime.AfterRender(func() { wasmruntime.Focus(e.fnSearchRef) })
return vdom.Div(vdom.Attr("class", "sticky top-0 z-10 -m-1.5 mb-1 border-b border-neutral-200 bg-white p-1.5"),
return vdom.Div(vdom.Attr("class", "sticky top-0 z-10 -m-1.5 mb-1 border-b border-line bg-surface p-1.5"),
vdom.Input(
vdom.WithRef(e.fnSearchRef),
vdom.Attr("type", "text"),
vdom.Attr("class", "w-full rounded-default border border-neutral-300 p-1 text-xs focus:outline-2 focus:outline-sky-500"),
vdom.Attr("class", "w-full rounded-default border border-line-strong p-1 text-xs focus:outline-2 focus:outline-sky-500"),
vdom.Attr("placeholder", "Search functions…"),
vdom.Attr("spellcheck", "false"),
vdom.Prop("value", e.fnSearch.Get()),
@@ -4069,12 +4089,12 @@ func (s *AutoTableState) calcPreview(e *calcEditor, src string, summary bool) *v
return nil
}
if _, err := CompileFormula(src); err != nil {
return vdom.P(vdom.Attr("class", "text-xs text-red-600"), vdom.Text(err.Error()))
return vdom.P(vdom.Attr("class", "text-xs text-red-600 dark:text-red-400"), vdom.Text(err.Error()))
}
rows := s.FilteredRows()
if len(rows) == 0 {
return vdom.P(vdom.Attr("class", "text-xs text-neutral-500"), vdom.Text("Formula is valid."))
return vdom.P(vdom.Attr("class", "text-xs text-ink-muted"), vdom.Text("Formula is valid."))
}
ctx := NewCalcContext(rows, s.cols, s.Calculated(), s.read)
@@ -4087,10 +4107,10 @@ func (s *AutoTableState) calcPreview(e *calcEditor, src string, summary bool) *v
n, err := EvalFormula(src, target)
if err != nil {
return vdom.P(vdom.Attr("class", "text-xs text-red-600"), vdom.Text(err.Error()))
return vdom.P(vdom.Attr("class", "text-xs text-red-600 dark:text-red-400"), vdom.Text(err.Error()))
}
out := FormatCalcResult(n, CalculatedDataType(e.dataType.Get()), calcPrecisionOf(e.precision.Get()), "", "", "")
return vdom.P(vdom.Attr("class", "text-xs text-neutral-500"), vdom.Text(prefix+out))
return vdom.P(vdom.Attr("class", "text-xs text-ink-muted"), vdom.Text(prefix+out))
}
// insertAtCaret drops text where the cursor is, rather than at the end — which is
@@ -4121,7 +4141,7 @@ func (e *calcEditor) insertAtCaretOffset(text string, back int) {
func calcField(label string, children ...*vdom.VNode) *vdom.VNode {
nodes := []*vdom.VNode{
vdom.Span(vdom.Attr("class", "text-xs font-medium text-neutral-600"), vdom.Text(label)),
vdom.Span(vdom.Attr("class", "text-xs font-medium text-ink-soft"), vdom.Text(label)),
}
nodes = append(nodes, children...)
return vdom.Div(kids([]vdom.Mod{vdom.Attr("class", "flex flex-col gap-1")}, nodes)...)

View File

@@ -362,17 +362,17 @@ func TestHighlightFormula(t *testing.T) {
src string
want []string // fragments that must appear
}{
{"cell ref", "[Revenue]", []string{`<span class="text-sky-600">[Revenue]</span>`}},
{"column ref", "{Revenue}", []string{`<span class="text-violet-600">{Revenue}</span>`}},
{"cell ref", "[Revenue]", []string{`<span class="` + fmlCellClass + `">[Revenue]</span>`}},
{"column ref", "{Revenue}", []string{`<span class="` + fmlColumnClass + `">{Revenue}</span>`}},
{"nested braces stay one span", "{Revenue:1:ROW()}",
[]string{`<span class="text-violet-600">{Revenue:1:ROW()}</span>`}},
[]string{`<span class="` + fmlColumnClass + `">{Revenue:1:ROW()}</span>`}},
{"function", "SUM(",
[]string{`<span class="text-emerald-700 font-semibold">SUM</span>`}},
[]string{`<span class="` + fmlFuncClass + `">SUM</span>`}},
{"function with a space before the paren", "SUM (",
[]string{`<span class="text-emerald-700 font-semibold">SUM</span>`}},
{"number", "12.5", []string{`<span class="text-amber-600">12.5</span>`}},
{"constant", "PI", []string{`<span class="text-amber-600">PI</span>`}},
{"operators", "1 + 2", []string{`<span class="text-neutral-400">+</span>`}},
[]string{`<span class="` + fmlFuncClass + `">SUM</span>`}},
{"number", "12.5", []string{`<span class="` + fmlNumberClass + `">12.5</span>`}},
{"constant", "PI", []string{`<span class="` + fmlNumberClass + `">PI</span>`}},
{"operators", "1 + 2", []string{`<span class="` + fmlOpClass + `">+</span>`}},
{"a bare word is not coloured", "Revenue", []string{"Revenue"}},
}
for _, c := range cases {
@@ -455,7 +455,7 @@ func TestEditorOpensOnTheChooser(t *testing.T) {
e.advanced.Set(true)
e.formula.Set("[Gross revenue] * 12")
adv := renderNode(s.calcForm(e, false))
if !strings.Contains(adv, `<span class="text-sky-600">[Gross revenue]</span>`) {
if !strings.Contains(adv, `<span class="`+fmlCellClass+`">[Gross revenue]</span>`) {
t.Errorf("the formula is not syntax-highlighted:\n%s", adv)
}
for _, want := range []string{"Column", "Function", "Constant"} {

View File

@@ -21,7 +21,7 @@ var badgeColors = map[string]string{
"blue": "text-white bg-sky-800",
"amber": "text-white bg-amber-700",
"neutral": "text-white bg-neutral-500",
"muted": "text-neutral-400 bg-transparent",
"muted": "text-ink-faint bg-transparent",
}
// BadgeProps configures Badge. When OnClick is set the badge renders as a

View File

@@ -25,8 +25,8 @@ const btnBase = "inline-flex items-center justify-center gap-2 cursor-pointer te
var btnColors = map[string]string{
"neutral": "shadow-xs bg-neutral-700 text-white hover:bg-neutral-800",
"white": "shadow-xs bg-white text-black border border-neutral-300 hover:bg-neutral-50",
"light-neutral": "shadow-xs bg-neutral-50 text-black border border-neutral-300 hover:bg-neutral-100",
"white": "shadow-xs bg-surface text-ink border border-line-strong hover:bg-surface-muted",
"light-neutral": "shadow-xs bg-surface-muted text-ink border border-line-strong hover:bg-surface-raised",
"blue": "shadow-xs bg-sky-700 text-white hover:bg-sky-800",
"dark-blue": "shadow-xs bg-sky-900 text-white hover:bg-sky-950",
"green": "shadow-xs bg-green-700 text-white hover:bg-green-800",
@@ -36,23 +36,23 @@ var btnColors = map[string]string{
"yellow": "shadow-xs bg-yellow-700 text-white hover:bg-yellow-800",
"orange": "shadow-xs bg-orange-600 text-white hover:bg-orange-700",
"primary": "shadow-xs bg-primary text-white hover:bg-primary-hover",
"secondary": "shadow-none bg-neutral-100 text-neutral-700 border border-neutral-300 hover:bg-neutral-200",
"ghost": "shadow-none bg-transparent text-neutral-600 border-none hover:bg-neutral-100",
"secondary": "shadow-none bg-surface-raised text-ink-soft border border-line-strong hover:bg-surface-strong",
"ghost": "shadow-none bg-transparent text-ink-soft border-none hover:bg-surface-raised",
}
var btnOutlineColors = map[string]string{
"neutral": "text-neutral-700",
"white": "text-neutral-300",
"light-neutral": "text-neutral-300",
"blue": "text-sky-700",
"neutral": "text-ink-soft",
"white": "text-ink-faint",
"light-neutral": "text-ink-faint",
"blue": "text-sky-700 dark:text-sky-400",
"dark-blue": "text-sky-900",
"green": "text-green-700",
"green": "text-green-700 dark:text-green-400",
"dark-green": "text-green-900",
"red": "text-red-700",
"dark-red": "text-red-900",
"yellow": "text-yellow-700",
"orange": "text-orange-600",
"primary": "text-primary",
"primary": "text-accent",
}
const btnOutlineBase = "bg-transparent shadow-[inset_0_0_0_1px_currentColor] hover:shadow-[inset_0_0_0_2px_currentColor]"
@@ -139,7 +139,7 @@ func iconSize(small bool) int {
func ButtonLink(onClick func(), children ...*vdom.VNode) *vdom.VNode {
mods := []vdom.Mod{
vdom.Attr("type", "button"),
vdom.Attr("class", "cursor-pointer bg-transparent border-none p-0 font-[inherit] text-sky-700 hover:underline"),
vdom.Attr("class", "cursor-pointer bg-transparent border-none p-0 font-[inherit] text-sky-700 dark:text-sky-400 hover:underline"),
}
if onClick != nil {
mods = append(mods, vdom.On(vdom.EVENT_CLICK, onClick))
@@ -151,7 +151,7 @@ func ButtonLink(onClick func(), children ...*vdom.VNode) *vdom.VNode {
func ButtonLinkRed(onClick func(), children ...*vdom.VNode) *vdom.VNode {
mods := []vdom.Mod{
vdom.Attr("type", "button"),
vdom.Attr("class", "cursor-pointer bg-transparent border-none p-0 font-[inherit] text-red-600 hover:underline"),
vdom.Attr("class", "cursor-pointer bg-transparent border-none p-0 font-[inherit] text-red-600 dark:text-red-400 hover:underline"),
}
if onClick != nil {
mods = append(mods, vdom.On(vdom.EVENT_CLICK, onClick))
@@ -174,10 +174,10 @@ func SegmentedButtons(options []SegmentedButtonOption, value string, onChange fu
sizeCls = "py-0.5 px-2 text-xs"
}
const baseCls = "inline-flex items-center justify-center gap-1.5 flex-1 cursor-pointer font-medium transition-colors whitespace-nowrap disabled:opacity-50 disabled:cursor-not-allowed"
const activeCls = "bg-white text-text-heading shadow-sm"
const inactiveCls = "text-neutral-500 hover:text-neutral-700"
const activeCls = "bg-surface text-text-heading shadow-sm"
const inactiveCls = "text-ink-muted hover:text-ink-soft"
mods := []vdom.Mod{vdom.Attr("class", cx("flex items-center gap-0.5 rounded-md bg-neutral-100 p-0.5", class))}
mods := []vdom.Mod{vdom.Attr("class", cx("flex items-center gap-0.5 rounded-md bg-surface-raised p-0.5", class))}
for _, opt := range options {
state := inactiveCls
if opt.Value == value {
@@ -201,7 +201,7 @@ func SegmentedButtons(options []SegmentedButtonOption, value string, onChange fu
// BackLink is an inline chevron-left anchor. onDark switches to on-dark colors.
func BackLink(href, text string, onDark bool) *vdom.VNode {
color := "text-neutral-600 hover:text-neutral-900"
color := "text-ink-soft hover:text-ink"
if onDark {
color = "text-text-on-dark-muted hover:text-text-on-dark"
}

View File

@@ -26,18 +26,18 @@ var calMonthsShort = []string{
// exported from Calendar.tsx; here they stay unexported with a cal prefix so the
// two ported files can share them without widening the package's public API. --
const calPickerRoot = "p-2 min-w-[240px]"
const calMonthRoot = "p-0 min-w-0 w-full bg-white border border-neutral-200 rounded-default shadow-sm overflow-hidden"
const calMonthRoot = "p-0 min-w-0 w-full bg-surface border border-line rounded-default shadow-sm overflow-hidden"
const calHeaderPicker = "flex items-center justify-between mb-2 gap-1"
const calHeaderMonth = "flex items-center justify-between gap-1 py-3 px-4 border-b border-neutral-200 bg-neutral-50"
const calHeaderMonth = "flex items-center justify-between gap-1 py-3 px-4 border-b border-line bg-surface-muted"
const calNavBtn = "bg-transparent border-0 p-1 cursor-pointer text-text-muted rounded-sm flex items-center justify-center hover:bg-neutral-100 hover:text-text-body"
const calNavBtn = "bg-transparent border-0 p-1 cursor-pointer text-text-muted rounded-sm flex items-center justify-center hover:bg-surface-raised hover:text-text-body"
const calMyPicker = "text-sm font-semibold text-text-heading mx-3 whitespace-nowrap"
const calMyMonth = "text-lg font-heading mx-4 flex-1 text-center font-semibold text-text-heading whitespace-nowrap"
const calWeekdaysPicker = "grid grid-cols-7 gap-[2px] mb-1"
const calWeekdaysMonth = "grid grid-cols-7 border-b border-neutral-200"
const calWeekdaysMonth = "grid grid-cols-7 border-b border-line"
const calWeekdayPicker = "text-center text-xs font-semibold text-text-muted p-1"
const calWeekdayMonth = "text-center text-xs font-semibold text-text-muted p-2 uppercase tracking-wider"
@@ -45,9 +45,9 @@ const calDaysPicker = "grid grid-cols-7 gap-[2px]"
const calDaysMonth = "grid grid-cols-7"
const calDayPickerBase = "aspect-square flex items-center justify-center text-sm bg-transparent border-0 rounded-sm cursor-pointer text-text-body p-0"
const calDayMonthBase = "min-h-[6.5rem] flex flex-col items-stretch justify-start p-1.5 border-r border-b border-neutral-200 text-left gap-1 text-xs bg-transparent cursor-pointer"
const calDayMonthBase = "min-h-[6.5rem] flex flex-col items-stretch justify-start p-1.5 border-r border-b border-line text-left gap-1 text-xs bg-transparent cursor-pointer"
const calSelect = "flex-1 py-1 px-2 text-sm font-semibold border border-neutral-200 rounded-sm bg-white text-text-heading cursor-pointer focus:outline-hidden focus:border-primary"
const calSelect = "flex-1 py-1 px-2 text-sm font-semibold border border-line rounded-sm bg-surface text-text-heading cursor-pointer focus:outline-hidden focus:border-primary"
// Calendar variants.
const (
@@ -123,10 +123,10 @@ func calDayClassPicker(empty, selected, today bool) string {
if empty {
c = cx(c, "cursor-default")
} else {
c = cx(c, "hover:bg-neutral-100")
c = cx(c, "hover:bg-surface-raised")
}
if today {
c = cx(c, "font-bold text-primary")
c = cx(c, "font-bold text-accent")
}
if selected {
c = cx(c, "!bg-primary !text-white")
@@ -142,9 +142,9 @@ func calDayClassMonth(idx int, empty, selected bool) string {
c = cx(c, "border-r-0")
}
if empty {
c = cx(c, "bg-neutral-50 cursor-default")
c = cx(c, "bg-surface-muted cursor-default")
} else {
c = cx(c, "hover:bg-neutral-50")
c = cx(c, "hover:bg-surface-muted")
}
if selected {
c = cx(c, "bg-primary/10 text-text-body")

View File

@@ -5,15 +5,15 @@ import "kjol/vdom"
// Port of web/kit/Cards.tsx. The `ui-card`/`no-flex` marker classes are kept so
// any page CSS targeting them still applies; baseline styling is Tailwind.
const cardBase = "ui-card bg-white shadow-sm rounded-default w-full"
const cardBase = "ui-card bg-surface shadow-sm rounded-default w-full"
const cardWithPadding = cardBase + " p-5 flex-1"
const cardNoFlex = cardBase + " no-flex p-5"
const cardNoPaddingNoFlex = cardBase + " no-padding no-flex"
const borderCard = "border border-neutral-300 rounded-default p-5 w-full"
const borderCard = "border border-line-strong rounded-default p-5 w-full"
// cutCornerCard uses two clip-path pseudo-elements to notch opposite corners.
const cutCornerCard = "relative isolate p-5 w-full " +
"before:content-[''] before:absolute before:inset-0 before:bg-neutral-300 before:-z-20 " +
"before:content-[''] before:absolute before:inset-0 before:bg-surface-strong before:-z-20 " +
"before:[clip-path:polygon(16px_0,100%_0,100%_calc(100%_-_16px),calc(100%_-_16px)_100%,0_100%,0_16px)] " +
"after:content-[''] after:absolute after:inset-[1px] after:bg-white after:-z-10 " +
"after:[clip-path:polygon(15px_0,100%_0,100%_calc(100%_-_15px),calc(100%_-_15px)_100%,0_100%,0_15px)]"
@@ -47,7 +47,7 @@ func BorderCutCornerCard(class string, children ...*vdom.VNode) *vdom.VNode {
return cardDiv(cutCornerCard, class, children)
}
const cardHeader = "text-xl tracking-tight text-black mb-5"
const cardHeader = "text-xl tracking-tight text-ink mb-5"
const cardHeaderHR = "text-neutral-200 mt-1 mb-3"
// CardHeader renders a card title followed by a divider.
@@ -66,7 +66,7 @@ func CardHeaderTextCenter(class string, children ...*vdom.VNode) *vdom.VNode {
// CardSubheader renders a smaller secondary heading.
func CardSubheader(class string, children ...*vdom.VNode) *vdom.VNode {
return vdom.Div(kids([]vdom.Mod{vdom.Attr("class", cx("text-lg tracking-tight text-black mb-2", class))}, children)...)
return vdom.Div(kids([]vdom.Mod{vdom.Attr("class", cx("text-lg tracking-tight text-ink mb-2", class))}, children)...)
}
// CardSpacer is vertical spacing between cards.

View File

@@ -35,7 +35,7 @@ import (
// GridHeaderCls is the base <th> class for CellGrid headers (exported, matching
// the TSX GRID_HEADER_CLS).
const GridHeaderCls = "border-b border-r border-neutral-300 bg-neutral-50 px-1.5 py-1.5 text-left text-xs font-bold uppercase text-black whitespace-nowrap last:border-r-0"
const GridHeaderCls = "border-b border-r border-line-strong bg-surface-muted px-1.5 py-1.5 text-left text-xs font-bold uppercase text-ink whitespace-nowrap last:border-r-0"
var cgLeadingDigits = regexp.MustCompile(`^\d+`)
var cgLeadingFloat = regexp.MustCompile(`^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?`)
@@ -185,7 +185,7 @@ type SortableHeaderProps struct {
// column and calls OnSort(SortKey) on click.
func SortableHeader(p SortableHeaderProps) *vdom.VNode {
isActive := p.SortKey != "" && p.Current == p.SortKey
cls := GridHeaderCls + " cursor-pointer select-none hover:bg-neutral-200"
cls := GridHeaderCls + " cursor-pointer select-none hover:bg-surface-strong"
if sz := cellGridColumnSize(p.Width, p.MinWidth); sz != "" {
cls += " " + sz
}
@@ -311,9 +311,9 @@ func CellGrid(p CellGridProps) *vdom.VNode {
if p.Dense {
rowHCls = "h-6"
}
readonlyTdCls := "border-b border-r border-neutral-300 bg-black/5 px-2 text-neutral-700 align-middle " + rowHCls
const editableTdCls = "border-b border-r border-neutral-300 p-0 relative align-middle"
const inputCls = "absolute inset-0 w-full border-none outline-none bg-transparent px-2 placeholder:text-neutral-400 focus:bg-red-50 focus:shadow-[inset_0_0_0_2px_var(--color-red-500)]"
readonlyTdCls := "border-b border-r border-line-strong bg-black/5 px-2 text-ink-soft align-middle " + rowHCls
const editableTdCls = "border-b border-r border-line-strong p-0 relative align-middle"
const inputCls = "absolute inset-0 w-full border-none outline-none bg-transparent px-2 placeholder:text-ink-faint focus:bg-red-50 dark:bg-red-950 focus:shadow-[inset_0_0_0_2px_var(--color-red-500)]"
conflicts := cellGridConflictSets(p)
isConflict := func(field string, value any) bool {
@@ -417,7 +417,7 @@ func CellGrid(p CellGridProps) *vdom.VNode {
if inList {
tdCls += " relative"
if hasConflict {
tdCls += " bg-amber-100"
tdCls += " bg-amber-100 dark:bg-amber-900"
}
}
im := col.InputMode
@@ -441,7 +441,7 @@ func CellGrid(p CellGridProps) *vdom.VNode {
)
mods := []vdom.Mod{vdom.Attr("class", tdCls), input}
if inList && hasConflict {
mods = append(mods, vdom.Span(vdom.Attr("class", "pointer-events-none absolute right-0.5 top-1/2 -translate-y-1/2 text-amber-600"),
mods = append(mods, vdom.Span(vdom.Attr("class", "pointer-events-none absolute right-0.5 top-1/2 -translate-y-1/2 text-amber-600 dark:text-amber-400"),
vdom.Attr("title", "Duplicate value"),
Icon("triangle-exclamation", 12, ""),
))
@@ -461,7 +461,7 @@ func CellGrid(p CellGridProps) *vdom.VNode {
for _, col := range p.Columns {
cells = append(cells, renderCell(row, col))
}
bodyRows = append(bodyRows, vdom.Tr(kids([]vdom.Mod{vdom.Attr("class", "odd:bg-white even:bg-neutral-100")}, cells)...))
bodyRows = append(bodyRows, vdom.Tr(kids([]vdom.Mod{vdom.Attr("class", "odd:bg-surface even:bg-surface-raised")}, cells)...))
}
tbody := vdom.Tbody(kids(nil, bodyRows)...)
@@ -469,7 +469,7 @@ func CellGrid(p CellGridProps) *vdom.VNode {
if p.Dense {
tableCls = "min-w-full w-max border-collapse text-xs"
}
return vdom.Div(vdom.Attr("class", "relative max-w-full overflow-x-auto border border-neutral-300 rounded-default bg-white tabular-nums"),
return vdom.Div(vdom.Attr("class", "relative max-w-full overflow-x-auto border border-line-strong rounded-default bg-surface tabular-nums"),
vdom.Table(vdom.Attr("class", tableCls), thead, tbody),
)
}

174
go/webui/code.go Normal file
View File

@@ -0,0 +1,174 @@
package webui
import (
"html"
"strings"
)
// Go syntax highlighting, for the code samples a documentation page shows.
//
// It is a LEXER, not a parser: it classifies tokens and gives up gracefully on anything
// it does not understand, because a highlighter that can fail to render is worse than
// one that occasionally paints an identifier the wrong colour. Unterminated strings and
// comments run to the end of the input rather than throwing.
//
// Output is HTML, and every run of source text passes through html.EscapeString on the
// way out — the input is Go source, which is full of `<`, `>` and `&`, and one of the
// snippets this is meant to display is literally a block of HTML.
// A code block is dark in BOTH themes — a light code block on a light page is a
// different kind of thing, and switching it with the theme means the snippet you were
// reading changes colour under you. So these are fixed on-dark colours, not theme
// tokens: the surface they sit on never changes.
const (
goCommentClass = "text-neutral-400"
goStringClass = "text-emerald-300"
goKeywordClass = "text-sky-300"
goNumberClass = "text-amber-300"
goFuncClass = "text-violet-300"
)
var goKeywords = map[string]bool{
"break": true, "case": true, "chan": true, "const": true, "continue": true,
"default": true, "defer": true, "else": true, "fallthrough": true, "for": true,
"func": true, "go": true, "goto": true, "if": true, "import": true,
"interface": true, "map": true, "package": true, "range": true, "return": true,
"select": true, "struct": true, "switch": true, "type": true, "var": true,
// Not keywords to the Go spec — predeclared identifiers — but every editor colours
// them, and a reader looking for `nil` is looking for the same kind of thing.
"nil": true, "true": true, "false": true, "iota": true,
"string": true, "int": true, "int64": true, "float64": true, "bool": true,
"byte": true, "rune": true, "any": true, "error": true,
}
// HighlightGo turns Go source into HTML with the tokens wrapped in coloured spans.
//
// The result is meant for vdom.Raw inside a <pre>: it contains no block elements and
// preserves the source's whitespace exactly, so the <pre> does the layout.
func HighlightGo(src string) string {
var b strings.Builder
b.Grow(len(src) * 2)
i := 0
for i < len(src) {
c := src[i]
switch {
// Line comment — including the //gowasm: directives, which are the most
// important line in several of these snippets.
case c == '/' && i+1 < len(src) && src[i+1] == '/':
end := strings.IndexByte(src[i:], '\n')
if end < 0 {
end = len(src)
} else {
end += i
}
span(&b, goCommentClass, src[i:end])
i = end
// Block comment.
case c == '/' && i+1 < len(src) && src[i+1] == '*':
end := strings.Index(src[i+2:], "*/")
if end < 0 {
end = len(src)
} else {
end = i + 2 + end + 2
}
span(&b, goCommentClass, src[i:end])
i = end
// Interpreted string. Ends at the closing quote or the line's end — an
// unterminated string is a typo in a snippet, not a reason to paint the rest of
// the file green.
case c == '"':
i = quoted(&b, src, i, '"', true)
// Raw string: no escapes, and it may span lines.
case c == '`':
i = quoted(&b, src, i, '`', false)
// Rune literal.
case c == '\'':
i = quoted(&b, src, i, '\'', true)
case isDigit(c):
j := i
for j < len(src) && (isDigit(src[j]) || isHexish(src[j])) {
j++
}
span(&b, goNumberClass, src[i:j])
i = j
case isIdentStart(c):
j := i
for j < len(src) && isIdentPart(src[j]) {
j++
}
word := src[i:j]
switch {
case goKeywords[word]:
span(&b, goKeywordClass, word)
case callAhead(src, j):
// An identifier immediately followed by "(" is being called (or is a type
// being converted to). Colouring it is what makes the shape of a snippet
// readable at a glance.
span(&b, goFuncClass, word)
default:
b.WriteString(html.EscapeString(word))
}
i = j
default:
b.WriteString(html.EscapeString(string(c)))
i++
}
}
return b.String()
}
// quoted consumes a quoted literal starting at i and writes it as a string span.
// escapes reports whether a backslash escapes the next byte (false for raw strings).
func quoted(b *strings.Builder, src string, i int, quote byte, escapes bool) int {
j := i + 1
for j < len(src) {
if escapes && src[j] == '\\' && j+1 < len(src) {
j += 2
continue
}
if src[j] == quote {
j++
break
}
if escapes && src[j] == '\n' {
break // unterminated: stop at the line end rather than eating the file
}
j++
}
span(b, goStringClass, src[i:j])
return j
}
// callAhead reports whether the next non-space byte at or after i is an opening paren.
func callAhead(src string, i int) bool {
for i < len(src) && (src[i] == ' ' || src[i] == '\t') {
i++
}
return i < len(src) && src[i] == '('
}
func span(b *strings.Builder, class, text string) {
b.WriteString(`<span class="`)
b.WriteString(class)
b.WriteString(`">`)
b.WriteString(html.EscapeString(text))
b.WriteString(`</span>`)
}
// isDigit already exists in the package (autotable.go) — reused rather than shadowed.
func isHexish(c byte) bool {
return c == '.' || c == 'x' || c == 'X' || c == '_' ||
(c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
}
func isIdentStart(c byte) bool { return c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') }
func isIdentPart(c byte) bool { return isIdentStart(c) || isDigit(c) }

99
go/webui/code_test.go Normal file
View File

@@ -0,0 +1,99 @@
package webui
import (
"strings"
"testing"
)
// The highlighter emits HTML, and its input is Go source — which is full of <, > and &.
// Anything that reaches the page unescaped is markup injection into your own docs page:
// a snippet containing `<div>` would render a div.
func TestHighlightGoEscapes(t *testing.T) {
got := HighlightGo(`s := "<div class=\"x\">" // a & b`)
if strings.Contains(got, "<div") {
t.Errorf("a `<div>` in the SOURCE reached the output as markup:\n%s", got)
}
if !strings.Contains(got, "&lt;div") {
t.Errorf("the angle bracket was not escaped:\n%s", got)
}
if !strings.Contains(got, "&amp;") {
t.Errorf("the ampersand was not escaped:\n%s", got)
}
}
func TestHighlightGoClassifies(t *testing.T) {
got := HighlightGo("func main() { x := 42 // note\n}")
for _, want := range []struct{ what, class, text string }{
{"keyword", goKeywordClass, "func"},
{"call", goFuncClass, "main"},
{"number", goNumberClass, "42"},
{"comment", goCommentClass, "// note"},
} {
if !strings.Contains(got, `<span class="`+want.class+`">`+want.text+`</span>`) {
t.Errorf("%s %q was not highlighted:\n%s", want.what, want.text, got)
}
}
}
// The //gowasm: directives are the most important line in half these snippets. They are
// comments, and must survive as such.
func TestHighlightGoKeepsDirectives(t *testing.T) {
got := HighlightGo("//gowasm:page / static layout=public\nfunc HomePage() {}")
if !strings.Contains(got, `<span class="`+goCommentClass+`">//gowasm:page / static layout=public</span>`) {
t.Errorf("the directive was not kept whole as a comment:\n%s", got)
}
}
// A lexer that can hang or eat the rest of the file on malformed input would take the
// whole page down with it. Unterminated literals stop; they do not run away.
func TestHighlightGoSurvivesMalformedInput(t *testing.T) {
for _, src := range []string{
`x := "unterminated`,
"y := `unterminated raw",
"/* unterminated block",
`z := '`,
"",
} {
got := HighlightGo(src)
// The text must all still be there — mangling is not an acceptable failure mode
// either. Compare on the visible characters, ignoring the spans.
if plain := stripTags(got); plain != src {
t.Errorf("input %q came out as %q", src, plain)
}
}
}
// Nothing is dropped: every byte of the source is still on the page, in order.
func TestHighlightGoIsLossless(t *testing.T) {
src := "package app\n\nimport \"strings\"\n\nfunc f(n int) string {\n\treturn strings.Repeat(\"x\", n) // pad\n}\n"
if plain := stripTags(HighlightGo(src)); plain != src {
t.Errorf("the highlighter changed the source.\n got: %q\nwant: %q", plain, src)
}
}
// stripTags removes the spans and unescapes, recovering the original source.
func stripTags(s string) string {
var b strings.Builder
for i := 0; i < len(s); {
if s[i] == '<' {
j := strings.IndexByte(s[i:], '>')
if j < 0 {
break
}
i += j + 1
continue
}
b.WriteByte(s[i])
i++
}
out := b.String()
// Reverse html.EscapeString, innermost last.
out = strings.ReplaceAll(out, "&lt;", "<")
out = strings.ReplaceAll(out, "&gt;", ">")
out = strings.ReplaceAll(out, "&#34;", `"`)
out = strings.ReplaceAll(out, "&#39;", "'")
out = strings.ReplaceAll(out, "&amp;", "&")
return out
}

View File

@@ -49,9 +49,9 @@ func crmTabsPanels(p CrmTabGroupProps) []*vdom.VNode {
// --- CrmTabGroup: boxed top-accent tabs -------------------------------------
const crmTabRow = "flex w-full overflow-x-auto text-sm"
const crmTabBase = "flex items-center gap-1.5 cursor-pointer p-4 font-medium border-neutral-300 transition-colors"
const crmTabInactive = "border-b text-neutral-500 hover:text-neutral-800"
const crmTabActive = "border-x border-t-2 border-t-sky-700 text-primary"
const crmTabBase = "flex items-center gap-1.5 cursor-pointer p-4 font-medium border-line-strong transition-colors"
const crmTabInactive = "border-b text-ink-muted hover:text-ink"
const crmTabActive = "border-x border-t-2 border-t-sky-700 text-accent"
const crmTabBadge = "inline-flex items-center justify-center min-w-5 h-5 px-1 text-xs font-semibold bg-primary text-white rounded-full"
// CrmTabGroup renders boxed tabs with a sky-blue top accent; content sits flat
@@ -79,7 +79,7 @@ func CrmTabGroup(p CrmTabGroupProps) *vdom.VNode {
}
row = append(row, vdom.Button(btn...))
}
row = append(row, vdom.Div(vdom.Attr("class", "flex-1 border-b border-neutral-300")))
row = append(row, vdom.Div(vdom.Attr("class", "flex-1 border-b border-line-strong")))
return vdom.Div(vdom.Attr("class", "w-full"),
vdom.Div(row...),
@@ -89,12 +89,12 @@ func CrmTabGroup(p CrmTabGroupProps) *vdom.VNode {
// --- CrmSubTabGroup: segmented control --------------------------------------
const crmSubTabWrap = "flex pb-3 border-b border-neutral-300 overflow-x-auto"
const crmSubTabGroup = "inline-flex items-stretch rounded-md border border-neutral-300 overflow-hidden text-sm select-none"
const crmSubTabWrap = "flex pb-3 border-b border-line-strong overflow-x-auto"
const crmSubTabGroup = "inline-flex items-stretch rounded-md border border-line-strong overflow-hidden text-sm select-none"
const crmSubTabBase = "flex items-center gap-1.5 py-1 px-3 cursor-pointer font-medium whitespace-nowrap transition-colors"
const crmSubTabDivider = "border-l border-neutral-300"
const crmSubTabDivider = "border-l border-line-strong"
const crmSubTabActive = "bg-neutral-500 text-white"
const crmSubTabInactive = "bg-white text-neutral-600 hover:bg-neutral-100 hover:text-neutral-900"
const crmSubTabInactive = "bg-surface text-ink-soft hover:bg-surface-raised hover:text-ink"
const crmSubTabBadge = "inline-flex items-center justify-center min-w-5 h-5 px-1 text-xs font-semibold bg-black/10 text-current rounded-full"
// CrmSubTabGroup renders a left-aligned segmented control (interlocking

View File

@@ -30,7 +30,7 @@ import (
const datePickerWrap = "relative w-full min-w-0"
const datePickerField = "relative w-full min-w-0 cursor-pointer [&_.ui-form]:m-0 [&_input]:cursor-text"
const datePickerDropdown = "bg-white border border-neutral-200 rounded-default shadow-[0_4px_12px_rgba(0,0,0,0.15)] p-1 min-w-[16rem]"
const datePickerDropdown = "bg-surface border border-line rounded-default shadow-[0_4px_12px_rgba(0,0,0,0.15)] p-1 min-w-[16rem]"
const datePickerIconBtn = "absolute inset-y-0 right-0 z-[1] flex items-center justify-center bg-transparent border-0 px-2 cursor-pointer text-text-muted leading-none hover:text-text-body pointer-events-auto"
const datePickerClearBtn = "absolute inset-y-0 right-8 z-[1] flex items-center justify-center bg-transparent border-0 px-1.5 cursor-pointer text-text-muted leading-none hover:text-text-body pointer-events-auto"

View File

@@ -403,6 +403,13 @@ type FloatingTriggerProps struct {
// OnClick runs in addition to the toggle (which is suppressed when the trigger
// opens on hover).
OnClick func()
// NoToggle anchors the panel to this element without making it a switch.
//
// A text field is not a switch: the panel opens because you typed, and closes
// because you chose something. Toggling on click means clicking back into the field
// to fix a typo dismisses the results you were reading.
NoToggle bool
}
// Trigger renders the element the panel is anchored to.
@@ -436,6 +443,10 @@ func (f *Floating) Trigger(p FloatingTriggerProps, children ...*vdom.VNode) *vdo
if p.OnClick != nil {
mods = append(mods, vdom.On(vdom.EVENT_CLICK, p.OnClick))
}
} else if p.NoToggle {
if p.OnClick != nil {
mods = append(mods, vdom.On(vdom.EVENT_CLICK, p.OnClick))
}
} else {
mods = append(mods, vdom.On(vdom.EVENT_CLICK, func() {
if p.OnClick != nil {

View File

@@ -23,28 +23,28 @@ import (
// -- shared Tailwind class strings (verbatim from Forms.tsx) -------------------
const formInputBase = "bg-white block w-full border rounded-default shadow-xs text-sm focus:outline-2 focus:outline-offset-1 disabled:bg-neutral-100 disabled:cursor-not-allowed"
const formInputBase = "bg-surface block w-full border rounded-default shadow-xs text-sm focus:outline-2 focus:outline-offset-1 disabled:bg-surface-raised disabled:cursor-not-allowed"
const formInputBaseDark = "bg-dark text-text-on-dark placeholder:text-text-on-dark-faint block w-full border rounded-default shadow-xs text-sm focus:outline-2 focus:outline-offset-1 disabled:opacity-50 disabled:cursor-not-allowed"
const formErrorCls = "block text-red-600 text-xs mt-1"
const formErrorCls = "block text-red-600 dark:text-red-400 text-xs mt-1"
const formSuccessCls = "block text-green-600 text-xs mt-1"
const formInputGroupCls = "flex flex-row items-stretch w-full text-sm"
const formTriggerBase = "bg-white border border-neutral-300 rounded-default shadow-xs text-sm w-full text-left flex items-center justify-between gap-2 cursor-pointer disabled:bg-neutral-100 disabled:cursor-not-allowed"
const formTriggerBase = "bg-surface border border-line-strong rounded-default shadow-xs text-sm w-full text-left flex items-center justify-between gap-2 cursor-pointer disabled:bg-surface-raised disabled:cursor-not-allowed"
const formTriggerBaseDark = "bg-dark-raised border border-border-on-dark text-text-on-dark rounded-default shadow-xs text-sm w-full text-left flex items-center justify-between gap-2 cursor-pointer hover:border-border-on-dark-hover disabled:opacity-50 disabled:cursor-not-allowed"
const formDropdown = "bg-white border border-neutral-300 rounded-default shadow-lg max-h-60 overflow-auto"
const formDropdown = "bg-surface border border-line-strong rounded-default shadow-lg max-h-60 overflow-auto"
const formDropdownDark = "bg-dark-raised border border-border-on-dark rounded-default shadow-lg max-h-60 overflow-auto"
const formDropdownSearchWrap = "sticky top-0 bg-white border-b border-neutral-200 p-2"
const formDropdownSearchWrap = "sticky top-0 bg-surface border-b border-line p-2"
const formDropdownSearchWrapDark = "sticky top-0 bg-dark-raised border-b border-border-on-dark p-2"
const formDropdownSearchInput = "w-full bg-white border border-neutral-300 rounded-default shadow-xs text-sm p-1 focus:outline-2 focus:outline-sky-500 focus:outline-offset-1"
const formDropdownSearchInput = "w-full bg-surface border border-line-strong rounded-default shadow-xs text-sm p-1 focus:outline-2 focus:outline-sky-500 focus:outline-offset-1"
const formDropdownSearchInputDark = "w-full bg-dark border border-border-on-dark text-text-on-dark placeholder:text-text-on-dark-faint rounded-default shadow-xs text-sm p-1 focus:outline-2 focus:outline-sky-500 focus:outline-offset-1"
const formDropdownOption = "w-full text-left p-2 text-sm cursor-pointer flex items-center gap-2 bg-transparent border-none hover:bg-neutral-100 disabled:text-neutral-400 disabled:cursor-not-allowed whitespace-nowrap"
const formDropdownOption = "w-full text-left p-2 text-sm cursor-pointer flex items-center gap-2 bg-transparent border-none hover:bg-surface-raised disabled:text-ink-faint disabled:cursor-not-allowed whitespace-nowrap"
const formDropdownOptionDark = "w-full text-left p-2 text-sm cursor-pointer flex items-center gap-2 bg-transparent border-none text-text-on-dark hover:bg-white/5 disabled:text-text-on-dark-faint disabled:cursor-not-allowed whitespace-nowrap"
const formDropdownNoResults = "p-2 text-sm text-neutral-500 text-center"
const formDropdownNoResults = "p-2 text-sm text-ink-muted text-center"
const formDropdownNoResultsDark = "p-2 text-sm text-text-on-dark-muted text-center"
const formSelectAllWrap = "border-b border-neutral-200"
const formSelectAllBtn = "w-full text-left p-2 text-sm cursor-pointer text-neutral-600 font-medium bg-transparent border-none hover:bg-neutral-100"
const formSelectAllWrap = "border-b border-line"
const formSelectAllBtn = "w-full text-left p-2 text-sm cursor-pointer text-ink-soft font-medium bg-transparent border-none hover:bg-surface-raised"
// -- shared class builders -----------------------------------------------------
@@ -59,7 +59,7 @@ func formControlH(small bool) string {
// formFieldBorder picks the border/focus-outline color: error > success > normal.
// error/success are the message strings; non-empty means "present" (truthy).
func formFieldBorder(errMsg, successMsg string, onDark bool) string {
borderNormal := "border-neutral-300 focus:outline-sky-500"
borderNormal := "border-line-strong focus:outline-sky-500"
if onDark {
borderNormal = "border-border-on-dark focus:outline-sky-500"
}
@@ -100,8 +100,8 @@ func formTextareaCls(small bool, errMsg, extra string, onDark bool) string {
}
func formPrefixCls(small bool, errMsg string, onDark bool) string {
base := "bg-neutral-100 border shadow-xs border-r-0 flex items-center shrink-0 rounded-l-default"
borderNormal := "border-neutral-300"
base := "bg-surface-raised border shadow-xs border-r-0 flex items-center shrink-0 rounded-l-default"
borderNormal := "border-line-strong"
if onDark {
base = "bg-dark-raised text-text-on-dark-muted border shadow-xs border-r-0 flex items-center shrink-0 rounded-l-default"
borderNormal = "border-border-on-dark"
@@ -662,7 +662,7 @@ func FormLabel(p FormLabelProps, children ...*vdom.VNode) *vdom.VNode {
if p.Inline {
display = "inline"
}
color := "text-neutral-700"
color := "text-ink-soft"
if p.OnDark {
color = "text-text-on-dark"
}
@@ -685,8 +685,8 @@ func FormFileInput(p FormInputProps) *vdom.VNode {
filePad = "file:py-[2px] file:px-3"
}
class := cx(formInputBase,
"p-1 border-neutral-300 focus:outline-sky-500 cursor-pointer",
"file:ml-1 file:mr-2 file:bg-neutral-100 file:border file:border-neutral-300 file:rounded-default file:shadow-xs file:text-sm file:cursor-pointer file:hover:bg-neutral-200",
"p-1 border-line-strong focus:outline-sky-500 cursor-pointer",
"file:ml-1 file:mr-2 file:bg-surface-raised file:border file:border-line-strong file:rounded-default file:shadow-xs file:text-sm file:cursor-pointer file:hover:bg-surface-strong",
filePad, p.Class)
mods := []vdom.Mod{
vdom.Attr("type", "file"),
@@ -717,8 +717,8 @@ func FormSpacer() *vdom.VNode { return vdom.Div(vdom.Attr("class", "mb-3")) }
// FormFieldset wraps children in a bordered <fieldset> with an optional legend.
func FormFieldset(legend, class string, children ...*vdom.VNode) *vdom.VNode {
mods := []vdom.Mod{
vdom.Attr("class", cx("border border-neutral-300 rounded-default py-3 px-4", class)),
vdom.Legend(vdom.Attr("class", "px-2 text-sm font-medium text-neutral-600"), vdom.Text(legend)),
vdom.Attr("class", cx("border border-line-strong rounded-default py-3 px-4", class)),
vdom.Legend(vdom.Attr("class", "px-2 text-sm font-medium text-ink-soft"), vdom.Text(legend)),
}
mods = kids(mods, children)
return vdom.Fieldset(mods...)
@@ -954,7 +954,7 @@ func (d *dropdown) optionButton(opt FormSelectOption, idx int, selected, onDark,
cls = cx(cls, "py-1.5 px-2")
}
if idx == d.active.Get() {
cls = cx(cls, "bg-neutral-100")
cls = cx(cls, "bg-surface-raised")
}
mods := []vdom.Mod{
@@ -1073,7 +1073,7 @@ func (c *Combobox) Render(p FormComboboxProps) *vdom.VNode {
}
display := pick(p.Placeholder, "Select an option")
placeholderCls := "text-neutral-500"
placeholderCls := "text-ink-muted"
if p.OnDark {
placeholderCls = "text-text-on-dark-muted"
}
@@ -1082,7 +1082,7 @@ func (c *Combobox) Render(p FormComboboxProps) *vdom.VNode {
placeholderCls = ""
}
chevronCls := "text-neutral-400"
chevronCls := "text-ink-faint"
if p.OnDark {
chevronCls = "text-text-on-dark-muted"
}
@@ -1178,6 +1178,12 @@ type FormMultiSelectProps struct {
Small bool
Class string
FieldWidth string
// CollapsedLabel names the selection once the pills no longer fit. It defaults to
// "N items selected", which is right for a list of things and wrong for anything
// that is not — a column picker reading "5 items selected" tells you the count and
// hides what the count is OF.
CollapsedLabel func(n int) string
}
// Render draws the field and its panel.
@@ -1211,7 +1217,7 @@ func (m *MultiSelect) Render(p FormMultiSelectProps) *vdom.VNode {
AriaHasPopup: "listbox",
},
vdom.Div(face...),
IconInline(chevron, 16, "text-neutral-400"),
IconInline(chevron, 16, "text-ink-faint"),
)
if p.Disabled {
trigger.Attrs["disabled"] = "disabled"
@@ -1227,27 +1233,7 @@ func (m *MultiSelect) Render(p FormMultiSelectProps) *vdom.VNode {
// user is looking at and asked for; quietly selecting the hidden ones too would be
// a nasty surprise.
if p.ShowSelectAll && len(filtered) > 0 {
all := formAllSelected(filtered, p.Value)
label := "Select All"
if all {
label = "Deselect All"
}
mods := []vdom.Mod{vdom.Attr("type", "button"), vdom.Attr("class", formSelectAllBtn), vdom.Text(label)}
if p.OnChange != nil {
oc, cur, opts, allNow := p.OnChange, p.Value, filtered, all
mods = append(mods, vdom.On(vdom.EVENT_CLICK, func() {
if allNow {
next := cur
for _, o := range opts {
next = formRemoveStr(next, o.Value)
}
oc(next)
return
}
oc(formSelectAllValues(opts, cur))
}))
}
rows = append(rows, vdom.Button(mods...))
rows = append(rows, m.selectAllButton(filtered, p.Value, p.OnChange))
}
if len(filtered) == 0 {
@@ -1276,6 +1262,34 @@ func (m *MultiSelect) Render(p FormMultiSelectProps) *vdom.VNode {
)
}
// selectAllButton is the row at the top of the panel.
//
// It acts on what is VISIBLE. Selecting all of a filtered list is what the user is
// looking at and asked for; quietly selecting the hidden ones too would be a nasty
// surprise, and one they would not discover until they saved.
func (m *MultiSelect) selectAllButton(filtered []FormSelectOption, value []string, onChange func([]string)) *vdom.VNode {
all := formAllSelected(filtered, value)
label := "Select All"
if all {
label = "Deselect All"
}
mods := []vdom.Mod{vdom.Attr("type", "button"), vdom.Attr("class", formSelectAllBtn), vdom.Text(label)}
if onChange != nil {
mods = append(mods, vdom.On(vdom.EVENT_CLICK, func() {
if all {
next := value
for _, o := range filtered {
next = formRemoveStr(next, o.Value)
}
onChange(next)
return
}
onChange(formSelectAllValues(filtered, value))
}))
}
return vdom.Button(mods...)
}
// triggerFace is what the closed field shows: a placeholder, a row of removable pills,
// or "N items selected" when the pills will not fit.
//
@@ -1286,7 +1300,7 @@ func (m *MultiSelect) Render(p FormMultiSelectProps) *vdom.VNode {
func (m *MultiSelect) triggerFace(p FormMultiSelectProps, selected []FormSelectOption, maxTags int) []vdom.Mod {
if len(selected) == 0 {
return []vdom.Mod{vdom.Span(
vdom.Attr("class", "text-neutral-500 truncate"),
vdom.Attr("class", "text-ink-muted truncate"),
vdom.Text(pick(p.Placeholder, "Select options")),
)}
}
@@ -1315,7 +1329,7 @@ func (m *MultiSelect) triggerFace(p FormMultiSelectProps, selected []FormSelectO
ov, label := opt.Value, opt.Label
remove := []vdom.Mod{
vdom.Attr("type", "button"),
vdom.Attr("class", "bg-transparent border-none p-0 cursor-pointer flex items-center hover:text-neutral-900"),
vdom.Attr("class", "bg-transparent border-none p-0 cursor-pointer flex items-center hover:text-ink"),
vdom.Attr("aria-label", "Remove "+label),
IconInline("xmark", 10, ""),
}
@@ -1334,10 +1348,14 @@ func (m *MultiSelect) triggerFace(p FormMultiSelectProps, selected []FormSelectO
))
}
label := itemsSelected
if p.CollapsedLabel != nil {
label = p.CollapsedLabel
}
count := vdom.Span(
vdom.WithRef(m.countRef),
vdom.Attr("class", faceCls("truncate", !tooMany)),
vdom.Text(itemsSelected(len(selected))),
vdom.Text(label(len(selected))),
)
m.collapseFace(tooMany)
@@ -1390,7 +1408,7 @@ func itemsSelected(n int) string {
return strconv.Itoa(n) + " items selected"
}
const formMultiSelectTag = "inline-flex items-center gap-0.5 rounded-default bg-neutral-200 py-0.5 text-neutral-700 whitespace-nowrap leading-none"
const formMultiSelectTag = "inline-flex items-center gap-0.5 rounded-default bg-surface-strong py-0.5 text-ink-soft whitespace-nowrap leading-none"
func truncateRunes(s string, max int) string {
if max <= 0 {
@@ -1543,18 +1561,15 @@ var formTimezones = []FormSelectOption{
{Value: "Pacific/Honolulu", Label: "Hawaii"},
}
// -- intentionally omitted (no neutral-runtime equivalent) ---------------------
// -- the rest of the TSX kit ---------------------------------------------------
//
// NOTE: the following TSX exports are omitted. They depend on capabilities the
// neutral vdom runtime does not provide, and have no meaningful static form:
// These four were once listed here as unportable — "no neutral-runtime equivalent".
// That was true before the host API existed. It is not true now, and they all live in
// forms_async.go and signaturepad.go:
//
// - FormSignaturePad — freehand drawing on a <canvas> 2D context with
// mouse/touch tracking and SVG serialization.
// - FormAsyncCombobox — debounced async option loading via Promises/timers.
// - FormMultiSelectTrigger — a custom-trigger multi-select popover (Portal-free
// but still driven by document listeners + refs); its
// selection model is already covered by FormMultiSelect.
// - handleTaxIdInput / handleRateInput — standalone oninput handlers that mutate
// a DOM element in place; with no component consumer in
// this file and no DOM handle in handlers, they don't
// port. (Their masking logic mirrors formClean* above.)
// - SignaturePad — draws into an SVG rather than a canvas, so the thing you
// sign IS the value the caller stores.
// - AsyncCombobox — debounced, and it discards out-of-order responses.
// - MultiSelectTrigger — MultiSelect behind a trigger of your own.
// - MaskTaxID / MaskRate — the TSX's oninput handlers, as pure functions of the
// string. Testable, reusable, and they run on the server.

394
go/webui/forms_async.go Normal file
View File

@@ -0,0 +1,394 @@
package webui
import (
"strings"
"kjol/vdom"
"kjol/wasmruntime"
)
// The three form controls the first pass left out, plus the two input masks. All of
// them were listed as "no neutral-runtime equivalent" — which was true before the host
// API existed, and is not true now.
// ---- AsyncCombobox: options fetched as you type --------------------------
// AsyncCombobox is a combobox whose options come from somewhere else: a search endpoint,
// a database, a third-party API. You type, it asks, the answers appear.
//
// The asking is DEBOUNCED and the answers are ORDERED. Both matter and both are easy to
// get wrong: without debouncing, a six-letter query is six requests; without ordering, a
// slow response to "ab" can land after a fast one to "abcdef" and overwrite it, leaving
// the user staring at results for a query they finished typing a second ago. Every
// response carries the query it was for, and one that no longer matches the box is
// dropped.
//
// Create it once, alongside your signals — never inside a render.
type AsyncCombobox struct {
f *Floating
query *vdom.Signal[string]
results *vdom.Signal[[]FormSelectOption]
loading *vdom.Signal[bool]
active *vdom.Signal[int]
inputRef *vdom.Ref
timer int
minChars int
debounce int
search func(query string, done func([]FormSelectOption))
}
// AsyncComboboxOptions configures NewAsyncCombobox.
type AsyncComboboxOptions struct {
// Search is asked for options. Call done with the results — from a goroutine, from a
// fetch callback, whenever. Call it exactly once; calling it late is fine, since a
// stale answer is discarded rather than shown.
Search func(query string, done func([]FormSelectOption))
MinChars int // don't search below this many characters (default 2)
DebounceMs int // wait this long after the last keystroke (default 200)
Placement string
OnOpenChange func(bool)
}
// NewAsyncCombobox creates an async combobox.
func NewAsyncCombobox(o AsyncComboboxOptions) *AsyncCombobox {
c := &AsyncCombobox{
query: vdom.NewSignal(""),
results: vdom.NewSignal([]FormSelectOption{}),
loading: vdom.NewSignal(false),
active: vdom.NewSignal(-1),
inputRef: vdom.NewRef(),
minChars: o.MinChars,
debounce: o.DebounceMs,
search: o.Search,
}
if c.minChars == 0 {
c.minChars = 2
}
if c.debounce == 0 {
c.debounce = 200
}
c.f = NewFloating(FloatingOptions{
Placement: pick(o.Placement, PlacementBottomStart),
Offset: 4,
ConstrainToViewport: true,
Standalone: true,
OnOpenChange: func(open bool) {
if !open {
c.active.Set(-1)
}
if o.OnOpenChange != nil {
o.OnOpenChange(open)
}
},
})
return c
}
// IsOpen / Close drive the panel.
func (c *AsyncCombobox) IsOpen() bool { return c.f.IsOpen() }
func (c *AsyncCombobox) Close() { c.f.Hide() }
// Dispose cancels a pending search and removes the panel's listeners.
func (c *AsyncCombobox) Dispose() {
wasmruntime.ClearTimeout(c.timer)
c.f.Dispose()
}
// FormAsyncComboboxProps configures a render.
type FormAsyncComboboxProps struct {
Placeholder string
// OnSelect fires when an option is chosen. The box shows the option's label.
OnSelect func(opt FormSelectOption)
Disabled bool
Small bool
Class string
FieldWidth string
// EmptyMessage is shown when a search returns nothing (default "No matches").
EmptyMessage string
}
// Render draws the field and its panel.
func (c *AsyncCombobox) Render(p FormAsyncComboboxProps) *vdom.VNode {
input := vdom.Input(
vdom.WithRef(c.inputRef),
vdom.Attr("type", "text"),
vdom.Attr("class", cx(formInputBase, formControlH(p.Small), "border-none bg-transparent p-0 shadow-none focus-visible:outline-none")),
vdom.Attr("placeholder", pick(p.Placeholder, "Search…")),
vdom.Attr("autocomplete", "off"),
vdom.Attr("spellcheck", "false"),
vdom.Prop("value", c.query.Get()),
vdom.OnEvent(vdom.EVENT_INPUT, func(e vdom.Event) { c.onInput(e.Value()) }),
vdom.OnEvent(vdom.EVENT_KEYDOWN, func(e vdom.Event) { c.onKey(e, p) }),
)
if p.Disabled {
input.Attrs["disabled"] = "disabled"
}
pad := "p-2"
if p.Small {
pad = "p-1"
}
trigger := c.f.Trigger(FloatingTriggerProps{
Tag: "div",
Class: cx(formTriggerBase, formControlH(p.Small), pad),
AriaHasPopup: "listbox",
// The field is not a switch — see FloatingTriggerProps.NoToggle.
NoToggle: true,
},
vdom.Div(vdom.Attr("class", "min-w-0 flex-1"), input),
c.statusIcon(),
)
return vdom.Div(
vdom.Attr("class", formComboboxRootCls(p.Class, p.FieldWidth)),
trigger,
c.panel(p),
)
}
func (c *AsyncCombobox) statusIcon() *vdom.VNode {
if c.loading.Get() {
return vdom.Span(vdom.Attr("class", "shrink-0 animate-spin text-ink-faint"), IconInline("circle-notch", 14, ""))
}
return IconInline("magnifying-glass", 14, "shrink-0 text-ink-faint")
}
func (c *AsyncCombobox) panel(p FormAsyncComboboxProps) *vdom.VNode {
rows := []*vdom.VNode{}
switch {
case c.loading.Get():
rows = append(rows, vdom.Div(vdom.Attr("class", formDropdownNoResults), vdom.Text("Searching…")))
case len(c.results.Get()) == 0:
rows = append(rows, vdom.Div(vdom.Attr("class", formDropdownNoResults),
vdom.Text(pick(p.EmptyMessage, "No matches"))))
default:
for i, opt := range c.results.Get() {
i, opt := i, opt
cls := formDropdownOption
if i == c.active.Get() {
cls = cx(cls, "bg-surface-raised")
}
rows = append(rows, vdom.Button(
vdom.Attr("type", "button"),
vdom.Attr("class", cls),
vdom.Attr("role", "option"),
vdom.On(vdom.EVENT_CLICK, func() { c.choose(opt, p) }),
vdom.Text(opt.Label),
))
}
}
return c.f.Panel(FloatingPanelProps{Class: formDropdown, Role: "listbox"}, rows...)
}
func (c *AsyncCombobox) choose(opt FormSelectOption, p FormAsyncComboboxProps) {
c.query.Set(opt.Label)
c.f.Hide()
if p.OnSelect != nil {
p.OnSelect(opt)
}
}
// onInput debounces. A search per keystroke is a search per keystroke — and if the
// search costs a network round-trip, typing "engineering" is twelve of them.
func (c *AsyncCombobox) onInput(q string) {
c.query.Set(q)
c.active.Set(-1)
wasmruntime.ClearTimeout(c.timer)
if len([]rune(strings.TrimSpace(q))) < c.minChars {
c.loading.Set(false)
c.results.Set([]FormSelectOption{})
c.f.Hide()
return
}
c.loading.Set(true)
c.f.Show()
c.timer = wasmruntime.SetTimeout(c.debounce, func() { c.run(q) })
}
// run performs the search, and DISCARDS an answer that arrived for a query the user has
// since typed past. Out-of-order responses are the classic bug in this control.
func (c *AsyncCombobox) run(q string) {
if c.search == nil {
c.loading.Set(false)
return
}
c.search(q, func(opts []FormSelectOption) {
if c.query.Get() != q {
return // the box has moved on; this answer is for a question nobody is asking
}
c.loading.Set(false)
c.results.Set(opts)
if len(opts) > 0 {
c.f.Show()
}
})
}
func (c *AsyncCombobox) onKey(e vdom.Event, p FormAsyncComboboxProps) {
res := c.results.Get()
switch e.Key() {
case vdom.KEY_ARROW_DOWN:
e.PreventDefault()
if len(res) > 0 {
c.active.Set((c.active.Get() + 1) % len(res))
}
case vdom.KEY_ARROW_UP:
e.PreventDefault()
if len(res) > 0 {
next := c.active.Get() - 1
if next < 0 {
next = len(res) - 1
}
c.active.Set(next)
}
case vdom.KEY_ENTER:
if i := c.active.Get(); i >= 0 && i < len(res) {
e.PreventDefault()
c.choose(res[i], p)
}
}
}
// ---- MultiSelectTrigger: a multi-select behind your own trigger ----------
// MultiSelectTrigger is MultiSelect with the field replaced by whatever you like — a
// button, an icon, a table header. The selection model is identical; only the thing you
// click on differs. The AutoTable's column picker is one of these in spirit.
type MultiSelectTrigger struct{ *MultiSelect }
// NewMultiSelectTrigger creates one.
func NewMultiSelectTrigger(o DropdownOptions) *MultiSelectTrigger {
return &MultiSelectTrigger{MultiSelect: NewMultiSelect(o)}
}
// FormMultiSelectTriggerProps configures a render.
type FormMultiSelectTriggerProps struct {
// Trigger is your element. It is wrapped in the floating trigger, so it needs no
// open/close wiring of its own.
Trigger *vdom.VNode
Options []FormSelectOption
Value []string
OnChange func([]string)
Searchable bool
SearchPlaceholder string
ShowSelectAll bool
Small bool
Class string
PanelClass string
}
// Render draws the caller's trigger and the panel.
func (m *MultiSelectTrigger) Render(p FormMultiSelectTriggerProps) *vdom.VNode {
trigger := m.f.Trigger(FloatingTriggerProps{
Tag: "div",
Class: cx("inline-flex cursor-pointer items-center", p.Class),
AriaHasPopup: "listbox",
}, p.Trigger)
filtered := m.filter(p.Options)
rows := make([]*vdom.VNode, 0, len(filtered)+2)
if p.Searchable && m.IsOpen() {
rows = append(rows, m.searchBox(p.SearchPlaceholder, false, len(filtered)))
}
if p.ShowSelectAll && len(filtered) > 0 {
rows = append(rows, m.selectAllButton(filtered, p.Value, p.OnChange))
}
if len(filtered) == 0 {
rows = append(rows, m.noResults(false))
}
for i := range filtered {
opt := filtered[i]
on := formContainsStr(p.Value, opt.Value)
rows = append(rows, m.optionButton(opt, i, on, false, p.Small, func() {
if p.OnChange != nil {
p.OnChange(formToggleStr(p.Value, opt.Value))
}
}))
}
return vdom.Div(vdom.Attr("class", "relative inline-flex"),
trigger,
m.f.Panel(FloatingPanelProps{Class: cx(formDropdown, pick(p.PanelClass, "min-w-48")), Role: "listbox"}, rows...),
)
}
// ---- input masks --------------------------------------------------------
// MaskTaxID formats a US tax ID as the user types: 12-3456789.
//
// The TSX equivalents (handleTaxIdInput / handleRateInput) were oninput handlers that
// reached into the event and rewrote the element's value in place. These are pure
// functions of the string instead — they can be tested, reused on the server, and
// composed — and the caller wires one into an input the ordinary way:
//
// ui.FormInput(ui.FormInputProps{
// Value: taxID.Get(),
// OnInput: func(v string) { taxID.Set(ui.MaskTaxID(v)) },
// })
func MaskTaxID(s string) string {
digits := keepDigits(s)
if len(digits) > 9 {
digits = digits[:9]
}
if len(digits) <= 2 {
return digits
}
return digits[:2] + "-" + digits[2:]
}
// MaskRate formats a rate as it is typed: digits, one decimal point, at most three
// decimal places, no leading zeros.
func MaskRate(s string) string {
var b strings.Builder
seenDot := false
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c >= '0' && c <= '9':
b.WriteByte(c)
case c == '.' && !seenDot:
seenDot = true
b.WriteByte(c)
}
}
out := b.String()
if i := strings.IndexByte(out, '.'); i >= 0 {
whole, frac := out[:i], out[i+1:]
if len(frac) > 3 {
frac = frac[:3]
}
out = trimLeadingZeros(whole) + "." + frac
} else {
out = trimLeadingZeros(out)
}
return out
}
func keepDigits(s string) string {
var b strings.Builder
for i := 0; i < len(s); i++ {
if s[i] >= '0' && s[i] <= '9' {
b.WriteByte(s[i])
}
}
return b.String()
}
// trimLeadingZeros drops leading zeros but keeps a lone "0" — "007" is 7, and "0" is 0,
// but "" is not a number the user meant to type.
func trimLeadingZeros(s string) string {
i := 0
for i < len(s)-1 && s[i] == '0' {
i++
}
return s[i:]
}

View File

@@ -0,0 +1,163 @@
package webui
import (
"strings"
"testing"
"kjol/vdom"
)
// ---- input masks ----
func TestMaskTaxID(t *testing.T) {
for _, tc := range []struct{ in, want string }{
{"", ""},
{"1", "1"},
{"12", "12"},
{"123", "12-3"},
{"123456789", "12-3456789"},
{"12-3456789", "12-3456789"}, // idempotent: re-masking its own output
{"1234567890123", "12-3456789"}, // nine digits, no more
{"ab12cd3456789xy", "12-3456789"},
} {
if got := MaskTaxID(tc.in); got != tc.want {
t.Errorf("MaskTaxID(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestMaskRate(t *testing.T) {
for _, tc := range []struct{ in, want string }{
{"", ""},
{"5", "5"},
{"5.25", "5.25"},
{"5.2555", "5.255"}, // three decimals, no more
{"5.2.5", "5.25"}, // one decimal point, no more
{"007", "7"}, // no leading zeros...
{"0", "0"}, // ...but a lone zero is a number
{"0.5", "0.5"}, // and so is a leading zero before a point
{"$1,2a3.45", "123.45"},
{"5.25", "5.25"}, // idempotent
} {
if got := MaskRate(tc.in); got != tc.want {
t.Errorf("MaskRate(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
// A mask is applied to its own output on every keystroke, so anything that is not
// idempotent corrupts the field as you type.
func TestMasksAreIdempotent(t *testing.T) {
for _, in := range []string{"123456789", "12-345", "1", ""} {
once := MaskTaxID(in)
if twice := MaskTaxID(once); twice != once {
t.Errorf("MaskTaxID is not idempotent: %q -> %q -> %q", in, once, twice)
}
}
for _, in := range []string{"5.255", "0.5", "007", "12"} {
once := MaskRate(in)
if twice := MaskRate(once); twice != once {
t.Errorf("MaskRate is not idempotent: %q -> %q -> %q", in, once, twice)
}
}
}
// ---- async combobox ----
func asyncOpts(labels ...string) []FormSelectOption {
out := make([]FormSelectOption, len(labels))
for i, l := range labels {
out[i] = FormSelectOption{Value: strings.ToLower(l), Label: l}
}
return out
}
// The classic bug in a search-as-you-type box: a SLOW answer to an early query lands
// after a FAST answer to the query the user actually finished typing, and overwrites it.
// The user is then reading results for "ab" while the box says "abcdef".
//
// Every response carries the query it was for; one that no longer matches is dropped.
func TestAsyncComboboxDropsStaleResponses(t *testing.T) {
var pending []func([]FormSelectOption) // answers we have not given yet
var queries []string
c := NewAsyncCombobox(AsyncComboboxOptions{
MinChars: 2,
Search: func(q string, done func([]FormSelectOption)) {
queries = append(queries, q)
pending = append(pending, done)
},
})
// Type "ab", then "abcdef". (run() is called directly: the debounce timer is a
// browser timer, and this test has no browser.)
c.query.Set("ab")
c.run("ab")
c.query.Set("abcdef")
c.run("abcdef")
if len(pending) != 2 {
t.Fatalf("expected 2 searches, got %d (%v)", len(pending), queries)
}
// The answer to "abcdef" comes back first...
pending[1](asyncOpts("Abcdef Ltd"))
if got := c.results.Get(); len(got) != 1 || got[0].Label != "Abcdef Ltd" {
t.Fatalf("the current query's results were not shown: %v", got)
}
// ...and the slow answer to "ab" arrives afterwards. It must be DISCARDED.
pending[0](asyncOpts("Ab Corp", "Abacus"))
got := c.results.Get()
if len(got) != 1 || got[0].Label != "Abcdef Ltd" {
t.Errorf("a stale response overwrote the current results: %v", got)
}
}
// Below MinChars there is no search at all — a one-character query against a big table
// is a scan, and every user who ever types starts by typing one character.
func TestAsyncComboboxRespectsMinChars(t *testing.T) {
searched := 0
c := NewAsyncCombobox(AsyncComboboxOptions{
MinChars: 3,
Search: func(string, func([]FormSelectOption)) { searched++ },
})
c.onInput("a")
c.onInput("ab")
if searched != 0 {
t.Errorf("searched %d times below MinChars", searched)
}
if c.IsOpen() {
t.Error("the panel opened with nothing to show")
}
}
// Choosing an option closes the box and reports the whole option, not just its value —
// the caller usually needs the label back to display it.
func TestAsyncComboboxSelect(t *testing.T) {
var picked FormSelectOption
c := NewAsyncCombobox(AsyncComboboxOptions{
Search: func(_ string, done func([]FormSelectOption)) { done(asyncOpts("Ada Lovelace")) },
})
c.query.Set("ada")
c.run("ada")
c.f.Show()
node := c.Render(FormAsyncComboboxProps{OnSelect: func(o FormSelectOption) { picked = o }})
if !findAndClick(node, "Ada Lovelace") {
t.Fatalf("no result row to click:\n%s", vdom.RenderHTML(node))
}
if picked.Value != "ada lovelace" {
t.Errorf("OnSelect got %+v", picked)
}
if c.IsOpen() {
t.Error("choosing an option should close the panel")
}
if c.query.Get() != "Ada Lovelace" {
t.Errorf("the field should show the chosen label, got %q", c.query.Get())
}
}

View File

@@ -230,12 +230,12 @@ const (
FuzzyDisplayNone = "none" // render only the input (headless)
)
const fuzzyInputCls = "bg-white block w-full border border-neutral-300 rounded-default shadow-xs text-sm p-2 h-[38px] focus:outline-2 focus:outline-offset-1 focus:outline-sky-500"
const fuzzyInputCls = "bg-surface block w-full border border-line-strong rounded-default shadow-xs text-sm p-2 h-[38px] focus:outline-2 focus:outline-offset-1 focus:outline-sky-500"
// Mirror FormCombobox's dropdown styling: neutral hover / highlight.
const fuzzyDropdownCls = "bg-white border border-neutral-300 rounded-default shadow-lg max-h-60 overflow-auto"
const fuzzyDropdownOptionCls = "w-full text-left p-2 text-sm cursor-pointer flex items-center justify-between gap-2 bg-transparent border-none hover:bg-neutral-100 whitespace-nowrap"
const fuzzyDropdownOptionHighlightCls = "bg-neutral-100"
const fuzzyDropdownCls = "bg-surface border border-line-strong rounded-default shadow-lg max-h-60 overflow-auto"
const fuzzyDropdownOptionCls = "w-full text-left p-2 text-sm cursor-pointer flex items-center justify-between gap-2 bg-transparent border-none hover:bg-surface-raised whitespace-nowrap"
const fuzzyDropdownOptionHighlightCls = "bg-surface-raised"
// FuzzyMatchProps configures the FuzzyMatch component. Query / Open / Highlighted
// are caller-held state (read at the call site); the On* callbacks report changes.
@@ -262,7 +262,7 @@ func fuzzyHighlight(segments []FuzzySegment) []*vdom.VNode {
out := make([]*vdom.VNode, 0, len(segments))
for _, seg := range segments {
if seg.Match {
out = append(out, vdom.Span(vdom.Attr("class", "text-sky-700 font-semibold"), vdom.Text(seg.Text)))
out = append(out, vdom.Span(vdom.Attr("class", "text-sky-700 dark:text-sky-400 font-semibold"), vdom.Text(seg.Text)))
} else {
out = append(out, vdom.Span(vdom.Text(seg.Text)))
}
@@ -275,7 +275,7 @@ func fuzzyScoreBadge(show bool, score int) *vdom.VNode {
if !show {
return nil
}
return vdom.Span(vdom.Attr("class", "ml-3 shrink-0 text-xs text-neutral-400"), vdom.Text(strconv.Itoa(score)))
return vdom.Span(vdom.Attr("class", "ml-3 shrink-0 text-xs text-ink-faint"), vdom.Text(strconv.Itoa(score)))
}
// fuzzyListView renders the inline "list" display. Before the user types, all
@@ -296,12 +296,12 @@ func fuzzyListView(p FuzzyMatchProps, results []FuzzyRankedItem) *vdom.VNode {
for _, r := range items {
r := r
li := []vdom.Mod{
vdom.Attr("class", "flex items-center justify-between gap-3 px-2 py-1 rounded-default cursor-pointer hover:bg-neutral-100"),
vdom.Attr("class", "flex items-center justify-between gap-3 px-2 py-1 rounded-default cursor-pointer hover:bg-surface-raised"),
}
if p.OnSelect != nil {
li = append(li, vdom.On(vdom.EVENT_CLICK, func() { p.OnSelect(r.Value, r) }))
}
li = append(li, vdom.Span(kids([]vdom.Mod{vdom.Attr("class", "text-sm text-neutral-800")}, fuzzyHighlight(r.Segments))...))
li = append(li, vdom.Span(kids([]vdom.Mod{vdom.Attr("class", "text-sm text-ink")}, fuzzyHighlight(r.Segments))...))
if badge := fuzzyScoreBadge(p.ShowScores, r.Score); badge != nil {
li = append(li, badge)
}
@@ -309,7 +309,7 @@ func fuzzyListView(p FuzzyMatchProps, results []FuzzyRankedItem) *vdom.VNode {
}
outer = append(outer, vdom.Ul(ul...))
} else if strings.TrimSpace(p.Query) != "" {
outer = append(outer, vdom.P(vdom.Attr("class", "text-sm text-neutral-500 italic"),
outer = append(outer, vdom.P(vdom.Attr("class", "text-sm text-ink-muted italic"),
vdom.Text(`No matches for "`+p.Query+`".`)))
}
return vdom.Div(outer...)
@@ -338,7 +338,7 @@ func fuzzyDropdownView(p FuzzyMatchProps, results []FuzzyRankedItem) *vdom.VNode
if p.OnSelect != nil {
btnMods = append(btnMods, vdom.On(vdom.EVENT_CLICK, func() { p.OnSelect(r.Value, r) }))
}
btnMods = append(btnMods, vdom.Span(kids([]vdom.Mod{vdom.Attr("class", "text-neutral-800")}, fuzzyHighlight(r.Segments))...))
btnMods = append(btnMods, vdom.Span(kids([]vdom.Mod{vdom.Attr("class", "text-ink")}, fuzzyHighlight(r.Segments))...))
if badge := fuzzyScoreBadge(p.ShowScores, r.Score); badge != nil {
btnMods = append(btnMods, badge)
}

View File

@@ -24,7 +24,7 @@ func CodeBox(code, class string) *vdom.VNode {
func PageHeader(text, class string) *vdom.VNode {
return vdom.Header(vdom.Attr("class", class),
vdom.Div(vdom.Attr("class", "mt-1"),
vdom.H1(vdom.Attr("class", "text-center text-2xl font-light text-neutral-800 mb-2"), vdom.Text(text)),
vdom.H1(vdom.Attr("class", "text-center text-2xl font-light text-ink mb-2"), vdom.Text(text)),
vdom.Hr(vdom.Attr("class", "text-neutral-200 mb-2")),
),
)
@@ -34,7 +34,7 @@ func PageHeader(text, class string) *vdom.VNode {
func PageLink(href string, newTab bool, class string, children ...*vdom.VNode) *vdom.VNode {
mods := []vdom.Mod{
vdom.Attr("href", href),
vdom.Attr("class", cx("text-sky-700 hover:text-sky-800 hover:underline hover:decoration-1", class)),
vdom.Attr("class", cx("text-sky-700 dark:text-sky-400 hover:text-sky-800 hover:underline hover:decoration-1", class)),
}
if newTab {
mods = append(mods, vdom.Attr("target", "_blank"), vdom.Attr("rel", "noopener noreferrer"))
@@ -58,17 +58,17 @@ type BreadcrumbItem struct {
// Breadcrumbs renders a chevron-separated crumb trail; the last item is bold and
// not linked.
func Breadcrumbs(items []BreadcrumbItem) *vdom.VNode {
mods := []vdom.Mod{vdom.Attr("class", "flex flex-row items-center text-neutral-400 text-xs")}
mods := []vdom.Mod{vdom.Attr("class", "flex flex-row items-center text-ink-faint text-xs")}
for i, crumb := range items {
if i != len(items)-1 {
mods = append(mods, vdom.Span(vdom.Attr("class", "flex items-center"),
vdom.A(vdom.Attr("href", crumb.URL),
vdom.Attr("class", "text-neutral-500 cursor-pointer no-underline hover:text-neutral-700 hover:underline"),
vdom.Attr("class", "text-ink-muted cursor-pointer no-underline hover:text-ink-soft hover:underline"),
vdom.Text(crumb.DisplayText)),
Icon("chevron-right", 12, "mx-[0.15rem] opacity-50"),
))
} else {
mods = append(mods, vdom.Span(vdom.Attr("class", "text-neutral-700 font-medium"), vdom.Text(crumb.DisplayText)))
mods = append(mods, vdom.Span(vdom.Attr("class", "text-ink-soft font-medium"), vdom.Text(crumb.DisplayText)))
}
}
return vdom.Div(mods...)

View File

@@ -24,6 +24,18 @@ func RegisterIcon(name, viewBox, content string) {
iconRegistry[name] = iconDef{viewBox: viewBox, content: content}
}
// HasIcon reports whether a name resolves to an icon.
//
// It exists so an app can TEST that the names it uses are real. An unknown name renders
// a correctly-sized but empty box — the layout survives, which is the right runtime
// behaviour and a terrible debugging experience: the icon is simply not there, and
// nothing anywhere says why. A one-line test over the names you reference turns that
// into a build failure.
func HasIcon(name string) bool {
_, ok := iconRegistry[name]
return ok
}
// Icon renders a named icon at the given pixel size (0 => 16). Extra classes are
// appended. Unknown names render an empty, correctly-sized box (never blanks the
// layout). Mirrors Icons.tsx's <svg fill=currentColor width height>innerHTML</svg>.
@@ -59,7 +71,7 @@ func IconSuccess(name string, size int, class string) *vdom.VNode {
return Icon(name, size, cx("inline-block align-middle text-green-600", class))
}
func IconError(name string, size int, class string) *vdom.VNode {
return Icon(name, size, cx("inline-block align-middle text-red-600", class))
return Icon(name, size, cx("inline-block align-middle text-red-600 dark:text-red-400", class))
}
// IconContainer is a flex row that vertically centers an icon + text.
@@ -136,6 +148,32 @@ func init() {
reg("globe", "M12 21a9 9 0 0 0 8.72-6.75M12 21a9 9 0 0 1-8.72-6.75M12 21c2.49 0 4.5-4.03 4.5-9S14.49 3 12 3m0 18c-2.49 0-4.5-4.03-4.5-9S9.51 3 12 3m0 0a9 9 0 0 1 7.84 4.58M12 3a9 9 0 0 0-7.84 4.58m15.68 0A11.95 11.95 0 0 1 12 10.5c-3 0-5.74-1.1-7.84-2.92m15.68 0A8.96 8.96 0 0 1 21 12c0 .78-.1 1.53-.28 2.25m0 0A17.92 17.92 0 0 1 12 16.5c-3.16 0-6.13-.82-8.72-2.25m0 0A9.02 9.02 0 0 1 3 12c0-1.6.42-3.11 1.16-4.42")
reg("user", "M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.5 20.12a7.5 7.5 0 0 1 15 0A17.93 17.93 0 0 1 12 21.75c-2.68 0-5.22-.58-7.5-1.63Z")
// --- documentation / section icons ---
reg("book-open", "M12 6.04A8.97 8.97 0 0 0 6 3.75c-1.05 0-2.06.18-3 .51v14.25A8.99 8.99 0 0 1 6 18c2.3 0 4.4.87 6 2.29m0-14.25a8.97 8.97 0 0 1 6-2.29c1.05 0 2.06.18 3 .51v14.25A8.99 8.99 0 0 0 18 18a8.97 8.97 0 0 0-6 2.29m0-14.25v14.25")
reg("chart-column", "M3.75 20.25h16.5M6.75 20.25v-6.75m4.5 6.75V8.25m4.5 12V4.5m4.5 15.75v-9")
reg("server", "M3.75 6.75h16.5v4.5H3.75v-4.5Zm0 6h16.5v4.5H3.75v-4.5ZM6.75 9h.01M6.75 15h.01")
reg("cloud-arrow-down", "M12 9.75v6.75m0 0-3-3m3 3 3-3m-8.25 6a4.5 4.5 0 0 1-1.41-8.78 5.25 5.25 0 0 1 10.23-2.33 3 3 0 0 1 3.76 3.85A3.75 3.75 0 0 1 18 19.5H6.75Z")
reg("squares", "M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6Zm0 9.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 8.25 20.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6Zm0 9.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25A2.25 2.25 0 0 1 13.5 18v-2.25Z")
reg("layers", "m2.25 12 9 5.25L20.25 12M2.25 7.5l9-5.25 9 5.25-9 5.25-9-5.25Zm0 9 9 5.25 9-5.25")
reg("table", "M3.75 5.25h16.5v13.5H3.75V5.25Zm0 4.5h16.5m-16.5 4.5h16.5M9.75 9.75v9")
reg("code", "m6.75 7.5-4.5 4.5 4.5 4.5m10.5-9 4.5 4.5-4.5 4.5M14.25 3.75l-4.5 16.5")
reg("bolt", "M14.25 2.25 4.5 13.5h6l-.75 8.25L19.5 10.5h-6l.75-8.25Z")
// A keel is the spine of a hull — the thing everything else is built on. Which is
// the whole idea behind the name, so the boat is not merely decorative.
reg("sailboat", "M11.25 3.75v12M11.25 15.75H4.5l6.75-12M14.25 15.75h4.5l-4.5-7.5zM2.25 18.75h19.5l-2.4 3H4.65z")
reg("cube", "m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9")
reg("shield-check", "M9 12.75 11.25 15 15 9.75m-3-7.04A11.96 11.96 0 0 1 3.6 6 12 12 0 0 0 3 9.75c0 5.59 3.82 10.29 9 11.62 5.18-1.33 9-6.03 9-11.62 0-1.31-.21-2.57-.6-3.75h-.15a11.96 11.96 0 0 1-8.25-3.29Z")
// --- theme ---
reg("sun", "M12 3v1.5m0 15V21m9-9h-1.5m-15 0H3m15.36-6.36-1.06 1.06M6.7 17.3l-1.06 1.06m12.72 0-1.06-1.06M6.7 6.7 5.64 5.64M16.5 12a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0Z")
reg("moon", "M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79Z")
// --- search / loading ---
reg("magnifying-glass", "M21 21l-5.2-5.2M17 10.5a6.5 6.5 0 1 1-13 0 6.5 6.5 0 0 1 13 0Z")
// An open arc, so that spinning it actually reads as motion — a full ring rotating
// looks like a ring standing still.
reg("circle-notch", "M21 12a9 9 0 0 0-9-9")
// --- status (the toast set) ---
reg("info", "M11.25 11.25h1.5v4.5M12 8.25h.01M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z")
reg("circle-info", "M11.25 11.25h1.5v4.5M12 8.25h.01M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z")

View File

@@ -57,11 +57,11 @@ import "kjol/vdom"
// MenuDivider, MenuSection and MenuGroup stay free functions: they hold no state
// and have nothing to close.
const menuCls = "bg-white rounded-default shadow-lg border border-neutral-200 p-1.5 min-w-48 max-h-96 overflow-y-auto"
const menuItemCls = "flex items-center gap-2 w-full py-1.5 px-2 rounded-default text-sm text-left text-neutral-700 bg-transparent border-0 cursor-pointer hover:bg-neutral-100 hover:text-neutral-900 focus:bg-neutral-100 focus:outline-hidden disabled:opacity-50 disabled:cursor-not-allowed"
const menuDividerCls = "my-1 -mx-1.5 border-0 border-t border-neutral-200"
const menuSectionCls = "pt-1.5 pb-0.5 px-2 text-[10px] font-semibold text-neutral-400 uppercase tracking-wide text-left"
const menuSubmenuTriggerCls = "flex items-center justify-between gap-2 w-full py-1.5 px-2 rounded-default text-sm text-left text-neutral-700 cursor-pointer hover:bg-neutral-100 hover:text-neutral-900"
const menuCls = "bg-surface rounded-default shadow-lg border border-line p-1.5 min-w-48 max-h-96 overflow-y-auto"
const menuItemCls = "flex items-center gap-2 w-full py-1.5 px-2 rounded-default text-sm text-left text-ink-soft bg-transparent border-0 cursor-pointer hover:bg-surface-raised hover:text-ink focus:bg-surface-raised focus:outline-hidden disabled:opacity-50 disabled:cursor-not-allowed"
const menuDividerCls = "my-1 -mx-1.5 border-0 border-t border-line"
const menuSectionCls = "pt-1.5 pb-0.5 px-2 text-[10px] font-semibold text-ink-faint uppercase tracking-wide text-left"
const menuSubmenuTriggerCls = "flex items-center justify-between gap-2 w-full py-1.5 px-2 rounded-default text-sm text-left text-ink-soft cursor-pointer hover:bg-surface-raised hover:text-ink"
// defaultHoverCloseDelay is the TSX's 150ms grace period after the cursor leaves.
const defaultHoverCloseDelay = 150
@@ -375,7 +375,7 @@ func (m *Menu) Anchor(p MenuAnchorProps, children ...*vdom.VNode) *vdom.VNode {
}
mods = kids(mods, children)
if target == "_blank" {
mods = append(mods, Icon("arrow-right", 12, "shrink-0 ml-auto text-neutral-400"))
mods = append(mods, Icon("arrow-right", 12, "shrink-0 ml-auto text-ink-faint"))
}
return vdom.A(mods...)
}
@@ -413,7 +413,7 @@ func (m *Menu) Submenu(p SubmenuProps, children ...*vdom.VNode) *vdom.VNode {
OnClick: m.f.Toggle,
},
vdom.Span(label...),
Icon("chevron-right", 16, "shrink-0 ml-auto text-neutral-400"),
Icon("chevron-right", 16, "shrink-0 ml-auto text-ink-faint"),
)
menuSetAttr(trigger, "role", "menuitem")
// Entering the trigger means entering the submenu; it must not let an ancestor

View File

@@ -98,7 +98,7 @@ const modalContainerCenter = "items-center"
const modalBackdrop = "fixed inset-0 bg-black/30"
const modalBase = "relative bg-white shadow-md text-sm w-full rounded-default max-h-[calc(100dvh_-_5rem)] flex flex-col overflow-hidden"
const modalBase = "relative bg-surface shadow-md text-sm w-full rounded-default max-h-[calc(100dvh_-_5rem)] flex flex-col overflow-hidden"
var modalSizes = map[ModalSize]string{
ModalSmall: "max-w-md",
@@ -113,19 +113,19 @@ var modalSizes = map[ModalSize]string{
ModalFull: "max-w-none",
}
const modalHeader = "flex items-center justify-between py-5 px-7 pb-4 border-b border-neutral-200 text-lg font-semibold text-text-heading"
const modalHeader = "flex items-center justify-between py-5 px-7 pb-4 border-b border-line text-lg font-semibold text-text-heading"
const modalHeaderCloseOnly = "flex items-center justify-end p-4 pb-1"
const modalCloseBtn = "cursor-pointer text-neutral-500 bg-transparent border-0 p-0 leading-none hover:text-neutral-700"
const modalCloseBtn = "cursor-pointer text-ink-muted bg-transparent border-0 p-0 leading-none hover:text-ink-soft"
const modalBody = "px-7 py-6 overflow-y-auto flex-1 min-h-0 [background:linear-gradient(var(--color-white),var(--color-white))_bottom_/_100%_3rem_no-repeat_local,linear-gradient(to_bottom,transparent,var(--color-white))_bottom_/_100%_3rem_no-repeat_scroll,var(--color-white)]"
const modalFooter = "py-4 px-7 pb-[calc(1rem_+_env(safe-area-inset-bottom,0px))] flex flex-row justify-end gap-2 border-t border-neutral-200 bg-neutral-50 rounded-b-default"
const modalFooter = "py-4 px-7 pb-[calc(1rem_+_env(safe-area-inset-bottom,0px))] flex flex-row justify-end gap-2 border-t border-line bg-surface-muted rounded-b-default"
const modalFooterSpacer = "h-2"
const modalWizardError = "flex items-center gap-2 py-3 px-8 text-sm text-red-700 bg-red-50 border-t border-red-200"
const modalWizardErrorIcon = "shrink-0 text-red-500"
const modalWizardError = "flex items-center gap-2 py-3 px-8 text-sm text-red-700 bg-red-50 dark:bg-red-950 border-t border-red-200 dark:border-red-900"
const modalWizardErrorIcon = "shrink-0 text-red-500 dark:text-red-400"
// Confirm modal
const modalConfirmWrap = "flex justify-end gap-2"
const modalConfirmCancel = "py-2 px-4 text-sm border border-neutral-300 rounded-default bg-transparent cursor-pointer hover:bg-neutral-50"
const modalConfirmCancel = "py-2 px-4 text-sm border border-line-strong rounded-default bg-transparent cursor-pointer hover:bg-surface-muted"
const modalConfirmOkBase = "py-2 px-4 text-sm rounded-default border-0 cursor-pointer text-white"
var modalConfirmOkVariants = map[string]string{
@@ -137,20 +137,20 @@ var modalConfirmOkVariants = map[string]string{
const modalWizardHeader = "flex flex-col items-center gap-2 flex-1"
const modalWizardTitleRow = "flex items-center justify-between w-full"
const modalWizardTitle = "text-xl"
const modalWizardStepName = "text-xs font-semibold text-neutral-600 uppercase tracking-wider"
const modalWizardStepName = "text-xs font-semibold text-ink-soft uppercase tracking-wider"
const modalWizardSteps = "flex items-center justify-between relative w-full max-w-64"
const modalWizardTrack = "absolute top-1/2 left-0 right-0 h-0.5 bg-neutral-200 -translate-y-1/2"
const modalWizardTrack = "absolute top-1/2 left-0 right-0 h-0.5 bg-surface-strong -translate-y-1/2"
const modalWizardTrackFill = "h-full bg-primary transition-[width] duration-300 ease-in-out"
const modalWizardStepWrap = "relative z-[1]"
const modalStepIndicatorBase = "w-7 h-7 rounded-full flex items-center justify-center text-xs font-semibold border-2 shrink-0 transition-all duration-300 ease-in-out"
const modalStepIndicatorPending = "border-neutral-300 text-neutral-400 bg-white"
const modalStepIndicatorPending = "border-line-strong text-ink-faint bg-surface"
const modalStepIndicatorActive = "bg-primary text-white border-primary"
const modalStepIndicatorCompleted = "bg-primary text-white border-primary"
// Wizard footer
const modalWizardFooter = "flex items-center justify-between w-full gap-2"
const modalWizardBtnBase = "py-2 px-5 text-sm rounded-default cursor-pointer border-0 disabled:opacity-40 disabled:cursor-not-allowed"
const modalWizardBtnBack = "bg-transparent border border-neutral-300 text-neutral-700 enabled:hover:bg-neutral-50"
const modalWizardBtnBack = "bg-transparent border border-line-strong text-ink-soft enabled:hover:bg-surface-muted"
const modalWizardBtnNext = "bg-neutral-800 text-white enabled:hover:bg-neutral-900"
const modalWizardBtnFinish = "bg-primary text-white enabled:hover:bg-red-700"

View File

@@ -29,7 +29,7 @@ package webui
import "kjol/vdom"
const popoverCls = "bg-white rounded-default shadow-lg border border-neutral-200"
const popoverCls = "bg-surface rounded-default shadow-lg border border-line"
// TSX defaults: offset ?? 8, hoverDelay ?? 0, hoverCloseDelay ?? 150.
const popoverOffset = 8

View File

@@ -45,14 +45,14 @@ const (
PrettyTableSizeSuperCompact PrettyTableSize = 2
)
const ptTblContainer = "relative flex flex-col w-full h-full bg-white rounded-default overflow-hidden"
const ptTblContainer = "relative flex flex-col w-full h-full bg-surface rounded-default overflow-hidden"
const ptTblWrapper = "overflow-x-auto w-full"
const ptTblBase = "min-w-full"
const ptHeaderContent = "transition-transform duration-150 ease-in-out"
const ptHeaderInnerBase = "flex justify-between gap-2 items-center"
var ptHeaderColorCls = map[PrettyTableHeaderColor]string{
PrettyTableColorDefault: "bg-neutral-50",
PrettyTableColorDefault: "bg-surface-muted",
PrettyTableColorBlue: "bg-sky-700 text-white",
PrettyTableColorGreen: "bg-green-700 text-white",
PrettyTableColorGray: "bg-neutral-600 text-white",
@@ -70,11 +70,11 @@ var ptHeaderTextCls = map[PrettyTableHeaderColor]string{
// ptRowHoverCls is applied to the tbody when Hover is on (matches the TSX
// PT_ROW_HOVER_CLS map).
var ptRowHoverCls = map[PrettyTableHeaderColor]string{
PrettyTableColorDefault: "[&_tr:hover]:!bg-neutral-200",
PrettyTableColorBlue: "[&_tr:hover]:!bg-sky-100",
PrettyTableColorGreen: "[&_tr:hover]:!bg-green-100",
PrettyTableColorGray: "[&_tr:hover]:!bg-neutral-200",
PrettyTableColorDarkBlue: "[&_tr:hover]:!bg-sky-100",
PrettyTableColorDefault: "[&_tr:hover]:!bg-surface-strong",
PrettyTableColorBlue: "[&_tr:hover]:!bg-sky-100 dark:bg-sky-900",
PrettyTableColorGreen: "[&_tr:hover]:!bg-green-100 dark:bg-green-900",
PrettyTableColorGray: "[&_tr:hover]:!bg-surface-strong",
PrettyTableColorDarkBlue: "[&_tr:hover]:!bg-sky-100 dark:bg-sky-900",
}
var ptHeaderPaddingCls = map[PrettyTableSize]string{
@@ -127,13 +127,13 @@ type PrettyTableOptions struct {
func prettyTableBodyClass(o PrettyTableOptions) string {
c := ptBodyPaddingCls[o.Size]
if o.BorderY {
c = cx(c, "[&_td+td]:border-l [&_td+td]:border-neutral-300")
c = cx(c, "[&_td+td]:border-l [&_td+td]:border-line-strong")
}
if o.Alternate {
c = cx(c, "[&_tr:nth-child(even)]:bg-neutral-100")
c = cx(c, "[&_tr:nth-child(even)]:bg-surface-raised")
}
if o.BorderX {
c = cx(c, "[&_tr:not(:last-child)]:border-b [&_tr:not(:last-child)]:border-neutral-300")
c = cx(c, "[&_tr:not(:last-child)]:border-b [&_tr:not(:last-child)]:border-line-strong")
}
if o.Hover {
c = cx(c, ptRowHoverCls[o.Color])
@@ -146,7 +146,7 @@ func prettyTableBodyClass(o PrettyTableOptions) string {
func PrettyTable(columns []PrettyTableColumn, opts PrettyTableOptions, children ...*vdom.VNode) *vdom.VNode {
containerCls := ptTblContainer
if opts.SurroundingBorder {
containerCls = cx(containerCls, "border border-neutral-300")
containerCls = cx(containerCls, "border border-line-strong")
}
if opts.Shadow {
containerCls = cx(containerCls, "shadow-sm")
@@ -178,7 +178,7 @@ func PrettyTable(columns []PrettyTableColumn, opts PrettyTableOptions, children
))
}
thead := vdom.Thead(vdom.Attr("class", "[&_th]:border-b [&_th]:border-neutral-300"),
thead := vdom.Thead(vdom.Attr("class", "[&_th]:border-b [&_th]:border-line-strong"),
vdom.Tr(kids(nil, headerCells)...),
)

View File

@@ -32,7 +32,7 @@ func (f *RemoteFlash) Fire() { f.visible.Set(true) }
// Clear hides the flash.
func (f *RemoteFlash) Clear() { f.visible.Set(false) }
const remoteUpdateFlashCls = "remote-update-flash inline-flex items-center gap-1 rounded-full bg-emerald-100 border border-emerald-300 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-widest text-emerald-700"
const remoteUpdateFlashCls = "remote-update-flash inline-flex items-center gap-1 rounded-full bg-emerald-100 dark:bg-emerald-900 border border-emerald-300 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-widest text-emerald-700 dark:text-emerald-400"
// RemoteUpdateFlash is a small pill that briefly shows "Updated". It renders
// nothing (nil) when when is false, mirroring the TSX <Show when=…>.

View File

@@ -4,10 +4,10 @@ package webui
import "kjol/vdom"
const sidebarNavRoot = "bg-white rounded-default shadow-sm border border-neutral-200 py-2"
const sidebarNavRoot = "bg-surface rounded-default shadow-sm border border-line py-2"
const sidebarNavList = "list-none m-0 p-0"
const sidebarNavBtn = "w-full text-left py-2 pr-3 pl-4 text-sm cursor-pointer bg-transparent text-neutral-600 border-0 hover:text-neutral-900 hover:bg-neutral-50"
const sidebarNavSubBtn = "w-full text-left py-1.5 pr-3 pl-8 text-xs cursor-pointer bg-transparent text-neutral-500 border-0 hover:text-neutral-900 hover:bg-neutral-50"
const sidebarNavBtn = "w-full text-left py-2 pr-3 pl-4 text-sm cursor-pointer bg-transparent text-ink-soft border-0 hover:text-ink hover:bg-surface-muted"
const sidebarNavSubBtn = "w-full text-left py-1.5 pr-3 pl-8 text-xs cursor-pointer bg-transparent text-ink-muted border-0 hover:text-ink hover:bg-surface-muted"
const sidebarNavIcon = "mr-2"
const sidebarLayoutRoot = "grid grid-cols-1 gap-8 min-h-screen items-start lg:grid-cols-12"

265
go/webui/signaturepad.go Normal file
View File

@@ -0,0 +1,265 @@
package webui
import (
"strconv"
"strings"
"kjol/vdom"
"kjol/wasmruntime"
)
// Port of FormSignaturePad (web/uikit/Forms.tsx) — freehand signing.
//
// Two deliberate departures from the TSX.
//
// It draws into an SVG, not a <canvas>. The component's OUTPUT is SVG — the TSX kept
// strokes in memory, painted them onto a canvas for the user, and serialized a separate
// SVG string for the caller. That is two renderers for one drawing, and they can
// disagree. Here the SVG the user is looking at IS the value the caller gets, so it
// cannot be wrong. It also means a stored signature renders on the SERVER: the same
// markup, no client needed to see it.
//
// And the in-flight stroke never touches a signal. A pointer moves sixty times a second,
// and a signal write re-renders the whole page; the live stroke is written straight at
// the element with SetHTML, and only the FINISHED stroke is committed. Without that,
// signing your name would re-render the document a few hundred times.
//
// Create it once, alongside your signals — never inside a render.
type SignaturePad struct {
svgRef *vdom.Ref
// strokes are the finished ones: committed, re-rendered, part of the value.
strokes *vdom.Signal[[]sigStroke]
// live is the stroke being drawn right now. NOT a signal — see above.
live sigStroke
drawing bool
unsubs []wasmruntime.Unsub
onChange func(svg string)
width float64
height float64
}
type sigPoint struct{ X, Y float64 }
type sigStroke []sigPoint
// SignaturePadOptions configures NewSignaturePad.
type SignaturePadOptions struct {
// Width and Height are the SVG's coordinate space (its viewBox), not its size on
// screen — the element scales to its container and the strokes scale with it.
// Default 600x120.
Width, Height float64
// OnChange receives the signature as an SVG document, or "" when it is cleared.
OnChange func(svg string)
}
// NewSignaturePad creates a signature pad.
func NewSignaturePad(o SignaturePadOptions) *SignaturePad {
if o.Width <= 0 {
o.Width = 600
}
if o.Height <= 0 {
o.Height = 120
}
return &SignaturePad{
svgRef: vdom.NewRef(),
strokes: vdom.NewSignal([]sigStroke{}),
onChange: o.OnChange,
width: o.Width,
height: o.Height,
}
}
// SignaturePadProps configures a render.
type SignaturePadProps struct {
Class string
Hint string // shown while empty; default "Sign above"
ClearText string // default "Clear"
Disabled bool
HideFooter bool // no hint / Clear button — for showing a signature back, read-only
}
// IsEmpty reports whether anything has been drawn.
func (s *SignaturePad) IsEmpty() bool { return len(s.strokes.Get()) == 0 }
// SVG is the signature as a standalone SVG document — the value to store. It is "" when
// the pad is empty, so an empty pad is an empty string rather than a blank drawing.
func (s *SignaturePad) SVG() string {
strokes := s.strokes.Get()
if len(strokes) == 0 {
return ""
}
var b strings.Builder
b.WriteString(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 `)
b.WriteString(sigNum(s.width) + " " + sigNum(s.height))
b.WriteString(`" width="` + sigNum(s.width) + `" height="` + sigNum(s.height) + `">`)
b.WriteString(sigPaths(strokes))
b.WriteString(`</svg>`)
return b.String()
}
// Clear empties the pad.
func (s *SignaturePad) Clear() {
s.endDrag()
s.live = nil
s.strokes.Set([]sigStroke{})
if s.onChange != nil {
s.onChange("")
}
}
// Dispose removes any listeners left behind by an interrupted drag.
func (s *SignaturePad) Dispose() { s.endDrag() }
// Render draws the pad.
func (s *SignaturePad) Render(p SignaturePadProps) *vdom.VNode {
svgMods := []vdom.Mod{
vdom.WithRef(s.svgRef),
vdom.Attr("viewBox", "0 0 "+sigNum(s.width)+" "+sigNum(s.height)),
// touch-none: without it, drawing on a phone scrolls the page instead.
vdom.Attr("class", "block h-auto w-full cursor-crosshair touch-none"),
vdom.Attr("role", "img"),
vdom.Raw(sigPaths(s.strokes.Get())),
}
if !p.Disabled {
svgMods = append(svgMods, vdom.OnEvent(vdom.EVENT_POINTERDOWN, s.onDown))
}
kids := []*vdom.VNode{vdom.Svg(svgMods...)}
if !p.HideFooter {
hint := ""
if s.IsEmpty() {
hint = pick(p.Hint, "Sign above")
}
clear := []vdom.Mod{
vdom.Attr("type", "button"),
vdom.Attr("class", "text-xs text-ink-muted hover:text-ink disabled:opacity-50"),
vdom.Text(pick(p.ClearText, "Clear")),
}
if p.Disabled || s.IsEmpty() {
clear = append(clear, vdom.Attr("disabled", "disabled"))
} else {
clear = append(clear, vdom.On(vdom.EVENT_CLICK, s.Clear))
}
kids = append(kids, vdom.Div(
vdom.Attr("class", "flex items-center justify-between border-t border-line bg-surface-muted px-2 py-1"),
vdom.Span(vdom.Attr("class", "text-xs italic text-ink-faint"), vdom.Text(hint)),
vdom.Button(clear...),
))
}
mods := []vdom.Mod{vdom.Attr("class", cx("overflow-hidden rounded-default border border-line-strong bg-surface", p.Class))}
for _, k := range kids {
mods = append(mods, k)
}
return vdom.Div(mods...)
}
// ---- drawing ------------------------------------------------------------
func (s *SignaturePad) onDown(e vdom.Event) {
e.PreventDefault()
s.endDrag() // a previous drag that never got its pointerup (alt-tab, say)
s.drawing = true
s.live = sigStroke{s.point(e)}
// The listeners go on the DOCUMENT, not the element. Drag off the edge of the pad
// and the stroke should follow the cursor and finish when you let go — with element
// listeners the pointer simply escapes and the stroke is left half-drawn.
s.unsubs = append(s.unsubs,
wasmruntime.OnDocument(vdom.EVENT_POINTERMOVE, false, s.onMove),
wasmruntime.OnDocument(vdom.EVENT_POINTERUP, false, s.onUp),
wasmruntime.OnDocument(vdom.EVENT_POINTERCANCEL, false, s.onUp),
)
}
func (s *SignaturePad) onMove(e vdom.Event) {
if !s.drawing {
return
}
s.live = append(s.live, s.point(e))
// Imperative. This runs on every pointer move; a signal here would re-render the
// entire application between one pixel of ink and the next.
wasmruntime.SetHTML(s.svgRef, sigPaths(append(append([]sigStroke{}, s.strokes.Get()...), s.live)))
}
func (s *SignaturePad) onUp(vdom.Event) {
if !s.drawing {
return
}
s.endDrag()
// A stroke of one point is a click, not a mark. Dropping it keeps a stray tap from
// counting as a signature.
if len(s.live) >= 2 {
s.strokes.Set(append(append([]sigStroke{}, s.strokes.Get()...), s.live))
if s.onChange != nil {
s.onChange(s.SVG())
}
}
s.live = nil
}
func (s *SignaturePad) endDrag() {
s.drawing = false
for _, u := range s.unsubs {
u()
}
s.unsubs = nil
}
// point maps a pointer's viewport coordinates into the SVG's coordinate space. The
// element is fluid and the viewBox is fixed, so the two differ by whatever the browser
// scaled the SVG to — measuring is the only way to know.
func (s *SignaturePad) point(e vdom.Event) sigPoint {
r := wasmruntime.Measure(s.svgRef)
if r.Width == 0 || r.Height == 0 {
return sigPoint{}
}
return sigPoint{
X: (float64(e.ClientX()) - r.X) * (s.width / r.Width),
Y: (float64(e.ClientY()) - r.Y) * (s.height / r.Height),
}
}
// ---- serialization ------------------------------------------------------
// sigPaths renders strokes as SVG <path> elements, smoothed.
//
// Straight lines between raw pointer samples look like a seismograph, not handwriting.
// Each segment is a quadratic curve THROUGH the sampled point and ending at the midpoint
// of the next one, which is the standard trick for turning a polyline into something
// that reads as a pen stroke.
func sigPaths(strokes []sigStroke) string {
var b strings.Builder
for _, st := range strokes {
if len(st) < 2 {
continue
}
b.WriteString(`<path d="M`)
b.WriteString(sigNum(st[0].X) + "," + sigNum(st[0].Y))
if len(st) == 2 {
b.WriteString(" L" + sigNum(st[1].X) + "," + sigNum(st[1].Y))
} else {
for i := 1; i < len(st)-1; i++ {
mx := (st[i].X + st[i+1].X) / 2
my := (st[i].Y + st[i+1].Y) / 2
b.WriteString(" Q" + sigNum(st[i].X) + "," + sigNum(st[i].Y) + "," + sigNum(mx) + "," + sigNum(my))
}
last := st[len(st)-1]
b.WriteString(" L" + sigNum(last.X) + "," + sigNum(last.Y))
}
b.WriteString(`" fill="none" stroke="#1a1a2e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>`)
}
return b.String()
}
// sigNum formats a coordinate to one decimal, without a trailing ".0" — the markup is
// the value the caller stores, and there is no reason to store 600.0 as six bytes.
func sigNum(f float64) string {
return strconv.FormatFloat(f, 'f', -1, 64)
}

View File

@@ -0,0 +1,88 @@
package webui
import (
"strings"
"testing"
"kjol/vdom"
)
// The pad's value IS the markup it shows — that is the whole reason it draws into an SVG
// rather than a canvas. If the two could differ, the user would sign one thing and the
// caller would store another.
func TestSignaturePadValueIsWhatIsDrawn(t *testing.T) {
var got string
pad := NewSignaturePad(SignaturePadOptions{OnChange: func(svg string) { got = svg }})
pad.strokes.Set([]sigStroke{{{X: 10, Y: 20}, {X: 30, Y: 40}, {X: 50, Y: 20}}})
value := pad.SVG()
shown := vdom.RenderHTML(pad.Render(SignaturePadProps{}))
// Every path in the value is present in the rendered element.
paths := sigPaths(pad.strokes.Get())
if paths == "" {
t.Fatal("no path was generated for a three-point stroke")
}
if !strings.Contains(value, paths) {
t.Error("the emitted SVG does not contain the strokes it was built from")
}
if !strings.Contains(shown, `d="M10,20`) {
t.Errorf("the rendered pad is not showing the stroke:\n%s", shown)
}
_ = got
}
// An empty pad is an empty string, not a blank drawing. A caller storing the value must
// be able to ask "did they sign?" without parsing SVG.
func TestSignaturePadEmptyIsEmptyString(t *testing.T) {
pad := NewSignaturePad(SignaturePadOptions{})
if !pad.IsEmpty() {
t.Error("a fresh pad should be empty")
}
if pad.SVG() != "" {
t.Errorf("an empty pad produced %q, want \"\"", pad.SVG())
}
}
// Clear reports the emptiness to the caller. Without the callback a cleared pad would
// leave the last signature sitting in whatever the caller stored it in.
func TestSignaturePadClearNotifies(t *testing.T) {
got := "not called"
pad := NewSignaturePad(SignaturePadOptions{OnChange: func(svg string) { got = svg }})
pad.strokes.Set([]sigStroke{{{X: 1, Y: 1}, {X: 2, Y: 2}}})
pad.Clear()
if got != "" {
t.Errorf("OnChange got %q on clear, want \"\"", got)
}
if !pad.IsEmpty() {
t.Error("the pad is not empty after Clear")
}
}
// A single point is a tap, not a mark: it has no length, produces no path, and must not
// count as a signature.
func TestSignaturePadIgnoresSinglePointStrokes(t *testing.T) {
if got := sigPaths([]sigStroke{{{X: 5, Y: 5}}}); got != "" {
t.Errorf("a one-point stroke produced a path: %q", got)
}
}
// Two points are a straight line; three or more are smoothed into curves. Handwriting
// drawn as raw polylines looks like a seismograph.
func TestSignaturePadSmoothsLongStrokes(t *testing.T) {
line := sigPaths([]sigStroke{{{X: 0, Y: 0}, {X: 10, Y: 10}}})
if !strings.Contains(line, " L10,10") {
t.Errorf("a two-point stroke should be a straight line: %q", line)
}
if strings.Contains(line, "Q") {
t.Errorf("a two-point stroke has nothing to smooth: %q", line)
}
curve := sigPaths([]sigStroke{{{X: 0, Y: 0}, {X: 10, Y: 10}, {X: 20, Y: 0}}})
if !strings.Contains(curve, "Q") {
t.Errorf("a three-point stroke should be smoothed: %q", curve)
}
}

View File

@@ -17,8 +17,8 @@ import (
// defaultIndex prop likewise collapses into the caller-supplied ActiveIndex.
const tabsBase = "flex items-center gap-1.5 cursor-pointer py-2 px-4 text-sm font-medium bg-transparent border-0 border-b-2 transition-[color,border-color] duration-150"
const tabsInactive = "text-text-muted border-neutral-200 hover:text-text-body"
const tabsActive = "text-primary border-primary"
const tabsInactive = "text-text-muted border-line hover:text-text-body"
const tabsActive = "text-accent border-primary"
// TabItem is one tab: a title, an optional numeric badge (shown only when > 0),
// and optional inline panel content.
@@ -119,7 +119,7 @@ func TabGroup(p TabGroupProps) *vdom.VNode {
header = append(header, vdom.Button(btn...))
}
if p.Actions != nil {
header = append(header, vdom.Div(vdom.Attr("class", "tab-actions flex-1 self-end border-b-2 border-neutral-200 flex items-center justify-end pb-1"),
header = append(header, vdom.Div(vdom.Attr("class", "tab-actions flex-1 self-end border-b-2 border-line flex items-center justify-end pb-1"),
vdom.Div(vdom.Attr("class", "flex items-center min-w-0"), p.Actions),
))
}

207
go/webui/theme.go Normal file
View File

@@ -0,0 +1,207 @@
package webui
import (
"kjol/vdom"
"kjol/wasmruntime"
)
// Dark mode.
//
// The kit is themed by TOKENS, not by a dark: variant on every class. A component says
// bg-surface, text-ink, border-line; the theme decides what those mean. Flipping the
// theme is therefore one class on <html> and a block of CSS variables — not four hundred
// class strings, each of which is a chance to forget one and leave a white card sitting
// in the middle of a dark page.
//
// Only genuinely COLOURED things (an alert's red tint, a validation message) carry dark:
// variants, because those are not surfaces or ink and there is no token that means "red,
// but for a dark background".
//
// The app must define the tokens and the variant in its stylesheet. See the example's
// css/app.css; the required set is listed in ThemeTokens below.
// ThemeTokens is the contract between the kit and an app's stylesheet: the CSS custom
// properties every component assumes exist, with light values in :root and dark values
// under .dark.
//
// It is a documentation constant, not code — but it is here, next to the components that
// depend on it, rather than in a README nobody opens when a card comes out the wrong
// colour.
const ThemeTokens = `
--color-surface page and card backgrounds
--color-surface-muted a subtle fill (table stripes, footers)
--color-surface-raised a hovered or filled row
--color-surface-strong the strongest neutral fill
--color-line ordinary borders and dividers
--color-line-strong an input's border — the one you must be able to see
--color-ink body text and headings
--color-ink-soft secondary text
--color-ink-muted labels, captions
--color-ink-faint placeholders, disabled text
--color-accent accent TEXT and icons (readable on the surface)
--color-primary accent FILLS, which carry white text
--color-primary-hover
--color-primary-subtle a tinted panel
--color-primary-border
`
// ThemeMode is what the user chose. System is the default: follow the OS until told
// otherwise, because a site that ignores the OS setting is a site that flashes white at
// someone who asked their whole computer not to.
type ThemeMode string
const (
ThemeSystem ThemeMode = "system"
ThemeLight ThemeMode = "light"
ThemeDark ThemeMode = "dark"
)
// themeStorageKey is where the choice is kept. The bootstrap script in the document head
// reads the SAME key — see ThemeBootScript.
const themeStorageKey = "kjol-theme"
// Theme is the site-wide theme controller. Create one, call Init once after mount, and
// render its Toggle wherever the switch belongs.
type Theme struct {
mode *vdom.Signal[ThemeMode]
// dark is the RESOLVED answer: what "system" actually means right now.
dark *vdom.Signal[bool]
stop wasmruntime.Unsub
}
// NewTheme creates the controller. It reads no storage and touches no DOM — Init does
// that, once the document exists.
func NewTheme() *Theme {
return &Theme{
mode: vdom.NewSignal(ThemeSystem),
dark: vdom.NewSignal(false),
}
}
// Init adopts the stored choice and starts following the OS while the mode is "system".
// Call it once, after the app has mounted.
//
// It does not FLASH, because it is not what puts the class on <html> for the first
// paint: ThemeBootScript already did that, before any of this code existed. Init only
// takes over.
func (t *Theme) Init() {
if s, ok := wasmruntime.StorageGet(themeStorageKey); ok {
switch ThemeMode(s) {
case ThemeLight:
t.mode.Set(ThemeLight)
case ThemeDark:
t.mode.Set(ThemeDark)
}
}
t.apply()
// Follow the OS, but only while the user has not overridden it.
t.stop = wasmruntime.OnMediaChange("(prefers-color-scheme: dark)", func(bool) {
if t.mode.Get() == ThemeSystem {
t.apply()
}
})
}
// Dispose stops following the OS.
func (t *Theme) Dispose() {
if t.stop != nil {
t.stop()
}
}
// Mode is the user's choice; IsDark is what that currently resolves to.
func (t *Theme) Mode() ThemeMode { return t.mode.Get() }
func (t *Theme) IsDark() bool { return t.dark.Get() }
// Set changes the theme. "system" forgets the choice entirely rather than storing the
// current resolution — otherwise "follow my OS" would quietly freeze at whatever the OS
// happened to say the day you chose it.
func (t *Theme) Set(m ThemeMode) {
t.mode.Set(m)
if m == ThemeSystem {
wasmruntime.StorageRemove(themeStorageKey)
} else {
wasmruntime.StorageSet(themeStorageKey, string(m))
}
t.apply()
}
// Toggle flips between light and dark. It resolves "system" first, so the first click
// does what it looks like it will do — the opposite of what you are looking at.
func (t *Theme) Toggle() {
if t.resolve() {
t.Set(ThemeLight)
return
}
t.Set(ThemeDark)
}
// resolve turns the mode into a yes or no.
func (t *Theme) resolve() bool {
switch t.mode.Get() {
case ThemeDark:
return true
case ThemeLight:
return false
default:
return wasmruntime.PrefersDark()
}
}
func (t *Theme) apply() {
dark := t.resolve()
t.dark.Set(dark)
wasmruntime.SetRootClass("dark", dark)
}
// ThemeToggleProps configures the switch.
type ThemeToggleProps struct {
Class string
// Small renders an icon-only button.
Small bool
}
// ThemeToggle is the switch: one button, showing the theme you would get by pressing it.
func (t *Theme) ThemeToggle(p ThemeToggleProps) *vdom.VNode {
icon, label := "moon", "Dark"
if t.IsDark() {
icon, label = "sun", "Light"
}
cls := "inline-flex items-center gap-2 rounded-default border border-line px-2.5 py-1.5 text-sm text-ink-soft hover:bg-surface-raised hover:text-ink"
if p.Small {
cls = "inline-flex h-8 w-8 items-center justify-center rounded-default border border-line text-ink-soft hover:bg-surface-raised hover:text-ink"
}
mods := []vdom.Mod{
vdom.Attr("type", "button"),
vdom.Attr("class", cx(cls, p.Class)),
vdom.Attr("aria-label", "Switch to "+label+" theme"),
vdom.Attr("title", "Switch to "+label+" theme"),
vdom.On(vdom.EVENT_CLICK, t.Toggle),
IconInline(icon, 15, ""),
}
if !p.Small {
mods = append(mods, vdom.Span(vdom.Text(label)))
}
return vdom.Button(mods...)
}
// ThemeBootScript is the inline script an app puts in its <head>, BEFORE any stylesheet
// or markup.
//
// It exists to prevent the flash. The server cannot read localStorage, so it cannot know
// which theme to render; if the class were applied by the WebAssembly after it loads,
// every dark-mode user would be shown a white page for as long as the binary takes to
// download, and then have it yanked out from under them. This runs first, synchronously,
// and the first paint is already correct.
//
// It is ten lines of JavaScript in a project that has none. That is the price of the
// browser giving the page no way to ask about localStorage before it paints, and it is
// worth paying — the alternative is a white flash on every single load.
const ThemeBootScript = `<script>(function(){try{
var m = localStorage.getItem("kjol-theme");
var dark = m === "dark" || (!m && matchMedia("(prefers-color-scheme: dark)").matches);
if (dark) document.documentElement.classList.add("dark");
}catch(e){}})();</script>`

View File

@@ -72,7 +72,7 @@ var toastContainerPositions = map[ToastPosition]string{
ToastBottomCenter: "bottom-4 left-1/2 -translate-x-1/2 flex-col-reverse",
}
const toastBase = "relative overflow-hidden rounded-default shadow-lg border border-neutral-200 border-l-4 bg-white min-w-72 max-w-md transition-[opacity,transform] duration-150 ease-out"
const toastBase = "relative overflow-hidden rounded-default shadow-lg border border-line border-l-4 bg-surface min-w-72 max-w-md transition-[opacity,transform] duration-150 ease-out"
var toastTypeBorder = map[ToastType]string{
ToastSuccess: "border-l-green-700",
@@ -84,9 +84,9 @@ var toastTypeBorder = map[ToastType]string{
var toastIconColor = map[ToastType]string{
ToastSuccess: "text-green-600",
ToastError: "text-red-600",
ToastError: "text-red-600 dark:text-red-400",
ToastWarning: "text-yellow-600",
ToastInfo: "text-sky-700",
ToastInfo: "text-sky-700 dark:text-sky-400",
ToastGeneric: "",
}
@@ -108,10 +108,10 @@ func ToastItem(t Toast, onDismiss func(string), progressRef *vdom.Ref) *vdom.VNo
if icon != "" {
row = append(row, Icon(icon, 20, cx("shrink-0 mt-0.5", toastIconColor[typ])))
}
row = append(row, vdom.Div(vdom.Attr("class", "flex-1 text-sm text-neutral-800"), vdom.Text(t.Message)))
row = append(row, vdom.Div(vdom.Attr("class", "flex-1 text-sm text-ink"), vdom.Text(t.Message)))
if t.Dismissible {
dismiss := []vdom.Mod{
vdom.Attr("class", "shrink-0 cursor-pointer text-neutral-400 hover:text-neutral-600 bg-transparent border-0 p-0 transition-colors"),
vdom.Attr("class", "shrink-0 cursor-pointer text-ink-faint hover:text-ink-soft bg-transparent border-0 p-0 transition-colors"),
vdom.Attr("aria-label", "Dismiss"),
}
if onDismiss != nil {
@@ -133,13 +133,13 @@ func ToastItem(t Toast, onDismiss func(string), progressRef *vdom.Ref) *vdom.VNo
// string on every render is what stops the reconciler's attribute diff from
// resetting it back to 100% mid-countdown.
bar := []vdom.Mod{
vdom.Attr("class", "h-full bg-neutral-300"),
vdom.Attr("class", "h-full bg-surface-strong"),
vdom.Attr("style", "width:100%"),
}
if progressRef != nil {
bar = append(bar, vdom.WithRef(progressRef))
}
mods = append(mods, vdom.Div(vdom.Attr("class", "h-1 w-full bg-neutral-100"),
mods = append(mods, vdom.Div(vdom.Attr("class", "h-1 w-full bg-surface-raised"),
vdom.Div(bar...),
))
}

View File

@@ -14,7 +14,7 @@ func ToggleSwitch(checked bool, onChange func(bool), label, description string,
onChange(!checked)
}
trackState := "bg-neutral-300"
trackState := "bg-surface-strong"
if checked {
trackState = "bg-primary"
}
@@ -24,7 +24,7 @@ func ToggleSwitch(checked bool, onChange func(bool), label, description string,
if checked {
knobState = "translate-x-[18px]"
}
knobCls := cx("inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform", knobState)
knobCls := cx("inline-block h-4 w-4 transform rounded-full bg-surface shadow-sm transition-transform", knobState)
ariaChecked := "false"
if checked {
@@ -47,16 +47,16 @@ func ToggleSwitch(checked bool, onChange func(bool), label, description string,
if label != "" || description != "" {
text := []vdom.Mod{vdom.Attr("class", "flex flex-col leading-tight")}
if label != "" {
labelColor := "text-neutral-800"
labelColor := "text-ink"
if disabled {
labelColor = "text-neutral-400"
labelColor = "text-ink-faint"
}
text = append(text, vdom.Span(vdom.Attr("class", cx("text-sm select-none", labelColor)),
vdom.On(vdom.EVENT_CLICK, toggle),
vdom.Text(label)))
}
if description != "" {
text = append(text, vdom.Span(vdom.Attr("class", "text-xs text-neutral-500"), vdom.Text(description)))
text = append(text, vdom.Span(vdom.Attr("class", "text-xs text-ink-muted"), vdom.Text(description)))
}
mods = append(mods, vdom.Div(text...))
}

View File

@@ -155,7 +155,7 @@ const (
// Tailwind classes, verbatim from the TSX.
const (
tutorialPopoverClass = "fixed z-200 bg-white rounded-default shadow-lg border border-neutral-200 max-w-sm"
tutorialPopoverClass = "fixed z-200 bg-surface rounded-default shadow-lg border border-line max-w-sm"
tutorialSpotlightClass = "fixed z-150 pointer-events-none rounded-default"
tutorialCatcherClass = "fixed inset-0 -z-10 cursor-pointer"
tutorialBackdropClass = "fixed inset-0 bg-black/50 z-150"
@@ -701,23 +701,23 @@ func (t *Tutorial) content(step *TutorialStep, idx, total int) *vdom.VNode {
// Header: optional title + "X of N", and a close button.
headerLeft := []vdom.Mod{vdom.Attr("class", "flex items-center gap-2")}
if step != nil && step.Title != "" {
headerLeft = append(headerLeft, vdom.Span(vdom.Attr("class", "font-medium text-neutral-900"),
headerLeft = append(headerLeft, vdom.Span(vdom.Attr("class", "font-medium text-ink"),
vdom.Text(step.Title)))
}
headerLeft = append(headerLeft, vdom.Span(vdom.Attr("class", "text-xs text-neutral-500"),
headerLeft = append(headerLeft, vdom.Span(vdom.Attr("class", "text-xs text-ink-muted"),
vdom.Text(strconv.Itoa(idx+1)+" of "+strconv.Itoa(total))))
header := vdom.Div(vdom.Attr("class", "flex items-center justify-between p-4 pb-2"),
vdom.Div(headerLeft...),
vdom.Button(vdom.Attr("type", "button"),
vdom.Attr("class", "cursor-pointer text-neutral-400 bg-transparent border-0 p-0 leading-none transition-colors hover:text-neutral-600"),
vdom.Attr("class", "cursor-pointer text-ink-faint bg-transparent border-0 p-0 leading-none transition-colors hover:text-ink-soft"),
vdom.On(vdom.EVENT_CLICK, t.End),
Icon("xmark", 18, ""),
),
)
// Body.
bodyMods := []vdom.Mod{vdom.Attr("class", "px-4 pb-4 text-sm text-neutral-700")}
bodyMods := []vdom.Mod{vdom.Attr("class", "px-4 pb-4 text-sm text-ink-soft")}
if step != nil && step.Content != nil {
if c := step.Content(); c != nil {
bodyMods = append(bodyMods, c)
@@ -749,7 +749,7 @@ func (t *Tutorial) content(step *TutorialStep, idx, total int) *vdom.VNode {
if total > 1 {
dotMods := []vdom.Mod{vdom.Attr("class", "flex justify-center gap-1.5 pb-3")}
for i := 0; i < total; i++ {
state := "bg-neutral-300"
state := "bg-surface-strong"
if i == idx {
state = "bg-sky-600 scale-110"
}