From d52151cc1a6e89806def275985a968dcfc775f12 Mon Sep 17 00:00:00 2001 From: Max Amundsen Date: Mon, 13 Jul 2026 15:06:09 -0400 Subject: [PATCH] rename packages, refactor out tailwind compiler, --- .vscode/launch.json | 16 +- .vscode/tasks.json | 74 ++- CLAUDE.md | 16 +- go/bundler/public_tailwind.go | 19 - go/cmd/bundle/main.go | 12 +- go/cmd/examples/go-wasm-web/README.md | 28 +- go/cmd/examples/go-wasm-web/build.sh | 36 -- go/cmd/examples/go-wasm-web/build/main.go | 41 ++ .../go-wasm-web/buildsteps/buildsteps.go | 106 ++++ go/cmd/examples/go-wasm-web/server/main.go | 33 +- .../examples/go-wasm-web/wasm/main_native.go | 4 +- go/cmd/examples/go-wasm-web/wwwroot/app.css | 2 +- go/cmd/twcss/main.go | 4 +- go/{bundler => tw}/tailwind.go | 2 +- go/{bundler => tw}/tailwind_test.go | 2 +- go/tw/tw.go | 59 ++ go/{bundler => tw}/tw_preflight.css | 0 go/{bundler => tw}/tw_theme.css | 0 go/wasmdevserver/devserver.go | 100 ++- go/wasmdevserver/devserver_wasm.go | 11 + go/{bundler => webbundler}/alias.go | 2 +- go/{bundler => webbundler}/build.go | 2 +- go/{bundler => webbundler}/compile_solid.go | 10 +- .../compile_solid_gen.go | 4 +- .../compile_solid_render_test.go | 2 +- .../compile_solid_test.go | 2 +- go/{bundler => webbundler}/config.go | 2 +- go/{bundler => webbundler}/crosstree_test.go | 2 +- go/{bundler => webbundler}/css.go | 10 +- go/{bundler => webbundler}/export_shim.go | 2 +- go/{bundler => webbundler}/faicons.go | 4 +- go/{bundler => webbundler}/genroutes.go | 2 +- go/{bundler => webbundler}/genssr.go | 2 +- .../hmr_browser_test.go | 2 +- go/{bundler => webbundler}/hmr_client.go | 2 +- go/{bundler => webbundler}/hmr_client_test.go | 2 +- go/{bundler => webbundler}/hmr_e2e_test.go | 2 +- go/{bundler => webbundler}/hmr_error_test.go | 2 +- go/{bundler => webbundler}/hmr_server.go | 4 +- go/{bundler => webbundler}/hmr_vendor.go | 2 +- go/{bundler => webbundler}/hmr_watch.go | 2 +- go/{bundler => webbundler}/hmr_ws.go | 4 +- .../hmr_ws_integration_test.go | 2 +- go/{bundler => webbundler}/hmr_ws_test.go | 2 +- go/{bundler => webbundler}/import_check.go | 2 +- go/{bundler => webbundler}/js.go | 4 +- go/{bundler => webbundler}/js/ssr/dom.js | 0 go/{bundler => webbundler}/jsx.go | 4 +- go/{bundler => webbundler}/jsx_bench_test.go | 2 +- go/{bundler => webbundler}/jsx_dev_test.go | 2 +- go/{bundler => webbundler}/jsx_test.go | 2 +- go/{bundler => webbundler}/renderer.go | 2 +- go/{bundler => webbundler}/segment.go | 2 +- go/{bundler => webbundler}/segment_test.go | 4 +- go/{bundler => webbundler}/ssr.go | 4 +- go/{bundler => webbundler}/ssr_test.go | 4 +- go/{bundler => webbundler}/ssrcache.go | 2 +- go/{bundler => webbundler}/vendor_plugins.go | 2 +- go/webui/autotable.go | 320 ++++++++-- go/webui/autotable_calc_test.go | 112 ++++ go/webui/forms.go | 580 ++++++++++++------ go/webui/forms_dropdown_test.go | 221 +++++++ 62 files changed, 1446 insertions(+), 460 deletions(-) delete mode 100644 go/bundler/public_tailwind.go delete mode 100755 go/cmd/examples/go-wasm-web/build.sh create mode 100644 go/cmd/examples/go-wasm-web/build/main.go create mode 100644 go/cmd/examples/go-wasm-web/buildsteps/buildsteps.go rename go/{bundler => tw}/tailwind.go (99%) rename go/{bundler => tw}/tailwind_test.go (99%) create mode 100644 go/tw/tw.go rename go/{bundler => tw}/tw_preflight.css (100%) rename go/{bundler => tw}/tw_theme.css (100%) create mode 100644 go/wasmdevserver/devserver_wasm.go rename go/{bundler => webbundler}/alias.go (99%) rename go/{bundler => webbundler}/build.go (99%) rename go/{bundler => webbundler}/compile_solid.go (96%) rename go/{bundler => webbundler}/compile_solid_gen.go (99%) rename go/{bundler => webbundler}/compile_solid_render_test.go (99%) rename go/{bundler => webbundler}/compile_solid_test.go (99%) rename go/{bundler => webbundler}/config.go (99%) rename go/{bundler => webbundler}/crosstree_test.go (99%) rename go/{bundler => webbundler}/css.go (94%) rename go/{bundler => webbundler}/export_shim.go (99%) rename go/{bundler => webbundler}/faicons.go (98%) rename go/{bundler => webbundler}/genroutes.go (99%) rename go/{bundler => webbundler}/genssr.go (99%) rename go/{bundler => webbundler}/hmr_browser_test.go (99%) rename go/{bundler => webbundler}/hmr_client.go (99%) rename go/{bundler => webbundler}/hmr_client_test.go (98%) rename go/{bundler => webbundler}/hmr_e2e_test.go (99%) rename go/{bundler => webbundler}/hmr_error_test.go (99%) rename go/{bundler => webbundler}/hmr_server.go (99%) rename go/{bundler => webbundler}/hmr_vendor.go (99%) rename go/{bundler => webbundler}/hmr_watch.go (99%) rename go/{bundler => webbundler}/hmr_ws.go (98%) rename go/{bundler => webbundler}/hmr_ws_integration_test.go (99%) rename go/{bundler => webbundler}/hmr_ws_test.go (99%) rename go/{bundler => webbundler}/import_check.go (99%) rename go/{bundler => webbundler}/js.go (98%) rename go/{bundler => webbundler}/js/ssr/dom.js (100%) rename go/{bundler => webbundler}/jsx.go (96%) rename go/{bundler => webbundler}/jsx_bench_test.go (98%) rename go/{bundler => webbundler}/jsx_dev_test.go (98%) rename go/{bundler => webbundler}/jsx_test.go (98%) rename go/{bundler => webbundler}/renderer.go (99%) rename go/{bundler => webbundler}/segment.go (99%) rename go/{bundler => webbundler}/segment_test.go (98%) rename go/{bundler => webbundler}/ssr.go (99%) rename go/{bundler => webbundler}/ssr_test.go (97%) rename go/{bundler => webbundler}/ssrcache.go (99%) rename go/{bundler => webbundler}/vendor_plugins.go (99%) create mode 100644 go/webui/forms_dropdown_test.go diff --git a/.vscode/launch.json b/.vscode/launch.json index 63e6bc5d..946ca046 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,7 +1,12 @@ { // Debug configs for the gowasm example. cwd is the example dir because the - // dev server resolves ./wwwroot, ./app, ./wasm and the watched engine dir + // dev server resolves ./wwwroot, ./app, ./wasm and the watched engine dirs // relative to it. + // + // The dev-server configs preLaunchTask the prebuild (codegen + Tailwind). Codegen must + // run before the server is compiled — it writes app/*.gen.go, which the server imports — + // and under the debugger the binary is built by Delve, not by the server's own Build + // hook, so nothing else would generate it. "version": "0.2.0", "configurations": [ { @@ -11,7 +16,8 @@ "mode": "auto", "program": "${workspaceFolder}/go/cmd/examples/go-wasm-web/server", "cwd": "${workspaceFolder}/go/cmd/examples/go-wasm-web", - "args": ["-addr", ":8085"] + "args": ["-addr", ":8085"], + "preLaunchTask": "gowasm: prebuild" }, { "name": "gowasm: dev server (no watch)", @@ -20,7 +26,11 @@ "mode": "auto", "program": "${workspaceFolder}/go/cmd/examples/go-wasm-web/server", "cwd": "${workspaceFolder}/go/cmd/examples/go-wasm-web", - "args": ["-watch=false"] + // -watch=false skips the server's own Build entirely, so wwwroot is served exactly + // as it sits on disk. The prebuild is what puts current CSS and generated code + // there; without it you would be debugging against stale artefacts. + "args": ["-watch=false"], + "preLaunchTask": "gowasm: prebuild" }, { "name": "gowasm: codegen (wasmgen)", diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 603f36e2..49eb81b1 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -2,14 +2,62 @@ // Tasks for the kjol repo. The gowasm example is a nested module at // go/cmd/examples/go-wasm-web, so its tasks set cwd there; the module-wide // Go tasks run in go/. + // + // The example's build pipeline is Go, not a shell script — see + // go/cmd/examples/go-wasm-web/buildsteps. The dev server calls the same steps on every + // save, and a bash script would not run for anyone on Windows. "gowasm: prebuild" is + // codegen + Tailwind, and everything that runs the app depends on it; the two steps are + // also exposed on their own so you can rerun just the one you need. "version": "2.0.0", "tasks": [ { - "label": "gowasm: dev server (hot reload)", - "detail": "Run the go-wasm-web example: codegen + SSR + hot reload on :8085", + "label": "gowasm: prebuild", + "detail": "Codegen (pages/layouts/server components) then Tailwind. Dependency of the run + debug configs.", + "dependsOrder": "sequence", + "dependsOn": ["gowasm: codegen", "gowasm: tailwind"], + "problemMatcher": [], + "group": "build" + }, + { + "label": "gowasm: codegen", + "detail": "Regenerate app/*.gen.go from the //gowasm: directives (pages, layouts, server components)", "type": "shell", - "command": "go run ./server", + "command": "go", + "args": ["run", "kjol/cmd/wasmgen", "./app"], "options": { "cwd": "${workspaceFolder}/go/cmd/examples/go-wasm-web" }, + "problemMatcher": ["$go"], + "presentation": { "reveal": "silent", "panel": "shared" }, + "group": "build" + }, + { + "label": "gowasm: tailwind", + "detail": "Compile css/app.css -> wwwroot/app.css, scanning the webui kit + the example's Go markup", + "type": "shell", + "command": "go", + "args": [ + "run", "./cmd/twcss", + "-entry", "cmd/examples/go-wasm-web/css/app.css", + "-out", "cmd/examples/go-wasm-web/wwwroot/app.css", + "-base", ".", + "webui/**/*.go", + "cmd/examples/go-wasm-web/app/**/*.go", + "cmd/examples/go-wasm-web/server/**/*.go" + ], + // Run from the kjol module root so the Tailwind engine's deps resolve in kjol's + // go.mod, not the example's. + "options": { "cwd": "${workspaceFolder}/go" }, + "problemMatcher": ["$go"], + "presentation": { "reveal": "silent", "panel": "shared" }, + "group": "build" + }, + { + "label": "gowasm: dev server (hot reload)", + "detail": "SSR + hot reload on :8085. Rebuilds Go on save; a .css save recompiles Tailwind only and swaps the stylesheet in place.", + "type": "shell", + "command": "go", + "args": ["run", "./server"], + "options": { "cwd": "${workspaceFolder}/go/cmd/examples/go-wasm-web" }, + "dependsOn": ["gowasm: prebuild"], "isBackground": true, "problemMatcher": { "owner": "go", @@ -19,27 +67,19 @@ }, "background": { "activeOnStart": true, - "beginsPattern": "rebuilding", - "endsPattern": "serving|reloading clients" + "beginsPattern": "rebuilding|recompiling", + "endsPattern": "serving|reloading clients|stylesheet updated" } }, "presentation": { "reveal": "always", "panel": "dedicated", "clear": true }, "group": { "kind": "build", "isDefault": true } }, { - "label": "gowasm: build (build.sh)", - "detail": "One-off build of the example: codegen + wasm + stage wasm_exec.js", + "label": "gowasm: build", + "detail": "One-off cold build of the example: codegen + Tailwind + wasm + stage wasm_exec.js", "type": "shell", - "command": "./build.sh", - "options": { "cwd": "${workspaceFolder}/go/cmd/examples/go-wasm-web" }, - "problemMatcher": ["$go"], - "group": "build" - }, - { - "label": "gowasm: codegen", - "detail": "Regenerate app/*.gen.go from //gowasm: directives", - "type": "shell", - "command": "go run kjol/cmd/wasmgen ./app", + "command": "go", + "args": ["run", "./build"], "options": { "cwd": "${workspaceFolder}/go/cmd/examples/go-wasm-web" }, "problemMatcher": ["$go"], "group": "build" diff --git a/CLAUDE.md b/CLAUDE.md index 6729f74f..4fd6b132 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,23 +24,29 @@ Each top-level directory is one language / build root: ``` kjol/ go/ all Go. Module `kjol` (go.mod lives in go/). Imports are `kjol/`. - web/ all JS/TS (browser + SSR). No build system of its own; built by go/bundler. + web/ all JS/TS (browser + SSR). No build system of its own; built by go/webbundler. # future: cpp/ kotlin/ swift/ ``` -Language-first, **not** feature-first. Consequence: the **bundler is Go** and lives in -`go/bundler` even though it builds `web/`. Don't "fix" this by splitting it. +Language-first, **not** feature-first. Consequence: the **web bundler is Go** and lives in +`go/webbundler` even though it builds `web/`. Don't "fix" this by splitting it. ### go/ — module `kjol` Packages, imported as `kjol/`: `appenv basic chrono config csv dbutil finance httputil -l4g security snailmail validation bundler`, plus the **gowasm** web-UI engine (`vdom` +l4g security snailmail validation webbundler tw`, plus the **gowasm** web-UI engine (`vdom` `wasmruntime` `rsc` `wasmdevserver`, and `webui` — a Tailwind-styled component kit ported from `web/kit`; author components in pure Go compiled to WebAssembly; all stdlib-only), and -`cmd/{bundle,migrate,loc,passgen,typecheck,wasmgen}`. A runnable +`cmd/{bundle,twcss,migrate,loc,passgen,typecheck,wasmgen}`. A runnable example lives in `cmd/examples/go-wasm-web` (its own nested module so its go-chart dep stays out of kjol). +`webbundler` is the **JS** build (TSX → Solid → esbuild). `tw` is the **Tailwind v4 +compiler**, and it is deliberately NOT inside it: Tailwind only reads text and writes CSS, +and the text is just as likely to be Go — the gowasm kit writes its markup in Go and has no +JS build at all. Keeping it in the bundler made every Go-only consumer drag a JavaScript +bundler along for a CSS file. + Build / test (run from repo root): ``` go -C go build ./... diff --git a/go/bundler/public_tailwind.go b/go/bundler/public_tailwind.go deleted file mode 100644 index f6e52a37..00000000 --- a/go/bundler/public_tailwind.go +++ /dev/null @@ -1,19 +0,0 @@ -package bundler - -// CompileTailwind compiles a Tailwind v4 entry stylesheet with kjol's native -// engine, discovering utility candidates from the given source globs (relative -// to baseDir). The scanner is language-agnostic — it extracts candidate class -// tokens from any text file — so this works for markup authored in Go (e.g. the -// go-wasm-web example's webui components) just as well as .tsx/.html. -// -// entryCSS is the stylesheet source (typically `@import "tailwindcss";` plus an -// `@theme { … }` block). Returns minified CSS. This is the app-bundler engine -// (twCompile/scanSources) exposed for consumers that don't go through Build. -func CompileTailwind(entryCSS, baseDir string, sourceGlobs []string) (string, error) { - candidates := scanSources(baseDir, sourceGlobs) - compiled, _, err := twCompile(entryCSS, baseDir, candidates) - if err != nil { - return "", err - } - return m.String("text/css", compiled) -} diff --git a/go/cmd/bundle/main.go b/go/cmd/bundle/main.go index 7c238f1c..3336e948 100644 --- a/go/cmd/bundle/main.go +++ b/go/cmd/bundle/main.go @@ -1,14 +1,14 @@ package main -// Thin CLI wrapper around kjol/bundler. The bundler wires its own Go-native -// Solid JSX compiler (see bundler.Build), so this wrapper carries no build logic. +// Thin CLI wrapper around kjol/webbundler. The bundler wires its own Go-native +// Solid JSX compiler (see webbundler.Build), so this wrapper carries no build logic. import ( "flag" "fmt" "os" - "kjol/bundler" + "kjol/webbundler" ) func main() { @@ -19,15 +19,15 @@ func main() { genTS := flag.String("gen-ts", "", "Directory for the generated TS icon registry (default /src/ui/generated).") flag.Parse() - cfg := bundler.Config{ + cfg := webbundler.Config{ AppFrontend: *app, WebDir: *web, Output: *out, GenGoDir: *genGo, GenTSDir: *genTS, } - if err := bundler.Build(cfg); err != nil { + if err := webbundler.Build(cfg); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } -} \ No newline at end of file +} diff --git a/go/cmd/examples/go-wasm-web/README.md b/go/cmd/examples/go-wasm-web/README.md index 6f569601..55740228 100644 --- a/go/cmd/examples/go-wasm-web/README.md +++ b/go/cmd/examples/go-wasm-web/README.md @@ -33,10 +33,30 @@ a `static` route, and fetching only exists on the client, so the fetches no-op during SSR: the server pre-renders the page's **spinner**, and the client runs them for real after hydration. -Styling is **Tailwind**: the dev server (and `build.sh`) run `kjol/cmd/twcss`, -which scans the Go markup + the `webui` kit for utility classes and compiles -`css/app.css` → `wwwroot/app.css` with kjol's native Tailwind v4 engine. There is -**no Bootstrap and no hand-written CSS**. (`build.sh` does a one-off build.) +Styling is **Tailwind**: the build runs `kjol/cmd/twcss`, which scans the Go markup + +the `webui` kit for utility classes and compiles `css/app.css` → `wwwroot/app.css` with +kjol's native Tailwind v4 engine (`kjol/tw`). There is **no Bootstrap and no +hand-written CSS**. + +Saving a `.css` file recompiles **only** Tailwind and swaps the stylesheet into the live +page — no wasm rebuild, no reload, no lost state. Saving a `.go` file does the full +rebuild and hot-swaps the wasm. + +## Building + +The build is Go, not a shell script — `buildsteps/` holds the four steps (codegen → +Tailwind → wasm → `wasm_exec.js` shim), and both the one-off build and the dev server's +watch loop call the *same* functions, so they cannot drift apart. + +``` +go run ./build # one-off: codegen + Tailwind + wasm + shim +go run ./server # dev server: does the same build, then watches and hot-reloads +``` + +In VS Code these are the `gowasm: build` and `gowasm: dev server (hot reload)` tasks; +both run through `gowasm: prebuild` (codegen + Tailwind), which is also the +`preLaunchTask` of the debug configs — under the debugger the binary is built by Delve, +so nothing else would generate `app/*.gen.go`. ## This is a separate module diff --git a/go/cmd/examples/go-wasm-web/build.sh b/go/cmd/examples/go-wasm-web/build.sh deleted file mode 100755 index cd3980c4..00000000 --- a/go/cmd/examples/go-wasm-web/build.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env bash -# Pre-compile step: directive codegen, Tailwind CSS, WebAssembly build, JS shim. -# Run the dev server instead (go run ./server) for hot reload; this is for a -# one-off/production-style build. Run from anywhere. -set -euo pipefail - -cd "$(dirname "$0")" -KJOL_GO="$(cd ../../.. && pwd)" - -echo "==> Generating directive glue (//gowasm:page, //gowasm:layout, //gowasm:server)" -go run kjol/cmd/wasmgen ./app - -echo "==> Compiling Tailwind CSS -> wwwroot/app.css (scanning webui + Go markup)" -# Run twcss from the kjol module root so the Tailwind engine's deps resolve there -# (not in this example module — keeps its go.mod lean). -( cd "$KJOL_GO" && go run ./cmd/twcss \ - -entry cmd/examples/go-wasm-web/css/app.css \ - -out cmd/examples/go-wasm-web/wwwroot/app.css \ - -base . \ - 'webui/**/*.go' \ - 'cmd/examples/go-wasm-web/app/**/*.go' \ - 'cmd/examples/go-wasm-web/server/**/*.go' ) - -echo "==> Compiling ./wasm to wwwroot/app.wasm (GOOS=js GOARCH=wasm)" -GOOS=js GOARCH=wasm go build -o wwwroot/app.wasm ./wasm - -echo "==> Copying Go's wasm_exec.js shim into wwwroot/" -GOROOT="$(go env GOROOT)" -shim="$GOROOT/lib/wasm/wasm_exec.js" # Go >= 1.24 -[ -f "$shim" ] || shim="$GOROOT/misc/wasm/wasm_exec.js" # Go <= 1.23 -rm -f wwwroot/wasm_exec.js # GOROOT copy is read-only; remove before overwriting -cp "$shim" wwwroot/wasm_exec.js -chmod u+w wwwroot/wasm_exec.js - -echo "==> Done. Run the server with: go run ./server" -echo " then open http://localhost:8085" diff --git a/go/cmd/examples/go-wasm-web/build/main.go b/go/cmd/examples/go-wasm-web/build/main.go new file mode 100644 index 00000000..d6dfc13a --- /dev/null +++ b/go/cmd/examples/go-wasm-web/build/main.go @@ -0,0 +1,41 @@ +// Command build runs the example's full pre-compile step once: directive codegen, +// Tailwind, the wasm binary, and Go's JS shim. +// +// go run ./build # from cmd/examples/go-wasm-web +// +// For day-to-day work run the dev server instead (`go run ./server`) — it performs +// these same steps on every save and hot-swaps the result into the browser. This +// command is for a cold build, CI, or an editor's pre-launch task. +package main + +import ( + "log" + "os" + + "gowasmweb/buildsteps" +) + +func main() { + log.SetFlags(0) + + steps := []struct { + name string + run func() ([]byte, error) + }{ + {"generating directive glue (//gowasm:page, //gowasm:layout, //gowasm:server)", buildsteps.Codegen}, + {"compiling Tailwind CSS -> wwwroot/app.css", buildsteps.Tailwind}, + {"compiling ./wasm -> wwwroot/app.wasm (GOOS=js GOARCH=wasm)", buildsteps.Wasm}, + {"copying Go's wasm_exec.js shim into wwwroot/", buildsteps.Shim}, + } + + for _, s := range steps { + log.Println("==>", s.name) + if out, err := s.run(); err != nil { + os.Stderr.Write(out) + log.Fatalln("build failed:", err) + } + } + + log.Println("==> Done. Run the server with: go run ./server") + log.Println(" then open http://localhost:8085") +} diff --git a/go/cmd/examples/go-wasm-web/buildsteps/buildsteps.go b/go/cmd/examples/go-wasm-web/buildsteps/buildsteps.go new file mode 100644 index 00000000..73391b64 --- /dev/null +++ b/go/cmd/examples/go-wasm-web/buildsteps/buildsteps.go @@ -0,0 +1,106 @@ +// Package buildsteps is the example's build pipeline: directive codegen, Tailwind, +// the wasm binary, and Go's JS shim. +// +// It is Go, not a shell script, for three reasons. The dev server needs to call these +// steps on every save and cannot shell out to bash on Windows. Editors need to run +// them as tasks, and a task that only works on one platform is a task half the team +// cannot use. And the one-off build and the watch build must be the SAME steps — the +// moment they are two scripts, they drift, and the bug only shows up in whichever one +// you use less. +// +// Run the whole thing with `go run ./build`. +package buildsteps + +import ( + "os" + "os/exec" + "path/filepath" + "strings" +) + +// kjolRoot is the kjol Go module root, relative to the example directory. The Tailwind +// and codegen commands are run FROM there so the engine's dependencies resolve in +// kjol's own go.mod, and this example's stays lean. +const kjolRoot = "../../.." + +// Wwwroot is where every build artefact lands, and what the dev server serves. +const Wwwroot = "wwwroot" + +// Codegen regenerates app/*.gen.go from the //gowasm: directives — the routes, the +// layouts, and the client stubs for server components. It runs FIRST: everything after +// it compiles the code it writes. +func Codegen() ([]byte, error) { + return run("go", "run", "kjol/cmd/wasmgen", "./app") +} + +// Tailwind compiles css/app.css to wwwroot/app.css, scanning the webui kit and this +// example's Go markup for utility candidates. +// +// It scans .go files, which is the whole point of kjol's native engine: the markup is +// written in Go, so that is where the class names are. There is no JS build here at +// all. +func Tailwind() ([]byte, error) { + cmd := exec.Command("go", "run", "./cmd/twcss", + "-entry", "cmd/examples/go-wasm-web/css/app.css", + "-out", "cmd/examples/go-wasm-web/wwwroot/app.css", + "-base", ".", + "webui/**/*.go", + "cmd/examples/go-wasm-web/app/**/*.go", + "cmd/examples/go-wasm-web/server/**/*.go", + ) + cmd.Dir = kjolRoot + return cmd.CombinedOutput() +} + +// Wasm compiles ./wasm to wwwroot/app.wasm. +func Wasm() ([]byte, error) { + cmd := exec.Command("go", "build", "-o", filepath.Join(Wwwroot, "app.wasm"), "./wasm") + cmd.Env = append(os.Environ(), "GOOS=js", "GOARCH=wasm") + return cmd.CombinedOutput() +} + +// Shim copies Go's wasm_exec.js into wwwroot. It is the loader the browser needs to +// start a Go wasm binary, it ships with the toolchain, and it must match the compiler +// that produced the binary — so it is copied from GOROOT rather than vendored. +func Shim() ([]byte, error) { + out, err := exec.Command("go", "env", "GOROOT").Output() + if err != nil { + return out, err + } + goroot := strings.TrimSpace(string(out)) + + for _, src := range []string{ + filepath.Join(goroot, "lib", "wasm", "wasm_exec.js"), // Go >= 1.24 + filepath.Join(goroot, "misc", "wasm", "wasm_exec.js"), // Go <= 1.23 + } { + b, err := os.ReadFile(src) + if err != nil { + continue + } + dst := filepath.Join(Wwwroot, "wasm_exec.js") + // The GOROOT copy is read-only, and so is the copy we made last time. Remove it + // first, or the write fails with a permission error that says nothing useful. + os.Remove(dst) + return nil, os.WriteFile(dst, b, 0o644) + } + return nil, os.ErrNotExist +} + +// All is the full build, in order. It is what the dev server runs on a code change and +// what `go run ./build` runs once. +// +// Returned output is the failing command's combined stdout+stderr, which the dev server +// puts straight into the browser's error overlay — so a compile error lands in front of +// you rather than in a terminal you were not looking at. +func All() ([]byte, error) { + for _, step := range []func() ([]byte, error){Codegen, Tailwind, Wasm, Shim} { + if out, err := step(); err != nil { + return out, err + } + } + return nil, nil +} + +func run(name string, args ...string) ([]byte, error) { + return exec.Command(name, args...).CombinedOutput() +} diff --git a/go/cmd/examples/go-wasm-web/server/main.go b/go/cmd/examples/go-wasm-web/server/main.go index ce8bf56a..9ed27d72 100644 --- a/go/cmd/examples/go-wasm-web/server/main.go +++ b/go/cmd/examples/go-wasm-web/server/main.go @@ -13,15 +13,13 @@ import ( "flag" "log" "net/http" - "os" - "os/exec" - "path/filepath" "kjol/httputil" "kjol/vdom" "kjol/wasmdevserver" "gowasmweb/app" + "gowasmweb/buildsteps" ) func main() { @@ -34,7 +32,8 @@ func main() { Dir: "./wwwroot", Watch: *watch, WatchDirs: []string{"app", "wasm", "css", "../../../webui", "../../../vdom", "../../../wasmruntime", "../../../rsc"}, // example + kjol engine + kit - Build: buildWasm, + Build: buildsteps.All, + BuildCSS: buildsteps.Tailwind, // a .css save skips codegen+wasm and hot-swaps the stylesheet Render: render, Document: document, Handle: apiRoutes, @@ -89,29 +88,3 @@ func document(inner string) string { ` } - -// buildWasm runs the directive codegen (kjol/cmd/wasmgen), compiles the Tailwind -// CSS (kjol/cmd/twcss, scanning the Go markup + webui kit), then compiles ./wasm -// to wwwroot/app.wasm. Returned combined output is shown in the browser overlay -// on failure. -func buildWasm() ([]byte, error) { - if out, err := exec.Command("go", "run", "kjol/cmd/wasmgen", "./app").CombinedOutput(); err != nil { - return out, err - } - // Compile Tailwind from the kjol module root (so the engine's deps resolve), - // scanning the webui kit + this example's Go markup for utility candidates. - tw := exec.Command("go", "run", "./cmd/twcss", - "-entry", "cmd/examples/go-wasm-web/css/app.css", - "-out", "cmd/examples/go-wasm-web/wwwroot/app.css", - "-base", ".", - "webui/**/*.go", - "cmd/examples/go-wasm-web/app/**/*.go", - "cmd/examples/go-wasm-web/server/**/*.go") - tw.Dir = "../../.." // kjol/go - if out, err := tw.CombinedOutput(); err != nil { - return out, err - } - cmd := exec.Command("go", "build", "-o", filepath.Join("wwwroot", "app.wasm"), "./wasm") - cmd.Env = append(os.Environ(), "GOOS=js", "GOARCH=wasm") - return cmd.CombinedOutput() -} diff --git a/go/cmd/examples/go-wasm-web/wasm/main_native.go b/go/cmd/examples/go-wasm-web/wasm/main_native.go index c4837820..a8d81773 100644 --- a/go/cmd/examples/go-wasm-web/wasm/main_native.go +++ b/go/cmd/examples/go-wasm-web/wasm/main_native.go @@ -2,8 +2,8 @@ // The wasm client entry point (main.go) builds only under GOOS=js GOARCH=wasm. // This native placeholder keeps the package buildable on the host so a plain -// `go build ./...` succeeds; the real client is built by build.sh / the dev -// server with GOOS=js GOARCH=wasm. +// `go build ./...` succeeds; the real client is built by ./build (or the dev +// server) with GOOS=js GOARCH=wasm. package main func main() {} diff --git a/go/cmd/examples/go-wasm-web/wwwroot/app.css b/go/cmd/examples/go-wasm-web/wwwroot/app.css index 7aac5cdc..c0f9c1a2 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-50{z-index:50}.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-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:#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-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: "*"; inherits: false; initial-value: 0; }@property --tw-translate-y{syntax: "*"; diff --git a/go/cmd/twcss/main.go b/go/cmd/twcss/main.go index 868cbf9c..899ed982 100644 --- a/go/cmd/twcss/main.go +++ b/go/cmd/twcss/main.go @@ -14,7 +14,7 @@ import ( "fmt" "os" - "kjol/bundler" + "kjol/tw" ) func main() { @@ -32,7 +32,7 @@ func main() { fmt.Fprintln(os.Stderr, "twcss:", err) os.Exit(1) } - css, err := bundler.CompileTailwind(string(src), *base, flag.Args()) + css, err := tw.CompileFiles(string(src), *base, flag.Args()) if err != nil { fmt.Fprintln(os.Stderr, "twcss:", err) os.Exit(1) diff --git a/go/bundler/tailwind.go b/go/tw/tailwind.go similarity index 99% rename from go/bundler/tailwind.go rename to go/tw/tailwind.go index 8e37abd3..a8c9b5df 100644 --- a/go/bundler/tailwind.go +++ b/go/tw/tailwind.go @@ -1,4 +1,4 @@ -package bundler +package tw // Native Go Tailwind v4 compiler. A from-scratch, pure-Go implementation of the // Tailwind v4 engine — CSS parser (AST), candidate scanner, utility/variant diff --git a/go/bundler/tailwind_test.go b/go/tw/tailwind_test.go similarity index 99% rename from go/bundler/tailwind_test.go rename to go/tw/tailwind_test.go index 554b24c9..6d142282 100644 --- a/go/bundler/tailwind_test.go +++ b/go/tw/tailwind_test.go @@ -1,4 +1,4 @@ -package bundler +package tw import ( "strings" diff --git a/go/tw/tw.go b/go/tw/tw.go new file mode 100644 index 00000000..dfa6a5f9 --- /dev/null +++ b/go/tw/tw.go @@ -0,0 +1,59 @@ +// Package tw is kjol's native Go Tailwind v4 compiler. +// +// It lives on its own, not inside the bundler, because Tailwind is not a +// JavaScript concern. The bundler builds web/ (TSX, Solid, esbuild); the Tailwind +// engine only ever reads text and writes CSS, and the text it reads is just as +// likely to be Go — the gowasm kit authors its markup in Go and has no JS build at +// all. Burying the compiler in the bundler made every Go-only consumer drag a +// JavaScript bundler along for a CSS file. +// +// The scanner is language-agnostic: it pulls candidate class tokens out of any text +// file, so `class="flex gap-2"` in a .tsx and `vdom.Attr("class", "flex gap-2")` in +// a .go are found the same way. +// +// The engine itself is in tailwind.go — a from-scratch port of Tailwind v4's +// compiler. This file is the surface the rest of kjol calls. +package tw + +import ( + "github.com/tdewolff/minify/v2" + mincss "github.com/tdewolff/minify/v2/css" +) + +var min *minify.M + +func init() { + min = minify.New() + min.AddFunc("text/css", mincss.Minify) +} + +// Scan extracts candidate utility class names from the files matched by patterns +// (globs, relative to baseDir). It reads text, not syntax: a candidate is any token +// that could plausibly be a class, and the compiler decides which ones actually are. +func Scan(baseDir string, patterns []string) []string { + return scanSources(baseDir, patterns) +} + +// Compile compiles a Tailwind entry stylesheet against a set of candidates, and +// returns the CSS along with how many utilities it emitted (useful for a build log — +// a sudden drop usually means the scanner stopped seeing a source tree). +// +// entryCSS is the stylesheet source: typically `@import "tailwindcss";` plus an +// `@theme { … }` block. Anything else in it — @font-face, plain rules — passes +// through untouched. +func Compile(entryCSS, baseDir string, candidates []string) (css string, utilities int, err error) { + return twCompile(entryCSS, baseDir, candidates) +} + +// CompileFiles is Scan + Compile + minify: the whole job, for a caller that just +// wants CSS out of a stylesheet and some source globs. +func CompileFiles(entryCSS, baseDir string, sourceGlobs []string) (string, error) { + compiled, _, err := twCompile(entryCSS, baseDir, scanSources(baseDir, sourceGlobs)) + if err != nil { + return "", err + } + return Minify(compiled) +} + +// Minify shrinks compiled CSS. +func Minify(css string) (string, error) { return min.String("text/css", css) } diff --git a/go/bundler/tw_preflight.css b/go/tw/tw_preflight.css similarity index 100% rename from go/bundler/tw_preflight.css rename to go/tw/tw_preflight.css diff --git a/go/bundler/tw_theme.css b/go/tw/tw_theme.css similarity index 100% rename from go/bundler/tw_theme.css rename to go/tw/tw_theme.css diff --git a/go/wasmdevserver/devserver.go b/go/wasmdevserver/devserver.go index 1825f615..e6140208 100644 --- a/go/wasmdevserver/devserver.go +++ b/go/wasmdevserver/devserver.go @@ -1,3 +1,5 @@ +//go:build !(js && wasm) + // Package wasmdevserver is a reusable development server for gowasm apps: it serves // the built web assets, renders routes server-side (SSR) at request time, hosts // the /rsc server-component endpoint, and hot-swaps the freshly built wasm into @@ -33,14 +35,21 @@ import ( // Config wires an app into the dev server. Render and Document are called per // request; Build is called for the initial build and on every source change. type Config struct { - Addr string // listen address (default ":8085") - Dir string // static assets dir; Build writes app.wasm here (default "./wwwroot") - Watch bool // rebuild on change + hot reload - WatchDirs []string // source dirs to watch when Watch is set - Build func() ([]byte, error) // (re)build the wasm bundle; combined output on failure - Render func(path string) (inner string, ok bool) // SSR the #app inner HTML for a route (ok=false => client-rendered) - Document func(inner string) string // wrap #app inner HTML in a full HTML document - Handle func(mux *http.ServeMux) // optional: register extra routes (e.g. app API endpoints) + Addr string // listen address (default ":8085") + Dir string // static assets dir; Build writes app.wasm here (default "./wwwroot") + Watch bool // rebuild on change + hot reload + WatchDirs []string // source dirs to watch when Watch is set + Build func() ([]byte, error) // (re)build everything; combined output on failure + + // BuildCSS recompiles ONLY the stylesheet. When a save touched stylesheets and no + // Go code, this runs instead of Build and the browser swaps the stylesheet in + // place — no wasm rebuild, no page reload, no lost state. Without it a colour + // tweak waits on the whole Go compiler, which is the difference between a design + // loop and a coffee break. + BuildCSS func() ([]byte, error) + Render func(path string) (inner string, ok bool) // SSR the #app inner HTML for a route (ok=false => client-rendered) + Document func(inner string) string // wrap #app inner HTML in a full HTML document + Handle func(mux *http.ServeMux) // optional: register extra routes (e.g. app API endpoints) } // Serve builds once (in watch mode), wires the routes, and blocks serving. @@ -116,15 +125,45 @@ func rootHandler(cfg Config) http.HandlerFunc { // ---- build + watch ------------------------------------------------------ +// watchLoop rebuilds on change, and treats a stylesheet edit differently from a code +// edit — because they cost wildly different amounts. +// +// A Go change means recompiling a multi-megabyte wasm binary. A CSS change means +// re-running Tailwind, which takes a moment. Putting both through the same path +// would make every tweak to a colour wait on the compiler, so a save that touched +// ONLY stylesheets runs BuildCSS and pushes a stylesheet swap: no wasm rebuild, no +// page reload, no lost state — the new CSS just appears. func watchLoop(cfg Config, h *hub) { - prev := fingerprint(cfg.WatchDirs) + prevGo := fingerprintExt(cfg.WatchDirs, ".go") + prevCSS := fingerprintExt(cfg.WatchDirs, ".css") + for { time.Sleep(300 * time.Millisecond) - fp := fingerprint(cfg.WatchDirs) - if fp == prev { + + fpGo := fingerprintExt(cfg.WatchDirs, ".go") + fpCSS := fingerprintExt(cfg.WatchDirs, ".css") + codeChanged := fpGo != prevGo + cssChanged := fpCSS != prevCSS + if !codeChanged && !cssChanged { continue } - prev = fp + prevGo, prevCSS = fpGo, fpCSS + + // Styles only: the cheap path. + if !codeChanged && cfg.BuildCSS != nil { + log.Println("stylesheet changed, recompiling CSS…") + h.broadcast(`{"type":"building"}`) + if out, err := cfg.BuildCSS(); err != nil { + log.Printf("CSS build failed: %v\n%s", err, out) + h.setError(string(out)) + continue + } + log.Println("CSS ok — swapping stylesheets") + h.clearError() + h.broadcast(`{"type":"css"}`) + continue + } + log.Println("change detected, rebuilding…") h.broadcast(`{"type":"building"}`) if cfg.Build == nil { @@ -141,12 +180,20 @@ func watchLoop(cfg Config, h *hub) { } } -// fingerprint changes whenever any .go file under dirs is modified. -func fingerprint(dirs []string) int64 { +// fingerprintExt changes whenever a file of the given extension under dirs is +// modified. +// +// .css is watched at all for a reason: the build compiles Tailwind, and the entry +// stylesheet (its @theme block, its @font-face rules, any hand-written CSS) is an +// INPUT to that build. Watching only .go meant saving app.css did nothing — no +// rebuild, no reload — and the change simply never reached the browser. Editing some +// unrelated Go file would then sweep it up by accident, which is a maddening way to +// find out. +func fingerprintExt(dirs []string, ext string) int64 { var fp int64 for _, d := range dirs { filepath.WalkDir(d, func(path string, e fs.DirEntry, err error) error { - if err != nil || e.IsDir() || !strings.HasSuffix(path, ".go") { + if err != nil || e.IsDir() || !strings.EqualFold(filepath.Ext(path), ext) { return nil } if info, err := e.Info(); err == nil { @@ -206,6 +253,28 @@ const clientJS = `// Injected by the dev server in watch mode. start(); // renders synchronously — no await between dispose and first paint } + // Swap every stylesheet for a freshly-fetched copy — no page reload, so scroll + // position, form state and the running wasm instance all survive a style tweak. + // + // The NEW link is loaded and only then does the old one go, on its onload. Removing + // it first would leave the page unstyled for however long the fetch takes, which + // reads as a flash of naked HTML every time you save. + function swapCSS() { + hideOverlay(); + var links = document.querySelectorAll('link[rel="stylesheet"]'); + for (var i = 0; i < links.length; i++) { + (function (old) { + var url = old.href.split("?")[0] + "?v=" + Date.now(); + var next = old.cloneNode(); + next.href = url; + next.onload = function () { if (old.parentNode) old.parentNode.removeChild(old); }; + next.onerror = function () { if (next.parentNode) next.parentNode.removeChild(next); }; + old.parentNode.insertBefore(next, old.nextSibling); + })(links[i]); + } + console.log("[hot reload] stylesheet updated"); + } + // Full-screen overlay showing the Go compiler output when a build fails. The // app underneath keeps running (and its state), so fixing the code and saving // clears the overlay and hot-swaps without losing anything. @@ -246,6 +315,7 @@ const clientJS = `// Injected by the dev server in watch mode. var msg = {}; try { msg = JSON.parse(e.data); } catch (_) { return; } if (msg.type === "reload") { hotSwap(); } // build ok: swap in place + else if (msg.type === "css") { swapCSS(); } // styles only: swap the stylesheet else if (msg.type === "error") { showOverlay(msg.msg); } // build failed: show compiler output else if (msg.type === "building") { console.log("[hot reload] rebuilding…"); } }; diff --git a/go/wasmdevserver/devserver_wasm.go b/go/wasmdevserver/devserver_wasm.go new file mode 100644 index 00000000..e96eab6d --- /dev/null +++ b/go/wasmdevserver/devserver_wasm.go @@ -0,0 +1,11 @@ +//go:build js && wasm + +// This file exists only so the package is not EMPTY under js/wasm. +// +// wasmdevserver is a server: it compiles Go, serves HTTP, and walks the filesystem — +// none of which a browser can do. The real file is excluded from the wasm build (it +// depends on rsc.Handler, which is itself server-only). But a package whose files are +// all excluded is a build error, not an empty package, and `GOOS=js go build ./...` +// would fail on it — which is exactly the command you run to check the half of kjol +// that DOES target wasm. So the package stays present here, and empty. +package wasmdevserver diff --git a/go/bundler/alias.go b/go/webbundler/alias.go similarity index 99% rename from go/bundler/alias.go rename to go/webbundler/alias.go index 7716e6f5..6ff46d80 100644 --- a/go/bundler/alias.go +++ b/go/webbundler/alias.go @@ -1,4 +1,4 @@ -package bundler +package webbundler import ( "fmt" diff --git a/go/bundler/build.go b/go/webbundler/build.go similarity index 99% rename from go/bundler/build.go rename to go/webbundler/build.go index e081c3db..4cd6ab0e 100644 --- a/go/bundler/build.go +++ b/go/webbundler/build.go @@ -18,7 +18,7 @@ // genssr.go public-page SSR bake + Go registry generation // ssr.go/renderer.go the goja SSR engine (also used by the server at runtime) // watch.go poll-and-rebuild watch loop -package bundler +package webbundler import ( "fmt" diff --git a/go/bundler/compile_solid.go b/go/webbundler/compile_solid.go similarity index 96% rename from go/bundler/compile_solid.go rename to go/webbundler/compile_solid.go index b8b7f7d7..038cdddc 100644 --- a/go/bundler/compile_solid.go +++ b/go/webbundler/compile_solid.go @@ -1,4 +1,4 @@ -package bundler +package webbundler // Go-native Solid JSX compiler (replaces babel-preset-solid running in goja). // @@ -397,3 +397,11 @@ func isAttrNameChar(b byte) bool { func isSpace(b byte) bool { return b == ' ' || b == '\t' || b == '\n' || b == '\r' } + +// isASCIILetter used to live in tailwind.go, which the Solid compiler happened to +// share a package with. It is a lexer helper, not a Tailwind one, so it moved here +// with the code that actually uses it when the Tailwind engine was factored out into +// kjol/tw. +func isASCIILetter(b byte) bool { + return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') +} diff --git a/go/bundler/compile_solid_gen.go b/go/webbundler/compile_solid_gen.go similarity index 99% rename from go/bundler/compile_solid_gen.go rename to go/webbundler/compile_solid_gen.go index 4828b835..66768e8f 100644 --- a/go/bundler/compile_solid_gen.go +++ b/go/webbundler/compile_solid_gen.go @@ -1,4 +1,4 @@ -package bundler +package webbundler // Go-native Solid codegen: JSX tree -> dom-expressions runtime output. // @@ -712,7 +712,7 @@ func (g *solidGen) isConstBinding(name string) bool { if !reSimpleIdent.MatchString(name) { return false } - if regexp.MustCompile(`\b(?:let|var)\s+`+regexp.QuoteMeta(name)+`\b`).MatchString(g.source) { + if regexp.MustCompile(`\b(?:let|var)\s+` + regexp.QuoteMeta(name) + `\b`).MatchString(g.source) { return false } return true diff --git a/go/bundler/compile_solid_render_test.go b/go/webbundler/compile_solid_render_test.go similarity index 99% rename from go/bundler/compile_solid_render_test.go rename to go/webbundler/compile_solid_render_test.go index 38ce4f62..7a265f6d 100644 --- a/go/bundler/compile_solid_render_test.go +++ b/go/webbundler/compile_solid_render_test.go @@ -1,4 +1,4 @@ -package bundler +package webbundler import ( "os" diff --git a/go/bundler/compile_solid_test.go b/go/webbundler/compile_solid_test.go similarity index 99% rename from go/bundler/compile_solid_test.go rename to go/webbundler/compile_solid_test.go index 2d1b7712..26cd05fb 100644 --- a/go/bundler/compile_solid_test.go +++ b/go/webbundler/compile_solid_test.go @@ -1,4 +1,4 @@ -package bundler +package webbundler import "testing" diff --git a/go/bundler/config.go b/go/webbundler/config.go similarity index 99% rename from go/bundler/config.go rename to go/webbundler/config.go index 047af08b..a98cddfb 100644 --- a/go/bundler/config.go +++ b/go/webbundler/config.go @@ -1,4 +1,4 @@ -package bundler +package webbundler import "path/filepath" diff --git a/go/bundler/crosstree_test.go b/go/webbundler/crosstree_test.go similarity index 99% rename from go/bundler/crosstree_test.go rename to go/webbundler/crosstree_test.go index 1309382a..b1d5cd4d 100644 --- a/go/bundler/crosstree_test.go +++ b/go/webbundler/crosstree_test.go @@ -1,4 +1,4 @@ -package bundler +package webbundler import ( "os" diff --git a/go/bundler/css.go b/go/webbundler/css.go similarity index 94% rename from go/bundler/css.go rename to go/webbundler/css.go index d5b6464b..80092803 100644 --- a/go/bundler/css.go +++ b/go/webbundler/css.go @@ -1,4 +1,4 @@ -package bundler +package webbundler // CSS pipeline: compiles the Tailwind entry stylesheet (frontend/css/style.css) // with the native Go Tailwind v4 engine (tailwind.go — twCompile/scanSources, no @@ -13,6 +13,8 @@ import ( "github.com/tdewolff/minify/v2" mincss "github.com/tdewolff/minify/v2/css" + + "kjol/tw" ) // Tailwind source patterns - configured here rather than in CSS so each bundle @@ -72,8 +74,8 @@ func compileCSSBundle(label string, twSources []string, outName string) (bundleS // Candidate classes come from the app sources (patterns relative to the css // dir) plus the shared kit tree scanned directly, so kit component classes are // present even though the kit lives outside the app frontend. - candidates := scanSources(cssDir, twSources) - kitCands := scanSources(uikitDir(), []string{"**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"}) + candidates := tw.Scan(cssDir, twSources) + kitCands := tw.Scan(uikitDir(), []string{"**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"}) candidates = dedupStrings(append(candidates, kitCands...)) // Prepend the shared @theme scaffold (kjol web/styles/theme.css) ahead of the @@ -86,7 +88,7 @@ func compileCSSBundle(label string, twSources []string, outName string) (bundleS } } - compiled, count, err := twCompile(input, cssDir, candidates) + compiled, count, err := tw.Compile(input, cssDir, candidates) if err != nil { return bundleStats{}, fmt.Errorf("tailwind compile (%s): %w", label, err) } diff --git a/go/bundler/export_shim.go b/go/webbundler/export_shim.go similarity index 99% rename from go/bundler/export_shim.go rename to go/webbundler/export_shim.go index 9ac4e5e0..a4cc6008 100644 --- a/go/bundler/export_shim.go +++ b/go/webbundler/export_shim.go @@ -1,4 +1,4 @@ -package bundler +package webbundler // defaultExportShimPlugin bridges two ESM-strictness mismatches that // existed in the previous custom bundler: diff --git a/go/bundler/faicons.go b/go/webbundler/faicons.go similarity index 98% rename from go/bundler/faicons.go rename to go/webbundler/faicons.go index 50d70696..af9df873 100644 --- a/go/bundler/faicons.go +++ b/go/webbundler/faicons.go @@ -1,4 +1,4 @@ -package bundler +package webbundler import ( "fmt" @@ -200,7 +200,7 @@ func parseFASvg(svg string) (x, y, w, h, path string, ok bool) { return "0", "0", vb[1], vb[2], strings.Join(ds, " "), true // unparseable dims — use as-is } mx, my := vw/10, vh/10 - return numStr(mx), numStr(my), numStr(vw-2*mx), numStr(vh-2*my), strings.Join(ds, " "), true + return numStr(mx), numStr(my), numStr(vw - 2*mx), numStr(vh - 2*my), strings.Join(ds, " "), true } func numStr(f float64) string { return strconv.FormatFloat(f, 'f', -1, 64) } diff --git a/go/bundler/genroutes.go b/go/webbundler/genroutes.go similarity index 99% rename from go/bundler/genroutes.go rename to go/webbundler/genroutes.go index 1c62e097..29bc17fa 100644 --- a/go/bundler/genroutes.go +++ b/go/webbundler/genroutes.go @@ -1,4 +1,4 @@ -package bundler +package webbundler // Public-route code generation. The single source of truth is the TypeScript // manifest frontend/src/pages/public/pages.ts. This reads it (via esbuild + diff --git a/go/bundler/genssr.go b/go/webbundler/genssr.go similarity index 99% rename from go/bundler/genssr.go rename to go/webbundler/genssr.go index ef3886a5..7f30e695 100644 --- a/go/bundler/genssr.go +++ b/go/webbundler/genssr.go @@ -1,4 +1,4 @@ -package bundler +package webbundler // Public-page SSR code generation. For each page in the manifest // (frontend/src/pages/public/pages.ts) this renders the solid-js/html component diff --git a/go/bundler/hmr_browser_test.go b/go/webbundler/hmr_browser_test.go similarity index 99% rename from go/bundler/hmr_browser_test.go rename to go/webbundler/hmr_browser_test.go index 42bdf690..f8b6e326 100644 --- a/go/bundler/hmr_browser_test.go +++ b/go/webbundler/hmr_browser_test.go @@ -1,6 +1,6 @@ //go:build dev -package bundler +package webbundler import ( "context" diff --git a/go/bundler/hmr_client.go b/go/webbundler/hmr_client.go similarity index 99% rename from go/bundler/hmr_client.go rename to go/webbundler/hmr_client.go index 60e602a6..7cd73d3b 100644 --- a/go/bundler/hmr_client.go +++ b/go/webbundler/hmr_client.go @@ -1,6 +1,6 @@ //go:build dev -package bundler +package webbundler import "net/http" diff --git a/go/bundler/hmr_client_test.go b/go/webbundler/hmr_client_test.go similarity index 98% rename from go/bundler/hmr_client_test.go rename to go/webbundler/hmr_client_test.go index d77fe8d5..747975fa 100644 --- a/go/bundler/hmr_client_test.go +++ b/go/webbundler/hmr_client_test.go @@ -1,6 +1,6 @@ //go:build dev -package bundler +package webbundler import ( "strings" diff --git a/go/bundler/hmr_e2e_test.go b/go/webbundler/hmr_e2e_test.go similarity index 99% rename from go/bundler/hmr_e2e_test.go rename to go/webbundler/hmr_e2e_test.go index 875950b1..1bcf4593 100644 --- a/go/bundler/hmr_e2e_test.go +++ b/go/webbundler/hmr_e2e_test.go @@ -1,6 +1,6 @@ //go:build dev -package bundler +package webbundler import ( "encoding/json" diff --git a/go/bundler/hmr_error_test.go b/go/webbundler/hmr_error_test.go similarity index 99% rename from go/bundler/hmr_error_test.go rename to go/webbundler/hmr_error_test.go index 05a8d6e3..d8cae461 100644 --- a/go/bundler/hmr_error_test.go +++ b/go/webbundler/hmr_error_test.go @@ -1,6 +1,6 @@ //go:build dev -package bundler +package webbundler import ( "errors" diff --git a/go/bundler/hmr_server.go b/go/webbundler/hmr_server.go similarity index 99% rename from go/bundler/hmr_server.go rename to go/webbundler/hmr_server.go index 1689730b..28dee3bf 100644 --- a/go/bundler/hmr_server.go +++ b/go/webbundler/hmr_server.go @@ -1,6 +1,6 @@ //go:build dev -package bundler +package webbundler // The development HMR server: it serves the SPA source tree as unbundled native // ES modules (transformed on the fly), tracks the module import graph, watches @@ -8,7 +8,7 @@ package bundler // WebSocket. Editing a .tsx component swaps it in place (solid-refresh); editing // a non-boundary module bubbles up to a full page reload. // -// This is the reason internal/bundler grew a `dev` build tag: the whole HMR +// This is the reason webbundler grew a `dev` build tag: the whole HMR // subsystem (this file, hmr_ws.go, hmr_vendor.go, hmr_client.go, hmr_watch.go, // and the solid-refresh bits of jsx.go) compiles only under `-tags dev`, so the // production server and the plain `cmd/bundle` build carry none of it. diff --git a/go/bundler/hmr_vendor.go b/go/webbundler/hmr_vendor.go similarity index 99% rename from go/bundler/hmr_vendor.go rename to go/webbundler/hmr_vendor.go index 9d0a2bcf..914d6689 100644 --- a/go/bundler/hmr_vendor.go +++ b/go/webbundler/hmr_vendor.go @@ -1,6 +1,6 @@ //go:build dev -package bundler +package webbundler // Vendor handling for the dev server. Bare specifiers (solid-js, @solidjs/router, // solid-refresh, …) are served as single pre-bundled ESM files under /@vendor/, diff --git a/go/bundler/hmr_watch.go b/go/webbundler/hmr_watch.go similarity index 99% rename from go/bundler/hmr_watch.go rename to go/webbundler/hmr_watch.go index 3a9729da..f4a6be30 100644 --- a/go/bundler/hmr_watch.go +++ b/go/webbundler/hmr_watch.go @@ -1,6 +1,6 @@ //go:build dev -package bundler +package webbundler // The dev watcher: a lightweight mtime poll over frontend/src and frontend/css // (no external dependency). A source-module change is turned into an HMR update diff --git a/go/bundler/hmr_ws.go b/go/webbundler/hmr_ws.go similarity index 98% rename from go/bundler/hmr_ws.go rename to go/webbundler/hmr_ws.go index 31df61f1..0ade3077 100644 --- a/go/bundler/hmr_ws.go +++ b/go/webbundler/hmr_ws.go @@ -1,12 +1,12 @@ //go:build dev -package bundler +package webbundler // A minimal RFC 6455 WebSocket server — just enough for one-way server→browser // push of HMR messages. We hand-roll it (rather than add a dependency) because // the surface we need is tiny: the handshake, unmasked server text frames, and a // read loop that answers pings and notices close. No per-message compression, no -// fragmentation, no client→server application data. All of internal/bundler's +// fragmentation, no client→server application data. All of webbundler's // HMR support is behind `//go:build dev`, so prod builds compile none of it. import ( diff --git a/go/bundler/hmr_ws_integration_test.go b/go/webbundler/hmr_ws_integration_test.go similarity index 99% rename from go/bundler/hmr_ws_integration_test.go rename to go/webbundler/hmr_ws_integration_test.go index c143ba28..3ace2813 100644 --- a/go/bundler/hmr_ws_integration_test.go +++ b/go/webbundler/hmr_ws_integration_test.go @@ -1,6 +1,6 @@ //go:build dev -package bundler +package webbundler import ( "bufio" diff --git a/go/bundler/hmr_ws_test.go b/go/webbundler/hmr_ws_test.go similarity index 99% rename from go/bundler/hmr_ws_test.go rename to go/webbundler/hmr_ws_test.go index 2a5e6c62..63b80739 100644 --- a/go/bundler/hmr_ws_test.go +++ b/go/webbundler/hmr_ws_test.go @@ -1,6 +1,6 @@ //go:build dev -package bundler +package webbundler import ( "bufio" diff --git a/go/bundler/import_check.go b/go/webbundler/import_check.go similarity index 99% rename from go/bundler/import_check.go rename to go/webbundler/import_check.go index c476f7fe..f0186a70 100644 --- a/go/bundler/import_check.go +++ b/go/webbundler/import_check.go @@ -1,4 +1,4 @@ -package bundler +package webbundler // esbuild silently resolves a `.js` import to a sibling `.ts`/`.tsx`/`.jsx`, // which lets misnamed specifiers slip through. This check catches them up diff --git a/go/bundler/js.go b/go/webbundler/js.go similarity index 98% rename from go/bundler/js.go rename to go/webbundler/js.go index 295283ea..d55a2916 100644 --- a/go/bundler/js.go +++ b/go/webbundler/js.go @@ -1,4 +1,4 @@ -package bundler +package webbundler import ( "encoding/json" @@ -17,7 +17,7 @@ import ( // transforms (hmr_server.go). __ENV_TYPE__ carries the Go compile-time // deployment environment (appenv.Environment) into the JS, where // frontend/src/env.ts reads it. The build-time SSR render deliberately does NOT -// define it (see internal/bundler/ssr.go), so the env badge renders only after +// define it (see ssr.go), so the env badge renders only after // the client takeover. func esbuildDefine() map[string]string { return map[string]string{ diff --git a/go/bundler/js/ssr/dom.js b/go/webbundler/js/ssr/dom.js similarity index 100% rename from go/bundler/js/ssr/dom.js rename to go/webbundler/js/ssr/dom.js diff --git a/go/bundler/jsx.go b/go/webbundler/jsx.go similarity index 96% rename from go/bundler/jsx.go rename to go/webbundler/jsx.go index 25ad764c..8296a3e8 100644 --- a/go/bundler/jsx.go +++ b/go/webbundler/jsx.go @@ -1,4 +1,4 @@ -// Solid JSX/TSX compilation (part of package bundler): compiles Solid JSX/TSX to +// Solid JSX/TSX compilation (part of package webbundler): compiles Solid JSX/TSX to // optimized Solid dom-expressions output (template cloning + fine-grained // updates) with a Go-native compiler — no Node, no Babel, no goja. The compiler // lives in compile_solid.go (parser) and compile_solid_gen.go (codegen); @@ -10,7 +10,7 @@ // per-declaration incremental caching is gone. A small in-memory cache dedups // identical transforms within a process (e.g. a module served to several page // loads under HMR); there is no on-disk cache and no compiler bootstrap. -package bundler +package webbundler import ( "crypto/sha256" diff --git a/go/bundler/jsx_bench_test.go b/go/webbundler/jsx_bench_test.go similarity index 98% rename from go/bundler/jsx_bench_test.go rename to go/webbundler/jsx_bench_test.go index fcd96248..8f200b8a 100644 --- a/go/bundler/jsx_bench_test.go +++ b/go/webbundler/jsx_bench_test.go @@ -1,4 +1,4 @@ -package bundler +package webbundler import ( "fmt" diff --git a/go/bundler/jsx_dev_test.go b/go/webbundler/jsx_dev_test.go similarity index 98% rename from go/bundler/jsx_dev_test.go rename to go/webbundler/jsx_dev_test.go index 0042e409..3b6154e9 100644 --- a/go/bundler/jsx_dev_test.go +++ b/go/webbundler/jsx_dev_test.go @@ -1,4 +1,4 @@ -package bundler +package webbundler import ( "strings" diff --git a/go/bundler/jsx_test.go b/go/webbundler/jsx_test.go similarity index 98% rename from go/bundler/jsx_test.go rename to go/webbundler/jsx_test.go index 5378a770..2339e2e0 100644 --- a/go/bundler/jsx_test.go +++ b/go/webbundler/jsx_test.go @@ -1,4 +1,4 @@ -package bundler +package webbundler import ( "strings" diff --git a/go/bundler/renderer.go b/go/webbundler/renderer.go similarity index 99% rename from go/bundler/renderer.go rename to go/webbundler/renderer.go index ae95c68a..bed6b9ce 100644 --- a/go/bundler/renderer.go +++ b/go/webbundler/renderer.go @@ -1,4 +1,4 @@ -package bundler +package webbundler import ( "encoding/json" diff --git a/go/bundler/segment.go b/go/webbundler/segment.go similarity index 99% rename from go/bundler/segment.go rename to go/webbundler/segment.go index 247d6a48..6da13711 100644 --- a/go/bundler/segment.go +++ b/go/webbundler/segment.go @@ -1,4 +1,4 @@ -package bundler +package webbundler // Top-level source segmentation. segmentTopLevel splits a module into contiguous // chunks at top-level declaration boundaries; the Go Solid compiler uses it to diff --git a/go/bundler/segment_test.go b/go/webbundler/segment_test.go similarity index 98% rename from go/bundler/segment_test.go rename to go/webbundler/segment_test.go index d0e9033d..d0685c26 100644 --- a/go/bundler/segment_test.go +++ b/go/webbundler/segment_test.go @@ -1,4 +1,4 @@ -package bundler +package webbundler import ( "os" @@ -8,7 +8,7 @@ import ( ) // walkRepoTSX returns every .tsx/.jsx under frontend/src (relative to the -// package dir, which is internal/bundler). +// package dir, which is webbundler). func walkRepoTSX(t *testing.T) []string { t.Helper() root := filepath.Join("..", "..", "frontend", "src") diff --git a/go/bundler/ssr.go b/go/webbundler/ssr.go similarity index 99% rename from go/bundler/ssr.go rename to go/webbundler/ssr.go index d4c45d78..0b6871d9 100644 --- a/go/bundler/ssr.go +++ b/go/webbundler/ssr.go @@ -1,4 +1,4 @@ -// SSR (part of package bundler): server-renders the public-page Solid components +// SSR (part of package webbundler): server-renders the public-page Solid components // to HTML strings by running the (unmodified) client Solid runtime inside a goja // JS engine against a minimal Go-backed DOM (dom.js). Components are authored in // JSX/TSX and compiled to optimized Solid output at build time by the Go-native @@ -11,7 +11,7 @@ // is imported by the server; the build-time entries are used by the bundler. // // -mta -package bundler +package webbundler import ( "crypto/sha256" diff --git a/go/bundler/ssr_test.go b/go/webbundler/ssr_test.go similarity index 97% rename from go/bundler/ssr_test.go rename to go/webbundler/ssr_test.go index 8b8daacd..37c730de 100644 --- a/go/bundler/ssr_test.go +++ b/go/webbundler/ssr_test.go @@ -1,4 +1,4 @@ -package bundler +package webbundler import ( "path/filepath" @@ -132,7 +132,7 @@ func TestRenderHomeSkeleton(t *testing.T) { for _, want := range []string{ `class="page-home"`, `The Ultimate Funding and Investing Solution`, - `viewBox="0 0 24 24"`, // SVG attribute case preserved + `viewBox="0 0 24 24"`, // SVG attribute case preserved `background-image: url(/images/public/hero-bg.jpg)`, // static string style baked into the template `animate-pulse`, // skeleton bars (SSR forces showSkeleton) `90 Day`, // term labels shown in the skeleton diff --git a/go/bundler/ssrcache.go b/go/webbundler/ssrcache.go similarity index 99% rename from go/bundler/ssrcache.go rename to go/webbundler/ssrcache.go index b4a5d841..af769fad 100644 --- a/go/bundler/ssrcache.go +++ b/go/webbundler/ssrcache.go @@ -1,4 +1,4 @@ -package bundler +package webbundler // Build-time caching + parallelism for public-page SSR (see genssr.go). // diff --git a/go/bundler/vendor_plugins.go b/go/webbundler/vendor_plugins.go similarity index 99% rename from go/bundler/vendor_plugins.go rename to go/webbundler/vendor_plugins.go index d17ab86e..05da81ef 100644 --- a/go/bundler/vendor_plugins.go +++ b/go/webbundler/vendor_plugins.go @@ -1,4 +1,4 @@ -package bundler +package webbundler import ( "encoding/json" diff --git a/go/webui/autotable.go b/go/webui/autotable.go index 86583639..4a667118 100644 --- a/go/webui/autotable.go +++ b/go/webui/autotable.go @@ -1686,8 +1686,8 @@ type AutoTableState struct { // thRefs memoizes header-cell refs, so a resize can measure them. thRefs map[string]*vdom.Ref - // openSignals memoizes per-dropdown open state (see openSignal). - openSignals map[string]*vdom.Signal[bool] + // multiSelects memoizes the dropdown controllers (see multiSelect). + multiSelects map[string]*MultiSelect // resolved by the last Render; kept so callers (export, toolbar actions) can ask // what the current filter actually selected. @@ -2116,34 +2116,33 @@ func (s *AutoTableState) MultiSelectSearch(identifier, placeholder string, value for _, v := range values { options = append(options, FormSelectOption{Value: v, Label: v}) } - open := s.openSignal(identifier) - return vdom.Div(vdom.Attr("class", AUTOTABLE_SEARCH_FIELD), - FormMultiSelect(FormMultiSelectProps{ + s.multiSelect(identifier).Render(FormMultiSelectProps{ Options: options, Value: s.SearchValues(identifier), Placeholder: pick(placeholder, "Any"), + Searchable: true, ShowSelectAll: true, - Open: open.Get(), - OnToggle: func() { open.Set(!open.Get()) }, OnChange: func(vs []string) { s.SetSearchValues(identifier, vs, true) }, }), ) } -// openSignal memoizes the open state of a dropdown, keyed by identifier. It has to -// be memoized: a signal created fresh inside a render would reset to closed on -// every render, so the dropdown could never stay open. -func (s *AutoTableState) openSignal(key string) *vdom.Signal[bool] { - if s.openSignals == nil { - s.openSignals = map[string]*vdom.Signal[bool]{} +// multiSelect memoizes a dropdown controller, keyed by identifier. +// +// It MUST be memoized. A controller built inside a render would get fresh refs and a +// fresh closed state every frame — the dropdown could never stay open, and the +// outside-click listener would be watching an element that no longer exists. +func (s *AutoTableState) multiSelect(key string) *MultiSelect { + if s.multiSelects == nil { + s.multiSelects = map[string]*MultiSelect{} } - sig, ok := s.openSignals[key] + ms, ok := s.multiSelects[key] if !ok { - sig = vdom.NewSignal(false) - s.openSignals[key] = sig + ms = NewMultiSelect(DropdownOptions{}) + s.multiSelects[key] = ms } - return sig + return ms } // DateSearch renders a date field bound to an identifier. A date RANGE is two of @@ -2719,17 +2718,13 @@ func (s *AutoTableState) ColumnPicker() *vdom.VNode { if len(options) == 0 { return nil } - open := s.openSignal("__columns__") - - return FormMultiSelect(FormMultiSelectProps{ + return s.multiSelect("__columns__").Render(FormMultiSelectProps{ Options: options, Value: selected, Placeholder: "Columns", Searchable: true, ShowSelectAll: true, FieldWidth: "w-52", - Open: open.Get(), - OnToggle: func() { open.Set(!open.Get()) }, OnChange: func(visible []string) { show := map[string]bool{} for _, k := range visible { @@ -3266,10 +3261,14 @@ type calcEditor struct { position *vdom.Signal[int] errorMsg *vdom.Signal[string] - opOpen *vdom.Signal[bool] - colMenu *Menu - fnMenu *Menu - constMenu *Menu + opSelect *MultiSelect // the Basic tab's operand picker + + // The function menu's own search box. + fnSearch *vdom.Signal[string] + fnSearchRef *vdom.Ref + colMenu *Menu + fnMenu *Menu + constMenu *Menu // The textarea and the highlight overlay behind it. Both are needed: the caret // insert writes into the textarea, and the overlay's scroll has to follow it. @@ -3282,27 +3281,40 @@ func (s *AutoTableState) calcEditorState() *calcEditor { return s.editor } e := &calcEditor{ - view: vdom.NewSignal("menu"), - editingID: vdom.NewSignal(""), - advanced: vdom.NewSignal(false), - name: vdom.NewSignal(""), - fn: vdom.NewSignal(string(CALC_FN_SUM)), - operands: vdom.NewSignal([]string{}), - formula: vdom.NewSignal(""), - dataType: vdom.NewSignal(string(CALC_TYPE_NUMBER)), - precision: vdom.NewSignal(""), - position: vdom.NewSignal(int(COL_POS_RIGHT)), - errorMsg: vdom.NewSignal(""), - opOpen: vdom.NewSignal(false), - formulaRef: vdom.NewRef(), - overlayRef: vdom.NewRef(), + view: vdom.NewSignal("menu"), + editingID: vdom.NewSignal(""), + advanced: vdom.NewSignal(false), + name: vdom.NewSignal(""), + fn: vdom.NewSignal(string(CALC_FN_SUM)), + operands: vdom.NewSignal([]string{}), + formula: vdom.NewSignal(""), + dataType: vdom.NewSignal(string(CALC_TYPE_NUMBER)), + precision: vdom.NewSignal(""), + position: vdom.NewSignal(int(COL_POS_RIGHT)), + errorMsg: vdom.NewSignal(""), + opSelect: NewMultiSelect(DropdownOptions{}), + fnSearch: vdom.NewSignal(""), + fnSearchRef: vdom.NewRef(), + formulaRef: vdom.NewRef(), + overlayRef: vdom.NewRef(), // Standalone: these menus live INSIDE the editor's popover, and without it the // single-open manager would read them as a rival panel and close their own // parent as they opened. colMenu: NewMenu(MenuOptions{Placement: PlacementBottomStart, Standalone: true}), - fnMenu: NewMenu(MenuOptions{Placement: PlacementBottomStart, Standalone: true}), constMenu: NewMenu(MenuOptions{Placement: PlacementBottomStart, Standalone: true}), } + // Built after e exists, because it closes over it: closing the menu abandons the + // query. Reopening to a stale filter — four of thirty-five functions showing, for + // no visible reason — is worse than retyping. + e.fnMenu = NewMenu(MenuOptions{ + Placement: PlacementBottomStart, + Standalone: true, + OnOpenChange: func(open bool) { + if !open { + e.fnSearch.Set("") + } + }, + }) e.pop = NewPopover(PopoverProps{ Placement: PlacementBottomEnd, // Closing the popover abandons the draft — reopening should start clean rather @@ -3382,12 +3394,124 @@ var calcDataTypes = []struct { {CALC_TYPE_PLAIN, "Plain"}, } -// FormulaFunctionNames are the functions a formula may call, for the insert menu. -var FormulaFunctionNames = []string{ - "SUM", "AVERAGE", "MEDIAN", "MODE", "MIN", "MAX", "COUNT", - "ABS", "ROUND", "FLOOR", "CEILING", "SQRT", "POWER", "MOD", "EXP", "LN", "LOG", - "SIN", "COS", "TAN", "ASIN", "ACOS", "ATAN", "ATAN2", "SINH", "COSH", "TANH", - "RADIANS", "DEGREES", "IF", "AND", "OR", "NOT", "ROW", +// FormulaFunction is one entry in the insert menu: what to type, what it takes, and +// what it does. The signature and the description are not decoration — they are what +// makes a list of thirty-five names usable by someone who does not already know them, +// and they are what the menu's search matches against. +type FormulaFunction struct { + Name string + Sig string + Desc string +} + +// FormulaFunctionGroup is a category of functions. +type FormulaFunctionGroup struct { + Label string + Fns []FormulaFunction +} + +// FormulaFunctionGroups are the functions a formula may call, grouped the way someone +// looking for one would think about them — by what they are FOR, not alphabetically. +// Someone who wants a total looks under Aggregate; nobody scans an A-to-Z list from +// ABS to TANH hoping to recognise something. +var FormulaFunctionGroups = []FormulaFunctionGroup{ + {Label: "Aggregate", Fns: []FormulaFunction{ + {"SUM", "SUM(range)", "Total of the values"}, + {"AVERAGE", "AVERAGE(range)", "Mean of the values"}, + {"MEDIAN", "MEDIAN(range)", "Middle value"}, + {"MODE", "MODE(range)", "Most frequent value"}, + {"MIN", "MIN(range)", "Smallest value"}, + {"MAX", "MAX(range)", "Largest value"}, + {"COUNT", "COUNT(range)", "How many numbers"}, + }}, + {Label: "Math", Fns: []FormulaFunction{ + {"ABS", "ABS(n)", "Absolute value"}, + {"ROUND", "ROUND(n, digits)", "Round to digits"}, + {"FLOOR", "FLOOR(n)", "Round down"}, + {"CEILING", "CEILING(n)", "Round up"}, + {"SQRT", "SQRT(n)", "Square root"}, + {"POWER", "POWER(n, p)", "n to the power p"}, + {"MOD", "MOD(n, d)", "Remainder of n ÷ d"}, + {"EXP", "EXP(n)", "e to the power n"}, + {"LN", "LN(n)", "Natural log (base e)"}, + {"LOG", "LOG(n, base)", "Log, base 10 by default"}, + }}, + {Label: "Trigonometry", Fns: []FormulaFunction{ + {"SIN", "SIN(angle)", "Sine (radians)"}, + {"COS", "COS(angle)", "Cosine (radians)"}, + {"TAN", "TAN(angle)", "Tangent (radians)"}, + {"ASIN", "ASIN(n)", "Inverse sine"}, + {"ACOS", "ACOS(n)", "Inverse cosine"}, + {"ATAN", "ATAN(n)", "Inverse tangent"}, + {"ATAN2", "ATAN2(x, y)", "Angle of point (x, y)"}, + {"SINH", "SINH(n)", "Hyperbolic sine"}, + {"COSH", "COSH(n)", "Hyperbolic cosine"}, + {"TANH", "TANH(n)", "Hyperbolic tangent"}, + {"PI", "PI()", "π constant"}, + {"RADIANS", "RADIANS(deg)", "Degrees → radians"}, + {"DEGREES", "DEGREES(rad)", "Radians → degrees"}, + }}, + {Label: "Logic", Fns: []FormulaFunction{ + {"IF", "IF(test, then, else)", "Choose by condition"}, + {"AND", "AND(a, b, …)", "True if all are true"}, + {"OR", "OR(a, b, …)", "True if any are true"}, + {"NOT", "NOT(a)", "Negate"}, + }}, + {Label: "Row", Fns: []FormulaFunction{ + {"ROW", "ROW()", "Current row number"}, + }}, +} + +// FormulaConstant is a named constant, with what it is worth. +type FormulaConstant struct { + Name string + Desc string +} + +// FormulaConstantOptions are the constants a formula may write bare, for the insert +// menu. The approximate value is shown because "PHI" tells you nothing and "≈ 1.618" +// tells you everything. +var FormulaConstantOptions = []FormulaConstant{ + {"PI", "π ≈ 3.14159"}, + {"E", "Euler's number ≈ 2.71828"}, + {"TAU", "2π ≈ 6.28319"}, + {"PHI", "Golden ratio ≈ 1.61803"}, + {"SQRT2", "√2 ≈ 1.41421"}, +} + +// FormulaFunctionNames flattens the groups — for anything that just needs the names. +func FormulaFunctionNames() []string { + var out []string + for _, g := range FormulaFunctionGroups { + for _, f := range g.Fns { + out = append(out, f.Name) + } + } + return out +} + +// filterFormulaGroups narrows the menu by a query, matching name, signature AND +// description — so "total" finds SUM, which is the whole point of carrying the prose +// around. Empty groups drop out rather than leaving a bare heading behind. +func filterFormulaGroups(query string) []FormulaFunctionGroup { + q := strings.TrimSpace(strings.ToLower(query)) + if q == "" { + return FormulaFunctionGroups + } + var out []FormulaFunctionGroup + for _, g := range FormulaFunctionGroups { + var fns []FormulaFunction + for _, f := range g.Fns { + hay := strings.ToLower(f.Name + " " + f.Sig + " " + f.Desc) + if strings.Contains(hay, q) { + fns = append(fns, f) + } + } + if len(fns) > 0 { + out = append(out, FormulaFunctionGroup{Label: g.Label, Fns: fns}) + } + } + return out } // operandOption is one entry in the operand pickers and the column insert menu: the @@ -3743,14 +3867,12 @@ func (s *AutoTableState) calcBasicEditor(e *calcEditor, summary bool) *vdom.VNod msOpts = append(msOpts, FormSelectOption{Value: o.Key, Label: o.Label}) } children = append(children, calcField("Columns (combined per row)", - FormMultiSelect(FormMultiSelectProps{ + e.opSelect.Render(FormMultiSelectProps{ Options: msOpts, Value: e.operands.Get(), Placeholder: "Select columns…", Searchable: true, Small: true, - Open: e.opOpen.Get(), - OnToggle: func() { e.opOpen.Set(!e.opOpen.Get()) }, OnChange: func(v []string) { e.operands.Set(v) }, }))) } @@ -3796,25 +3918,45 @@ func (s *AutoTableState) calcAdvancedEditor(e *calcEditor, summary bool) *vdom.V ) } - fnItems := make([]*vdom.VNode, 0, len(FormulaFunctionNames)) - for _, name := range FormulaFunctionNames { - fname := name - fnItems = append(fnItems, e.fnMenu.Item(MenuItemProps{ - OnClick: func() { e.insertAtCaret(fname + "()") }, - }, vdom.Span(vdom.Attr("class", "font-mono text-emerald-700"), vdom.Text(fname)))) + // The function menu, grouped by category and searchable. Thirty-five names in one + // flat list is a wall; grouped by what they are FOR, with a signature and a + // sentence, it is something you can actually find SUM in. + fnItems := []*vdom.VNode{e.fnSearchBox()} + 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.Text("No functions match"), + )) + } + for _, g := range groups { + fnItems = append(fnItems, MenuSection("", vdom.Text(g.Label))) + for _, fn := range g.Fns { + f := fn + fnItems = append(fnItems, e.fnMenu.Item(MenuItemProps{ + OnClick: func() { e.insertFunction(f) }, + }, + vdom.Div(vdom.Attr("class", "flex flex-col items-start gap-0.5 min-w-0"), + // 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)), + ), + )) + } } - constNames := make([]string, 0, len(FormulaConstants)) - for name := range FormulaConstants { - constNames = append(constNames, name) - } - sort.Strings(constNames) - constItems := make([]*vdom.VNode, 0, len(constNames)) - for _, name := range constNames { - cname := name + constItems := make([]*vdom.VNode, 0, len(FormulaConstantOptions)) + for _, c := range FormulaConstantOptions { + cc := c constItems = append(constItems, e.constMenu.Item(MenuItemProps{ - OnClick: func() { e.insertAtCaret(cname) }, - }, vdom.Span(vdom.Attr("class", "font-mono text-amber-600"), vdom.Text(cname)))) + 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)), + ), + )) } menus := vdom.Div(vdom.Attr("class", "flex flex-wrap items-center gap-1"), @@ -3876,6 +4018,44 @@ func (s *AutoTableState) calcAdvancedEditor(e *calcEditor, summary bool) *vdom.V return calcField("Formula", children...) } +// fnSearchBox filters the function menu. It is sticky, so it stays reachable as the +// list scrolls, and it is focused when the menu opens — you can type "total", see SUM, +// and never touch the mouse. +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"), + 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("placeholder", "Search functions…"), + vdom.Attr("spellcheck", "false"), + vdom.Prop("value", e.fnSearch.Get()), + vdom.OnEvent(vdom.EVENT_INPUT, func(ev vdom.Event) { e.fnSearch.Set(ev.Value()) }), + ), + ) +} + +// insertFunction drops a call in and puts the caret INSIDE the parentheses, ready for +// the first argument — SUM() with the cursor after the "(" rather than after the ")", +// which would make you move it back every single time. +// +// A no-argument function (ROW(), PI()) has nothing to type, so the caret goes after it. +func (e *calcEditor) insertFunction(f FormulaFunction) { + noArgs := strings.HasSuffix(f.Sig, "()") + e.insertAtCaretOffset(f.Name+"()", boolToInt(!noArgs)) + e.fnSearch.Set("") + e.fnMenu.Close() +} + +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} + // syncOverlayScroll keeps the colours under the caret. Imperative on purpose: this // fires on every scroll frame, and a signal write would re-render the whole table. func (e *calcEditor) syncOverlayScroll() { @@ -3916,7 +4096,11 @@ func (s *AutoTableState) calcPreview(e *calcEditor, src string, summary bool) *v // insertAtCaret drops text where the cursor is, rather than at the end — which is // the whole point of an insert menu. It needs the caret position and focus back // afterwards, both of which come from the host API. -func (e *calcEditor) insertAtCaret(text string) { +func (e *calcEditor) insertAtCaret(text string) { e.insertAtCaretOffset(text, 0) } + +// insertAtCaretOffset inserts text and leaves the caret `back` characters from its +// end — so a function can land with the cursor between its parentheses. +func (e *calcEditor) insertAtCaretOffset(text string, back int) { src := e.formula.Get() start, end := wasmruntime.SelectionRange(e.formulaRef) if start < 0 || start > len(src) || end < start || end > len(src) { @@ -3927,7 +4111,7 @@ func (e *calcEditor) insertAtCaret(text string) { // The textarea's value is written by the render this signal just scheduled, so the // caret can only be placed once that render has landed. - caret := start + len(text) + caret := start + len(text) - back wasmruntime.AfterRender(func() { wasmruntime.Focus(e.formulaRef) wasmruntime.SetSelectionRange(e.formulaRef, caret, caret) diff --git a/go/webui/autotable_calc_test.go b/go/webui/autotable_calc_test.go index 1c6a9f3f..2d6eb83d 100644 --- a/go/webui/autotable_calc_test.go +++ b/go/webui/autotable_calc_test.go @@ -490,3 +490,115 @@ func TestEditingJumpsToTheRightForm(t *testing.T) { t.Error("reset did not return the editor to a clean chooser") } } + +// ---- the function insert menu ---- + +// Thirty-five function names in one flat list is a wall. Grouped by what they are FOR, +// with a signature and a sentence, it is something you can find SUM in. +func TestFormulaFunctionGroups(t *testing.T) { + want := []string{"Aggregate", "Math", "Trigonometry", "Logic", "Row"} + if len(FormulaFunctionGroups) != len(want) { + t.Fatalf("got %d groups, want %d", len(FormulaFunctionGroups), len(want)) + } + for i, label := range want { + if FormulaFunctionGroups[i].Label != label { + t.Errorf("group %d is %q, want %q", i, FormulaFunctionGroups[i].Label, label) + } + } + + // Every function carries a signature and a description — they are what the menu + // shows and what its search matches, not decoration. + for _, g := range FormulaFunctionGroups { + for _, f := range g.Fns { + if f.Name == "" || f.Sig == "" || f.Desc == "" { + t.Errorf("%s/%s is missing its signature or description: %+v", g.Label, f.Name, f) + } + if !strings.HasPrefix(f.Sig, f.Name+"(") { + t.Errorf("%s's signature %q does not start with its own name", f.Name, f.Sig) + } + } + } +} + +// Every function the menu offers must actually EXIST in the engine — an insert menu +// that offers a function the evaluator rejects is worse than no menu. +func TestEveryOfferedFunctionEvaluates(t *testing.T) { + // A formula per function, using its own arity. + args := map[string]string{ + "IF": "IF(1, 2, 3)", "ATAN2": "ATAN2(1, 1)", "POWER": "POWER(2, 3)", + "MOD": "MOD(5, 2)", "LOG": "LOG(100, 10)", "ROUND": "ROUND(1.5, 0)", + "ROW": "ROW()", "PI": "PI()", "AND": "AND(1, 1)", "OR": "OR(1, 0)", "NOT": "NOT(0)", + } + for _, g := range FormulaFunctionGroups { + for _, f := range g.Fns { + src, ok := args[f.Name] + if !ok { + src = f.Name + "(1)" + } + if _, err := CompileFormula(src); err != nil { + t.Errorf("the menu offers %s, but %q does not compile: %v", f.Name, src, err) + } + } + } +} + +// And every constant it offers must be one the evaluator knows. +func TestEveryOfferedConstantExists(t *testing.T) { + if len(FormulaConstantOptions) != len(FormulaConstants) { + t.Errorf("the menu offers %d constants, the engine knows %d", + len(FormulaConstantOptions), len(FormulaConstants)) + } + for _, c := range FormulaConstantOptions { + if _, ok := FormulaConstants[c.Name]; !ok { + t.Errorf("the menu offers %q, which the engine does not know", c.Name) + } + if c.Desc == "" { + t.Errorf("%q has no description — the name alone says nothing", c.Name) + } + } +} + +// The menu's search matches the DESCRIPTION too, which is the whole point of carrying +// the prose around: someone who wants a total types "total", not "SUM". +func TestFormulaMenuSearch(t *testing.T) { + got := filterFormulaGroups("total") + found := false + for _, g := range got { + for _, f := range g.Fns { + if f.Name == "SUM" { + found = true + } + } + } + if !found { + t.Error(`searching "total" did not find SUM — the description is not being matched`) + } + + // Empty groups drop out rather than leaving a bare heading behind. + for _, g := range filterFormulaGroups("sine") { + if len(g.Fns) == 0 { + t.Errorf("group %q survived the filter with no functions in it", g.Label) + } + } + + // An empty query is everything. + if len(filterFormulaGroups("")) != len(FormulaFunctionGroups) { + t.Error("an empty query should show every group") + } + // A query matching nothing yields nothing (the menu shows its own empty state). + if got := filterFormulaGroups("zzzz"); len(got) != 0 { + t.Errorf("a query matching nothing returned %d groups", len(got)) + } +} + +func TestFormulaFunctionNamesFlattensTheGroups(t *testing.T) { + names := FormulaFunctionNames() + if len(names) < 30 { + t.Errorf("flattened to %d names, want the full set", len(names)) + } + for _, want := range []string{"SUM", "ROUND", "ATAN2", "IF", "ROW"} { + if !contains2(names, want) { + t.Errorf("%s missing from the flattened names", want) + } + } +} diff --git a/go/webui/forms.go b/go/webui/forms.go index 03c121ed..380b231b 100644 --- a/go/webui/forms.go +++ b/go/webui/forms.go @@ -5,6 +5,7 @@ import ( "strings" "kjol/vdom" + "kjol/wasmruntime" ) // Port of web/kit/Forms.tsx. Reactive accessors collapse to plain values, and @@ -790,22 +791,234 @@ func FormTimezoneSelector(p FormSelectProps) *vdom.VNode { return FormSelect(p, opts...) } -// -- combobox / multi-select (approximated) ------------------------------------ +// -- combobox / multi-select --------------------------------------------------- // -// NOTE: FormCombobox, FormSearchableSelect and FormMultiSelect in the TSX open a -// solid-js Portal, measure the trigger with getBoundingClientRect + a -// requestAnimationFrame position tracker (floating-ui style), focus-trap the -// search box, filter options against the query, track a keyboard-highlighted -// index, and close on document mousedown. None of that has a neutral-runtime -// equivalent, so these ports: -// - take `Open bool` + `OnToggle func()` instead of an internal open signal, -// - render the dropdown inline, positioned with `absolute top-full` utilities -// rather than fixed computed coordinates (no Portal), -// - render ALL options (the search box is decorative — client-side filtering, -// keyboard nav, and highlight tracking are dropped), -// - keep selection fully functional through Value + OnChange. +// Both are built on the Floating controller, which is where their close behaviour +// comes from: an outside mousedown, and Escape closing the topmost panel only. The +// previous port took `Open bool` + `OnToggle func()` from the caller and had NEITHER +// — a dropdown, once open, stayed open until you clicked its trigger again. It also +// rendered its panel in-flow, so it was clipped by any scrolling ancestor, and its +// search box was decorative: it filtered nothing. +// +// # Where a multi-select differs from a combobox, deliberately +// +// A combobox picks ONE thing, so choosing closes it. A multi-select picks SEVERAL, so +// choosing does NOT — you are mid-selection, and closing the panel under you after +// each tick would make it unusable. That is why both are controllers over the same +// Floating but only one of them hides on select. +// +// Everything else they share: the panel is portaled to document.body (so it escapes +// overflow), positioned by measurement, flipped when it will not fit, and closed by an +// outside click or Escape. -// FormComboboxProps configures FormCombobox / FormSearchableSelect. +// dropdown is the shared half of Combobox and MultiSelect. +type dropdown struct { + f *Floating + + search *vdom.Signal[string] + searchRef *vdom.Ref + // active is the keyboard-highlighted option, as an index into the FILTERED list. + // -1 means nothing is highlighted. + active *vdom.Signal[int] +} + +// DropdownOptions configures NewCombobox / NewMultiSelect. +type DropdownOptions struct { + // Placement defaults to bottom-start. The panel flips above the field when there + // is not room below. + Placement string + OnOpenChange func(bool) +} + +func newDropdown(o DropdownOptions, closeOnSelect bool) *dropdown { + d := &dropdown{ + search: vdom.NewSignal(""), + searchRef: vdom.NewRef(), + active: vdom.NewSignal(-1), + } + d.f = NewFloating(FloatingOptions{ + Placement: pick(o.Placement, PlacementBottomStart), + Offset: 4, + // A long option list near the bottom of the page scrolls inside its own box + // rather than running off the screen. + ConstrainToViewport: true, + // Standalone: these open INSIDE other floatings (a filter popover, the calc + // editor's form). Without it the single-open manager would read the dropdown as + // a rival panel and close the very popover it belongs to. + Standalone: true, + OnOpenChange: func(open bool) { + // Closing abandons the query. Reopening to a stale filter — showing three of + // twenty options for no visible reason — is worse than retyping. + if !open { + d.search.Set("") + d.active.Set(-1) + } + if o.OnOpenChange != nil { + o.OnOpenChange(open) + } + }, + }) + _ = closeOnSelect + return d +} + +// IsOpen / Open / Close / Toggle drive the panel. +func (d *dropdown) IsOpen() bool { return d.f.IsOpen() } +func (d *dropdown) Open() { d.f.Show() } +func (d *dropdown) Close() { d.f.Hide() } +func (d *dropdown) Toggle() { d.f.Toggle() } + +// Dispose removes the panel's listeners. Call it if the component owning this +// dropdown goes away while the panel might still be open. +func (d *dropdown) Dispose() { d.f.Dispose() } + +// filter narrows the options by the search query — the box actually filters now. +// Matching is case-insensitive on the label, which is what the user can see. +func (d *dropdown) filter(opts []FormSelectOption) []FormSelectOption { + q := strings.TrimSpace(strings.ToLower(d.search.Get())) + if q == "" { + return opts + } + out := make([]FormSelectOption, 0, len(opts)) + for _, o := range opts { + if strings.Contains(strings.ToLower(o.Label), q) { + out = append(out, o) + } + } + return out +} + +// searchBox is the filter field at the top of the panel. It is focused when the panel +// opens, so a searchable dropdown can be driven entirely from the keyboard. +func (d *dropdown) searchBox(placeholder string, onDark bool, count int) *vdom.VNode { + wrapCls, inputCls := formDropdownSearchWrap, formDropdownSearchInput + if onDark { + wrapCls, inputCls = formDropdownSearchWrapDark, formDropdownSearchInputDark + } + + // Focus it once the panel is in the DOM. Scheduled rather than called: the input + // does not exist yet at the moment the signal that opens the panel is written. + wasmruntime.AfterRender(func() { wasmruntime.Focus(d.searchRef) }) + + return vdom.Div(vdom.Attr("class", wrapCls), + vdom.Input( + vdom.WithRef(d.searchRef), + vdom.Attr("type", "text"), + vdom.Attr("class", inputCls), + vdom.Attr("placeholder", pick(placeholder, "Search...")), + vdom.Attr("spellcheck", "false"), + vdom.Prop("value", d.search.Get()), + vdom.OnEvent(vdom.EVENT_INPUT, func(e vdom.Event) { + d.search.Set(e.Value()) + d.active.Set(-1) // the old highlight indexed a different list + }), + vdom.OnEvent(vdom.EVENT_KEYDOWN, func(e vdom.Event) { d.onSearchKey(e, count) }), + ), + ) +} + +// onSearchKey is arrow-key navigation over the filtered list. Escape is NOT handled +// here: Floating already closes the topmost panel on Escape, and handling it twice +// would close a dropdown and the popover around it with one press. +func (d *dropdown) onSearchKey(e vdom.Event, count int) { + switch e.Key() { + case vdom.KEY_ARROW_DOWN: + e.PreventDefault() + if count > 0 { + d.active.Set((d.active.Get() + 1) % count) + } + case vdom.KEY_ARROW_UP: + e.PreventDefault() + if count > 0 { + next := d.active.Get() - 1 + if next < 0 { + next = count - 1 + } + d.active.Set(next) + } + } +} + +// optionButton is one row of the panel. +func (d *dropdown) optionButton(opt FormSelectOption, idx int, selected, onDark, small bool, onClick func()) *vdom.VNode { + cls := formDropdownOption + if onDark { + cls = formDropdownOptionDark + } + if small { + cls = cx(cls, "py-1.5 px-2") + } + if idx == d.active.Get() { + cls = cx(cls, "bg-neutral-100") + } + + mods := []vdom.Mod{ + vdom.Attr("type", "button"), + vdom.Attr("class", cls), + vdom.Attr("role", "option"), + vdom.Attr("aria-selected", strconv.FormatBool(selected)), + } + if opt.Disabled { + mods = append(mods, vdom.Attr("disabled", "disabled")) + } else if onClick != nil { + mods = append(mods, vdom.On(vdom.EVENT_CLICK, onClick)) + } + return vdom.Button(append(mods, vdom.Text(opt.Label))...) +} + +func (d *dropdown) noResults(onDark bool) *vdom.VNode { + cls := formDropdownNoResults + if onDark { + cls = formDropdownNoResultsDark + } + return vdom.Div(vdom.Attr("class", cls), vdom.Text("No options found")) +} + +// panel wraps the dropdown's rows in the floating panel. Width is matched to the +// field so the options line up under it. +func (d *dropdown) panel(onDark bool, children ...*vdom.VNode) *vdom.VNode { + cls := formDropdown + if onDark { + cls = formDropdownDark + } + // The exact width is written by matchFieldWidth once the field has been measured; + // min-w-48 is only a floor, so a very narrow field still gets a readable list. + return d.f.Panel(FloatingPanelProps{ + Role: "listbox", + Class: cx("min-w-48", cls), + }, children...) +} + +// matchFieldWidth sizes the panel to the field it hangs off. Done imperatively, from +// a measurement, because the two are no longer DOM relatives: the panel is portaled to +// document.body, so it cannot simply be `w-full`. +func (d *dropdown) matchFieldWidth() { + wasmruntime.AfterRender(func() { + w := wasmruntime.Measure(d.f.triggerRef).Width + if w > 0 { + wasmruntime.SetStyle(d.f.panelRef, "width", px(w)) + } + }) +} + +// ---- Combobox: pick one ---- + +// Combobox is a single-select dropdown. Choosing an option closes it. +// +// Create it once, alongside your signals — never inside a render function, which +// would rebuild its refs and open state every frame: +// +// team := webui.NewCombobox(webui.DropdownOptions{}) +// … +// team.Render(webui.FormComboboxProps{Options: opts, Value: v.Get(), OnChange: v.Set}) +type Combobox struct{ *dropdown } + +// NewCombobox creates a single-select dropdown. +func NewCombobox(o DropdownOptions) *Combobox { + return &Combobox{dropdown: newDropdown(o, true)} +} + +// FormComboboxProps configures Combobox.Render. type FormComboboxProps struct { Options []FormSelectOption Value string @@ -819,13 +1032,10 @@ type FormComboboxProps struct { OnDark bool MaxDisplayLength int Disabled bool - // Open/OnToggle replace the internal open signal (see NOTE above). - Open bool - OnToggle func() } -// FormCombobox is a single-select dropdown styled like a listbox trigger. -func FormCombobox(p FormComboboxProps) *vdom.VNode { +// Render draws the field and its panel. +func (c *Combobox) Render(p FormComboboxProps) *vdom.VNode { var selected *FormSelectOption for i := range p.Options { if p.Options[i].Value == p.Value { @@ -840,13 +1050,7 @@ func FormCombobox(p FormComboboxProps) *vdom.VNode { placeholderCls = "text-text-on-dark-muted" } if selected != nil { - display = selected.Label - if p.MaxDisplayLength > 0 { - r := []rune(display) - if len(r) > p.MaxDisplayLength { - display = strings.TrimRight(string(r[:p.MaxDisplayLength]), " ") + "…" - } - } + display = truncateRunes(selected.Label, p.MaxDisplayLength) placeholderCls = "" } @@ -855,89 +1059,72 @@ func FormCombobox(p FormComboboxProps) *vdom.VNode { chevronCls = "text-text-on-dark-muted" } chevron := "chevron-down" - if p.Open { + if c.IsOpen() { chevron = "chevron-up" } - triggerMods := []vdom.Mod{ - vdom.Attr("type", "button"), - vdom.Attr("class", formTriggerCls(p.Small, p.OnDark)), + trigger := c.f.Trigger(FloatingTriggerProps{ + Class: formTriggerCls(p.Small, p.OnDark), + AriaHasPopup: "listbox", + }, vdom.Span(vdom.Attr("class", cx("min-w-0 truncate", placeholderCls)), vdom.Text(display)), IconInline(chevron, 16, chevronCls), - } + ) if p.Disabled { - triggerMods = append(triggerMods, vdom.Attr("disabled", "disabled")) - } - if p.OnToggle != nil { - triggerMods = append(triggerMods, vdom.On(vdom.EVENT_CLICK, p.OnToggle)) + trigger.Attrs["disabled"] = "disabled" } - rootMods := []vdom.Mod{ + filtered := c.filter(p.Options) + rows := make([]*vdom.VNode, 0, len(filtered)+1) + if p.Searchable && c.IsOpen() { + rows = append(rows, c.searchBox(p.SearchPlaceholder, p.OnDark, len(filtered))) + } + if len(filtered) == 0 { + rows = append(rows, c.noResults(p.OnDark)) + } + for i := range filtered { + opt := filtered[i] + rows = append(rows, c.optionButton(opt, i, opt.Value == p.Value, p.OnDark, p.Small, func() { + if p.OnChange != nil { + p.OnChange(opt.Value) + } + c.Close() // one choice, so we are done + })) + } + + if c.IsOpen() { + c.matchFieldWidth() + } + + return vdom.Div( vdom.Attr("class", formComboboxRootCls(p.Class, p.FieldWidth)), - vdom.Button(triggerMods...), - } - - if p.Open { - dropdownCls := formDropdown - if p.OnDark { - dropdownCls = formDropdownDark - } - ddMods := []vdom.Mod{vdom.Attr("class", cx("absolute left-0 top-full mt-1 z-50 w-full", dropdownCls))} - - if p.Searchable { - searchWrapCls := formDropdownSearchWrap - searchInputCls := formDropdownSearchInput - if p.OnDark { - searchWrapCls = formDropdownSearchWrapDark - searchInputCls = formDropdownSearchInputDark - } - ddMods = append(ddMods, vdom.Div(vdom.Attr("class", searchWrapCls), - vdom.Input(vdom.Attr("type", "text"), - vdom.Attr("class", searchInputCls), - vdom.Attr("placeholder", pick(p.SearchPlaceholder, "Search...")), - ), - )) - } - - optionCls := formDropdownOption - if p.OnDark { - optionCls = formDropdownOptionDark - } - if p.Small { - optionCls = cx(optionCls, "py-1.5 px-2") - } - - if len(p.Options) == 0 { - noResultsCls := formDropdownNoResults - if p.OnDark { - noResultsCls = formDropdownNoResultsDark - } - ddMods = append(ddMods, vdom.Div(vdom.Attr("class", noResultsCls), vdom.Text("No options found"))) - } - for _, opt := range p.Options { - ov := opt.Value - optMods := []vdom.Mod{vdom.Attr("type", "button"), vdom.Attr("class", optionCls)} - if opt.Disabled { - optMods = append(optMods, vdom.Attr("disabled", "disabled")) - } else if p.OnChange != nil { - oc := p.OnChange - optMods = append(optMods, vdom.On(vdom.EVENT_CLICK, func() { oc(ov) })) - } - optMods = append(optMods, vdom.Text(opt.Label)) - ddMods = append(ddMods, vdom.Button(optMods...)) - } - rootMods = append(rootMods, vdom.Div(ddMods...)) - } - return vdom.Div(rootMods...) + trigger, + c.panel(p.OnDark, rows...), + ) } -// FormSearchableSelect is a FormCombobox with the search box shown. -func FormSearchableSelect(p FormComboboxProps) *vdom.VNode { - p.Searchable = true - return FormCombobox(p) +// NewSearchableSelect is a Combobox with the search box on — the old +// FormSearchableSelect. +func NewSearchableSelect(o DropdownOptions) *Combobox { return NewCombobox(o) } + +// ---- MultiSelect: pick several ---- + +// MultiSelect is a multi-select dropdown with removable tags and an optional +// select-all row. +// +// Unlike a Combobox it does NOT close when an option is chosen: you are mid-selection, +// and closing the panel after each tick would make it useless. It closes on an outside +// click, on Escape, or when its trigger is clicked again. +// +// Create it once, alongside your signals — never inside a render function. +type MultiSelect struct{ *dropdown } + +// NewMultiSelect creates a multi-select dropdown. +func NewMultiSelect(o DropdownOptions) *MultiSelect { + return &MultiSelect{dropdown: newDropdown(o, false)} } -// FormMultiSelectProps configures FormMultiSelect. +// FormMultiSelectProps configures MultiSelect.Render. type FormMultiSelectProps struct { Options []FormSelectOption Value []string @@ -951,14 +1138,10 @@ type FormMultiSelectProps struct { Small bool Class string FieldWidth string - // Open/OnToggle replace the internal open signal (see NOTE above). - Open bool - OnToggle func() } -// FormMultiSelect is a multi-select dropdown with removable tags and an optional -// select-all row. Selection is functional through Value + OnChange. -func FormMultiSelect(p FormMultiSelectProps) *vdom.VNode { +// Render draws the field and its panel. +func (m *MultiSelect) Render(p FormMultiSelectProps) *vdom.VNode { maxTags := p.MaxTagsBeforeCollapse if maxTags == 0 { maxTags = 3 @@ -971,138 +1154,133 @@ func FormMultiSelect(p FormMultiSelectProps) *vdom.VNode { } } - // Trigger content: placeholder, tag list, or "N items selected". - var triggerContent *vdom.VNode + // The trigger's face: a placeholder, tags, or "N selected" once there are too many + // to show without the field growing unboundedly. + var face *vdom.VNode switch { case len(selected) == 0: - triggerContent = vdom.Span(vdom.Attr("class", "text-neutral-500"), vdom.Text(pick(p.Placeholder, "Select options"))) + face = vdom.Span(vdom.Attr("class", "text-neutral-500 truncate"), + vdom.Text(pick(p.Placeholder, "Select options"))) case len(selected) > maxTags: - n := len(selected) - suffix := "s" - if n == 1 { - suffix = "" - } - triggerContent = vdom.Span(vdom.Text(strconv.Itoa(n) + " item" + suffix + " selected")) + face = vdom.Span(vdom.Attr("class", "truncate"), + vdom.Text(strconv.Itoa(len(selected))+" selected")) default: - tagSize := "py-0.5 px-2 text-sm" - if p.Small { - tagSize = "py-0.5 px-1.5 text-xs" - } - tagCls := cx("inline-flex items-center gap-0.5 bg-neutral-200 rounded-default whitespace-nowrap leading-none", tagSize) - tagsMods := []vdom.Mod{vdom.Attr("class", "flex flex-nowrap gap-1 overflow-hidden items-center")} + tags := []vdom.Mod{vdom.Attr("class", "flex flex-wrap items-center gap-1 min-w-0")} for _, opt := range selected { ov := opt.Value - // NOTE: the remove button nests inside the trigger button (as in the TSX); - // the neutral Event has no stopPropagation, so a remove click also toggles - // the dropdown. Harmless given selection is idempotent through OnChange. - rmMods := []vdom.Mod{ + 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("aria-label", "Remove "+opt.Label), IconInline("xmark", 10, ""), } if p.OnChange != nil { - oc := p.OnChange - cur := p.Value - rmMods = append(rmMods, vdom.On(vdom.EVENT_CLICK, func() { oc(formRemoveStr(cur, ov)) })) + oc, cur := p.OnChange, p.Value + remove = append(remove, vdom.OnEvent(vdom.EVENT_CLICK, func(e vdom.Event) { + // The tag sits INSIDE the trigger, so without this the click would + // bubble up and toggle the panel open as it removed the tag. + e.StopPropagation() + oc(formRemoveStr(cur, ov)) + })) } - tag := vdom.Span(vdom.Attr("class", tagCls), + tags = append(tags, vdom.Span(vdom.Attr("class", formMultiSelectTag), vdom.Text(opt.Label), - vdom.Button(rmMods...), - ) - tagsMods = append(tagsMods, tag) + vdom.Button(remove...), + )) } - triggerContent = vdom.Div(tagsMods...) + face = vdom.Div(tags...) } pad := "p-2" if p.Small { pad = "p-1" } - triggerCls := cx(formTriggerBase, "overflow-hidden", formControlH(p.Small), pad) chevron := "chevron-down" - if p.Open { + if m.IsOpen() { chevron = "chevron-up" } - triggerMods := []vdom.Mod{ - vdom.Attr("type", "button"), - vdom.Attr("class", triggerCls), - vdom.Div(vdom.Attr("class", "flex-1 overflow-hidden flex items-center min-w-0"), triggerContent), + + trigger := m.f.Trigger(FloatingTriggerProps{ + Class: cx(formTriggerBase, "overflow-hidden", formControlH(p.Small), pad), + AriaHasPopup: "listbox", + }, + vdom.Div(vdom.Attr("class", "flex-1 overflow-hidden flex items-center min-w-0"), face), IconInline(chevron, 16, "text-neutral-400"), - } + ) if p.Disabled { - triggerMods = append(triggerMods, vdom.Attr("disabled", "disabled")) - } - if p.OnToggle != nil { - triggerMods = append(triggerMods, vdom.On(vdom.EVENT_CLICK, p.OnToggle)) + trigger.Attrs["disabled"] = "disabled" } - rootMods := []vdom.Mod{ - vdom.Attr("class", formComboboxRootCls(p.Class, p.FieldWidth)), - vdom.Button(triggerMods...), + 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.Open { - ddMods := []vdom.Mod{vdom.Attr("class", cx("absolute left-0 top-full mt-1 z-50 w-full", formDropdown))} - - if p.Searchable { - ddMods = append(ddMods, vdom.Div(vdom.Attr("class", formDropdownSearchWrap), - vdom.Input(vdom.Attr("type", "text"), - vdom.Attr("class", formDropdownSearchInput), - vdom.Attr("placeholder", pick(p.SearchPlaceholder, "Search...")), - ), - )) + // Select-all 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. + if p.ShowSelectAll && len(filtered) > 0 { + all := formAllSelected(filtered, p.Value) + label := "Select All" + if all { + label = "Deselect All" } - - if p.ShowSelectAll && len(p.Options) > 0 { - all := formAllSelected(p.Options, p.Value) - label := "Select All" - if all { - label = "Deselect All" - } - saMods := []vdom.Mod{vdom.Attr("type", "button"), vdom.Attr("class", formSelectAllBtn), vdom.Text(label)} - if p.OnChange != nil { - oc := p.OnChange - opts := p.Options - cur := p.Value - allNow := all - saMods = append(saMods, vdom.On(vdom.EVENT_CLICK, func() { - if allNow { - oc(formDeselectAll(opts, cur)) - } else { - oc(formSelectAllValues(opts, cur)) + 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) } - })) - } - ddMods = append(ddMods, vdom.Div(vdom.Attr("class", formSelectAllWrap), vdom.Button(saMods...))) + oc(next) + return + } + oc(formSelectAllValues(opts, cur)) + })) } - - if len(p.Options) == 0 { - ddMods = append(ddMods, vdom.Div(vdom.Attr("class", formDropdownNoResults), vdom.Text("No options found"))) - } - for _, opt := range p.Options { - ov := opt.Value - cbMods := []vdom.Mod{vdom.Attr("type", "checkbox"), vdom.Attr("style", "pointer-events:none")} - if formContainsStr(p.Value, opt.Value) { - cbMods = append(cbMods, vdom.Attr("checked", "checked")) - } - optMods := []vdom.Mod{ - vdom.Attr("type", "button"), - vdom.Attr("class", formDropdownOption), - vdom.Input(cbMods...), - vdom.Text(opt.Label), - } - if opt.Disabled { - optMods = append(optMods, vdom.Attr("disabled", "disabled")) - } else if p.OnChange != nil { - oc := p.OnChange - cur := p.Value - optMods = append(optMods, vdom.On(vdom.EVENT_CLICK, func() { oc(formToggleStr(cur, ov)) })) - } - ddMods = append(ddMods, vdom.Button(optMods...)) - } - rootMods = append(rootMods, vdom.Div(ddMods...)) + rows = append(rows, vdom.Button(mods...)) } - return vdom.Div(rootMods...) + + 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)) + } + // Deliberately NOT closing: this is a MULTI-select, and the user is very + // likely about to tick another one. + })) + } + + if m.IsOpen() { + m.matchFieldWidth() + } + + return vdom.Div( + vdom.Attr("class", formComboboxRootCls(p.Class, p.FieldWidth)), + trigger, + m.panel(false, rows...), + ) +} + +const formMultiSelectTag = "inline-flex items-center gap-1 rounded-default bg-neutral-100 px-1.5 py-0.5 text-xs text-neutral-700 max-w-full" + +func truncateRunes(s string, max int) string { + if max <= 0 { + return s + } + r := []rune(s) + if len(r) <= max { + return s + } + return strings.TrimRight(string(r[:max]), " ") + "…" } // -- []string selection helpers (for FormMultiSelect) -------------------------- diff --git a/go/webui/forms_dropdown_test.go b/go/webui/forms_dropdown_test.go new file mode 100644 index 00000000..acaeabd8 --- /dev/null +++ b/go/webui/forms_dropdown_test.go @@ -0,0 +1,221 @@ +package webui + +import ( + "strings" + "testing" + + "kjol/vdom" +) + +// fakeEvent is a vdom.Event with no DOM behind it — enough to drive Floating's +// close handlers natively. Target() is nil, which the outside-click test reads (quite +// correctly) as "this click was not inside the panel". +type fakeEvent struct{ key string } + +func (fakeEvent) PreventDefault() {} +func (fakeEvent) StopPropagation() {} +func (fakeEvent) Value() string { return "" } +func (fakeEvent) Checked() bool { return false } +func (e fakeEvent) Key() string { return e.key } +func (fakeEvent) ClientX() int { return 0 } +func (fakeEvent) ClientY() int { return 0 } +func (fakeEvent) Target() any { return nil } +func (fakeEvent) SetData(_, _ string) {} +func (fakeEvent) GetData(string) string { return "" } + +var _ vdom.Event = fakeEvent{} + +func dropdownOptions() []FormSelectOption { + return []FormSelectOption{ + {Value: "eng", Label: "Engineering"}, + {Value: "res", Label: "Research"}, + {Value: "net", Label: "Networking"}, + } +} + +// ---- closing ---- + +// The previous port took Open/OnToggle from the caller and had NO outside-click and +// NO Escape: once open, a dropdown stayed open until you clicked its trigger again. +// Both now come from Floating. + +func TestMultiSelectClosesOnOutsideClick(t *testing.T) { + m := NewMultiSelect(DropdownOptions{}) + m.Open() + if !m.IsOpen() { + t.Fatal("did not open") + } + + m.f.onOutside(fakeEvent{}) // a mousedown that landed outside the panel + if m.IsOpen() { + t.Error("a multi-select must close on an outside click") + } +} + +func TestMultiSelectClosesOnEscape(t *testing.T) { + m := NewMultiSelect(DropdownOptions{}) + m.Open() + + m.f.onKeydown(fakeEvent{key: vdom.KEY_ESCAPE}) + if m.IsOpen() { + t.Error("a multi-select must close on Escape") + } +} + +func TestComboboxClosesOnOutsideClickAndEscape(t *testing.T) { + c := NewCombobox(DropdownOptions{}) + + c.Open() + c.f.onOutside(fakeEvent{}) + if c.IsOpen() { + t.Error("a combobox must close on an outside click") + } + + c.Open() + c.f.onKeydown(fakeEvent{key: vdom.KEY_ESCAPE}) + if c.IsOpen() { + t.Error("a combobox must close on Escape") + } +} + +// Another key must NOT close it — otherwise typing in the search box would dismiss +// the thing you are searching. +func TestDropdownIgnoresOtherKeys(t *testing.T) { + m := NewMultiSelect(DropdownOptions{}) + m.Open() + m.f.onKeydown(fakeEvent{key: "a"}) + if !m.IsOpen() { + t.Error("an ordinary keypress closed the dropdown") + } +} + +// ---- where they differ, deliberately ---- + +// A combobox picks ONE thing, so choosing is the end of the interaction: it closes. +func TestComboboxClosesWhenAnOptionIsChosen(t *testing.T) { + c := NewCombobox(DropdownOptions{}) + got := "" + c.Open() + + // Render, then invoke the option's click handler the way the DOM would. + node := c.Render(FormComboboxProps{ + Options: dropdownOptions(), + OnChange: func(v string) { got = v }, + }) + clickOption(t, node, "Research") + + if got != "res" { + t.Errorf("OnChange got %q, want \"res\"", got) + } + if c.IsOpen() { + t.Error("a combobox should close once an option is chosen") + } +} + +// A multi-select picks SEVERAL, so choosing is NOT the end: closing the panel after +// every tick would make it unusable. This is the difference the two have to keep. +func TestMultiSelectStaysOpenWhenAnOptionIsChosen(t *testing.T) { + m := NewMultiSelect(DropdownOptions{}) + var got []string + m.Open() + + node := m.Render(FormMultiSelectProps{ + Options: dropdownOptions(), + Value: []string{}, + OnChange: func(v []string) { got = v }, + }) + clickOption(t, node, "Research") + + if len(got) != 1 || got[0] != "res" { + t.Errorf("OnChange got %v, want [res]", got) + } + if !m.IsOpen() { + t.Error("a multi-select must STAY OPEN when an option is ticked — you are mid-selection") + } +} + +// ---- the search box actually filters ---- +// +// It used to be decorative: it rendered, and filtered nothing. + +func TestDropdownSearchFilters(t *testing.T) { + m := NewMultiSelect(DropdownOptions{}) + m.Open() + + all := m.filter(dropdownOptions()) + if len(all) != 3 { + t.Fatalf("an empty query filtered to %d options, want all 3", len(all)) + } + + m.search.Set("res") + got := m.filter(dropdownOptions()) + if len(got) != 1 || got[0].Value != "res" { + t.Errorf("query \"res\" matched %v, want just Research", got) + } + + // Case-insensitive, on the label the user can actually see. + m.search.Set("NETWORK") + if got := m.filter(dropdownOptions()); len(got) != 1 || got[0].Value != "net" { + t.Errorf("query \"NETWORK\" matched %v, want Networking", got) + } +} + +// Closing abandons the query — reopening to a stale filter, showing one of three +// options for no visible reason, is worse than retyping. +func TestClosingClearsTheSearch(t *testing.T) { + m := NewMultiSelect(DropdownOptions{}) + m.Open() + m.search.Set("res") + + m.Close() + if m.search.Get() != "" { + t.Errorf("search survived the close: %q", m.search.Get()) + } +} + +// These open INSIDE other floatings (a filter popover, the calculated-column form). +// Without Standalone the single-open manager reads the dropdown as a rival panel and +// closes the very popover it lives in. +func TestDropdownsAreStandalone(t *testing.T) { + m := NewMultiSelect(DropdownOptions{}) + if !m.f.opts.Standalone { + t.Error("a dropdown must be Standalone, or opening it closes its own parent popover") + } +} + +// clickOption finds an option button by its label and invokes its click handler. +func clickOption(t *testing.T, n *vdom.VNode, label string) { + t.Helper() + if !findAndClick(n, label) { + t.Fatalf("no clickable option labelled %q in the rendered dropdown", label) + } +} + +func findAndClick(n *vdom.VNode, label string) bool { + if n == nil { + return false + } + if n.Tag == "button" && nodeText(n) == label { + if h := n.Events[vdom.EVENT_CLICK]; h != nil { + h(fakeEvent{}) + return true + } + } + for _, c := range n.Children { + if findAndClick(c, label) { + return true + } + } + return false +} + +func nodeText(n *vdom.VNode) string { + if n.Tag == "" { + return n.Text + } + var b strings.Builder + for _, c := range n.Children { + b.WriteString(nodeText(c)) + } + return b.String() +}