diff --git a/CLAUDE.md b/CLAUDE.md index 4fd6b132..a0b19e5f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/ diff --git a/go/cmd/examples/go-wasm-web/app/chart.go b/go/cmd/examples/go-wasm-web/app/chart.go index 95e1e4fa..48985fca 100644 --- a/go/cmd/examples/go-wasm-web/app/chart.go +++ b/go/cmd/examples/go-wasm-web/app/chart.go @@ -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()))), + ) + } +}` diff --git a/go/cmd/examples/go-wasm-web/app/data.go b/go/cmd/examples/go-wasm-web/app/data.go index 45fa2311..4f2eac8c 100644 --- a/go/cmd/examples/go-wasm-web/app/data.go +++ b/go/cmd/examples/go-wasm-web/app/data.go @@ -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)), ) } } diff --git a/go/cmd/examples/go-wasm-web/app/docs.go b/go/cmd/examples/go-wasm-web/app/docs.go new file mode 100644 index 00000000..5a51f787 --- /dev/null +++ b/go/cmd/examples/go-wasm-web/app/docs.go @@ -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.` diff --git a/go/cmd/examples/go-wasm-web/app/icons_test.go b/go/cmd/examples/go-wasm-web/app/icons_test.go new file mode 100644 index 00000000..361e0bc6 --- /dev/null +++ b/go/cmd/examples/go-wasm-web/app/icons_test.go @@ -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 +} diff --git a/go/cmd/examples/go-wasm-web/app/kit.go b/go/cmd/examples/go-wasm-web/app/kit.go index 8992c488..2107f587 100644 --- a/go/cmd/examples/go-wasm-web/app/kit.go +++ b/go/cmd/examples/go-wasm-web/app/kit.go @@ -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 +})` diff --git a/go/cmd/examples/go-wasm-web/app/landing_test.go b/go/cmd/examples/go-wasm-web/app/landing_test.go new file mode 100644 index 00000000..7b15117c --- /dev/null +++ b/go/cmd/examples/go-wasm-web/app/landing_test.go @@ -0,0 +1,120 @@ +package app + +import ( + "strings" + "testing" + + "kjol/vdom" +) + +// The landing page's whole claim is that its two panes are ONE function: the live +// component on the left, and the HTML string the server sends on the right. If they +// could drift, the page would be a lie told in the most embarrassing possible place. +// +// So: render it, click the button the way the browser would, render again, and check +// that BOTH panes moved. A pane rendered from a stale copy of the tree — or from a +// second, hand-written one — fails here. +func TestLandingPanesShareOneTree(t *testing.T) { + page := HomePage(Deps{Path: func() string { return "/" }}) + + html := vdom.RenderHTML(page()) + if !strings.Contains(html, "clicked 0 times") { + t.Fatalf("the live pane did not render its initial state:\n%s", html) + } + // The right-hand pane is the ESCAPED HTML of the same tree, so the markup it shows + // appears in the page's own markup double-escaped: <div ... + if !strings.Contains(html, "<div class=") { + t.Fatal("the right-hand pane is not showing rendered HTML at all") + } + + clickButton(t, page(), "Click me") + + html = vdom.RenderHTML(page()) + if strings.Count(html, "clicked 1 times") < 2 { + t.Errorf("after one click, %d panes say \"clicked 1 times\" — both should:\n%s", + strings.Count(html, "clicked 1 times"), html) + } +} + +// The byte count under the right-hand pane is the length of the string actually shown, +// not a number typed in by hand — so it has to move when the markup does. +func TestLandingByteCountIsReal(t *testing.T) { + page := HomePage(Deps{Path: func() string { return "/" }}) + + before := byteCountLabel(t, vdom.RenderHTML(page())) + clickButton(t, page(), "Click me") + // "clicked 0 times" -> "clicked 1 times" is the same length, so click into double + // digits, where the markup genuinely grows by one byte. + for i := 0; i < 10; i++ { + clickButton(t, page(), "Click me") + } + after := byteCountLabel(t, vdom.RenderHTML(page())) + + if before == after { + t.Errorf("the markup grew by a digit but the byte count did not move (%s) — it is not measuring the string", before) + } +} + +// byteCountLabel pulls the "N bytes of HTML" caption out of the rendered page. +func byteCountLabel(t *testing.T, html string) string { + t.Helper() + i := strings.Index(html, " bytes of HTML") + if i < 0 { + t.Fatal("no byte-count caption on the landing page") + } + start := strings.LastIndexByte(html[:i], '>') + 1 + return html[start : i+len(" bytes of HTML")] +} + +// clickButton finds a button by its label and fires its click handler. +func clickButton(t *testing.T, n *vdom.VNode, label string) { + t.Helper() + if !findAndClickButton(n, label) { + t.Fatalf("no clickable button labelled %q on the page", label) + } +} + +func findAndClickButton(n *vdom.VNode, label string) bool { + if n == nil { + return false + } + if n.Tag == "button" && strings.Contains(textOf(n), label) { + if h := n.Events[vdom.EVENT_CLICK]; h != nil { + h(clickEvent{}) + return true + } + } + for _, c := range n.Children { + if findAndClickButton(c, label) { + return true + } + } + return false +} + +func textOf(n *vdom.VNode) string { + if n.Tag == "" { + return n.Text + } + var b strings.Builder + for _, c := range n.Children { + b.WriteString(textOf(c)) + } + return b.String() +} + +// clickEvent is a vdom.Event with no DOM behind it — enough to invoke a handler. +type clickEvent struct{} + +func (clickEvent) PreventDefault() {} +func (clickEvent) StopPropagation() {} +func (clickEvent) Value() string { return "" } +func (clickEvent) Checked() bool { return false } +func (clickEvent) Key() string { return "" } +func (clickEvent) ClientX() int { return 0 } +func (clickEvent) ClientY() int { return 0 } +func (clickEvent) Target() any { return nil } +func (clickEvent) SetData(_, _ string) {} +func (clickEvent) GetData(string) string { return "" } + +var _ vdom.Event = clickEvent{} diff --git a/go/cmd/examples/go-wasm-web/app/overlays.go b/go/cmd/examples/go-wasm-web/app/overlays.go index 8f45d1bd..86666ad9 100644 --- a/go/cmd/examples/go-wasm-web/app/overlays.go +++ b/go/cmd/examples/go-wasm-web/app/overlays.go @@ -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.` diff --git a/go/cmd/examples/go-wasm-web/app/pages.go b/go/cmd/examples/go-wasm-web/app/pages.go index 1a4d989d..128a1003 100644 --- a/go/cmd/examples/go-wasm-web/app/pages.go +++ b/go/cmd/examples/go-wasm-web/app/pages.go @@ -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 have to be the same object, or the button and the +// page disagree about what theme you are in. +// +// The client calls Theme.Init() after mounting (see wasm/main.go); on the server it is +// inert, and the document's boot script has already put the right class on . +var Theme = ui.NewTheme() + func itoa(n int) string { return strconv.Itoa(n) } // Layout wraps a page's content with shared chrome (declared with //gowasm:layout, @@ -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"), + ), + ) + } +}` diff --git a/go/cmd/examples/go-wasm-web/app/routes.gen.go b/go/cmd/examples/go-wasm-web/app/routes.gen.go index ac5a961a..7af6b876 100644 --- a/go/cmd/examples/go-wasm-web/app/routes.gen.go +++ b/go/cmd/examples/go-wasm-web/app/routes.gen.go @@ -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", diff --git a/go/cmd/examples/go-wasm-web/app/server_counter.go b/go/cmd/examples/go-wasm-web/app/server_counter.go index 236dc082..54c15b3d 100644 --- a/go/cmd/examples/go-wasm-web/app/server_counter.go +++ b/go/cmd/examples/go-wasm-web/app/server_counter.go @@ -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()))), ) } diff --git a/go/cmd/examples/go-wasm-web/app/table.go b/go/cmd/examples/go-wasm-web/app/table.go index 22301e65..48911209 100644 --- a/go/cmd/examples/go-wasm-web/app/table.go +++ b/go/cmd/examples/go-wasm-web/app/table.go @@ -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` diff --git a/go/cmd/examples/go-wasm-web/css/app.css b/go/cmd/examples/go-wasm-web/css/app.css index c0d7709d..03e343ef 100644 --- a/go/cmd/examples/go-wasm-web/css/app.css +++ b/go/cmd/examples/go-wasm-web/css/app.css @@ -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 , 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.) */ diff --git a/go/cmd/examples/go-wasm-web/server/main.go b/go/cmd/examples/go-wasm-web/server/main.go index 9ed27d72..1869a709 100644 --- a/go/cmd/examples/go-wasm-web/server/main.go +++ b/go/cmd/examples/go-wasm-web/server/main.go @@ -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
and the markup, so hydration's childNodes line up. The // dev server injects the livereload script before 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 ` -gowasm — a tiny Blazor-like engine +Kjol Web — Go + WASM +` + webui.ThemeBootScript + ` - +
` + inner + `
diff --git a/go/cmd/examples/go-wasm-web/wasm/main.go b/go/cmd/examples/go-wasm-web/wasm/main.go index 9090fd2d..2157ef2d 100644 --- a/go/cmd/examples/go-wasm-web/wasm/main.go +++ b/go/cmd/examples/go-wasm-web/wasm/main.go @@ -32,4 +32,10 @@ func main() { } else { wasmruntime.Run(render) // client-rendered route } + + // Adopt the theme AFTER mounting. The class is already on — 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() } diff --git a/go/cmd/examples/go-wasm-web/wwwroot/app.css b/go/cmd/examples/go-wasm-web/wwwroot/app.css index a0c82a62..b0465f65 100644 --- a/go/cmd/examples/go-wasm-web/wwwroot/app.css +++ b/go/cmd/examples/go-wasm-web/wwwroot/app.css @@ -1,5 +1,5 @@ @layer theme,base,components,utilities;@layer theme{:root,:host{--font-sans:"Lora", ui-serif, Georgia, Cambria, "Times New Roman", serif;--font-serif:"Lora", ui-serif, Georgia, Cambria, "Times New Roman", serif;--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', - monospace;--color-red-50:oklch(97.1% 0.013 17.38);--color-red-100:oklch(93.6% 0.032 17.717);--color-red-200:oklch(88.5% 0.062 18.334);--color-red-300:oklch(80.8% 0.114 19.571);--color-red-400:oklch(70.4% 0.191 22.216);--color-red-500:oklch(63.7% 0.237 25.331);--color-red-600:oklch(57.7% 0.245 27.325);--color-red-700:oklch(50.5% 0.213 27.518);--color-red-800:oklch(44.4% 0.177 26.899);--color-red-900:oklch(39.6% 0.141 25.723);--color-red-950:oklch(25.8% 0.092 26.042);--color-orange-50:oklch(98% 0.016 73.684);--color-orange-100:oklch(95.4% 0.038 75.164);--color-orange-200:oklch(90.1% 0.076 70.697);--color-orange-300:oklch(83.7% 0.128 66.29);--color-orange-400:oklch(75% 0.183 55.934);--color-orange-500:oklch(70.5% 0.213 47.604);--color-orange-600:oklch(64.6% 0.222 41.116);--color-orange-700:oklch(55.3% 0.195 38.402);--color-orange-800:oklch(47% 0.157 37.304);--color-orange-900:oklch(40.8% 0.123 38.172);--color-orange-950:oklch(26.6% 0.079 36.259);--color-amber-50:oklch(98.7% 0.022 95.277);--color-amber-100:oklch(96.2% 0.059 95.617);--color-amber-200:oklch(92.4% 0.12 95.746);--color-amber-300:oklch(87.9% 0.169 91.605);--color-amber-400:oklch(82.8% 0.189 84.429);--color-amber-500:oklch(76.9% 0.188 70.08);--color-amber-600:oklch(66.6% 0.179 58.318);--color-amber-700:oklch(55.5% 0.163 48.998);--color-amber-800:oklch(47.3% 0.137 46.201);--color-amber-900:oklch(41.4% 0.112 45.904);--color-amber-950:oklch(27.9% 0.077 45.635);--color-yellow-50:oklch(98.7% 0.026 102.212);--color-yellow-100:oklch(97.3% 0.071 103.193);--color-yellow-200:oklch(94.5% 0.129 101.54);--color-yellow-300:oklch(90.5% 0.182 98.111);--color-yellow-400:oklch(85.2% 0.199 91.936);--color-yellow-500:oklch(79.5% 0.184 86.047);--color-yellow-600:oklch(68.1% 0.162 75.834);--color-yellow-700:oklch(55.4% 0.135 66.442);--color-yellow-800:oklch(47.6% 0.114 61.907);--color-yellow-900:oklch(42.1% 0.095 57.708);--color-yellow-950:oklch(28.6% 0.066 53.813);--color-lime-50:oklch(98.6% 0.031 120.757);--color-lime-100:oklch(96.7% 0.067 122.328);--color-lime-200:oklch(93.8% 0.127 124.321);--color-lime-300:oklch(89.7% 0.196 126.665);--color-lime-400:oklch(84.1% 0.238 128.85);--color-lime-500:oklch(76.8% 0.233 130.85);--color-lime-600:oklch(64.8% 0.2 131.684);--color-lime-700:oklch(53.2% 0.157 131.589);--color-lime-800:oklch(45.3% 0.124 130.933);--color-lime-900:oklch(40.5% 0.101 131.063);--color-lime-950:oklch(27.4% 0.072 132.109);--color-green-50:oklch(98.2% 0.018 155.826);--color-green-100:oklch(96.2% 0.044 156.743);--color-green-200:oklch(92.5% 0.084 155.995);--color-green-300:oklch(87.1% 0.15 154.449);--color-green-400:oklch(79.2% 0.209 151.711);--color-green-500:oklch(72.3% 0.219 149.579);--color-green-600:oklch(62.7% 0.194 149.214);--color-green-700:oklch(52.7% 0.154 150.069);--color-green-800:oklch(44.8% 0.119 151.328);--color-green-900:oklch(39.3% 0.095 152.535);--color-green-950:oklch(26.6% 0.065 152.934);--color-emerald-50:oklch(97.9% 0.021 166.113);--color-emerald-100:oklch(95% 0.052 163.051);--color-emerald-200:oklch(90.5% 0.093 164.15);--color-emerald-300:oklch(84.5% 0.143 164.978);--color-emerald-400:oklch(76.5% 0.177 163.223);--color-emerald-500:oklch(69.6% 0.17 162.48);--color-emerald-600:oklch(59.6% 0.145 163.225);--color-emerald-700:oklch(50.8% 0.118 165.612);--color-emerald-800:oklch(43.2% 0.095 166.913);--color-emerald-900:oklch(37.8% 0.077 168.94);--color-emerald-950:oklch(26.2% 0.051 172.552);--color-teal-50:oklch(98.4% 0.014 180.72);--color-teal-100:oklch(95.3% 0.051 180.801);--color-teal-200:oklch(91% 0.096 180.426);--color-teal-300:oklch(85.5% 0.138 181.071);--color-teal-400:oklch(77.7% 0.152 181.912);--color-teal-500:oklch(70.4% 0.14 182.503);--color-teal-600:oklch(60% 0.118 184.704);--color-teal-700:oklch(51.1% 0.096 186.391);--color-teal-800:oklch(43.7% 0.078 188.216);--color-teal-900:oklch(38.6% 0.063 188.416);--color-teal-950:oklch(27.7% 0.046 192.524);--color-cyan-50:oklch(98.4% 0.019 200.873);--color-cyan-100:oklch(95.6% 0.045 203.388);--color-cyan-200:oklch(91.7% 0.08 205.041);--color-cyan-300:oklch(86.5% 0.127 207.078);--color-cyan-400:oklch(78.9% 0.154 211.53);--color-cyan-500:oklch(71.5% 0.143 215.221);--color-cyan-600:oklch(60.9% 0.126 221.723);--color-cyan-700:oklch(52% 0.105 223.128);--color-cyan-800:oklch(45% 0.085 224.283);--color-cyan-900:oklch(39.8% 0.07 227.392);--color-cyan-950:oklch(30.2% 0.056 229.695);--color-sky-50:oklch(97.7% 0.013 236.62);--color-sky-100:oklch(95.1% 0.026 236.824);--color-sky-200:oklch(90.1% 0.058 230.902);--color-sky-300:oklch(82.8% 0.111 230.318);--color-sky-400:oklch(74.6% 0.16 232.661);--color-sky-500:oklch(68.5% 0.169 237.323);--color-sky-600:oklch(58.8% 0.158 241.966);--color-sky-700:oklch(50% 0.134 242.749);--color-sky-800:oklch(44.3% 0.11 240.79);--color-sky-900:oklch(39.1% 0.09 240.876);--color-sky-950:oklch(29.3% 0.066 243.157);--color-blue-50:oklch(97% 0.014 254.604);--color-blue-100:oklch(93.2% 0.032 255.585);--color-blue-200:oklch(88.2% 0.059 254.128);--color-blue-300:oklch(80.9% 0.105 251.813);--color-blue-400:oklch(70.7% 0.165 254.624);--color-blue-500:oklch(62.3% 0.214 259.815);--color-blue-600:oklch(54.6% 0.245 262.881);--color-blue-700:oklch(48.8% 0.243 264.376);--color-blue-800:oklch(42.4% 0.199 265.638);--color-blue-900:oklch(37.9% 0.146 265.522);--color-blue-950:oklch(28.2% 0.091 267.935);--color-indigo-50:oklch(96.2% 0.018 272.314);--color-indigo-100:oklch(93% 0.034 272.788);--color-indigo-200:oklch(87% 0.065 274.039);--color-indigo-300:oklch(78.5% 0.115 274.713);--color-indigo-400:oklch(67.3% 0.182 276.935);--color-indigo-500:oklch(58.5% 0.233 277.117);--color-indigo-600:oklch(51.1% 0.262 276.966);--color-indigo-700:oklch(45.7% 0.24 277.023);--color-indigo-800:oklch(39.8% 0.195 277.366);--color-indigo-900:oklch(35.9% 0.144 278.697);--color-indigo-950:oklch(25.7% 0.09 281.288);--color-violet-50:oklch(96.9% 0.016 293.756);--color-violet-100:oklch(94.3% 0.029 294.588);--color-violet-200:oklch(89.4% 0.057 293.283);--color-violet-300:oklch(81.1% 0.111 293.571);--color-violet-400:oklch(70.2% 0.183 293.541);--color-violet-500:oklch(60.6% 0.25 292.717);--color-violet-600:oklch(54.1% 0.281 293.009);--color-violet-700:oklch(49.1% 0.27 292.581);--color-violet-800:oklch(43.2% 0.232 292.759);--color-violet-900:oklch(38% 0.189 293.745);--color-violet-950:oklch(28.3% 0.141 291.089);--color-purple-50:oklch(97.7% 0.014 308.299);--color-purple-100:oklch(94.6% 0.033 307.174);--color-purple-200:oklch(90.2% 0.063 306.703);--color-purple-300:oklch(82.7% 0.119 306.383);--color-purple-400:oklch(71.4% 0.203 305.504);--color-purple-500:oklch(62.7% 0.265 303.9);--color-purple-600:oklch(55.8% 0.288 302.321);--color-purple-700:oklch(49.6% 0.265 301.924);--color-purple-800:oklch(43.8% 0.218 303.724);--color-purple-900:oklch(38.1% 0.176 304.987);--color-purple-950:oklch(29.1% 0.149 302.717);--color-fuchsia-50:oklch(97.7% 0.017 320.058);--color-fuchsia-100:oklch(95.2% 0.037 318.852);--color-fuchsia-200:oklch(90.3% 0.076 319.62);--color-fuchsia-300:oklch(83.3% 0.145 321.434);--color-fuchsia-400:oklch(74% 0.238 322.16);--color-fuchsia-500:oklch(66.7% 0.295 322.15);--color-fuchsia-600:oklch(59.1% 0.293 322.896);--color-fuchsia-700:oklch(51.8% 0.253 323.949);--color-fuchsia-800:oklch(45.2% 0.211 324.591);--color-fuchsia-900:oklch(40.1% 0.17 325.612);--color-fuchsia-950:oklch(29.3% 0.136 325.661);--color-pink-50:oklch(97.1% 0.014 343.198);--color-pink-100:oklch(94.8% 0.028 342.258);--color-pink-200:oklch(89.9% 0.061 343.231);--color-pink-300:oklch(82.3% 0.12 346.018);--color-pink-400:oklch(71.8% 0.202 349.761);--color-pink-500:oklch(65.6% 0.241 354.308);--color-pink-600:oklch(59.2% 0.249 0.584);--color-pink-700:oklch(52.5% 0.223 3.958);--color-pink-800:oklch(45.9% 0.187 3.815);--color-pink-900:oklch(40.8% 0.153 2.432);--color-pink-950:oklch(28.4% 0.109 3.907);--color-rose-50:oklch(96.9% 0.015 12.422);--color-rose-100:oklch(94.1% 0.03 12.58);--color-rose-200:oklch(89.2% 0.058 10.001);--color-rose-300:oklch(81% 0.117 11.638);--color-rose-400:oklch(71.2% 0.194 13.428);--color-rose-500:oklch(64.5% 0.246 16.439);--color-rose-600:oklch(58.6% 0.253 17.585);--color-rose-700:oklch(51.4% 0.222 16.935);--color-rose-800:oklch(45.5% 0.188 13.697);--color-rose-900:oklch(41% 0.159 10.272);--color-rose-950:oklch(27.1% 0.105 12.094);--color-slate-50:oklch(98.4% 0.003 247.858);--color-slate-100:oklch(96.8% 0.007 247.896);--color-slate-200:oklch(92.9% 0.013 255.508);--color-slate-300:oklch(86.9% 0.022 252.894);--color-slate-400:oklch(70.4% 0.04 256.788);--color-slate-500:oklch(55.4% 0.046 257.417);--color-slate-600:oklch(44.6% 0.043 257.281);--color-slate-700:oklch(37.2% 0.044 257.287);--color-slate-800:oklch(27.9% 0.041 260.031);--color-slate-900:oklch(20.8% 0.042 265.755);--color-slate-950:oklch(12.9% 0.042 264.695);--color-gray-50:oklch(98.5% 0.002 247.839);--color-gray-100:oklch(96.7% 0.003 264.542);--color-gray-200:oklch(92.8% 0.006 264.531);--color-gray-300:oklch(87.2% 0.01 258.338);--color-gray-400:oklch(70.7% 0.022 261.325);--color-gray-500:oklch(55.1% 0.027 264.364);--color-gray-600:oklch(44.6% 0.03 256.802);--color-gray-700:oklch(37.3% 0.034 259.733);--color-gray-800:oklch(27.8% 0.033 256.848);--color-gray-900:oklch(21% 0.034 264.665);--color-gray-950:oklch(13% 0.028 261.692);--color-zinc-50:oklch(98.5% 0 0);--color-zinc-100:oklch(96.7% 0.001 286.375);--color-zinc-200:oklch(92% 0.004 286.32);--color-zinc-300:oklch(87.1% 0.006 286.286);--color-zinc-400:oklch(70.5% 0.015 286.067);--color-zinc-500:oklch(55.2% 0.016 285.938);--color-zinc-600:oklch(44.2% 0.017 285.786);--color-zinc-700:oklch(37% 0.013 285.805);--color-zinc-800:oklch(27.4% 0.006 286.033);--color-zinc-900:oklch(21% 0.006 285.885);--color-zinc-950:oklch(14.1% 0.005 285.823);--color-neutral-50:oklch(98.5% 0 0);--color-neutral-100:oklch(97% 0 0);--color-neutral-200:oklch(92.2% 0 0);--color-neutral-300:oklch(87% 0 0);--color-neutral-400:oklch(70.8% 0 0);--color-neutral-500:oklch(55.6% 0 0);--color-neutral-600:oklch(43.9% 0 0);--color-neutral-700:oklch(37.1% 0 0);--color-neutral-800:oklch(26.9% 0 0);--color-neutral-900:oklch(20.5% 0 0);--color-neutral-950:oklch(14.5% 0 0);--color-stone-50:oklch(98.5% 0.001 106.423);--color-stone-100:oklch(97% 0.001 106.424);--color-stone-200:oklch(92.3% 0.003 48.717);--color-stone-300:oklch(86.9% 0.005 56.366);--color-stone-400:oklch(70.9% 0.01 56.259);--color-stone-500:oklch(55.3% 0.013 58.071);--color-stone-600:oklch(44.4% 0.011 73.639);--color-stone-700:oklch(37.4% 0.01 67.558);--color-stone-800:oklch(26.8% 0.007 34.298);--color-stone-900:oklch(21.6% 0.006 56.043);--color-stone-950:oklch(14.7% 0.004 49.25);--color-mauve-50:oklch(98.5% 0 0);--color-mauve-100:oklch(96% 0.003 325.6);--color-mauve-200:oklch(92.2% 0.005 325.62);--color-mauve-300:oklch(86.5% 0.012 325.68);--color-mauve-400:oklch(71.1% 0.019 323.02);--color-mauve-500:oklch(54.2% 0.034 322.5);--color-mauve-600:oklch(43.5% 0.029 321.78);--color-mauve-700:oklch(36.4% 0.029 323.89);--color-mauve-800:oklch(26.3% 0.024 320.12);--color-mauve-900:oklch(21.2% 0.019 322.12);--color-mauve-950:oklch(14.5% 0.008 326);--color-olive-50:oklch(98.8% 0.003 106.5);--color-olive-100:oklch(96.6% 0.005 106.5);--color-olive-200:oklch(93% 0.007 106.5);--color-olive-300:oklch(88% 0.011 106.6);--color-olive-400:oklch(73.7% 0.021 106.9);--color-olive-500:oklch(58% 0.031 107.3);--color-olive-600:oklch(46.6% 0.025 107.3);--color-olive-700:oklch(39.4% 0.023 107.4);--color-olive-800:oklch(28.6% 0.016 107.4);--color-olive-900:oklch(22.8% 0.013 107.4);--color-olive-950:oklch(15.3% 0.006 107.1);--color-mist-50:oklch(98.7% 0.002 197.1);--color-mist-100:oklch(96.3% 0.002 197.1);--color-mist-200:oklch(92.5% 0.005 214.3);--color-mist-300:oklch(87.2% 0.007 219.6);--color-mist-400:oklch(72.3% 0.014 214.4);--color-mist-500:oklch(56% 0.021 213.5);--color-mist-600:oklch(45% 0.017 213.2);--color-mist-700:oklch(37.8% 0.015 216);--color-mist-800:oklch(27.5% 0.011 216.9);--color-mist-900:oklch(21.8% 0.008 223.9);--color-mist-950:oklch(14.8% 0.004 228.8);--color-taupe-50:oklch(98.6% 0.002 67.8);--color-taupe-100:oklch(96% 0.002 17.2);--color-taupe-200:oklch(92.2% 0.005 34.3);--color-taupe-300:oklch(86.8% 0.007 39.5);--color-taupe-400:oklch(71.4% 0.014 41.2);--color-taupe-500:oklch(54.7% 0.021 43.1);--color-taupe-600:oklch(43.8% 0.017 39.3);--color-taupe-700:oklch(36.7% 0.016 35.7);--color-taupe-800:oklch(26.8% 0.011 36.5);--color-taupe-900:oklch(21.4% 0.009 43.1);--color-taupe-950:oklch(14.7% 0.004 49.3);--color-black:#000;--color-white:#fff;--spacing:0.25rem;--breakpoint-sm:40rem;--breakpoint-md:48rem;--breakpoint-lg:64rem;--breakpoint-xl:80rem;--breakpoint-2xl:96rem;--container-3xs:16rem;--container-2xs:18rem;--container-xs:20rem;--container-sm:24rem;--container-md:28rem;--container-lg:32rem;--container-xl:36rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--container-6xl:72rem;--container-7xl:80rem;--text-xs:0.75rem;--text-xs--line-height:calc(1 / 0.75);--text-sm:0.875rem;--text-sm--line-height:calc(1.25 / 0.875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--text-6xl:3.75rem;--text-6xl--line-height:1;--text-7xl:4.5rem;--text-7xl--line-height:1;--text-8xl:6rem;--text-8xl--line-height:1;--text-9xl:8rem;--text-9xl--line-height:1;--font-weight-thin:100;--font-weight-extralight:200;--font-weight-light:300;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--font-weight-extrabold:800;--font-weight-black:900;--tracking-tighter:-0.05em;--tracking-tight:-0.025em;--tracking-normal:0em;--tracking-wide:0.025em;--tracking-wider:0.05em;--tracking-widest:0.1em;--leading-tight:1.25;--leading-snug:1.375;--leading-normal:1.5;--leading-relaxed:1.625;--leading-loose:2;--radius-xs:0.125rem;--radius-sm:0.25rem;--radius-md:0.375rem;--radius-lg:0.5rem;--radius-xl:0.75rem;--radius-2xl:1rem;--radius-3xl:1.5rem;--radius-4xl:2rem;--shadow-2xs:0 1px rgb(0 0 0 / 0.05);--shadow-xs:0 1px 2px 0 rgb(0 0 0 / 0.05);--shadow-sm:0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);--shadow-md:0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);--shadow-lg:0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);--shadow-xl:0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);--shadow-2xl:0 25px 50px -12px rgb(0 0 0 / 0.25);--inset-shadow-2xs:inset 0 1px rgb(0 0 0 / 0.05);--inset-shadow-xs:inset 0 1px 1px rgb(0 0 0 / 0.05);--inset-shadow-sm:inset 0 2px 4px rgb(0 0 0 / 0.05);--drop-shadow-xs:0 1px 1px rgb(0 0 0 / 0.05);--drop-shadow-sm:0 1px 2px rgb(0 0 0 / 0.15);--drop-shadow-md:0 3px 3px rgb(0 0 0 / 0.12);--drop-shadow-lg:0 4px 4px rgb(0 0 0 / 0.15);--drop-shadow-xl:0 9px 7px rgb(0 0 0 / 0.1);--drop-shadow-2xl:0 25px 25px rgb(0 0 0 / 0.15);--text-shadow-2xs:0px 1px 0px rgb(0 0 0 / 0.15);--text-shadow-xs:0px 1px 1px rgb(0 0 0 / 0.2);--text-shadow-sm:0px 1px 0px rgb(0 0 0 / 0.075), 0px 1px 1px rgb(0 0 0 / 0.075), 0px 2px 2px rgb(0 0 0 / 0.075);--text-shadow-md:0px 1px 1px rgb(0 0 0 / 0.1), 0px 1px 2px rgb(0 0 0 / 0.1), 0px 2px 4px rgb(0 0 0 / 0.1);--text-shadow-lg:0px 1px 2px rgb(0 0 0 / 0.1), 0px 3px 2px rgb(0 0 0 / 0.1), 0px 4px 8px rgb(0 0 0 / 0.1);--ease-in:cubic-bezier(0.4, 0, 1, 1);--ease-out:cubic-bezier(0, 0, 0.2, 1);--ease-in-out:cubic-bezier(0.4, 0, 0.2, 1);--animate-spin:spin 1s linear infinite;--animate-ping:ping 1s cubic-bezier(0, 0, 0.2, 1) infinite;--animate-pulse:pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;--animate-bounce:bounce 1s infinite;--blur-xs:4px;--blur-sm:8px;--blur-md:12px;--blur-lg:16px;--blur-xl:24px;--blur-2xl:40px;--blur-3xl:64px;--perspective-dramatic:100px;--perspective-near:300px;--perspective-normal:500px;--perspective-midrange:800px;--perspective-distant:1200px;--aspect-video:16 / 9;--default-transition-duration:150ms;--default-transition-timing-function:cubic-bezier(0.4, 0, 0.2, 1);--default-font-family:var(--font-sans);--default-font-feature-settings:initial;--default-font-variation-settings:initial;--default-mono-font-family:var(--font-mono);--default-mono-font-feature-settings:initial;--default-mono-font-variation-settings:initial;--radius-default:0.375rem;--color-primary:#4f46e5;--color-primary-hover:#4338ca;--color-text-heading:#111827;--color-text-on-dark:#f9fafb;--color-text-on-dark-muted:#9ca3af}}@keyframes spin{to{transform:rotate(360deg)}}@keyframes ping{75%,100%{transform:scale(2);opacity:0}}@keyframes pulse{50%{opacity:.5}}@keyframes bounce{0%,100%{transform:translateY(-25%);animation-timing-function:cubic-bezier(.8,0,1,1)}50%{transform:none;animation-timing-function:cubic-bezier(0,0,.2,1)}}@layer base{*,::after,::before,::backdrop,::file-selector-button{box-sizing:border-box;margin:0;padding:0;border:0 solid}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;tab-size:4;font-family:var(--default-font-family);font-feature-settings:var(--default-font-feature-settings);font-variation-settings:var(--default-font-variation-settings);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family);font-feature-settings:var(--default-mono-font-feature-settings);font-variation-settings:var(--default-mono-font-variation-settings);font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea,::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;border-radius:0;background-color:initial;opacity:1}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports(not (-webkit-appearance:-apple-pay-button)) or (contain-intrinsic-size:1px){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit,::-webkit-datetime-edit-year-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]),::file-selector-button{appearance:button}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@font-face{font-family:lora;font-style:italic;font-weight:400 700;font-display:swap;src:url(/fonts/lora-latin-ext-italic.woff2)format("woff2");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:lora;font-style:italic;font-weight:400 700;font-display:swap;src:url(/fonts/lora-latin-italic.woff2)format("woff2");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:lora;font-style:normal;font-weight:400 700;font-display:swap;src:url(/fonts/lora-latin-ext-normal.woff2)format("woff2");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:lora;font-style:normal;font-weight:400 700;font-display:swap;src:url(/fonts/lora-latin-normal.woff2)format("woff2");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@layer utilities{.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{inset:0}.inset-y-0{inset-block:0}.top-0{top:0}.top-1\/2{top:calc(1/2 * 100%)}.top-20{top:calc(var(--spacing) * 20)}.top-4{top:calc(var(--spacing) * 4)}.top-full{top:100%}.-right-2{right:calc(var(--spacing) * -2)}.right-0{right:0}.right-0\.5{right:calc(var(--spacing) * .5)}.right-4{right:calc(var(--spacing) * 4)}.right-8{right:calc(var(--spacing) * 8)}.bottom-4{bottom:calc(var(--spacing) * 4)}.left-0{left:0}.left-1\/2{left:calc(1/2 * 100%)}.left-4{left:calc(var(--spacing) * 4)}.isolate{isolation:isolate}.-z-10{z-index:calc(10 * -1)}.z-10{z-index:10}.z-150{z-index:150}.z-200{z-index:200}.z-\[100\]{z-index:100}.z-\[110\]{z-index:110}.z-\[1\]{z-index:1}.z-\[200\]{z-index:200}.col-span-1{grid-column:span 1/span 1}.-m-1\.5{margin:calc(var(--spacing) * -1.5)}.m-0{margin:0}.-mx-1\.5{margin-inline:calc(var(--spacing) * -1.5)}.mx-3{margin-inline:calc(var(--spacing) * 3)}.mx-4{margin-inline:calc(var(--spacing) * 4)}.mx-\[0\.15rem\]{margin-inline:.15rem}.mx-auto{margin-inline:auto}.my-1{margin-block:var(--spacing)}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-12{margin-top:calc(var(--spacing) * 12)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mr-5{margin-right:calc(var(--spacing) * 5)}.mb-1{margin-bottom:var(--spacing)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.ml-1{margin-left:var(--spacing)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-3{margin-left:calc(var(--spacing) * 3)}.ml-4{margin-left:calc(var(--spacing) * 4)}.ml-auto{margin-left:auto}.box-border{box-sizing:border-box}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.aspect-square{aspect-ratio:1/1}.h-0{height:0}.h-0\.5{height:calc(var(--spacing) * .5)}.h-1{height:var(--spacing)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-72{height:calc(var(--spacing) * 72)}.h-8{height:calc(var(--spacing) * 8)}.h-\[30px\]{height:30px}.h-\[38px\]{height:38px}.h-dvh{height:100dvh}.h-full{height:100%}.h-screen{height:100vh}.max-h-60{max-height:calc(var(--spacing) * 60)}.max-h-64{max-height:calc(var(--spacing) * 64)}.max-h-96{max-height:calc(var(--spacing) * 96)}.max-h-\[34rem\]{max-height:34rem}.max-h-\[calc\(100dvh_-_5rem\)\]{max-height:calc(100dvh - 5rem)}.max-h-\[calc\(100vh_-_7rem\)\]{max-height:calc(100vh - 7rem)}.max-h-dvh{max-height:100dvh}.min-h-0{min-height:0}.min-h-9{min-height:calc(var(--spacing) * 9)}.min-h-\[6\.5rem\]{min-height:6.5rem}.min-h-screen{min-height:100vh}.w-0{width:0}.w-1{width:var(--spacing)}.w-10{width:calc(var(--spacing) * 10)}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-4{width:calc(var(--spacing) * 4)}.w-40{width:calc(var(--spacing) * 40)}.w-52{width:calc(var(--spacing) * 52)}.w-56{width:calc(var(--spacing) * 56)}.w-6{width:calc(var(--spacing) * 6)}.w-64{width:calc(var(--spacing) * 64)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-\[26rem\]{width:26rem}.w-auto{width:auto}.w-full{width:100%}.w-max{width:max-content}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-64{max-width:calc(var(--spacing) * 64)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-7xl{max-width:var(--container-7xl)}.max-w-80{max-width:calc(var(--spacing) * 80)}.max-w-\[100rem\]{max-width:100rem}.max-w-\[11rem\]{max-width:11rem}.max-w-\[14rem\]{max-width:14rem}.max-w-\[16rem\]{max-width:16rem}.max-w-\[20rem\]{max-width:20rem}.max-w-\[90rem\]{max-width:90rem}.max-w-full{max-width:100%}.max-w-md{max-width:var(--container-md)}.max-w-none{max-width:none}.max-w-screen{max-width:100vw}.max-w-sm{max-width:var(--container-sm)}.max-w-xl{max-width:var(--container-xl)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:0}.min-w-48{min-width:calc(var(--spacing) * 48)}.min-w-5{min-width:calc(var(--spacing) * 5)}.min-w-72{min-width:calc(var(--spacing) * 72)}.min-w-8{min-width:calc(var(--spacing) * 8)}.min-w-\[10rem\]{min-width:10rem}.min-w-\[16rem\]{min-width:16rem}.min-w-\[240px\]{min-width:240px}.min-w-\[7rem\]{min-width:7rem}.min-w-\[8rem\]{min-width:8rem}.min-w-\[9rem\]{min-width:9rem}.min-w-full{min-width:100%}.flex-1{flex:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.table-fixed{table-layout:fixed}.border-collapse{border-collapse:collapse}.-translate-x-1\/2{--tw-translate-x:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-x-0\.5{--tw-translate-x:calc(var(--spacing) * 0.5);translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-x-\[18px\]{--tw-translate-x:18px;translate:var(--tw-translate-x)var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x)var(--tw-translate-y)}.scale-110{--tw-scale-x:110%;--tw-scale-y:110%;--tw-scale-z:110%;scale:var(--tw-scale-x)var(--tw-scale-y)}.scale-95{--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x)var(--tw-scale-y)}.rotate-180{rotate:180deg}.rotate-90{rotate:90deg}.transform{transform:var(--tw-rotate-x,)var(--tw-rotate-y,)var(--tw-rotate-z,)var(--tw-skew-x,)var(--tw-skew-y,)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-col-resize{cursor:col-resize}.cursor-default{cursor:default}.cursor-grab{cursor:grab}.cursor-pointer{cursor:pointer}.resize{resize:both}.resize-none{resize:none}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-7{grid-template-columns:repeat(7,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-row{flex-direction:row}.flex-row-reverse{flex-direction:row-reverse}.flex-nowrap{flex-wrap:nowrap}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.items-stretch{align-items:stretch}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.justify-start{justify-content:flex-start}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-8{gap:calc(var(--spacing) * 8)}.gap-\[2px\]{gap:2px}.space-y-6{ :where(& > :not(:last-child)) { --tw-space-y-reverse: 0; margin-block-start: calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse)); margin-block-end: calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse))); }}.space-y-8{ :where(& > :not(:last-child)) { --tw-space-y-reverse: 0; margin-block-start: calc(calc(var(--spacing) * 8) * var(--tw-space-y-reverse)); margin-block-end: calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse))); }}.self-end{align-self:flex-end}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.overflow-y-hidden{overflow-y:hidden}.rounded{border-radius:.25rem}.rounded-default{border-radius:var(--radius-default)}.rounded-full{border-radius:calc(infinity * 1px)}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.rounded-l-default{border-top-left-radius:var(--radius-default);border-bottom-left-radius:var(--radius-default)}.rounded-l-none{border-top-left-radius:0;border-bottom-left-radius:0}.rounded-b-default{border-bottom-right-radius:var(--radius-default);border-bottom-left-radius:var(--radius-default)}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-4{border-style:var(--tw-border-style);border-width:4px}.border-x{border-inline-style:var(--tw-border-style);border-inline-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-t-2{border-top-style:var(--tw-border-style);border-top-width:2px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-r-0{border-right-style:var(--tw-border-style);border-right-width:0}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-4{border-left-style:var(--tw-border-style);border-left-width:4px}.border-none{--tw-border-style:none;border-style:none}.border-emerald-300{border-color:var(--color-emerald-300)}.border-green-200{border-color:var(--color-green-200)}.border-green-500{border-color:var(--color-green-500)}.border-neutral-100{border-color:var(--color-neutral-100)}.border-neutral-200{border-color:var(--color-neutral-200)}.border-neutral-300{border-color:var(--color-neutral-300)}.border-neutral-700{border-color:var(--color-neutral-700)}.border-neutral-800{border-color:var(--color-neutral-800)}.border-primary{border-color:var(--color-primary)}.border-red-200{border-color:var(--color-red-200)}.border-red-500{border-color:var(--color-red-500)}.border-sky-200{border-color:var(--color-sky-200)}.border-sky-700{border-color:var(--color-sky-700)}.border-transparent{border-color:transparent}.border-yellow-200{border-color:var(--color-yellow-200)}.border-t-sky-700{border-top-color:var(--color-sky-700)}.border-t-transparent{border-top-color:transparent}.border-l-green-700{border-left-color:var(--color-green-700)}.border-l-neutral-300{border-left-color:var(--color-neutral-300)}.border-l-neutral-400{border-left-color:var(--color-neutral-400)}.border-l-red-700{border-left-color:var(--color-red-700)}.border-l-sky-800{border-left-color:var(--color-sky-800)}.border-l-yellow-500{border-left-color:var(--color-yellow-500)}.\!bg-primary{background-color:var(--color-primary)!important}.bg-amber-100{background-color:var(--color-amber-100)}.bg-amber-100\!{background-color:var(--color-amber-100)!important}.bg-amber-700{background-color:var(--color-amber-700)}.bg-black\/10{background-color:color-mix(in oklab,var(--color-black) 10%,transparent)}.bg-black\/30{background-color:color-mix(in oklab,var(--color-black) 30%,transparent)}.bg-black\/5{background-color:color-mix(in oklab,var(--color-black) 5%,transparent)}.bg-black\/50{background-color:color-mix(in oklab,var(--color-black) 50%,transparent)}.bg-emerald-100{background-color:var(--color-emerald-100)}.bg-green-50{background-color:var(--color-green-50)}.bg-green-700{background-color:var(--color-green-700)}.bg-green-900{background-color:var(--color-green-900)}.bg-neutral-100{background-color:var(--color-neutral-100)}.bg-neutral-200{background-color:var(--color-neutral-200)}.bg-neutral-300{background-color:var(--color-neutral-300)}.bg-neutral-50{background-color:var(--color-neutral-50)}.bg-neutral-500{background-color:var(--color-neutral-500)}.bg-neutral-600{background-color:var(--color-neutral-600)}.bg-neutral-700{background-color:var(--color-neutral-700)}.bg-neutral-800{background-color:var(--color-neutral-800)}.bg-neutral-900{background-color:var(--color-neutral-900)}.bg-orange-500{background-color:var(--color-orange-500)}.bg-orange-600{background-color:var(--color-orange-600)}.bg-primary{background-color:var(--color-primary)}.bg-primary\/10{background-color:color-mix(in oklab,var(--color-primary) 10%,transparent)}.bg-red-50{background-color:var(--color-red-50)}.bg-red-600{background-color:var(--color-red-600)}.bg-red-700{background-color:var(--color-red-700)}.bg-red-900{background-color:var(--color-red-900)}.bg-sky-50{background-color:var(--color-sky-50)}.bg-sky-600{background-color:var(--color-sky-600)}.bg-sky-700{background-color:var(--color-sky-700)}.bg-sky-800{background-color:var(--color-sky-800)}.bg-sky-900{background-color:var(--color-sky-900)}.bg-transparent{background-color:initial}.bg-white{background-color:var(--color-white)}.bg-white\/10{background-color:color-mix(in oklab,var(--color-white) 10%,transparent)}.bg-yellow-400{background-color:var(--color-yellow-400)}.bg-yellow-50{background-color:var(--color-yellow-50)}.bg-yellow-700{background-color:var(--color-yellow-700)}.fill-current{fill:currentcolor}.p-0{padding:0}.p-0\.5{padding:calc(var(--spacing) * .5)}.p-1{padding:var(--spacing)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-8{padding:calc(var(--spacing) * 8)}.px-1{padding-inline:var(--spacing)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-7{padding-inline:calc(var(--spacing) * 7)}.px-8{padding-inline:calc(var(--spacing) * 8)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-10{padding-block:calc(var(--spacing) * 10)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-5{padding-block:calc(var(--spacing) * 5)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-px{padding-block:1px}.pt-1{padding-top:var(--spacing)}.pt-1\.5{padding-top:calc(var(--spacing) * 1.5)}.pt-10{padding-top:calc(var(--spacing) * 10)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pr-14{padding-right:calc(var(--spacing) * 14)}.pr-3{padding-right:calc(var(--spacing) * 3)}.pr-9{padding-right:calc(var(--spacing) * 9)}.pb-0\.5{padding-bottom:calc(var(--spacing) * .5)}.pb-1{padding-bottom:var(--spacing)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pb-\[calc\(1rem_\+_env\(safe-area-inset-bottom\,0px\)\)\]{padding-bottom:calc(1rem + env(safe-area-inset-bottom,0px))}.pl-4{padding-left:calc(var(--spacing) * 4)}.pl-8{padding-left:calc(var(--spacing) * 8)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-middle{vertical-align:middle}.font-\[inherit\]{font-family:inherit}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[0\.5rem\]{font-size:.5rem}.text-\[10px\]{font-size:10px}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-light{--tw-font-weight:var(--font-weight-light);font-weight:var(--font-weight-light)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.whitespace-nowrap{white-space:nowrap}.whitespace-pre{white-space:pre}.\!text-white{color:var(--color-white)!important}.text-amber-600{color:var(--color-amber-600)}.text-black{color:var(--color-black)}.text-current{color:currentcolor}.text-emerald-700{color:var(--color-emerald-700)}.text-gray-900{color:var(--color-gray-900)}.text-green-600{color:var(--color-green-600)}.text-green-700{color:var(--color-green-700)}.text-green-900{color:var(--color-green-900)}.text-neutral-100{color:var(--color-neutral-100)}.text-neutral-200{color:var(--color-neutral-200)}.text-neutral-300{color:var(--color-neutral-300)}.text-neutral-400{color:var(--color-neutral-400)}.text-neutral-500{color:var(--color-neutral-500)}.text-neutral-600{color:var(--color-neutral-600)}.text-neutral-700{color:var(--color-neutral-700)}.text-neutral-800{color:var(--color-neutral-800)}.text-neutral-900{color:var(--color-neutral-900)}.text-orange-600{color:var(--color-orange-600)}.text-primary{color:var(--color-primary)}.text-red-500{color:var(--color-red-500)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-red-900{color:var(--color-red-900)}.text-sky-600{color:var(--color-sky-600)}.text-sky-700{color:var(--color-sky-700)}.text-sky-900{color:var(--color-sky-900)}.text-text-heading{color:var(--color-text-heading)}.text-text-on-dark{color:var(--color-text-on-dark)}.text-text-on-dark-muted{color:var(--color-text-on-dark-muted)}.text-transparent{color:transparent}.text-violet-600{color:var(--color-violet-600)}.text-white{color:var(--color-white)}.text-yellow-600{color:var(--color-yellow-600)}.text-yellow-700{color:var(--color-yellow-700)}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,)var(--tw-slashed-zero,)var(--tw-numeric-figure,)var(--tw-numeric-spacing,)var(--tw-numeric-fraction,)}.no-underline{text-decoration-line:none}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.caret-neutral-800{caret-color:var(--color-neutral-800)}.opacity-0{opacity:0%}.opacity-50{opacity:50%}.opacity-60{opacity:60%}.shadow-\[0_4px_12px_rgba\(0\,0\,0\,0\.15\)\]{--tw-shadow:0 4px 12px var(--tw-shadow-color, rgba(0,0,0,0.15));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[inset_0_0_0_1px_currentColor\]{--tw-shadow:inset 0 0 0 1px var(--tw-shadow-color, currentColor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[inset_3px_0_0_0_theme\(colors\.amber\.500\)\]{--tw-shadow:inset 3px 0 0 0 var(--tw-shadow-color, theme(colors.amber.500));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-none{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xs{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.outline-hidden{--tw-outline-style:none;outline-style:none;@media(forced-colors:active){outline: 2px solid transparent; outline-offset: 2px;}}.outline-2{outline-style:var(--tw-outline-style);outline-width:2px}.outline-sky-500{outline-color:var(--color-sky-500)}.filter{filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[color\,border-color\]{transition-property:color,border-color;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[opacity\,transform\]{transition-property:opacity,transform;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[width\]{transition-property:width;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-150{--tw-duration:150ms;transition-duration:150ms}.duration-200{--tw-duration:200ms;transition-duration:200ms}.duration-300{--tw-duration:300ms;transition-duration:300ms}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.group-hover\/th\:opacity-50{&:is(:where(.group\/th):hover *){@media(hover:hover){opacity: 50%;}}}.file\:mr-2{&::file-selector-button{margin-right:calc(var(--spacing) * 2)}}.file\:ml-1{&::file-selector-button{margin-left:var(--spacing)}}.file\:cursor-pointer{&::file-selector-button{cursor:pointer}}.file\:rounded-default{&::file-selector-button{border-radius:var(--radius-default)}}.file\:border{&::file-selector-button{border-style:var(--tw-border-style);border-width:1px}}.file\:border-neutral-300{&::file-selector-button{border-color:var(--color-neutral-300)}}.file\:bg-neutral-100{&::file-selector-button{background-color:var(--color-neutral-100)}}.file\:px-3{&::file-selector-button{padding-inline:calc(var(--spacing) * 3)}}.file\:px-4{&::file-selector-button{padding-inline:calc(var(--spacing) * 4)}}.file\:py-\[2px\]{&::file-selector-button{padding-block:2px}}.file\:py-\[3px\]{&::file-selector-button{padding-block:3px}}.file\:text-sm{&::file-selector-button{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}}.file\:shadow-xs{&::file-selector-button{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}}.placeholder\:text-neutral-400{&::placeholder{color:var(--color-neutral-400)}}.before\:absolute{&::before{content:var(--tw-content);position:absolute}}.before\:inset-0{&::before{content:var(--tw-content);inset:0}}.before\:-z-20{&::before{content:var(--tw-content);z-index:calc(20 * -1)}}.before\:bg-neutral-300{&::before{content:var(--tw-content);background-color:var(--color-neutral-300)}}.before\:content-\[\'\'\]{&::before{--tw-content:'';content:var(--tw-content)}}.before\:\[clip-path\:polygon\(16px_0\,100\%_0\,100\%_calc\(100\%_-_16px\)\,calc\(100\%_-_16px\)_100\%\,0_100\%\,0_16px\)\]{&::before{content:var(--tw-content);clip-path:polygon(16px 0,100% 0,100% calc(100% - 16px),calc(100% - 16px) 100%,0 100%,0 16px)}}.after\:absolute{&::after{content:var(--tw-content);position:absolute}}.after\:inset-\[1px\]{&::after{content:var(--tw-content);inset:1px}}.after\:-z-10{&::after{content:var(--tw-content);z-index:calc(10 * -1)}}.after\:bg-white{&::after{content:var(--tw-content);background-color:var(--color-white)}}.after\:content-\[\'\'\]{&::after{--tw-content:'';content:var(--tw-content)}}.after\:\[clip-path\:polygon\(15px_0\,100\%_0\,100\%_calc\(100\%_-_15px\)\,calc\(100\%_-_15px\)_100\%\,0_100\%\,0_15px\)\]{&::after{content:var(--tw-content);clip-path:polygon(15px 0,100% 0,100% calc(100% - 15px),calc(100% - 15px) 100%,0 100%,0 15px)}}.last\:border-r-0{&:last-child{border-right-style:var(--tw-border-style);border-right-width:0}}.last\:border-b-0{&:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}}.odd\:bg-white{&:nth-child(odd){background-color:var(--color-white)}}.even\:bg-neutral-100{&:nth-child(even){background-color:var(--color-neutral-100)}}.hover\:bg-green-100{&:hover{@media(hover:hover){background-color: var(--color-green-100);}}}.hover\:bg-green-800{&:hover{@media(hover:hover){background-color: var(--color-green-800);}}}.hover\:bg-green-950{&:hover{@media(hover:hover){background-color: var(--color-green-950);}}}.hover\:bg-neutral-100{&:hover{@media(hover:hover){background-color: var(--color-neutral-100);}}}.hover\:bg-neutral-200{&:hover{@media(hover:hover){background-color: var(--color-neutral-200);}}}.hover\:bg-neutral-300{&:hover{@media(hover:hover){background-color: var(--color-neutral-300);}}}.hover\:bg-neutral-50{&:hover{@media(hover:hover){background-color: var(--color-neutral-50);}}}.hover\:bg-neutral-500{&:hover{@media(hover:hover){background-color: var(--color-neutral-500);}}}.hover\:bg-neutral-800{&:hover{@media(hover:hover){background-color: var(--color-neutral-800);}}}.hover\:bg-orange-700{&:hover{@media(hover:hover){background-color: var(--color-orange-700);}}}.hover\:bg-primary-hover{&:hover{@media(hover:hover){background-color: var(--color-primary-hover);}}}.hover\:bg-red-700{&:hover{@media(hover:hover){background-color: var(--color-red-700);}}}.hover\:bg-red-800{&:hover{@media(hover:hover){background-color: var(--color-red-800);}}}.hover\:bg-red-950{&:hover{@media(hover:hover){background-color: var(--color-red-950);}}}.hover\:bg-sky-100{&:hover{@media(hover:hover){background-color: var(--color-sky-100);}}}.hover\:bg-sky-500{&:hover{@media(hover:hover){background-color: var(--color-sky-500);}}}.hover\:bg-sky-500\/50{&:hover{@media(hover:hover){background-color: color-mix(in oklab,var(--color-sky-500) 50%,transparent);}}}.hover\:bg-sky-800{&:hover{@media(hover:hover){background-color: var(--color-sky-800);}}}.hover\:bg-sky-950{&:hover{@media(hover:hover){background-color: var(--color-sky-950);}}}.hover\:bg-white\/5{&:hover{@media(hover:hover){background-color: color-mix(in oklab,var(--color-white) 5%,transparent);}}}.hover\:bg-yellow-800{&:hover{@media(hover:hover){background-color: var(--color-yellow-800);}}}.hover\:text-neutral-600{&:hover{@media(hover:hover){color: var(--color-neutral-600);}}}.hover\:text-neutral-700{&:hover{@media(hover:hover){color: var(--color-neutral-700);}}}.hover\:text-neutral-800{&:hover{@media(hover:hover){color: var(--color-neutral-800);}}}.hover\:text-neutral-900{&:hover{@media(hover:hover){color: var(--color-neutral-900);}}}.hover\:text-sky-800{&:hover{@media(hover:hover){color: var(--color-sky-800);}}}.hover\:text-text-on-dark{&:hover{@media(hover:hover){color: var(--color-text-on-dark);}}}.hover\:text-white{&:hover{@media(hover:hover){color: var(--color-white);}}}.hover\:underline{&:hover{@media(hover:hover){text-decoration-line: underline;}}}.hover\:decoration-1{&:hover{@media(hover:hover){text-decoration-thickness: 1px;}}}.hover\:shadow-\[inset_0_0_0_2px_currentColor\]{&:hover{@media(hover:hover){--tw-shadow: inset 0 0 0 2px var(--tw-shadow-color,currentColor); box-shadow: var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);}}}.file\:hover\:bg-neutral-200{&::file-selector-button{&:hover{@media(hover:hover){background-color: var(--color-neutral-200);}}}}.focus\:border-primary{&:focus{border-color:var(--color-primary)}}.focus\:border-sky-500{&:focus{border-color:var(--color-sky-500)}}.focus\:bg-neutral-100{&:focus{background-color:var(--color-neutral-100)}}.focus\:bg-red-50{&:focus{background-color:var(--color-red-50)}}.focus\:shadow-\[inset_0_0_0_2px_var\(--color-red-500\)\]{&:focus{--tw-shadow:inset 0 0 0 2px var(--tw-shadow-color, var(--color-red-500));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}}.focus\:outline-hidden{&:focus{--tw-outline-style:none;outline-style:none;@media(forced-colors:active){outline: 2px solid transparent; outline-offset: 2px;}}}.focus\:outline-2{&:focus{outline-style:var(--tw-outline-style);outline-width:2px}}.focus\:outline-offset-1{&:focus{outline-offset:1px}}.focus\:outline-green-500{&:focus{outline-color:var(--color-green-500)}}.focus\:outline-red-500{&:focus{outline-color:var(--color-red-500)}}.focus\:outline-sky-500{&:focus{outline-color:var(--color-sky-500)}}.focus-visible\:outline-2{&:focus-visible{outline-style:var(--tw-outline-style);outline-width:2px}}.focus-visible\:outline-offset-2{&:focus-visible{outline-offset:2px}}.focus-visible\:outline-current{&:focus-visible{outline-color:currentcolor}}.focus-visible\:outline-primary{&:focus-visible{outline-color:var(--color-primary)}}.active\:bg-neutral-200{&:active{background-color:var(--color-neutral-200)}}.enabled\:hover\:bg-neutral-50{&:enabled{&:hover{@media(hover:hover){background-color: var(--color-neutral-50);}}}}.enabled\:hover\:bg-neutral-900{&:enabled{&:hover{@media(hover:hover){background-color: var(--color-neutral-900);}}}}.enabled\:hover\:bg-red-700{&:enabled{&:hover{@media(hover:hover){background-color: var(--color-red-700);}}}}.disabled\:cursor-not-allowed{&:disabled{cursor:not-allowed}}.disabled\:bg-neutral-100{&:disabled{background-color:var(--color-neutral-100)}}.disabled\:bg-neutral-50{&:disabled{background-color:var(--color-neutral-50)}}.disabled\:text-neutral-300{&:disabled{color:var(--color-neutral-300)}}.disabled\:text-neutral-400{&:disabled{color:var(--color-neutral-400)}}.disabled\:opacity-40{&:disabled{opacity:40%}}.disabled\:opacity-50{&:disabled{opacity:50%}}.disabled\:hover\:bg-transparent{&:disabled{&:hover{@media(hover:hover){background-color: transparent;}}}}.sm\:block{@media(width >= 40rem){display: block;}}.sm\:flex{@media(width >= 40rem){display: flex;}}.sm\:hidden{@media(width >= 40rem){display: none;}}.sm\:w-48{@media(width >= 40rem){width: calc(var(--spacing) * 48);}}.sm\:w-64{@media(width >= 40rem){width: calc(var(--spacing) * 64);}}.sm\:grow-0{@media(width >= 40rem){flex-grow: 0;}}.sm\:grid-cols-2{@media(width >= 40rem){grid-template-columns: repeat(2,minmax(0,1fr));}}.sm\:grid-cols-3{@media(width >= 40rem){grid-template-columns: repeat(3,minmax(0,1fr));}}.sm\:flex-row{@media(width >= 40rem){flex-direction: row;}}.md\:flex-initial{@media(width >= 48rem){flex: 0 auto;}}.md\:justify-start{@media(width >= 48rem){justify-content: flex-start;}}.lg\:col-span-10{@media(width >= 64rem){grid-column: span 10 / span 10;}}.lg\:col-span-2{@media(width >= 64rem){grid-column: span 2 / span 2;}}.lg\:col-span-5{@media(width >= 64rem){grid-column: span 5 / span 5;}}.lg\:col-span-7{@media(width >= 64rem){grid-column: span 7 / span 7;}}.lg\:block{@media(width >= 64rem){display: block;}}.lg\:h-full{@media(width >= 64rem){height: 100%;}}.lg\:grid-cols-12{@media(width >= 64rem){grid-template-columns: repeat(12,minmax(0,1fr));}}.lg\:self-start{@media(width >= 64rem){align-self: flex-start;}}.lg\:pr-8{@media(width >= 64rem){padding-right: calc(var(--spacing) * 8);}}.\[\&_\.ui-form\]\:m-0{& .ui-form{margin:0}}.\[\&_input\]\:cursor-text{& input{cursor:text}}.\[\&_td\]\:p-3{& td{padding:calc(var(--spacing) * 3)}}.\[\&_td\]\:p-4{& td{padding:calc(var(--spacing) * 4)}}.\[\&_td\]\:px-2{& td{padding-inline:calc(var(--spacing) * 2)}}.\[\&_td\]\:py-0\.5{& td{padding-block:calc(var(--spacing) * .5)}}.\[\&_td\]\:py-1{& td{padding-block:var(--spacing)}}.\[\&_td\+td\]\:border-l{& td+td{border-left-style:var(--tw-border-style);border-left-width:1px}}.\[\&_td\+td\]\:border-neutral-300{& td+td{border-color:var(--color-neutral-300)}}.\[\&_th\]\:border-b{& th{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}}.\[\&_th\]\:border-neutral-300{& th{border-color:var(--color-neutral-300)}}.\[\&_tr\:hover\]\:\!bg-green-100{& tr:hover{background-color:var(--color-green-100)!important}}.\[\&_tr\:hover\]\:\!bg-neutral-200{& tr:hover{background-color:var(--color-neutral-200)!important}}.\[\&_tr\:hover\]\:\!bg-sky-100{& tr:hover{background-color:var(--color-sky-100)!important}}.\[\&_tr\:not\(\:last-child\)\]\:border-b{& tr:not(:last-child){border-bottom-style:var(--tw-border-style);border-bottom-width:1px}}.\[\&_tr\:not\(\:last-child\)\]\:border-neutral-300{& tr:not(:last-child){border-color:var(--color-neutral-300)}}.\[\&_tr\:nth-child\(even\)\]\:bg-neutral-100{& tr:nth-child(even){background-color:var(--color-neutral-100)}}}@property --tw-translate-x{syntax: "*"; + monospace;--color-red-50:oklch(97.1% 0.013 17.38);--color-red-100:oklch(93.6% 0.032 17.717);--color-red-200:oklch(88.5% 0.062 18.334);--color-red-300:oklch(80.8% 0.114 19.571);--color-red-400:oklch(70.4% 0.191 22.216);--color-red-500:oklch(63.7% 0.237 25.331);--color-red-600:oklch(57.7% 0.245 27.325);--color-red-700:oklch(50.5% 0.213 27.518);--color-red-800:oklch(44.4% 0.177 26.899);--color-red-900:oklch(39.6% 0.141 25.723);--color-red-950:oklch(25.8% 0.092 26.042);--color-orange-50:oklch(98% 0.016 73.684);--color-orange-100:oklch(95.4% 0.038 75.164);--color-orange-200:oklch(90.1% 0.076 70.697);--color-orange-300:oklch(83.7% 0.128 66.29);--color-orange-400:oklch(75% 0.183 55.934);--color-orange-500:oklch(70.5% 0.213 47.604);--color-orange-600:oklch(64.6% 0.222 41.116);--color-orange-700:oklch(55.3% 0.195 38.402);--color-orange-800:oklch(47% 0.157 37.304);--color-orange-900:oklch(40.8% 0.123 38.172);--color-orange-950:oklch(26.6% 0.079 36.259);--color-amber-50:oklch(98.7% 0.022 95.277);--color-amber-100:oklch(96.2% 0.059 95.617);--color-amber-200:oklch(92.4% 0.12 95.746);--color-amber-300:oklch(87.9% 0.169 91.605);--color-amber-400:oklch(82.8% 0.189 84.429);--color-amber-500:oklch(76.9% 0.188 70.08);--color-amber-600:oklch(66.6% 0.179 58.318);--color-amber-700:oklch(55.5% 0.163 48.998);--color-amber-800:oklch(47.3% 0.137 46.201);--color-amber-900:oklch(41.4% 0.112 45.904);--color-amber-950:oklch(27.9% 0.077 45.635);--color-yellow-50:oklch(98.7% 0.026 102.212);--color-yellow-100:oklch(97.3% 0.071 103.193);--color-yellow-200:oklch(94.5% 0.129 101.54);--color-yellow-300:oklch(90.5% 0.182 98.111);--color-yellow-400:oklch(85.2% 0.199 91.936);--color-yellow-500:oklch(79.5% 0.184 86.047);--color-yellow-600:oklch(68.1% 0.162 75.834);--color-yellow-700:oklch(55.4% 0.135 66.442);--color-yellow-800:oklch(47.6% 0.114 61.907);--color-yellow-900:oklch(42.1% 0.095 57.708);--color-yellow-950:oklch(28.6% 0.066 53.813);--color-lime-50:oklch(98.6% 0.031 120.757);--color-lime-100:oklch(96.7% 0.067 122.328);--color-lime-200:oklch(93.8% 0.127 124.321);--color-lime-300:oklch(89.7% 0.196 126.665);--color-lime-400:oklch(84.1% 0.238 128.85);--color-lime-500:oklch(76.8% 0.233 130.85);--color-lime-600:oklch(64.8% 0.2 131.684);--color-lime-700:oklch(53.2% 0.157 131.589);--color-lime-800:oklch(45.3% 0.124 130.933);--color-lime-900:oklch(40.5% 0.101 131.063);--color-lime-950:oklch(27.4% 0.072 132.109);--color-green-50:oklch(98.2% 0.018 155.826);--color-green-100:oklch(96.2% 0.044 156.743);--color-green-200:oklch(92.5% 0.084 155.995);--color-green-300:oklch(87.1% 0.15 154.449);--color-green-400:oklch(79.2% 0.209 151.711);--color-green-500:oklch(72.3% 0.219 149.579);--color-green-600:oklch(62.7% 0.194 149.214);--color-green-700:oklch(52.7% 0.154 150.069);--color-green-800:oklch(44.8% 0.119 151.328);--color-green-900:oklch(39.3% 0.095 152.535);--color-green-950:oklch(26.6% 0.065 152.934);--color-emerald-50:oklch(97.9% 0.021 166.113);--color-emerald-100:oklch(95% 0.052 163.051);--color-emerald-200:oklch(90.5% 0.093 164.15);--color-emerald-300:oklch(84.5% 0.143 164.978);--color-emerald-400:oklch(76.5% 0.177 163.223);--color-emerald-500:oklch(69.6% 0.17 162.48);--color-emerald-600:oklch(59.6% 0.145 163.225);--color-emerald-700:oklch(50.8% 0.118 165.612);--color-emerald-800:oklch(43.2% 0.095 166.913);--color-emerald-900:oklch(37.8% 0.077 168.94);--color-emerald-950:oklch(26.2% 0.051 172.552);--color-teal-50:oklch(98.4% 0.014 180.72);--color-teal-100:oklch(95.3% 0.051 180.801);--color-teal-200:oklch(91% 0.096 180.426);--color-teal-300:oklch(85.5% 0.138 181.071);--color-teal-400:oklch(77.7% 0.152 181.912);--color-teal-500:oklch(70.4% 0.14 182.503);--color-teal-600:oklch(60% 0.118 184.704);--color-teal-700:oklch(51.1% 0.096 186.391);--color-teal-800:oklch(43.7% 0.078 188.216);--color-teal-900:oklch(38.6% 0.063 188.416);--color-teal-950:oklch(27.7% 0.046 192.524);--color-cyan-50:oklch(98.4% 0.019 200.873);--color-cyan-100:oklch(95.6% 0.045 203.388);--color-cyan-200:oklch(91.7% 0.08 205.041);--color-cyan-300:oklch(86.5% 0.127 207.078);--color-cyan-400:oklch(78.9% 0.154 211.53);--color-cyan-500:oklch(71.5% 0.143 215.221);--color-cyan-600:oklch(60.9% 0.126 221.723);--color-cyan-700:oklch(52% 0.105 223.128);--color-cyan-800:oklch(45% 0.085 224.283);--color-cyan-900:oklch(39.8% 0.07 227.392);--color-cyan-950:oklch(30.2% 0.056 229.695);--color-sky-50:oklch(97.7% 0.013 236.62);--color-sky-100:oklch(95.1% 0.026 236.824);--color-sky-200:oklch(90.1% 0.058 230.902);--color-sky-300:oklch(82.8% 0.111 230.318);--color-sky-400:oklch(74.6% 0.16 232.661);--color-sky-500:oklch(68.5% 0.169 237.323);--color-sky-600:oklch(58.8% 0.158 241.966);--color-sky-700:oklch(50% 0.134 242.749);--color-sky-800:oklch(44.3% 0.11 240.79);--color-sky-900:oklch(39.1% 0.09 240.876);--color-sky-950:oklch(29.3% 0.066 243.157);--color-blue-50:oklch(97% 0.014 254.604);--color-blue-100:oklch(93.2% 0.032 255.585);--color-blue-200:oklch(88.2% 0.059 254.128);--color-blue-300:oklch(80.9% 0.105 251.813);--color-blue-400:oklch(70.7% 0.165 254.624);--color-blue-500:oklch(62.3% 0.214 259.815);--color-blue-600:oklch(54.6% 0.245 262.881);--color-blue-700:oklch(48.8% 0.243 264.376);--color-blue-800:oklch(42.4% 0.199 265.638);--color-blue-900:oklch(37.9% 0.146 265.522);--color-blue-950:oklch(28.2% 0.091 267.935);--color-indigo-50:oklch(96.2% 0.018 272.314);--color-indigo-100:oklch(93% 0.034 272.788);--color-indigo-200:oklch(87% 0.065 274.039);--color-indigo-300:oklch(78.5% 0.115 274.713);--color-indigo-400:oklch(67.3% 0.182 276.935);--color-indigo-500:oklch(58.5% 0.233 277.117);--color-indigo-600:oklch(51.1% 0.262 276.966);--color-indigo-700:oklch(45.7% 0.24 277.023);--color-indigo-800:oklch(39.8% 0.195 277.366);--color-indigo-900:oklch(35.9% 0.144 278.697);--color-indigo-950:oklch(25.7% 0.09 281.288);--color-violet-50:oklch(96.9% 0.016 293.756);--color-violet-100:oklch(94.3% 0.029 294.588);--color-violet-200:oklch(89.4% 0.057 293.283);--color-violet-300:oklch(81.1% 0.111 293.571);--color-violet-400:oklch(70.2% 0.183 293.541);--color-violet-500:oklch(60.6% 0.25 292.717);--color-violet-600:oklch(54.1% 0.281 293.009);--color-violet-700:oklch(49.1% 0.27 292.581);--color-violet-800:oklch(43.2% 0.232 292.759);--color-violet-900:oklch(38% 0.189 293.745);--color-violet-950:oklch(28.3% 0.141 291.089);--color-purple-50:oklch(97.7% 0.014 308.299);--color-purple-100:oklch(94.6% 0.033 307.174);--color-purple-200:oklch(90.2% 0.063 306.703);--color-purple-300:oklch(82.7% 0.119 306.383);--color-purple-400:oklch(71.4% 0.203 305.504);--color-purple-500:oklch(62.7% 0.265 303.9);--color-purple-600:oklch(55.8% 0.288 302.321);--color-purple-700:oklch(49.6% 0.265 301.924);--color-purple-800:oklch(43.8% 0.218 303.724);--color-purple-900:oklch(38.1% 0.176 304.987);--color-purple-950:oklch(29.1% 0.149 302.717);--color-fuchsia-50:oklch(97.7% 0.017 320.058);--color-fuchsia-100:oklch(95.2% 0.037 318.852);--color-fuchsia-200:oklch(90.3% 0.076 319.62);--color-fuchsia-300:oklch(83.3% 0.145 321.434);--color-fuchsia-400:oklch(74% 0.238 322.16);--color-fuchsia-500:oklch(66.7% 0.295 322.15);--color-fuchsia-600:oklch(59.1% 0.293 322.896);--color-fuchsia-700:oklch(51.8% 0.253 323.949);--color-fuchsia-800:oklch(45.2% 0.211 324.591);--color-fuchsia-900:oklch(40.1% 0.17 325.612);--color-fuchsia-950:oklch(29.3% 0.136 325.661);--color-pink-50:oklch(97.1% 0.014 343.198);--color-pink-100:oklch(94.8% 0.028 342.258);--color-pink-200:oklch(89.9% 0.061 343.231);--color-pink-300:oklch(82.3% 0.12 346.018);--color-pink-400:oklch(71.8% 0.202 349.761);--color-pink-500:oklch(65.6% 0.241 354.308);--color-pink-600:oklch(59.2% 0.249 0.584);--color-pink-700:oklch(52.5% 0.223 3.958);--color-pink-800:oklch(45.9% 0.187 3.815);--color-pink-900:oklch(40.8% 0.153 2.432);--color-pink-950:oklch(28.4% 0.109 3.907);--color-rose-50:oklch(96.9% 0.015 12.422);--color-rose-100:oklch(94.1% 0.03 12.58);--color-rose-200:oklch(89.2% 0.058 10.001);--color-rose-300:oklch(81% 0.117 11.638);--color-rose-400:oklch(71.2% 0.194 13.428);--color-rose-500:oklch(64.5% 0.246 16.439);--color-rose-600:oklch(58.6% 0.253 17.585);--color-rose-700:oklch(51.4% 0.222 16.935);--color-rose-800:oklch(45.5% 0.188 13.697);--color-rose-900:oklch(41% 0.159 10.272);--color-rose-950:oklch(27.1% 0.105 12.094);--color-slate-50:oklch(98.4% 0.003 247.858);--color-slate-100:oklch(96.8% 0.007 247.896);--color-slate-200:oklch(92.9% 0.013 255.508);--color-slate-300:oklch(86.9% 0.022 252.894);--color-slate-400:oklch(70.4% 0.04 256.788);--color-slate-500:oklch(55.4% 0.046 257.417);--color-slate-600:oklch(44.6% 0.043 257.281);--color-slate-700:oklch(37.2% 0.044 257.287);--color-slate-800:oklch(27.9% 0.041 260.031);--color-slate-900:oklch(20.8% 0.042 265.755);--color-slate-950:oklch(12.9% 0.042 264.695);--color-gray-50:oklch(98.5% 0.002 247.839);--color-gray-100:oklch(96.7% 0.003 264.542);--color-gray-200:oklch(92.8% 0.006 264.531);--color-gray-300:oklch(87.2% 0.01 258.338);--color-gray-400:oklch(70.7% 0.022 261.325);--color-gray-500:oklch(55.1% 0.027 264.364);--color-gray-600:oklch(44.6% 0.03 256.802);--color-gray-700:oklch(37.3% 0.034 259.733);--color-gray-800:oklch(27.8% 0.033 256.848);--color-gray-900:oklch(21% 0.034 264.665);--color-gray-950:oklch(13% 0.028 261.692);--color-zinc-50:oklch(98.5% 0 0);--color-zinc-100:oklch(96.7% 0.001 286.375);--color-zinc-200:oklch(92% 0.004 286.32);--color-zinc-300:oklch(87.1% 0.006 286.286);--color-zinc-400:oklch(70.5% 0.015 286.067);--color-zinc-500:oklch(55.2% 0.016 285.938);--color-zinc-600:oklch(44.2% 0.017 285.786);--color-zinc-700:oklch(37% 0.013 285.805);--color-zinc-800:oklch(27.4% 0.006 286.033);--color-zinc-900:oklch(21% 0.006 285.885);--color-zinc-950:oklch(14.1% 0.005 285.823);--color-neutral-50:oklch(98.5% 0 0);--color-neutral-100:oklch(97% 0 0);--color-neutral-200:oklch(92.2% 0 0);--color-neutral-300:oklch(87% 0 0);--color-neutral-400:oklch(70.8% 0 0);--color-neutral-500:oklch(55.6% 0 0);--color-neutral-600:oklch(43.9% 0 0);--color-neutral-700:oklch(37.1% 0 0);--color-neutral-800:oklch(26.9% 0 0);--color-neutral-900:oklch(20.5% 0 0);--color-neutral-950:oklch(14.5% 0 0);--color-stone-50:oklch(98.5% 0.001 106.423);--color-stone-100:oklch(97% 0.001 106.424);--color-stone-200:oklch(92.3% 0.003 48.717);--color-stone-300:oklch(86.9% 0.005 56.366);--color-stone-400:oklch(70.9% 0.01 56.259);--color-stone-500:oklch(55.3% 0.013 58.071);--color-stone-600:oklch(44.4% 0.011 73.639);--color-stone-700:oklch(37.4% 0.01 67.558);--color-stone-800:oklch(26.8% 0.007 34.298);--color-stone-900:oklch(21.6% 0.006 56.043);--color-stone-950:oklch(14.7% 0.004 49.25);--color-mauve-50:oklch(98.5% 0 0);--color-mauve-100:oklch(96% 0.003 325.6);--color-mauve-200:oklch(92.2% 0.005 325.62);--color-mauve-300:oklch(86.5% 0.012 325.68);--color-mauve-400:oklch(71.1% 0.019 323.02);--color-mauve-500:oklch(54.2% 0.034 322.5);--color-mauve-600:oklch(43.5% 0.029 321.78);--color-mauve-700:oklch(36.4% 0.029 323.89);--color-mauve-800:oklch(26.3% 0.024 320.12);--color-mauve-900:oklch(21.2% 0.019 322.12);--color-mauve-950:oklch(14.5% 0.008 326);--color-olive-50:oklch(98.8% 0.003 106.5);--color-olive-100:oklch(96.6% 0.005 106.5);--color-olive-200:oklch(93% 0.007 106.5);--color-olive-300:oklch(88% 0.011 106.6);--color-olive-400:oklch(73.7% 0.021 106.9);--color-olive-500:oklch(58% 0.031 107.3);--color-olive-600:oklch(46.6% 0.025 107.3);--color-olive-700:oklch(39.4% 0.023 107.4);--color-olive-800:oklch(28.6% 0.016 107.4);--color-olive-900:oklch(22.8% 0.013 107.4);--color-olive-950:oklch(15.3% 0.006 107.1);--color-mist-50:oklch(98.7% 0.002 197.1);--color-mist-100:oklch(96.3% 0.002 197.1);--color-mist-200:oklch(92.5% 0.005 214.3);--color-mist-300:oklch(87.2% 0.007 219.6);--color-mist-400:oklch(72.3% 0.014 214.4);--color-mist-500:oklch(56% 0.021 213.5);--color-mist-600:oklch(45% 0.017 213.2);--color-mist-700:oklch(37.8% 0.015 216);--color-mist-800:oklch(27.5% 0.011 216.9);--color-mist-900:oklch(21.8% 0.008 223.9);--color-mist-950:oklch(14.8% 0.004 228.8);--color-taupe-50:oklch(98.6% 0.002 67.8);--color-taupe-100:oklch(96% 0.002 17.2);--color-taupe-200:oklch(92.2% 0.005 34.3);--color-taupe-300:oklch(86.8% 0.007 39.5);--color-taupe-400:oklch(71.4% 0.014 41.2);--color-taupe-500:oklch(54.7% 0.021 43.1);--color-taupe-600:oklch(43.8% 0.017 39.3);--color-taupe-700:oklch(36.7% 0.016 35.7);--color-taupe-800:oklch(26.8% 0.011 36.5);--color-taupe-900:oklch(21.4% 0.009 43.1);--color-taupe-950:oklch(14.7% 0.004 49.3);--color-black:#000;--color-white:#fff;--spacing:0.25rem;--breakpoint-sm:40rem;--breakpoint-md:48rem;--breakpoint-lg:64rem;--breakpoint-xl:80rem;--breakpoint-2xl:96rem;--container-3xs:16rem;--container-2xs:18rem;--container-xs:20rem;--container-sm:24rem;--container-md:28rem;--container-lg:32rem;--container-xl:36rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--container-6xl:72rem;--container-7xl:80rem;--text-xs:0.75rem;--text-xs--line-height:calc(1 / 0.75);--text-sm:0.875rem;--text-sm--line-height:calc(1.25 / 0.875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--text-6xl:3.75rem;--text-6xl--line-height:1;--text-7xl:4.5rem;--text-7xl--line-height:1;--text-8xl:6rem;--text-8xl--line-height:1;--text-9xl:8rem;--text-9xl--line-height:1;--font-weight-thin:100;--font-weight-extralight:200;--font-weight-light:300;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--font-weight-extrabold:800;--font-weight-black:900;--tracking-tighter:-0.05em;--tracking-tight:-0.025em;--tracking-normal:0em;--tracking-wide:0.025em;--tracking-wider:0.05em;--tracking-widest:0.1em;--leading-tight:1.25;--leading-snug:1.375;--leading-normal:1.5;--leading-relaxed:1.625;--leading-loose:2;--radius-xs:0.125rem;--radius-sm:0.25rem;--radius-md:0.375rem;--radius-lg:0.5rem;--radius-xl:0.75rem;--radius-2xl:1rem;--radius-3xl:1.5rem;--radius-4xl:2rem;--shadow-2xs:0 1px rgb(0 0 0 / 0.05);--shadow-xs:0 1px 2px 0 rgb(0 0 0 / 0.05);--shadow-sm:0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);--shadow-md:0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);--shadow-lg:0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);--shadow-xl:0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);--shadow-2xl:0 25px 50px -12px rgb(0 0 0 / 0.25);--inset-shadow-2xs:inset 0 1px rgb(0 0 0 / 0.05);--inset-shadow-xs:inset 0 1px 1px rgb(0 0 0 / 0.05);--inset-shadow-sm:inset 0 2px 4px rgb(0 0 0 / 0.05);--drop-shadow-xs:0 1px 1px rgb(0 0 0 / 0.05);--drop-shadow-sm:0 1px 2px rgb(0 0 0 / 0.15);--drop-shadow-md:0 3px 3px rgb(0 0 0 / 0.12);--drop-shadow-lg:0 4px 4px rgb(0 0 0 / 0.15);--drop-shadow-xl:0 9px 7px rgb(0 0 0 / 0.1);--drop-shadow-2xl:0 25px 25px rgb(0 0 0 / 0.15);--text-shadow-2xs:0px 1px 0px rgb(0 0 0 / 0.15);--text-shadow-xs:0px 1px 1px rgb(0 0 0 / 0.2);--text-shadow-sm:0px 1px 0px rgb(0 0 0 / 0.075), 0px 1px 1px rgb(0 0 0 / 0.075), 0px 2px 2px rgb(0 0 0 / 0.075);--text-shadow-md:0px 1px 1px rgb(0 0 0 / 0.1), 0px 1px 2px rgb(0 0 0 / 0.1), 0px 2px 4px rgb(0 0 0 / 0.1);--text-shadow-lg:0px 1px 2px rgb(0 0 0 / 0.1), 0px 3px 2px rgb(0 0 0 / 0.1), 0px 4px 8px rgb(0 0 0 / 0.1);--ease-in:cubic-bezier(0.4, 0, 1, 1);--ease-out:cubic-bezier(0, 0, 0.2, 1);--ease-in-out:cubic-bezier(0.4, 0, 0.2, 1);--animate-spin:spin 1s linear infinite;--animate-ping:ping 1s cubic-bezier(0, 0, 0.2, 1) infinite;--animate-pulse:pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;--animate-bounce:bounce 1s infinite;--blur-xs:4px;--blur-sm:8px;--blur-md:12px;--blur-lg:16px;--blur-xl:24px;--blur-2xl:40px;--blur-3xl:64px;--perspective-dramatic:100px;--perspective-near:300px;--perspective-normal:500px;--perspective-midrange:800px;--perspective-distant:1200px;--aspect-video:16 / 9;--default-transition-duration:150ms;--default-transition-timing-function:cubic-bezier(0.4, 0, 0.2, 1);--default-font-family:var(--font-sans);--default-font-feature-settings:initial;--default-font-variation-settings:initial;--default-mono-font-family:var(--font-mono);--default-mono-font-feature-settings:initial;--default-mono-font-variation-settings:initial;--radius-default:0.375rem;--color-primary:#0284c7;--color-primary-hover:#0369a1;--color-primary-subtle:#f0f9ff;--color-primary-border:#bae6fd;--color-accent:#0369a1;--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;--color-text-on-dark-muted:#9ca3af}}@keyframes spin{to{transform:rotate(360deg)}}@keyframes ping{75%,100%{transform:scale(2);opacity:0}}@keyframes pulse{50%{opacity:.5}}@keyframes bounce{0%,100%{transform:translateY(-25%);animation-timing-function:cubic-bezier(.8,0,1,1)}50%{transform:none;animation-timing-function:cubic-bezier(0,0,.2,1)}}@layer base{*,::after,::before,::backdrop,::file-selector-button{box-sizing:border-box;margin:0;padding:0;border:0 solid}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;tab-size:4;font-family:var(--default-font-family);font-feature-settings:var(--default-font-feature-settings);font-variation-settings:var(--default-font-variation-settings);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family);font-feature-settings:var(--default-mono-font-feature-settings);font-variation-settings:var(--default-mono-font-variation-settings);font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea,::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;border-radius:0;background-color:initial;opacity:1}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports(not (-webkit-appearance:-apple-pay-button)) or (contain-intrinsic-size:1px){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit,::-webkit-datetime-edit-year-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]),::file-selector-button{appearance:button}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@font-face{font-family:lora;font-style:italic;font-weight:400 700;font-display:swap;src:url(/fonts/lora-latin-ext-italic.woff2)format("woff2");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:lora;font-style:italic;font-weight:400 700;font-display:swap;src:url(/fonts/lora-latin-italic.woff2)format("woff2");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:lora;font-style:normal;font-weight:400 700;font-display:swap;src:url(/fonts/lora-latin-ext-normal.woff2)format("woff2");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:lora;font-style:normal;font-weight:400 700;font-display:swap;src:url(/fonts/lora-latin-normal.woff2)format("woff2");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}:root{--grid-line:rgba(15, 23, 42, 0.055)}.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;--color-accent:#7dd3fc;--color-primary-subtle:#0b2c3f;--color-primary-border:#0e4966;--color-text-heading:#f5f5f5;--grid-line:rgba(226, 232, 240, 0.05)}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);background-size:56px 56px;background-position:50% 0}.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%)}@layer utilities{.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{inset:0}.inset-y-0{inset-block:0}.top-0{top:0}.top-1\/2{top:calc(1/2 * 100%)}.top-20{top:calc(var(--spacing) * 20)}.top-4{top:calc(var(--spacing) * 4)}.top-\[3\.75rem\]{top:3.75rem}.top-full{top:100%}.-right-2{right:calc(var(--spacing) * -2)}.right-0{right:0}.right-0\.5{right:calc(var(--spacing) * .5)}.right-4{right:calc(var(--spacing) * 4)}.right-8{right:calc(var(--spacing) * 8)}.bottom-4{bottom:calc(var(--spacing) * 4)}.left-0{left:0}.left-1\/2{left:calc(1/2 * 100%)}.left-4{left:calc(var(--spacing) * 4)}.isolate{isolation:isolate}.-z-10{z-index:calc(10 * -1)}.z-10{z-index:10}.z-150{z-index:150}.z-20{z-index:20}.z-200{z-index:200}.z-\[100\]{z-index:100}.z-\[110\]{z-index:110}.z-\[1\]{z-index:1}.z-\[200\]{z-index:200}.col-span-1{grid-column:span 1/span 1}.-m-1\.5{margin:calc(var(--spacing) * -1.5)}.m-0{margin:0}.-mx-1\.5{margin-inline:calc(var(--spacing) * -1.5)}.mx-3{margin-inline:calc(var(--spacing) * 3)}.mx-4{margin-inline:calc(var(--spacing) * 4)}.mx-\[0\.15rem\]{margin-inline:.15rem}.mx-auto{margin-inline:auto}.my-1{margin-block:var(--spacing)}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-10{margin-top:calc(var(--spacing) * 10)}.mt-12{margin-top:calc(var(--spacing) * 12)}.mt-14{margin-top:calc(var(--spacing) * 14)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mr-5{margin-right:calc(var(--spacing) * 5)}.mb-1{margin-bottom:var(--spacing)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.ml-1{margin-left:var(--spacing)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-3{margin-left:calc(var(--spacing) * 3)}.ml-auto{margin-left:auto}.box-border{box-sizing:border-box}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.aspect-square{aspect-ratio:1/1}.h-0{height:0}.h-0\.5{height:calc(var(--spacing) * .5)}.h-1{height:var(--spacing)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-72{height:calc(var(--spacing) * 72)}.h-8{height:calc(var(--spacing) * 8)}.h-\[30px\]{height:30px}.h-\[38px\]{height:38px}.h-\[calc\(100vh-3\.75rem\)\]{height:calc(100vh - 3.75rem)}.h-auto{height:auto}.h-dvh{height:100dvh}.h-full{height:100%}.h-screen{height:100vh}.max-h-60{max-height:calc(var(--spacing) * 60)}.max-h-64{max-height:calc(var(--spacing) * 64)}.max-h-96{max-height:calc(var(--spacing) * 96)}.max-h-\[34rem\]{max-height:34rem}.max-h-\[calc\(100dvh_-_5rem\)\]{max-height:calc(100dvh - 5rem)}.max-h-\[calc\(100vh_-_7rem\)\]{max-height:calc(100vh - 7rem)}.max-h-dvh{max-height:100dvh}.min-h-0{min-height:0}.min-h-9{min-height:calc(var(--spacing) * 9)}.min-h-\[6\.5rem\]{min-height:6.5rem}.min-h-screen{min-height:100vh}.w-0{width:0}.w-1{width:var(--spacing)}.w-10{width:calc(var(--spacing) * 10)}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-4{width:calc(var(--spacing) * 4)}.w-40{width:calc(var(--spacing) * 40)}.w-52{width:calc(var(--spacing) * 52)}.w-56{width:calc(var(--spacing) * 56)}.w-6{width:calc(var(--spacing) * 6)}.w-64{width:calc(var(--spacing) * 64)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-\[26rem\]{width:26rem}.w-auto{width:auto}.w-full{width:100%}.w-max{width:max-content}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-64{max-width:calc(var(--spacing) * 64)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-7xl{max-width:var(--container-7xl)}.max-w-80{max-width:calc(var(--spacing) * 80)}.max-w-\[110rem\]{max-width:110rem}.max-w-\[11rem\]{max-width:11rem}.max-w-\[14rem\]{max-width:14rem}.max-w-\[16rem\]{max-width:16rem}.max-w-\[20rem\]{max-width:20rem}.max-w-\[90rem\]{max-width:90rem}.max-w-full{max-width:100%}.max-w-md{max-width:var(--container-md)}.max-w-none{max-width:none}.max-w-screen{max-width:100vw}.max-w-sm{max-width:var(--container-sm)}.max-w-xl{max-width:var(--container-xl)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:0}.min-w-48{min-width:calc(var(--spacing) * 48)}.min-w-5{min-width:calc(var(--spacing) * 5)}.min-w-72{min-width:calc(var(--spacing) * 72)}.min-w-8{min-width:calc(var(--spacing) * 8)}.min-w-\[10rem\]{min-width:10rem}.min-w-\[16rem\]{min-width:16rem}.min-w-\[240px\]{min-width:240px}.min-w-\[7rem\]{min-width:7rem}.min-w-\[8rem\]{min-width:8rem}.min-w-\[9rem\]{min-width:9rem}.min-w-full{min-width:100%}.flex-1{flex:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.table-fixed{table-layout:fixed}.border-collapse{border-collapse:collapse}.-translate-x-1\/2{--tw-translate-x:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-x-0\.5{--tw-translate-x:calc(var(--spacing) * 0.5);translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-x-\[18px\]{--tw-translate-x:18px;translate:var(--tw-translate-x)var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x)var(--tw-translate-y)}.scale-110{--tw-scale-x:110%;--tw-scale-y:110%;--tw-scale-z:110%;scale:var(--tw-scale-x)var(--tw-scale-y)}.scale-95{--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x)var(--tw-scale-y)}.rotate-180{rotate:180deg}.rotate-90{rotate:90deg}.transform{transform:var(--tw-rotate-x,)var(--tw-rotate-y,)var(--tw-rotate-z,)var(--tw-skew-x,)var(--tw-skew-y,)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-col-resize{cursor:col-resize}.cursor-crosshair{cursor:crosshair}.cursor-default{cursor:default}.cursor-grab{cursor:grab}.cursor-pointer{cursor:pointer}.touch-none{touch-action:none}.resize{resize:both}.resize-none{resize:none}.scroll-mt-24{scroll-margin-top:calc(var(--spacing) * 24)}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-7{grid-template-columns:repeat(7,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-row{flex-direction:row}.flex-row-reverse{flex-direction:row-reverse}.flex-nowrap{flex-wrap:nowrap}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.items-stretch{align-items:stretch}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.justify-start{justify-content:flex-start}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-8{gap:calc(var(--spacing) * 8)}.gap-\[2px\]{gap:2px}.space-y-0\.5{ :where(& > :not(:last-child)) { --tw-space-y-reverse: 0; margin-block-start: calc(calc(var(--spacing) * 0.5) * var(--tw-space-y-reverse)); margin-block-end: calc(calc(var(--spacing) * 0.5) * calc(1 - var(--tw-space-y-reverse))); }}.space-y-1\.5{ :where(& > :not(:last-child)) { --tw-space-y-reverse: 0; margin-block-start: calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse)); margin-block-end: calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse))); }}.space-y-2{ :where(& > :not(:last-child)) { --tw-space-y-reverse: 0; margin-block-start: calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse)); margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse))); }}.space-y-4{ :where(& > :not(:last-child)) { --tw-space-y-reverse: 0; margin-block-start: calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse)); margin-block-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse))); }}.self-end{align-self:flex-end}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.overflow-y-hidden{overflow-y:hidden}.rounded{border-radius:.25rem}.rounded-default{border-radius:var(--radius-default)}.rounded-full{border-radius:calc(infinity * 1px)}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.rounded-l-default{border-top-left-radius:var(--radius-default);border-bottom-left-radius:var(--radius-default)}.rounded-l-none{border-top-left-radius:0;border-bottom-left-radius:0}.rounded-b-default{border-bottom-right-radius:var(--radius-default);border-bottom-left-radius:var(--radius-default)}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-4{border-style:var(--tw-border-style);border-width:4px}.border-x{border-inline-style:var(--tw-border-style);border-inline-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-t-2{border-top-style:var(--tw-border-style);border-top-width:2px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-r-0{border-right-style:var(--tw-border-style);border-right-width:0}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-l-4{border-left-style:var(--tw-border-style);border-left-width:4px}.border-none{--tw-border-style:none;border-style:none}.border-emerald-300{border-color:var(--color-emerald-300)}.border-green-200{border-color:var(--color-green-200)}.border-green-500{border-color:var(--color-green-500)}.border-line{border-color:var(--color-line)}.border-line-strong{border-color:var(--color-line-strong)}.border-neutral-700{border-color:var(--color-neutral-700)}.border-neutral-800{border-color:var(--color-neutral-800)}.border-primary{border-color:var(--color-primary)}.border-primary-border{border-color:var(--color-primary-border)}.border-red-200{border-color:var(--color-red-200)}.border-red-500{border-color:var(--color-red-500)}.border-sky-200{border-color:var(--color-sky-200)}.border-sky-700{border-color:var(--color-sky-700)}.border-transparent{border-color:transparent}.border-yellow-200{border-color:var(--color-yellow-200)}.border-t-sky-700{border-top-color:var(--color-sky-700)}.border-t-transparent{border-top-color:transparent}.border-l-green-700{border-left-color:var(--color-green-700)}.border-l-neutral-300{border-left-color:var(--color-neutral-300)}.border-l-neutral-400{border-left-color:var(--color-neutral-400)}.border-l-red-700{border-left-color:var(--color-red-700)}.border-l-sky-800{border-left-color:var(--color-sky-800)}.border-l-yellow-500{border-left-color:var(--color-yellow-500)}.\!bg-primary{background-color:var(--color-primary)!important}.bg-amber-100{background-color:var(--color-amber-100)}.bg-amber-700{background-color:var(--color-amber-700)}.bg-black\/10{background-color:color-mix(in oklab,var(--color-black) 10%,transparent)}.bg-black\/30{background-color:color-mix(in oklab,var(--color-black) 30%,transparent)}.bg-black\/5{background-color:color-mix(in oklab,var(--color-black) 5%,transparent)}.bg-black\/50{background-color:color-mix(in oklab,var(--color-black) 50%,transparent)}.bg-emerald-100{background-color:var(--color-emerald-100)}.bg-green-50{background-color:var(--color-green-50)}.bg-green-700{background-color:var(--color-green-700)}.bg-green-900{background-color:var(--color-green-900)}.bg-ink{background-color:var(--color-ink)}.bg-neutral-500{background-color:var(--color-neutral-500)}.bg-neutral-600{background-color:var(--color-neutral-600)}.bg-neutral-700{background-color:var(--color-neutral-700)}.bg-neutral-800{background-color:var(--color-neutral-800)}.bg-neutral-900{background-color:var(--color-neutral-900)}.bg-orange-500{background-color:var(--color-orange-500)}.bg-orange-600{background-color:var(--color-orange-600)}.bg-primary{background-color:var(--color-primary)}.bg-primary-subtle{background-color:var(--color-primary-subtle)}.bg-primary\/10{background-color:color-mix(in oklab,var(--color-primary) 10%,transparent)}.bg-red-50{background-color:var(--color-red-50)}.bg-red-600{background-color:var(--color-red-600)}.bg-red-700{background-color:var(--color-red-700)}.bg-red-900{background-color:var(--color-red-900)}.bg-sky-50{background-color:var(--color-sky-50)}.bg-sky-600{background-color:var(--color-sky-600)}.bg-sky-700{background-color:var(--color-sky-700)}.bg-sky-800{background-color:var(--color-sky-800)}.bg-sky-900{background-color:var(--color-sky-900)}.bg-surface{background-color:var(--color-surface)}.bg-surface-muted{background-color:var(--color-surface-muted)}.bg-surface-raised{background-color:var(--color-surface-raised)}.bg-surface-strong{background-color:var(--color-surface-strong)}.bg-surface\/90{background-color:color-mix(in oklab,var(--color-surface) 90%,transparent)}.bg-transparent{background-color:initial}.bg-white\/10{background-color:color-mix(in oklab,var(--color-white) 10%,transparent)}.bg-white\/5{background-color:color-mix(in oklab,var(--color-white) 5%,transparent)}.bg-yellow-400{background-color:var(--color-yellow-400)}.bg-yellow-50{background-color:var(--color-yellow-50)}.bg-yellow-700{background-color:var(--color-yellow-700)}.fill-current{fill:currentcolor}.p-0{padding:0}.p-0\.5{padding:calc(var(--spacing) * .5)}.p-1{padding:var(--spacing)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-8{padding:calc(var(--spacing) * 8)}.px-1{padding-inline:var(--spacing)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-7{padding-inline:calc(var(--spacing) * 7)}.px-8{padding-inline:calc(var(--spacing) * 8)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-10{padding-block:calc(var(--spacing) * 10)}.py-14{padding-block:calc(var(--spacing) * 14)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-5{padding-block:calc(var(--spacing) * 5)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-px{padding-block:1px}.pt-1{padding-top:var(--spacing)}.pt-1\.5{padding-top:calc(var(--spacing) * 1.5)}.pt-10{padding-top:calc(var(--spacing) * 10)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-6{padding-top:calc(var(--spacing) * 6)}.pr-14{padding-right:calc(var(--spacing) * 14)}.pr-3{padding-right:calc(var(--spacing) * 3)}.pr-4{padding-right:calc(var(--spacing) * 4)}.pr-9{padding-right:calc(var(--spacing) * 9)}.pb-0\.5{padding-bottom:calc(var(--spacing) * .5)}.pb-1{padding-bottom:var(--spacing)}.pb-14{padding-bottom:calc(var(--spacing) * 14)}.pb-16{padding-bottom:calc(var(--spacing) * 16)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pb-6{padding-bottom:calc(var(--spacing) * 6)}.pb-\[calc\(1rem_\+_env\(safe-area-inset-bottom\,0px\)\)\]{padding-bottom:calc(1rem + env(safe-area-inset-bottom,0px))}.pl-4{padding-left:calc(var(--spacing) * 4)}.pl-8{padding-left:calc(var(--spacing) * 8)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-middle{vertical-align:middle}.align-top{vertical-align:top}.font-\[inherit\]{font-family:inherit}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[0\.5rem\]{font-size:.5rem}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-light{--tw-font-weight:var(--font-weight-light);font-weight:var(--font-weight-light)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.whitespace-nowrap{white-space:nowrap}.whitespace-pre{white-space:pre}.whitespace-pre-wrap{white-space:pre-wrap}.\!text-white{color:var(--color-white)!important}.text-accent{color:var(--color-accent)}.text-amber-300{color:var(--color-amber-300)}.text-amber-600{color:var(--color-amber-600)}.text-current{color:currentcolor}.text-emerald-300{color:var(--color-emerald-300)}.text-emerald-700{color:var(--color-emerald-700)}.text-gray-900{color:var(--color-gray-900)}.text-green-600{color:var(--color-green-600)}.text-green-700{color:var(--color-green-700)}.text-green-900{color:var(--color-green-900)}.text-ink{color:var(--color-ink)}.text-ink-faint{color:var(--color-ink-faint)}.text-ink-muted{color:var(--color-ink-muted)}.text-ink-soft{color:var(--color-ink-soft)}.text-neutral-100{color:var(--color-neutral-100)}.text-neutral-200{color:var(--color-neutral-200)}.text-neutral-400{color:var(--color-neutral-400)}.text-orange-600{color:var(--color-orange-600)}.text-red-500{color:var(--color-red-500)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-red-900{color:var(--color-red-900)}.text-sky-300{color:var(--color-sky-300)}.text-sky-600{color:var(--color-sky-600)}.text-sky-700{color:var(--color-sky-700)}.text-sky-900{color:var(--color-sky-900)}.text-surface{color:var(--color-surface)}.text-text-heading{color:var(--color-text-heading)}.text-text-on-dark{color:var(--color-text-on-dark)}.text-text-on-dark-muted{color:var(--color-text-on-dark-muted)}.text-transparent{color:transparent}.text-violet-300{color:var(--color-violet-300)}.text-violet-600{color:var(--color-violet-600)}.text-white{color:var(--color-white)}.text-yellow-600{color:var(--color-yellow-600)}.text-yellow-700{color:var(--color-yellow-700)}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,)var(--tw-slashed-zero,)var(--tw-numeric-figure,)var(--tw-numeric-spacing,)var(--tw-numeric-fraction,)}.no-underline{text-decoration-line:none}.underline{text-decoration-line:underline}.underline-offset-4{text-underline-offset:4px}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.caret-neutral-800{caret-color:var(--color-neutral-800)}.opacity-0{opacity:0%}.opacity-50{opacity:50%}.opacity-60{opacity:60%}.shadow-\[0_4px_12px_rgba\(0\,0\,0\,0\.15\)\]{--tw-shadow:0 4px 12px var(--tw-shadow-color, rgba(0,0,0,0.15));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[inset_0_0_0_1px_currentColor\]{--tw-shadow:inset 0 0 0 1px var(--tw-shadow-color, currentColor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[inset_3px_0_0_0_theme\(colors\.amber\.500\)\]{--tw-shadow:inset 3px 0 0 0 var(--tw-shadow-color, theme(colors.amber.500));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-none{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xs{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.outline-hidden{--tw-outline-style:none;outline-style:none;@media(forced-colors:active){outline: 2px solid transparent; outline-offset: 2px;}}.outline-2{outline-style:var(--tw-outline-style);outline-width:2px}.outline-sky-500{outline-color:var(--color-sky-500)}.filter{filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[color\,border-color\]{transition-property:color,border-color;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[opacity\,transform\]{transition-property:opacity,transform;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[width\]{transition-property:width;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-150{--tw-duration:150ms;transition-duration:150ms}.duration-200{--tw-duration:200ms;transition-duration:200ms}.duration-300{--tw-duration:300ms;transition-duration:300ms}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.group-hover\:text-accent{&:is(:where(.group):hover *){@media(hover:hover){color: var(--color-accent);}}}.group-hover\/th\:opacity-50{&:is(:where(.group\/th):hover *){@media(hover:hover){opacity: 50%;}}}.file\:mr-2{&::file-selector-button{margin-right:calc(var(--spacing) * 2)}}.file\:ml-1{&::file-selector-button{margin-left:var(--spacing)}}.file\:cursor-pointer{&::file-selector-button{cursor:pointer}}.file\:rounded-default{&::file-selector-button{border-radius:var(--radius-default)}}.file\:border{&::file-selector-button{border-style:var(--tw-border-style);border-width:1px}}.file\:border-line-strong{&::file-selector-button{border-color:var(--color-line-strong)}}.file\:bg-surface-raised{&::file-selector-button{background-color:var(--color-surface-raised)}}.file\:px-3{&::file-selector-button{padding-inline:calc(var(--spacing) * 3)}}.file\:px-4{&::file-selector-button{padding-inline:calc(var(--spacing) * 4)}}.file\:py-\[2px\]{&::file-selector-button{padding-block:2px}}.file\:py-\[3px\]{&::file-selector-button{padding-block:3px}}.file\:text-sm{&::file-selector-button{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}}.file\:shadow-xs{&::file-selector-button{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}}.placeholder\:text-ink-faint{&::placeholder{color:var(--color-ink-faint)}}.before\:absolute{&::before{content:var(--tw-content);position:absolute}}.before\:inset-0{&::before{content:var(--tw-content);inset:0}}.before\:-z-20{&::before{content:var(--tw-content);z-index:calc(20 * -1)}}.before\:bg-surface-strong{&::before{content:var(--tw-content);background-color:var(--color-surface-strong)}}.before\:content-\[\'\'\]{&::before{--tw-content:'';content:var(--tw-content)}}.before\:\[clip-path\:polygon\(16px_0\,100\%_0\,100\%_calc\(100\%_-_16px\)\,calc\(100\%_-_16px\)_100\%\,0_100\%\,0_16px\)\]{&::before{content:var(--tw-content);clip-path:polygon(16px 0,100% 0,100% calc(100% - 16px),calc(100% - 16px) 100%,0 100%,0 16px)}}.after\:absolute{&::after{content:var(--tw-content);position:absolute}}.after\:inset-\[1px\]{&::after{content:var(--tw-content);inset:1px}}.after\:-z-10{&::after{content:var(--tw-content);z-index:calc(10 * -1)}}.after\:bg-white{&::after{content:var(--tw-content);background-color:var(--color-white)}}.after\:content-\[\'\'\]{&::after{--tw-content:'';content:var(--tw-content)}}.after\:\[clip-path\:polygon\(15px_0\,100\%_0\,100\%_calc\(100\%_-_15px\)\,calc\(100\%_-_15px\)_100\%\,0_100\%\,0_15px\)\]{&::after{content:var(--tw-content);clip-path:polygon(15px 0,100% 0,100% calc(100% - 15px),calc(100% - 15px) 100%,0 100%,0 15px)}}.last\:border-r-0{&:last-child{border-right-style:var(--tw-border-style);border-right-width:0}}.last\:border-b-0{&:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}}.odd\:bg-surface{&:nth-child(odd){background-color:var(--color-surface)}}.even\:bg-surface-raised{&:nth-child(even){background-color:var(--color-surface-raised)}}.hover\:border-primary-border{&:hover{@media(hover:hover){border-color: var(--color-primary-border);}}}.hover\:bg-green-100{&:hover{@media(hover:hover){background-color: var(--color-green-100);}}}.hover\:bg-green-800{&:hover{@media(hover:hover){background-color: var(--color-green-800);}}}.hover\:bg-green-950{&:hover{@media(hover:hover){background-color: var(--color-green-950);}}}.hover\:bg-neutral-500{&:hover{@media(hover:hover){background-color: var(--color-neutral-500);}}}.hover\:bg-neutral-800{&:hover{@media(hover:hover){background-color: var(--color-neutral-800);}}}.hover\:bg-orange-700{&:hover{@media(hover:hover){background-color: var(--color-orange-700);}}}.hover\:bg-primary-hover{&:hover{@media(hover:hover){background-color: var(--color-primary-hover);}}}.hover\:bg-red-700{&:hover{@media(hover:hover){background-color: var(--color-red-700);}}}.hover\:bg-red-800{&:hover{@media(hover:hover){background-color: var(--color-red-800);}}}.hover\:bg-red-950{&:hover{@media(hover:hover){background-color: var(--color-red-950);}}}.hover\:bg-sky-100{&:hover{@media(hover:hover){background-color: var(--color-sky-100);}}}.hover\:bg-sky-500{&:hover{@media(hover:hover){background-color: var(--color-sky-500);}}}.hover\:bg-sky-500\/50{&:hover{@media(hover:hover){background-color: color-mix(in oklab,var(--color-sky-500) 50%,transparent);}}}.hover\:bg-sky-800{&:hover{@media(hover:hover){background-color: var(--color-sky-800);}}}.hover\:bg-sky-950{&:hover{@media(hover:hover){background-color: var(--color-sky-950);}}}.hover\:bg-surface-muted{&:hover{@media(hover:hover){background-color: var(--color-surface-muted);}}}.hover\:bg-surface-raised{&:hover{@media(hover:hover){background-color: var(--color-surface-raised);}}}.hover\:bg-surface-strong{&:hover{@media(hover:hover){background-color: var(--color-surface-strong);}}}.hover\:bg-white\/5{&:hover{@media(hover:hover){background-color: color-mix(in oklab,var(--color-white) 5%,transparent);}}}.hover\:bg-yellow-800{&:hover{@media(hover:hover){background-color: var(--color-yellow-800);}}}.hover\:text-ink{&:hover{@media(hover:hover){color: var(--color-ink);}}}.hover\:text-ink-soft{&:hover{@media(hover:hover){color: var(--color-ink-soft);}}}.hover\:text-sky-800{&:hover{@media(hover:hover){color: var(--color-sky-800);}}}.hover\:text-text-on-dark{&:hover{@media(hover:hover){color: var(--color-text-on-dark);}}}.hover\:text-white{&:hover{@media(hover:hover){color: var(--color-white);}}}.hover\:underline{&:hover{@media(hover:hover){text-decoration-line: underline;}}}.hover\:decoration-1{&:hover{@media(hover:hover){text-decoration-thickness: 1px;}}}.hover\:shadow-\[inset_0_0_0_2px_currentColor\]{&:hover{@media(hover:hover){--tw-shadow: inset 0 0 0 2px var(--tw-shadow-color,currentColor); box-shadow: var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);}}}.hover\:shadow-sm{&:hover{@media(hover:hover){--tw-shadow: 0 1px 3px 0 var(--tw-shadow-color,rgb(0 0 0 / 0.1)),0 1px 2px -1px var(--tw-shadow-color,rgb(0 0 0 / 0.1)); box-shadow: var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);}}}.file\:hover\:bg-surface-strong{&::file-selector-button{&:hover{@media(hover:hover){background-color: var(--color-surface-strong);}}}}.focus\:border-primary{&:focus{border-color:var(--color-primary)}}.focus\:border-sky-500{&:focus{border-color:var(--color-sky-500)}}.focus\:bg-red-50{&:focus{background-color:var(--color-red-50)}}.focus\:bg-surface-raised{&:focus{background-color:var(--color-surface-raised)}}.focus\:shadow-\[inset_0_0_0_2px_var\(--color-red-500\)\]{&:focus{--tw-shadow:inset 0 0 0 2px var(--tw-shadow-color, var(--color-red-500));box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}}.focus\:outline-hidden{&:focus{--tw-outline-style:none;outline-style:none;@media(forced-colors:active){outline: 2px solid transparent; outline-offset: 2px;}}}.focus\:outline-2{&:focus{outline-style:var(--tw-outline-style);outline-width:2px}}.focus\:outline-offset-1{&:focus{outline-offset:1px}}.focus\:outline-green-500{&:focus{outline-color:var(--color-green-500)}}.focus\:outline-red-500{&:focus{outline-color:var(--color-red-500)}}.focus\:outline-sky-500{&:focus{outline-color:var(--color-sky-500)}}.focus-visible\:outline-2{&:focus-visible{outline-style:var(--tw-outline-style);outline-width:2px}}.focus-visible\:outline-offset-2{&:focus-visible{outline-offset:2px}}.focus-visible\:outline-current{&:focus-visible{outline-color:currentcolor}}.focus-visible\:outline-primary{&:focus-visible{outline-color:var(--color-primary)}}.focus-visible\:outline-none{&:focus-visible{--tw-outline-style:none;outline-style:none}}.active\:bg-surface-strong{&:active{background-color:var(--color-surface-strong)}}.enabled\:hover\:bg-neutral-900{&:enabled{&:hover{@media(hover:hover){background-color: var(--color-neutral-900);}}}}.enabled\:hover\:bg-red-700{&:enabled{&:hover{@media(hover:hover){background-color: var(--color-red-700);}}}}.enabled\:hover\:bg-surface-muted{&:enabled{&:hover{@media(hover:hover){background-color: var(--color-surface-muted);}}}}.disabled\:cursor-not-allowed{&:disabled{cursor:not-allowed}}.disabled\:bg-surface-muted{&:disabled{background-color:var(--color-surface-muted)}}.disabled\:bg-surface-raised{&:disabled{background-color:var(--color-surface-raised)}}.disabled\:text-ink-faint{&:disabled{color:var(--color-ink-faint)}}.disabled\:opacity-40{&:disabled{opacity:40%}}.disabled\:opacity-50{&:disabled{opacity:50%}}.disabled\:hover\:bg-transparent{&:disabled{&:hover{@media(hover:hover){background-color: transparent;}}}}.sm\:block{@media(width >= 40rem){display: block;}}.sm\:flex{@media(width >= 40rem){display: flex;}}.sm\:grid{@media(width >= 40rem){display: grid;}}.sm\:hidden{@media(width >= 40rem){display: none;}}.sm\:w-48{@media(width >= 40rem){width: calc(var(--spacing) * 48);}}.sm\:w-64{@media(width >= 40rem){width: calc(var(--spacing) * 64);}}.sm\:grow-0{@media(width >= 40rem){flex-grow: 0;}}.sm\:grid-cols-2{@media(width >= 40rem){grid-template-columns: repeat(2,minmax(0,1fr));}}.sm\:grid-cols-3{@media(width >= 40rem){grid-template-columns: repeat(3,minmax(0,1fr));}}.sm\:flex-row{@media(width >= 40rem){flex-direction: row;}}.sm\:border-r{@media(width >= 40rem){border-right-style: var(--tw-border-style); border-right-width: 1px;}}.sm\:border-b-0{@media(width >= 40rem){border-bottom-style: var(--tw-border-style); border-bottom-width: 0px;}}.md\:flex-initial{@media(width >= 48rem){flex: 0 auto;}}.md\:justify-start{@media(width >= 48rem){justify-content: flex-start;}}.lg\:col-span-10{@media(width >= 64rem){grid-column: span 10 / span 10;}}.lg\:col-span-2{@media(width >= 64rem){grid-column: span 2 / span 2;}}.lg\:col-span-5{@media(width >= 64rem){grid-column: span 5 / span 5;}}.lg\:col-span-7{@media(width >= 64rem){grid-column: span 7 / span 7;}}.lg\:block{@media(width >= 64rem){display: block;}}.lg\:h-full{@media(width >= 64rem){height: 100%;}}.lg\:grid-cols-12{@media(width >= 64rem){grid-template-columns: repeat(12,minmax(0,1fr));}}.lg\:self-start{@media(width >= 64rem){align-self: flex-start;}}.lg\:pr-8{@media(width >= 64rem){padding-right: calc(var(--spacing) * 8);}}.dark\:border-green-900{&:where(.dark, .dark *){border-color:var(--color-green-900)}}.dark\:border-red-900{&:where(.dark, .dark *){border-color:var(--color-red-900)}}.dark\:border-sky-900{&:where(.dark, .dark *){border-color:var(--color-sky-900)}}.dark\:border-yellow-900{&:where(.dark, .dark *){border-color:var(--color-yellow-900)}}.dark\:bg-amber-900{&:where(.dark, .dark *){background-color:var(--color-amber-900)}}.dark\:bg-amber-900\!{&:where(.dark, .dark *){background-color:var(--color-amber-900)!important}}.dark\:bg-emerald-900{&:where(.dark, .dark *){background-color:var(--color-emerald-900)}}.dark\:bg-green-900{&:where(.dark, .dark *){background-color:var(--color-green-900)}}.dark\:bg-green-950{&:where(.dark, .dark *){background-color:var(--color-green-950)}}.dark\:bg-red-950{&:where(.dark, .dark *){background-color:var(--color-red-950)}}.dark\:bg-sky-900{&:where(.dark, .dark *){background-color:var(--color-sky-900)}}.dark\:bg-sky-950{&:where(.dark, .dark *){background-color:var(--color-sky-950)}}.dark\:bg-yellow-950{&:where(.dark, .dark *){background-color:var(--color-yellow-950)}}.dark\:text-amber-400{&:where(.dark, .dark *){color:var(--color-amber-400)}}.dark\:text-emerald-400{&:where(.dark, .dark *){color:var(--color-emerald-400)}}.dark\:text-green-400{&:where(.dark, .dark *){color:var(--color-green-400)}}.dark\:text-red-400{&:where(.dark, .dark *){color:var(--color-red-400)}}.dark\:text-sky-400{&:where(.dark, .dark *){color:var(--color-sky-400)}}.dark\:text-violet-400{&:where(.dark, .dark *){color:var(--color-violet-400)}}.\[\&_\.ui-form\]\:m-0{& .ui-form{margin:0}}.\[\&_input\]\:cursor-text{& input{cursor:text}}.\[\&_td\]\:p-3{& td{padding:calc(var(--spacing) * 3)}}.\[\&_td\]\:p-4{& td{padding:calc(var(--spacing) * 4)}}.\[\&_td\]\:px-2{& td{padding-inline:calc(var(--spacing) * 2)}}.\[\&_td\]\:py-0\.5{& td{padding-block:calc(var(--spacing) * .5)}}.\[\&_td\]\:py-1{& td{padding-block:var(--spacing)}}.\[\&_td\+td\]\:border-l{& td+td{border-left-style:var(--tw-border-style);border-left-width:1px}}.\[\&_td\+td\]\:border-line-strong{& td+td{border-color:var(--color-line-strong)}}.\[\&_th\]\:border-b{& th{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}}.\[\&_th\]\:border-line-strong{& th{border-color:var(--color-line-strong)}}.\[\&_tr\:hover\]\:\!bg-green-100{& tr:hover{background-color:var(--color-green-100)!important}}.\[\&_tr\:hover\]\:\!bg-sky-100{& tr:hover{background-color:var(--color-sky-100)!important}}.\[\&_tr\:hover\]\:\!bg-surface-strong{& tr:hover{background-color:var(--color-surface-strong)!important}}.\[\&_tr\:not\(\:last-child\)\]\:border-b{& tr:not(:last-child){border-bottom-style:var(--tw-border-style);border-bottom-width:1px}}.\[\&_tr\:not\(\:last-child\)\]\:border-line-strong{& tr:not(:last-child){border-color:var(--color-line-strong)}}.\[\&_tr\:nth-child\(even\)\]\:bg-surface-raised{& tr:nth-child(even){background-color:var(--color-surface-raised)}}}@property --tw-translate-x{syntax: "*"; inherits: false; initial-value: 0; }@property --tw-translate-y{syntax: "*"; @@ -116,6 +116,24 @@ initial-value: 100%; }@property --tw-drop-shadow-size{syntax: "*"; inherits: false; +}@property --tw-backdrop-blur{syntax: "*"; + inherits: false; +}@property --tw-backdrop-brightness{syntax: "*"; + inherits: false; +}@property --tw-backdrop-contrast{syntax: "*"; + inherits: false; +}@property --tw-backdrop-grayscale{syntax: "*"; + inherits: false; +}@property --tw-backdrop-hue-rotate{syntax: "*"; + inherits: false; +}@property --tw-backdrop-invert{syntax: "*"; + inherits: false; +}@property --tw-backdrop-opacity{syntax: "*"; + inherits: false; +}@property --tw-backdrop-saturate{syntax: "*"; + inherits: false; +}@property --tw-backdrop-sepia{syntax: "*"; + inherits: false; }@property --tw-duration{syntax: "*"; inherits: false; }@property --tw-ease{syntax: "*"; diff --git a/go/tw/custom_variant_test.go b/go/tw/custom_variant_test.go new file mode 100644 index 00000000..66964b89 --- /dev/null +++ b/go/tw/custom_variant_test.go @@ -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) + } +} diff --git a/go/tw/tailwind.go b/go/tw/tailwind.go index a8c9b5df..02d8d264 100644 --- a/go/tw/tailwind.go +++ b/go/tw/tailwind.go @@ -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 diff --git a/go/vdom/events.go b/go/vdom/events.go index 4e5aeaa9..338db570 100644 --- a/go/vdom/events.go +++ b/go/vdom/events.go @@ -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 diff --git a/go/wasmruntime/host_native.go b/go/wasmruntime/host_native.go index 5c0685df..3a99d132 100644 --- a/go/wasmruntime/host_native.go +++ b/go/wasmruntime/host_native.go @@ -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()) {} diff --git a/go/wasmruntime/host_wasm.go b/go/wasmruntime/host_wasm.go index 76e7297a..65834287 100644 --- a/go/wasmruntime/host_wasm.go +++ b/go/wasmruntime/host_wasm.go @@ -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 . +// +// 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 . 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 { diff --git a/go/webui/accordion.go b/go/webui/accordion.go index 2014e8d0..2e9d65b7 100644 --- a/go/webui/accordion.go +++ b/go/webui/accordion.go @@ -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" } diff --git a/go/webui/alerts.go b/go/webui/alerts.go index 1784e0d2..7d6a227b 100644 --- a/go/webui/alerts.go +++ b/go/webui/alerts.go @@ -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 diff --git a/go/webui/autotable.go b/go/webui/autotable.go index 4a667118..8732885c 100644 --- a/go/webui/autotable.go +++ b/go/webui/autotable.go @@ -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 = `e.g. [Revenue] / SUM({Revenue}) * 100` + formulaPlaceholder = `e.g. [Revenue] / SUM({Revenue}) * 100` - 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(``) + b.WriteString(``) b.WriteString(html.EscapeString(src[i:end])) b.WriteString(``) i = end @@ -3176,7 +3196,7 @@ func HighlightFormula(src string) string { } j++ } - b.WriteString(``) + b.WriteString(``) b.WriteString(html.EscapeString(src[i:j])) b.WriteString(``) i = j @@ -3186,7 +3206,7 @@ func HighlightFormula(src string) string { for j < len(src) && (isDigit(src[j]) || src[j] == '.') { j++ } - b.WriteString(``) + b.WriteString(``) b.WriteString(html.EscapeString(src[i:j])) b.WriteString(``) i = j @@ -3205,11 +3225,11 @@ func HighlightFormula(src string) string { } switch { case k < len(src) && src[k] == '(': - b.WriteString(``) + b.WriteString(``) b.WriteString(html.EscapeString(word)) b.WriteString(``) case isFormulaConstant(word): - b.WriteString(``) + b.WriteString(``) b.WriteString(html.EscapeString(word)) b.WriteString(``) default: @@ -3218,7 +3238,7 @@ func HighlightFormula(src string) string { i = j case strings.IndexByte("+-*/^%=<>(),:", c) >= 0: - b.WriteString(``) + b.WriteString(``) b.WriteString(html.EscapeString(string(c))) b.WriteString(``) 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)...) diff --git a/go/webui/autotable_calc_test.go b/go/webui/autotable_calc_test.go index 2d6eb83d..2741ce3c 100644 --- a/go/webui/autotable_calc_test.go +++ b/go/webui/autotable_calc_test.go @@ -362,17 +362,17 @@ func TestHighlightFormula(t *testing.T) { src string want []string // fragments that must appear }{ - {"cell ref", "[Revenue]", []string{`[Revenue]`}}, - {"column ref", "{Revenue}", []string{`{Revenue}`}}, + {"cell ref", "[Revenue]", []string{`[Revenue]`}}, + {"column ref", "{Revenue}", []string{`{Revenue}`}}, {"nested braces stay one span", "{Revenue:1:ROW()}", - []string{`{Revenue:1:ROW()}`}}, + []string{`{Revenue:1:ROW()}`}}, {"function", "SUM(", - []string{`SUM`}}, + []string{`SUM`}}, {"function with a space before the paren", "SUM (", - []string{`SUM`}}, - {"number", "12.5", []string{`12.5`}}, - {"constant", "PI", []string{`PI`}}, - {"operators", "1 + 2", []string{`+`}}, + []string{`SUM`}}, + {"number", "12.5", []string{`12.5`}}, + {"constant", "PI", []string{`PI`}}, + {"operators", "1 + 2", []string{`+`}}, {"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, `[Gross revenue]`) { + if !strings.Contains(adv, `[Gross revenue]`) { t.Errorf("the formula is not syntax-highlighted:\n%s", adv) } for _, want := range []string{"Column", "Function", "Constant"} { diff --git a/go/webui/badges.go b/go/webui/badges.go index 804039dd..9ddf907c 100644 --- a/go/webui/badges.go +++ b/go/webui/badges.go @@ -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 diff --git a/go/webui/buttons.go b/go/webui/buttons.go index a812b3b4..4993e366 100644 --- a/go/webui/buttons.go +++ b/go/webui/buttons.go @@ -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" } diff --git a/go/webui/calendar.go b/go/webui/calendar.go index eb49dcea..87c9f801 100644 --- a/go/webui/calendar.go +++ b/go/webui/calendar.go @@ -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") diff --git a/go/webui/cards.go b/go/webui/cards.go index 90f59cab..17a7aa28 100644 --- a/go/webui/cards.go +++ b/go/webui/cards.go @@ -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. diff --git a/go/webui/cellgrid.go b/go/webui/cellgrid.go index c10b2c74..4787ab33 100644 --- a/go/webui/cellgrid.go +++ b/go/webui/cellgrid.go @@ -35,7 +35,7 @@ import ( // GridHeaderCls is the base 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), ) } diff --git a/go/webui/code.go b/go/webui/code.go new file mode 100644 index 00000000..3845b8d1 --- /dev/null +++ b/go/webui/code.go @@ -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
: it contains no block elements and
+// preserves the source's whitespace exactly, so the 
 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(``)
+	b.WriteString(html.EscapeString(text))
+	b.WriteString(``)
+}
+
+// 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) }
diff --git a/go/webui/code_test.go b/go/webui/code_test.go
new file mode 100644
index 00000000..415c6c1a
--- /dev/null
+++ b/go/webui/code_test.go
@@ -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 `
` would render a div. +func TestHighlightGoEscapes(t *testing.T) { + got := HighlightGo(`s := "
" // a & b`) + + if strings.Contains(got, "` in the SOURCE reached the output as markup:\n%s", got) + } + if !strings.Contains(got, "<div") { + t.Errorf("the angle bracket was not escaped:\n%s", got) + } + if !strings.Contains(got, "&") { + 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, ``+want.text+``) { + 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, `//gowasm:page / static layout=public`) { + 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, "<", "<") + out = strings.ReplaceAll(out, ">", ">") + out = strings.ReplaceAll(out, """, `"`) + out = strings.ReplaceAll(out, "'", "'") + out = strings.ReplaceAll(out, "&", "&") + return out +} diff --git a/go/webui/crmtabs.go b/go/webui/crmtabs.go index 209e621c..1b9852eb 100644 --- a/go/webui/crmtabs.go +++ b/go/webui/crmtabs.go @@ -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 diff --git a/go/webui/datepicker.go b/go/webui/datepicker.go index dd8ea0ca..dbaf016f 100644 --- a/go/webui/datepicker.go +++ b/go/webui/datepicker.go @@ -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" diff --git a/go/webui/floating.go b/go/webui/floating.go index 4baaf11e..07a83716 100644 --- a/go/webui/floating.go +++ b/go/webui/floating.go @@ -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 { diff --git a/go/webui/forms.go b/go/webui/forms.go index 516c5694..166b5ead 100644 --- a/go/webui/forms.go +++ b/go/webui/forms.go @@ -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
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 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. diff --git a/go/webui/forms_async.go b/go/webui/forms_async.go new file mode 100644 index 00000000..3c2cbe1b --- /dev/null +++ b/go/webui/forms_async.go @@ -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:] +} diff --git a/go/webui/forms_async_test.go b/go/webui/forms_async_test.go new file mode 100644 index 00000000..a79431f7 --- /dev/null +++ b/go/webui/forms_async_test.go @@ -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()) + } +} diff --git a/go/webui/fuzzymatch.go b/go/webui/fuzzymatch.go index fb16accd..ca08624a 100644 --- a/go/webui/fuzzymatch.go +++ b/go/webui/fuzzymatch.go @@ -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) } diff --git a/go/webui/general.go b/go/webui/general.go index a46bcc6e..a9b9add7 100644 --- a/go/webui/general.go +++ b/go/webui/general.go @@ -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...) diff --git a/go/webui/icons.go b/go/webui/icons.go index d2c611ae..c5611cbe 100644 --- a/go/webui/icons.go +++ b/go/webui/icons.go @@ -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 innerHTML. @@ -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") diff --git a/go/webui/menu.go b/go/webui/menu.go index a67aabb2..25880884 100644 --- a/go/webui/menu.go +++ b/go/webui/menu.go @@ -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 diff --git a/go/webui/modal.go b/go/webui/modal.go index 1d6f0b39..9dbb758e 100644 --- a/go/webui/modal.go +++ b/go/webui/modal.go @@ -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" diff --git a/go/webui/popovers.go b/go/webui/popovers.go index ab7ed9a6..98f18328 100644 --- a/go/webui/popovers.go +++ b/go/webui/popovers.go @@ -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 diff --git a/go/webui/prettytable.go b/go/webui/prettytable.go index dabff303..f07c2131 100644 --- a/go/webui/prettytable.go +++ b/go/webui/prettytable.go @@ -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)...), ) diff --git a/go/webui/remoteupdateflash.go b/go/webui/remoteupdateflash.go index 44675626..11fc4a80 100644 --- a/go/webui/remoteupdateflash.go +++ b/go/webui/remoteupdateflash.go @@ -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 . diff --git a/go/webui/sidebar.go b/go/webui/sidebar.go index ee3db9b7..a42ac2ba 100644 --- a/go/webui/sidebar.go +++ b/go/webui/sidebar.go @@ -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" diff --git a/go/webui/signaturepad.go b/go/webui/signaturepad.go new file mode 100644 index 00000000..d44959d1 --- /dev/null +++ b/go/webui/signaturepad.go @@ -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 . 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(``) + b.WriteString(sigPaths(strokes)) + b.WriteString(``) + 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 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(``) + } + 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) +} diff --git a/go/webui/signaturepad_test.go b/go/webui/signaturepad_test.go new file mode 100644 index 00000000..373aca51 --- /dev/null +++ b/go/webui/signaturepad_test.go @@ -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) + } +} diff --git a/go/webui/tabs.go b/go/webui/tabs.go index e0105b60..e6985ad9 100644 --- a/go/webui/tabs.go +++ b/go/webui/tabs.go @@ -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), )) } diff --git a/go/webui/theme.go b/go/webui/theme.go new file mode 100644 index 00000000..882841ed --- /dev/null +++ b/go/webui/theme.go @@ -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 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 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 , 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 = `` diff --git a/go/webui/toast.go b/go/webui/toast.go index 105e0953..18641e08 100644 --- a/go/webui/toast.go +++ b/go/webui/toast.go @@ -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...), )) } diff --git a/go/webui/toggleswitch.go b/go/webui/toggleswitch.go index b6ea3c1e..c2601945 100644 --- a/go/webui/toggleswitch.go +++ b/go/webui/toggleswitch.go @@ -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...)) } diff --git a/go/webui/tutorial.go b/go/webui/tutorial.go index a97e9418..599bdc9e 100644 --- a/go/webui/tutorial.go +++ b/go/webui/tutorial.go @@ -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" }