Compare commits

..

30 Commits

Author SHA1 Message Date
cad6688174 fix ampersand ordering bug for fuzzy matching 2026-07-21 10:49:50 -04:00
2c14831c35 Merge branch 'master' of git.cdrateline.dev:National-CD-Rateline/kjol 2026-07-21 10:34:44 -04:00
a167f24cd3 Add "and" "ampersand" substitution to fuzzy match 2026-07-21 10:34:40 -04:00
8a922cbed1 Changed default legend position 2026-07-20 16:51:14 -04:00
50bef85367 Merge branch 'master' of git.cdrateline.dev:National-CD-Rateline/kjol 2026-07-20 16:29:01 -04:00
ee84f10cf0 update 3d chart visualization 2026-07-20 16:28:55 -04:00
b0b281a831 Added legend positioning for Chart 2026-07-20 16:26:31 -04:00
de7a6c6bdb Added 1.0 pie chart color pallette as a constant 2026-07-20 12:59:47 -04:00
b9864864a6 Merge branch 'master' of git.cdrateline.dev:National-CD-Rateline/kjol 2026-07-20 12:06:02 -04:00
c0ddef5923 Add aria check program to go/cmd 2026-07-20 12:05:55 -04:00
cae2997009 Tweaked buttons for accessibility 2026-07-20 09:36:09 -04:00
03b5bea72d restructure theme css 2026-07-17 16:58:16 -04:00
35f0ea6db3 add tilt field to chart 2026-07-17 16:14:10 -04:00
93acf143f2 merge text-xs override 2026-07-17 12:52:50 -04:00
85d07f069e refactor folder structure 2026-07-17 12:52:05 -04:00
4f0418ea6e Reverted text-xs override. text-ss replaces the text-xs override while tailwind's existing text-xs replaces our text-2xs. 2026-07-17 12:19:20 -04:00
dda0e738d4 Fix/add footer row to AutoTable 2026-07-17 10:33:33 -04:00
f190f436c3 add timedotgo ts package for handling js time like go does 2026-07-17 09:56:36 -04:00
dacd67354f update test file for test reasons 2026-07-17 09:27:38 -04:00
d69a3ecffa Merge branch 'master' of git.cdrateline.dev:National-CD-Rateline/kjol 2026-07-16 16:26:13 -04:00
530fdf6f75 Update charts to include titles and legends 2026-07-16 16:26:06 -04:00
055dbe3004 AutoTable types + footer element 2026-07-16 16:22:59 -04:00
81f0c0624e update to use curveMonotoneX alg for smothing curves 2026-07-16 15:21:50 -04:00
093bad311e update heatmap, add wasm charts 2026-07-16 14:55:16 -04:00
2477c2d6a2 Update 3d chart mode, add US heatmap, move kjol-web -> kjol-website 2026-07-16 12:40:49 -04:00
550e97aa9b begin new custom chart implementation for js ui 2026-07-16 11:45:17 -04:00
13a60a90a1 fix hmr test errors 2026-07-16 09:24:21 -04:00
bf42426c0b deven broke tailwind 2026-07-15 15:30:08 -04:00
342de9e18a Manually merge from 'pre-kjol' into 'master' 2026-07-15 15:11:29 -04:00
f2e0d22255 Update test.txt 2026-07-15 14:43:52 -04:00
194 changed files with 10883 additions and 1253 deletions

18
.gitignore vendored
View File

@@ -84,20 +84,20 @@ wwwroot/public.bundle.min.js
internal/handlers/public_pages.gen.go internal/handlers/public_pages.gen.go
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# kjol-web (go/cmd/kjol-web) build output. # kjol-website (go/cmd/kjol-website) build output.
# #
# The rules above came from the application repos and every one of them contains # The rules above came from the application repos and every one of them contains
# a slash, which anchors it to the directory holding this .gitignore — the repo # a slash, which anchors it to the directory holding this .gitignore — the repo
# root. So `wwwroot/bundle.min.js` matches /wwwroot/..., and NOT the identical # root. So `wwwroot/bundle.min.js` matches /wwwroot/..., and NOT the identical
# artifact under go/cmd/kjol-web/wwwroot/. These are the same rules, re-anchored. # artifact under go/cmd/kjol-website/wwwroot/. These are the same rules, re-anchored.
# #
# Everything here is regenerated by `go run ./build`; none of it is authored. # Everything here is regenerated by `go run ./build`; none of it is authored.
go/cmd/kjol-web/wwwroot/bundle.min.* go/cmd/kjol-website/wwwroot/bundle.min.*
go/cmd/kjol-web/wwwroot/public.bundle.min.* go/cmd/kjol-website/wwwroot/public.bundle.min.*
go/cmd/kjol-web/wwwroot/app.css go/cmd/kjol-website/wwwroot/app.css
go/cmd/kjol-web/wwwroot/wasm_exec.js go/cmd/kjol-website/wwwroot/wasm_exec.js
go/cmd/kjol-web/wwwroot/vendor/ go/cmd/kjol-website/wwwroot/vendor/
go/cmd/kjol-web/frontend/src/ui/generated/ go/cmd/kjol-website/frontend/src/ui/generated/
go/cmd/kjol-web/internal/handlers/public_pages.gen.go go/cmd/kjol-website/internal/handlers/public_pages.gen.go
# kjol framework (git submodule; deny-list above would otherwise ignore it) # kjol framework (git submodule; deny-list above would otherwise ignore it)
!/kjol !/kjol

44
.vscode/launch.json vendored
View File

@@ -1,68 +1,78 @@
{ {
// Debug configs for kjol-web (the website). cwd is the site dir because the dev server // Debug configs for kjol-website (the website). cwd is the site dir because the dev server
// resolves ./wwwroot, ./app, ./wasm, ./frontend and the watched engine dirs relative // resolves ./wwwroot, ./app, ./wasm, ./frontend and the watched engine dirs relative
// to it. // to it.
// //
// Every dev-server config preLaunchTasks "kjol-web: prebuild" (codegen -> Tailwind -> // Every dev-server config preLaunchTasks "kjol-website: prebuild" (codegen -> Tailwind ->
// Solid bundle). Codegen must run before the server is COMPILED — it writes // Solid bundle). 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 // 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. // by Delve, not by the server's own Build hook, so nothing else would generate it.
//
// GOWORK=off on every config: kjol-website is its own module (module kjolwebsite, replace kjol
// => ../..). Inside a parent project the parent's go.work — which lists kjol/go but not
// kjolwebsite — shadows it, and the build fails "main module (...) does not contain package
// .../kjol-website/server". Off, Go uses kjolwebsite's own go.mod; standalone it is a no-op.
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"name": "kjol-web: dev server", "name": "kjol-website: dev server",
"type": "go", "type": "go",
"request": "launch", "request": "launch",
"mode": "auto", "mode": "auto",
"program": "${workspaceFolder}/go/cmd/kjol-web/server", "program": "${workspaceFolder}/go/cmd/kjol-website/server",
"cwd": "${workspaceFolder}/go/cmd/kjol-web", "cwd": "${workspaceFolder}/go/cmd/kjol-website",
"env": { "GOWORK": "off" },
"args": ["-addr", ":8085"], "args": ["-addr", ":8085"],
"preLaunchTask": "kjol-web: prebuild" "preLaunchTask": "kjol-website: prebuild"
}, },
{ {
"name": "kjol-web: dev server (no watch)", "name": "kjol-website: dev server (no watch)",
"type": "go", "type": "go",
"request": "launch", "request": "launch",
"mode": "auto", "mode": "auto",
"program": "${workspaceFolder}/go/cmd/kjol-web/server", "program": "${workspaceFolder}/go/cmd/kjol-website/server",
"cwd": "${workspaceFolder}/go/cmd/kjol-web", "cwd": "${workspaceFolder}/go/cmd/kjol-website",
"env": { "GOWORK": "off" },
// -watch=false skips the server's own Build entirely, so wwwroot is served exactly // -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 generated code, CSS and JS // as it sits on disk. The prebuild is what puts current generated code, CSS and JS
// bundles there; without it you would be debugging against stale artefacts — and // bundles there; without it you would be debugging against stale artefacts — and
// with no bundle at all, every /js/* page would come up blank. // with no bundle at all, every /js/* page would come up blank.
"args": ["-watch=false"], "args": ["-watch=false"],
"preLaunchTask": "kjol-web: prebuild" "preLaunchTask": "kjol-website: prebuild"
}, },
{ {
"name": "kjol-web: cold build (-build)", "name": "kjol-website: cold build (-build)",
"type": "go", "type": "go",
"request": "launch", "request": "launch",
"mode": "auto", "mode": "auto",
// The same binary, with the flag that runs every build step once and exits. There is // The same binary, with the flag that runs every build step once and exits. There is
// no separate ./build command: the steps are a library, because the server imports // no separate ./build command: the steps are a library, because the server imports
// them and Go will not let you import a main. // them and Go will not let you import a main.
"program": "${workspaceFolder}/go/cmd/kjol-web/server", "program": "${workspaceFolder}/go/cmd/kjol-website/server",
"cwd": "${workspaceFolder}/go/cmd/kjol-web", "cwd": "${workspaceFolder}/go/cmd/kjol-website",
"env": { "GOWORK": "off" },
"args": ["-build"] "args": ["-build"]
}, },
{ {
"name": "kjol-web: codegen (wasmgen)", "name": "kjol-website: codegen (wasmgen)",
"type": "go", "type": "go",
"request": "launch", "request": "launch",
"mode": "auto", "mode": "auto",
"program": "${workspaceFolder}/go/cmd/wasmgen", "program": "${workspaceFolder}/go/cmd/wasmgen",
"cwd": "${workspaceFolder}/go/cmd/kjol-web", "cwd": "${workspaceFolder}/go/cmd/kjol-website",
"env": { "GOWORK": "off" },
"args": ["./app"] "args": ["./app"]
}, },
{ {
"name": "kjol-web: bundle (Solid)", "name": "kjol-website: bundle (Solid)",
"type": "go", "type": "go",
"request": "launch", "request": "launch",
"mode": "auto", "mode": "auto",
// Debug the JS build itself — the Solid compiler, the Tailwind engine, the SSR bake. // Debug the JS build itself — the Solid compiler, the Tailwind engine, the SSR bake.
// All of it is Go, so all of it takes a breakpoint. // All of it is Go, so all of it takes a breakpoint.
"program": "${workspaceFolder}/go/cmd/bundle", "program": "${workspaceFolder}/go/cmd/bundle",
"cwd": "${workspaceFolder}/go/cmd/kjol-web", "cwd": "${workspaceFolder}/go/cmd/kjol-website",
"env": { "GOWORK": "off" },
"args": [ "args": [
"-app", "frontend", "-app", "frontend",
"-web", "../../jsruntime", "-web", "../../jsruntime",

54
.vscode/tasks.json vendored
View File

@@ -1,60 +1,66 @@
{ {
// Tasks for the kjol repo. // Tasks for the kjol repo.
// //
// kjol-web — the website, and the runnable example of both web layers — is a nested // kjol-website — the website, and the runnable example of both web layers — is a nested
// module at go/cmd/kjol-web, so its tasks set cwd there; the module-wide Go tasks run // module at go/cmd/kjol-website, so its tasks set cwd there; the module-wide Go tasks run
// in go/. // in go/.
// //
// These used to be called "gowasm: ...", which stopped being true: the site is two // These used to be called "gowasm: ...", which stopped being true: the site is two
// front-ends now, and one build compiles Go to WebAssembly AND bundles a Solid app. // front-ends now, and one build compiles Go to WebAssembly AND bundles a Solid app.
// They are "kjol-web: ..." to match the directory they act on. // They are "kjol-website: ..." to match the directory they act on.
// //
// The build pipeline is Go, not a shell script — see go/cmd/kjol-web/build. The dev // The build pipeline is Go, not a shell script — see go/cmd/kjol-website/build. The dev
// server calls the same functions on every save, and a bash script would not run for // server calls the same functions on every save, and a bash script would not run for
// anyone on Windows. Note there is no `./build` COMMAND any more: the steps are a // anyone on Windows. Note there is no `./build` COMMAND any more: the steps are a
// library (the server imports them, and Go will not let you import a main), so a cold // library (the server imports them, and Go will not let you import a main), so a cold
// build is `go run ./server -build`. // build is `go run ./server -build`.
// //
// "kjol-web: prebuild" is what every run/debug config depends on. Codegen has to happen // "kjol-website: prebuild" is what every run/debug config depends on. Codegen has to happen
// before the server is COMPILED — it writes app/*.gen.go, which the server imports — and // 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, // under the debugger the binary is built by Delve, not by the server's own Build hook,
// so nothing else would generate it. Tailwind and the Solid bundle are in there too, so // so nothing else would generate it. Tailwind and the Solid bundle are in there too, so
// that a -watch=false session serves current artefacts instead of stale ones. Each step // that a -watch=false session serves current artefacts instead of stale ones. Each step
// is also exposed on its own, so you can rerun just the one you need. // is also exposed on its own, so you can rerun just the one you need.
//
// The kjol-website tasks (cwd go/cmd/kjol-website) set GOWORK=off: kjol-website is its own module
// (module kjolwebsite, replace kjol => ../..), and inside a parent project the parent's
// go.work — which does not list kjolwebsite — would shadow it and the go command would fail
// "main module (...) does not contain package .../kjol-website/...". The kjol-module tasks
// (cwd go) keep the workspace, since kjol/go is what a parent's go.work does list.
"version": "2.0.0", "version": "2.0.0",
"tasks": [ "tasks": [
{ {
"label": "kjol-web: prebuild", "label": "kjol-website: prebuild",
"detail": "Codegen -> Tailwind -> Solid bundle. Dependency of the run + debug configs.", "detail": "Codegen -> Tailwind -> Solid bundle. Dependency of the run + debug configs.",
"dependsOrder": "sequence", "dependsOrder": "sequence",
"dependsOn": ["kjol-web: codegen", "kjol-web: tailwind", "kjol-web: bundle (Solid)"], "dependsOn": ["kjol-website: codegen", "kjol-website: tailwind", "kjol-website: bundle (Solid)"],
"problemMatcher": [], "problemMatcher": [],
"group": "build" "group": "build"
}, },
{ {
"label": "kjol-web: codegen", "label": "kjol-website: codegen",
"detail": "Regenerate app/*.gen.go from the //gowasm: directives (pages, layouts, server components)", "detail": "Regenerate app/*.gen.go from the //gowasm: directives (pages, layouts, server components)",
"type": "shell", "type": "shell",
"command": "go", "command": "go",
"args": ["run", "kjol/cmd/wasmgen", "./app"], "args": ["run", "kjol/cmd/wasmgen", "./app"],
"options": { "cwd": "${workspaceFolder}/go/cmd/kjol-web" }, "options": { "cwd": "${workspaceFolder}/go/cmd/kjol-website", "env": { "GOWORK": "off" } },
"problemMatcher": ["$go"], "problemMatcher": ["$go"],
"presentation": { "reveal": "silent", "panel": "shared" }, "presentation": { "reveal": "silent", "panel": "shared" },
"group": "build" "group": "build"
}, },
{ {
"label": "kjol-web: tailwind", "label": "kjol-website: tailwind",
"detail": "Compile css/app.css -> wwwroot/app.css, scanning the webui kit + the site's Go markup", "detail": "Compile css/app.css -> wwwroot/app.css, scanning the webui kit + the site's Go markup",
"type": "shell", "type": "shell",
"command": "go", "command": "go",
"args": [ "args": [
"run", "./cmd/twcss", "run", "./cmd/twcss",
"-entry", "cmd/kjol-web/css/app.css", "-entry", "cmd/kjol-website/css/app.css",
"-out", "cmd/kjol-web/wwwroot/app.css", "-out", "cmd/kjol-website/wwwroot/app.css",
"-base", ".", "-base", ".",
"webui/**/*.go", "webui/**/*.go",
"cmd/kjol-web/app/**/*.go", "cmd/kjol-website/app/**/*.go",
"cmd/kjol-web/server/**/*.go" "cmd/kjol-website/server/**/*.go"
], ],
// Run from the kjol module root so the Tailwind engine's deps resolve in kjol's // Run from the kjol module root so the Tailwind engine's deps resolve in kjol's
// go.mod, not the site's. // go.mod, not the site's.
@@ -64,7 +70,7 @@
"group": "build" "group": "build"
}, },
{ {
"label": "kjol-web: bundle (Solid)", "label": "kjol-website: bundle (Solid)",
"detail": "TSX -> Solid -> esbuild, plus the SSR bake: wwwroot/bundle.min.{js,css} + public.bundle.min.*", "detail": "TSX -> Solid -> esbuild, plus the SSR bake: wwwroot/bundle.min.{js,css} + public.bundle.min.*",
"type": "shell", "type": "shell",
"command": "go", "command": "go",
@@ -77,19 +83,19 @@
"-out", "wwwroot", "-out", "wwwroot",
"-gen-ts", "frontend/src/ui/generated" "-gen-ts", "frontend/src/ui/generated"
], ],
"options": { "cwd": "${workspaceFolder}/go/cmd/kjol-web" }, "options": { "cwd": "${workspaceFolder}/go/cmd/kjol-website", "env": { "GOWORK": "off" } },
"problemMatcher": ["$go"], "problemMatcher": ["$go"],
"presentation": { "reveal": "silent", "panel": "shared" }, "presentation": { "reveal": "silent", "panel": "shared" },
"group": "build" "group": "build"
}, },
{ {
"label": "kjol-web: dev server (hot reload)", "label": "kjol-website: dev server (hot reload)",
"detail": "SSR + /rsc + hot reload on :8085. Rebuilds on save; a .css save recompiles Tailwind only and swaps the stylesheet in place.", "detail": "SSR + /rsc + hot reload on :8085. Rebuilds on save; a .css save recompiles Tailwind only and swaps the stylesheet in place.",
"type": "shell", "type": "shell",
"command": "go", "command": "go",
"args": ["run", "./server"], "args": ["run", "./server"],
"options": { "cwd": "${workspaceFolder}/go/cmd/kjol-web" }, "options": { "cwd": "${workspaceFolder}/go/cmd/kjol-website", "env": { "GOWORK": "off" } },
"dependsOn": ["kjol-web: prebuild"], "dependsOn": ["kjol-website: prebuild"],
"isBackground": true, "isBackground": true,
"problemMatcher": { "problemMatcher": {
"owner": "go", "owner": "go",
@@ -107,18 +113,18 @@
"group": { "kind": "build", "isDefault": true } "group": { "kind": "build", "isDefault": true }
}, },
{ {
"label": "kjol-web: build (cold)", "label": "kjol-website: build (cold)",
"detail": "Cold build, then exit: codegen + Tailwind + wasm + Solid bundle + wasm_exec.js.", "detail": "Cold build, then exit: codegen + Tailwind + wasm + Solid bundle + wasm_exec.js.",
"type": "shell", "type": "shell",
"command": "go", "command": "go",
"args": ["run", "./server", "-build"], "args": ["run", "./server", "-build"],
"options": { "cwd": "${workspaceFolder}/go/cmd/kjol-web" }, "options": { "cwd": "${workspaceFolder}/go/cmd/kjol-website", "env": { "GOWORK": "off" } },
"problemMatcher": ["$go"], "problemMatcher": ["$go"],
"group": "build" "group": "build"
}, },
{ {
"label": "kjol: build ./...", "label": "kjol: build ./...",
"detail": "Build the whole kjol module (does not descend into the nested kjol-web module)", "detail": "Build the whole kjol module (does not descend into the nested kjol-website module)",
"type": "shell", "type": "shell",
"command": "go build ./...", "command": "go build ./...",
"options": { "cwd": "${workspaceFolder}/go" }, "options": { "cwd": "${workspaceFolder}/go" },
@@ -155,11 +161,11 @@
"group": "test" "group": "test"
}, },
{ {
"label": "kjol-web: test", "label": "kjol-website: test",
"detail": "The site's own tests (SSR, the icon registry, the AutoTable export bytes). Its own module, so `kjol: test ./...` does not reach it.", "detail": "The site's own tests (SSR, the icon registry, the AutoTable export bytes). Its own module, so `kjol: test ./...` does not reach it.",
"type": "shell", "type": "shell",
"command": "go test ./...", "command": "go test ./...",
"options": { "cwd": "${workspaceFolder}/go/cmd/kjol-web" }, "options": { "cwd": "${workspaceFolder}/go/cmd/kjol-website", "env": { "GOWORK": "off" } },
"problemMatcher": ["$go"], "problemMatcher": ["$go"],
"group": "test" "group": "test"
} }

View File

@@ -43,13 +43,28 @@ writes CSS, and the text is just as likely to be Go (the gowasm kit writes its m
Go and has no JS build at all). Keeping it in the bundler made every Go-only consumer 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. drag a JavaScript bundler along for a CSS file.
`tw` also owns the **shared design system** as CSS, in three files: `tw_theme.css` and
`tw_preflight.css` are Tailwind's own defaults, and `tw/kjol_theme.css` is the kjol
extension layer — the semantic tokens (`surface`/`line`/`ink`/…), the class-based `dark`
variant and the chart palette that BOTH kits name. `tw.CompileApp` stacks them: base
Tailwind → kjol extensions → the app's brand `style.css`. So neither kit ships the tokens
in its own tree; both inherit them from the compiler, and an app carries only brand.
### go/ — module `kjol` ### go/ — module `kjol`
Packages, imported as `kjol/<name>`: `appenv basic chrono config csv dbutil finance httputil Packages, imported as `kjol/<name>`: `appenv basic chrono config csv dbutil finance httputil
l4g lexer security snailmail validation jsbundler tw`, plus the **gowasm** web-UI engine (`vdom` l4g lexer security snailmail validation jsbundler tw`, plus the **gowasm** web-UI engine
`wasmruntime` `rsc` `wasmdevserver`, and `webui` — a Tailwind-styled component kit ported (`wasmruntime` and its sub-packages `wasmruntime/vdom` + `wasmruntime/rsc`, `wasmdevserver`,
from `jsruntime/uikit`; author components in pure Go compiled to WebAssembly; all and `webui` — a Tailwind-styled component kit ported from `jsruntime/uikit`; author
stdlib-only), and `cmd/{bundle,twcss,migrate,loc,passgen,typecheck,wasmgen}`. components in pure Go compiled to WebAssembly; all stdlib-only), and
`cmd/{bundle,twcss,migrate,loc,passgen,typecheck,wasmgen}`.
`vdom` and `rsc` are **sub-packages of `wasmruntime`** — imported as `kjol/wasmruntime/vdom`
and `kjol/wasmruntime/rsc` (the package names stay `vdom` / `rsc`, so call sites are still
`vdom.VNode`, `rsc.Mount`). They nest cleanly because `vdom` imports nothing and both `rsc`
and `wasmruntime` import only `vdom` — no child imports the parent, so there is no cycle.
`wasmgen` emits these import paths in the `*.gen.go` glue, so if they ever move again, its
templates move with them.
`jsbundler` is the **JS** build (TSX → Solid → esbuild + the goja SSR bake). It was called `jsbundler` is the **JS** build (TSX → Solid → esbuild + the goja SSR bake). It was called
`webbundler`. `webbundler`.
@@ -59,10 +74,10 @@ plus `HighlightGo` / `HighlightC`. It is a lexer and not a parser on purpose —
escaped plain text rather than failing, and an unknown language is not an error. It is outside escaped plain text rather than failing, and an unknown language is not an error. It is outside
`webui` because it touches no DOM. **Its output is Tailwind class names**, so any stylesheet that `webui` because it touches no DOM. **Its output is Tailwind class names**, so any stylesheet that
has to render a code block must scan `lexer/**/*.go` — a build that forgets to still compiles and has to render a code block must scan `lexer/**/*.go` — a build that forgets to still compiles and
just renders the snippet unstyled (see `cmd/kjol-web/build.Tailwind`). The C tables deliberately just renders the snippet unstyled (see `cmd/kjol-website/build.Tailwind`). The C tables deliberately
mirror `c/lexer/lexer_c.c`; add a type to one, add it to the other. mirror `c/lexer/lexer_c.c`; add a type to one, add it to the other.
**`cmd/kjol-web` is the website**: the landing page and documentation for the whole **`cmd/kjol-website` is the website**: the landing page and documentation for the whole
codebase, and the runnable example of both web layers. It is its own nested module (so its codebase, and the runnable example of both web layers. It is its own nested module (so its
go-chart / esbuild / goja deps stay out of kjol) and it is ONE server running TWO go-chart / esbuild / goja deps stay out of kjol) and it is ONE server running TWO
front-ends — `/wasm/*` is the Go→WebAssembly SPA, `/js/*` is the Solid SPA, and `/` is a front-ends — `/wasm/*` is the Go→WebAssembly SPA, `/js/*` is the Solid SPA, and `/` is a
@@ -117,8 +132,8 @@ tokens).
`@custom-variant dark (&:where(.dark, .dark *));` is required — the built-in `dark` variant is a `@custom-variant dark (&:where(.dark, .dark *));` is required — the built-in `dark` variant is a
`prefers-color-scheme` media query, which a site with its own switch cannot use (the OS says one `prefers-color-scheme` media query, which a site with its own switch cannot use (the OS says one
thing, the switch says another, and the media query wins). For gowasm the app defines the tokens thing, the switch says another, and the media query wins). For gowasm the app defines the tokens
(see `webui.ThemeTokens`); for the Solid kit `jsruntime/styles/theme.css` defines them and the (see `webui.ThemeTokens`); for the Solid kit `tw/kjol_theme.css` defines them and the Tailwind
bundler prepends it, so the app's `style.css` carries brand only. engine prepends it (`tw.CompileApp`), so the app's `style.css` carries brand only.
Controllers: `webui.Theme` (Go) and `jsruntime/uikit/Theme.tsx` (Solid) — both read the same Controllers: `webui.Theme` (Go) and `jsruntime/uikit/Theme.tsx` (Solid) — both read the same
`kjol-theme` localStorage key, so a preference survives crossing between two front-ends. `kjol-theme` localStorage key, so a preference survives crossing between two front-ends.
@@ -135,12 +150,8 @@ get a white flash until the bundle loads. It is the only hand-written JavaScript
AutoTable must vendor them or the bundle fails to evaluate at all. AutoTable must vendor them or the bundle fails to evaluate at all.
- `icons/` — FontAwesome SVG source kit (the bundler scans usage and generates a per-app - `icons/` — FontAwesome SVG source kit (the bundler scans usage and generates a per-app
registry; the generated file is app-owned, not committed here). kjol ships only the SUBSET its registry; the generated file is app-owned, not committed here). kjol ships only the SUBSET its
own kit + `kjol-web` reference. An app's own `frontend/icons` is searched FIRST, so an app with own kit + `kjol-website` reference. An app's own `frontend/icons` is searched FIRST, so an app with
a fuller kit keeps it — see `jsbundler.iconsDirs`. a fuller kit keeps it — see `jsbundler.iconsDirs`.
- `styles/theme.css` — the `@theme` scaffold, the semantic tokens, the `.dark` overrides, and the
`:root` fa vars. **It does the `@import "tailwindcss"`**, because the bundler PREPENDS it to the
app's `style.css` and an `@import` has to come first. An app adopting the shared tree therefore
drops that import from its own stylesheet and keeps only brand.
- `auth/ utils/ hooks/ ssr/ env.ts basic.ts finance.ts superfun.ts types.d.ts` — generic TS - `auth/ utils/ hooks/ ssr/ env.ts basic.ts finance.ts superfun.ts types.d.ts` — generic TS
scaffolding. Apps import as `@kjol/*`. (Concrete permission constants stay app-side.) scaffolding. Apps import as `@kjol/*`. (Concrete permission constants stay app-side.)
@@ -181,7 +192,7 @@ class out in full.
| `appenv` | compile-time environment via build tags (`-tags staging` / `-tags production`); the bundler reads `appenv.Environment` for the JS `__ENV_TYPE__` define | | `appenv` | compile-time environment via build tags (`-tags staging` / `-tags production`); the bundler reads `appenv.Environment` for the JS `__ENV_TYPE__` define |
| `httputil.CorsMiddleware(CorsConfig{...})` | allowed domains + bundle-version source injected | | `httputil.CorsMiddleware(CorsConfig{...})` | allowed domains + bundle-version source injected |
| `wasmdevserver` | the app injects `Build` / `Render` / `Document` / `Handle` via `Config` | | `wasmdevserver` | the app injects `Build` / `Render` / `Document` / `Handle` via `Config` |
| `jsbundler` public pages | kjol generates the registry (`public_pages.gen.go`); the APP owns the `publicPage` type it is written against, and the document shell. See `cmd/kjol-web/internal/handlers`. | | `jsbundler` public pages | kjol generates the registry (`public_pages.gen.go`); the APP owns the `publicPage` type it is written against, and the document shell. See `cmd/kjol-website/internal/handlers`. |
## Stays app-side (never moves into kjol) ## Stays app-side (never moves into kjol)

308
go/cmd/aria-check/check.go Normal file
View File

@@ -0,0 +1,308 @@
package main
// check.go is the contrast checker proper: it takes the scanned class groups and the
// resolver, works out which foreground/background pairs actually co-occur (per theme
// and per variant state), resolves and composites their colours, and measures each
// pair against the WCAG threshold.
//
// The variant model is deliberately conservative. Only two contexts are evaluated:
// the resting light appearance and the resting dark appearance. `dark:` re-points a
// token (and re-points the whole variable environment), so it is a real second
// appearance worth checking. Interaction and pseudo states (hover:, focus:, group-*,
// data-*, …) are transient and are skipped rather than guessed at — pairing a
// hover-only background with a resting text colour invents an element that never
// renders. Responsive prefixes (sm:, md:, …) are stripped, since they change *when* a
// utility applies, not its colour.
import "strings"
// Options tunes the check.
type Options struct {
Level string // "AA" or "AAA"
MinOverride float64 // if > 0, the required ratio for normal-size text
AssumeSurface bool // check foreground-only groups against the page surface
}
// Finding is one evaluated foreground/background pair.
type Finding struct {
File string
Line int
Snip string
Theme theme
FG, BG string // the tokens ("" BG means the assumed page surface)
FGColor RGBA
BGColor RGBA
Ratio float64
Required float64
Large bool
Pass bool
}
func (f Finding) ThemeName() string {
if f.Theme == dark {
return "dark"
}
return "light"
}
// Check evaluates every group and returns all pairs (passing and failing); callers
// filter by Pass for reporting.
func Check(groups []ClassGroup, r *Resolver, opt Options) []Finding {
var out []Finding
for _, g := range groups {
out = append(out, checkGroup(g, r, opt)...)
}
return out
}
func checkGroup(g ClassGroup, r *Resolver, opt Options) []Finding {
var baseBG, baseFG, darkBG, darkFG []string
for _, tok := range g.Tokens {
cat := variantCategory(tok)
if cat == ctxSkip {
continue
}
switch r.side(tok) {
case "bg":
if cat == ctxDark {
darkBG = append(darkBG, tok)
} else {
baseBG = append(baseBG, tok)
darkBG = append(darkBG, tok) // a base bg also applies in dark unless overridden
}
case "fg":
if cat == ctxDark {
darkFG = append(darkFG, tok)
} else {
baseFG = append(baseFG, tok)
darkFG = append(darkFG, tok)
}
}
}
// A dark: override replaces the base for that property: if the group names any
// dark: background, only those apply in dark (and likewise for text).
if hasDarkOverride(g.Tokens, "bg") {
darkBG = onlyDark(g.Tokens, "bg", r)
}
if hasDarkOverride(g.Tokens, "fg") {
darkFG = onlyDark(g.Tokens, "fg", r)
}
large := isLargeText(g.Tokens)
required := requiredRatio(opt, large)
var out []Finding
seen := map[string]bool{} // dedupe identical (theme-independent) pairs
eval := func(t theme, bgs, fgs []string) {
if len(fgs) == 0 {
return
}
// Resolve the backgrounds to concrete backdrops. A background that resolves
// fully transparent (bg-transparent, or a token that is transparent in this
// theme) carries no contrast information — the real backdrop is an ancestor
// we cannot see — so it drops out. If nothing usable is left, this is the
// foreground-only case, checked against the page surface only under
// -assume-surface.
type backdropColor struct {
tok string
color RGBA
}
var bds []backdropColor
for _, bg := range bgs {
if c, ok := backdrop(r, bg, t); ok {
bds = append(bds, backdropColor{bg, c})
}
}
if len(bds) == 0 {
if !opt.AssumeSurface {
return
}
if s, ok := r.surface(t); ok {
bds = append(bds, backdropColor{"", s})
}
}
for _, bd := range bds {
bg, bgColor := bd.tok, bd.color
for _, fg := range fgs {
fgColor, ok := r.resolveToken(fg, t)
if !ok || fgColor.A == 0 {
continue // transparent / currentcolor / inherit: nothing to measure
}
ratio := contrastRatio(fgColor.over(bgColor), bgColor)
// Suppress a duplicate dark finding when the pair is a pure static
// colour (identical resolution in both themes) — it is already
// reported for light.
key := fg + "|" + bg + "|" + rgbaKey(fgColor) + "|" + rgbaKey(bgColor)
if seen[key] {
continue
}
seen[key] = true
out = append(out, Finding{
File: g.File, Line: g.Line, Snip: g.Snip, Theme: t,
FG: fg, BG: bg, FGColor: fgColor, BGColor: bgColor,
Ratio: ratio, Required: required, Large: large,
Pass: ratio+1e-9 >= required,
})
}
}
}
eval(light, baseBG, baseFG)
eval(dark, darkBG, darkFG)
return out
}
// backdrop resolves the background colour a foreground sits on. A fully transparent
// background is not a usable backdrop (ok=false) — its real colour comes from an
// ancestor we cannot resolve statically. A partially translucent background (e.g.
// bg-black/30) does tint what is behind it, so it is flattened onto the page surface,
// the best available assumption for the ancestor.
func backdrop(r *Resolver, bg string, t theme) (RGBA, bool) {
if bg == "" {
return r.surface(t)
}
c, ok := r.resolveToken(bg, t)
if !ok || c.A == 0 {
return RGBA{}, false
}
if c.Opaque() {
return c, true
}
surf, ok := r.surface(t)
if !ok {
return RGBA{}, false
}
return c.over(surf), true
}
// variant classification --------------------------------------------------
type ctxKind int
const (
ctxBase ctxKind = iota // resting appearance, applies light + dark
ctxDark // dark: only
ctxSkip // an interaction/pseudo state — not a resting appearance
)
// responsiveVariants change *when* a utility applies, not its colour, so they are
// transparent to pairing.
var responsiveVariants = map[string]bool{
"sm": true, "md": true, "lg": true, "xl": true, "2xl": true,
"xs": true, "ultrawide": true, "portrait": true, "landscape": true,
"motion-safe": true, "motion-reduce": true, "print": true, "rtl": true, "ltr": true,
}
// variantCategory decides which resting context (if any) a token belongs to.
func variantCategory(token string) ctxKind {
variants, _ := splitVariants(token)
hasDark := false
for _, v := range variants {
v = strings.TrimPrefix(v, "max-") // max-md: etc. are still responsive
if responsiveVariants[v] {
continue
}
if v == "dark" {
hasDark = true
continue
}
return ctxSkip // hover:, focus:, group-*, data-*, and anything unrecognised
}
if hasDark {
return ctxDark
}
return ctxBase
}
func hasDarkOverride(tokens []string, side string) bool {
for _, tok := range tokens {
if variantCategory(tok) != ctxDark {
continue
}
if sideOf(tok) == side {
return true
}
}
return false
}
func onlyDark(tokens []string, side string, r *Resolver) []string {
var out []string
for _, tok := range tokens {
if variantCategory(tok) == ctxDark && r.side(tok) == side {
out = append(out, tok)
}
}
return out
}
// sideOf classifies a token by its base utility prefix alone (no engine lookup),
// used where we only need bg-vs-fg intent.
func sideOf(token string) string {
_, base := splitVariants(token)
switch {
case strings.HasPrefix(base, "bg-"):
return "bg"
case strings.HasPrefix(base, "text-"):
return "fg"
}
return ""
}
// thresholds --------------------------------------------------------------
func requiredRatio(opt Options, large bool) float64 {
if strings.EqualFold(opt.Level, "AAA") {
if large {
return 4.5
}
return 7.0
}
// AA
if large {
return 3.0
}
if opt.MinOverride > 0 {
return opt.MinOverride
}
return 4.5
}
// isLargeText applies the WCAG large-text rule (≥24px, or ≥18.66px when bold) using
// Tailwind's default font-size scale. Sizes an app has overridden in its theme are
// not reflected here, so this is a best-effort classification.
func isLargeText(tokens []string) bool {
px := 16.0 // default body size if no size utility is present
bold := false
for _, tok := range tokens {
if variantCategory(tok) == ctxSkip {
continue
}
_, base := splitVariants(tok)
if sz, ok := fontSizePx[strings.TrimPrefix(base, "text-")]; ok && strings.HasPrefix(base, "text-") {
if sz > px {
px = sz
}
}
switch base {
case "font-bold", "font-extrabold", "font-black":
bold = true
}
}
return px >= 24 || (px >= 18.66 && bold)
}
// fontSizePx is Tailwind's default type scale (rem × 16), plus kjol's --text-ss.
var fontSizePx = map[string]float64{
"ss": 12.8, "xs": 12, "sm": 14, "base": 16, "lg": 18, "xl": 20,
"2xl": 24, "3xl": 30, "4xl": 36, "5xl": 48, "6xl": 60,
"7xl": 72, "8xl": 96, "9xl": 128,
}
func rgbaKey(c RGBA) string {
q := func(v float64) byte { return byte(clamp01(v) * 255) }
return string([]byte{q(c.R), q(c.G), q(c.B), q(c.A)})
}

View File

@@ -0,0 +1,175 @@
package main
import "testing"
// newTestResolver compiles the kjol theme layer (no app brand) against a fixed token
// set. baseDir "." is fine — nothing in the entry @imports a local file.
func newTestResolver(t *testing.T, tokens ...string) *Resolver {
t.Helper()
r, err := NewResolver("", ".", tokens)
if err != nil {
t.Fatalf("NewResolver: %v", err)
}
return r
}
func TestResolverSemanticTokens(t *testing.T) {
r := newTestResolver(t, "bg-surface", "text-ink", "text-white", "bg-red-500")
// Semantic surface: white in light, near-black in dark.
if c, ok := r.resolveToken("bg-surface", light); !ok || hexOf(c) != "#ffffff" {
t.Errorf("bg-surface light = %v (%s), want #ffffff", ok, hexOf(c))
}
if c, ok := r.resolveToken("bg-surface", dark); !ok || hexOf(c) != "#101013" {
t.Errorf("bg-surface dark = %v (%s), want #101013", ok, hexOf(c))
}
// text-white chases var(--color-white) → #fff.
if c, ok := r.resolveToken("text-white", light); !ok || hexOf(c) != "#ffffff" {
t.Errorf("text-white = %v (%s), want #ffffff", ok, hexOf(c))
}
// The palette OKLCH resolves to Tailwind's published hex.
if c, ok := r.resolveToken("bg-red-500", light); !ok || hexOf(c) != "#fb2c36" {
t.Errorf("bg-red-500 = %v (%s), want #fb2c36", ok, hexOf(c))
}
}
func TestResolverSideClassification(t *testing.T) {
r := newTestResolver(t, "bg-red-500", "text-ink", "flex", "text-sm", "p-4")
cases := map[string]string{
"bg-red-500": "bg",
"text-ink": "fg",
"flex": "", // not a colour utility
"text-sm": "", // font-size, not a colour
"p-4": "",
}
for tok, want := range cases {
if got := r.side(tok); got != want {
t.Errorf("side(%q) = %q, want %q", tok, got, want)
}
}
}
func TestResolverOpacityModifier(t *testing.T) {
r := newTestResolver(t, "bg-white/50")
c, ok := r.resolveToken("bg-white/50", light)
if !ok {
t.Fatal("bg-white/50 did not resolve")
}
approx(t, "bg-white/50 alpha", c.A, 0.5, 0.02)
}
func TestResolverArbitraryValue(t *testing.T) {
r := newTestResolver(t, "bg-[#123456]")
c, ok := r.resolveToken("bg-[#123456]", light)
if !ok || hexOf(c) != "#123456" {
t.Errorf("bg-[#123456] = %v (%s), want #123456", ok, hexOf(c))
}
}
func TestResolverDarkVariantToken(t *testing.T) {
r := newTestResolver(t, "dark:bg-surface")
// A dark: token resolves against the dark environment.
if c, ok := r.resolveToken("dark:bg-surface", dark); !ok || hexOf(c) != "#101013" {
t.Errorf("dark:bg-surface (dark) = %v (%s), want #101013", ok, hexOf(c))
}
}
// End to end: white-on-white fails; the semantic surface/ink pair passes in both
// themes (the two tokens move together, so dark mode stays legible).
func TestCheckGroupContrast(t *testing.T) {
r := newTestResolver(t, "bg-white", "text-white", "bg-surface", "text-ink")
// White on white: identical in both themes, so it collapses to one finding.
fail := checkGroup(ClassGroup{
File: "x.tsx", Line: 1, Tokens: []string{"bg-white", "text-white"},
}, r, Options{Level: "AA"})
if len(fail) != 1 || fail[0].Pass {
t.Fatalf("white-on-white should fail once, got %+v", fail)
}
// Surface/ink: light and dark are both checked (colours differ per theme) and
// both must pass.
pass := checkGroup(ClassGroup{
File: "x.tsx", Line: 2, Tokens: []string{"bg-surface", "text-ink"},
}, r, Options{Level: "AA"})
if len(pass) == 0 {
t.Fatal("surface/ink produced no findings")
}
for _, f := range pass {
if !f.Pass {
t.Errorf("surface/ink should pass in %s, got %.2f:1", f.ThemeName(), f.Ratio)
}
}
}
// A fixed palette background paired with an inverting semantic text token is a real
// dark-mode trap: bg-white stays white while text-ink climbs to near-white.
func TestCheckGroupFixedVsSemanticDarkTrap(t *testing.T) {
r := newTestResolver(t, "bg-white", "text-ink")
got := checkGroup(ClassGroup{
File: "x.tsx", Line: 1, Tokens: []string{"bg-white", "text-ink"},
}, r, Options{Level: "AA"})
var lightPass, darkFail bool
for _, f := range got {
if f.Theme == light && f.Pass {
lightPass = true
}
if f.Theme == dark && !f.Pass {
darkFail = true
}
}
if !lightPass || !darkFail {
t.Errorf("expected light pass + dark fail, got %+v", got)
}
}
// Scanner: a comment apostrophe ("panel's") must not open a string literal and
// swallow the class constants below it — the desync that produced dozens of bogus
// cross-paired findings.
func TestLexerSkipsCommentApostrophe(t *testing.T) {
src := []byte("// ModalSize selects the panel's max width.\n" +
"const a = \"text-white\"\n" +
"const b = \"bg-surface\"\n")
lits := extractLiterals(src)
if len(lits) != 2 {
t.Fatalf("expected 2 literals, got %d: %+v", len(lits), lits)
}
if lits[0].content != "text-white" || lits[1].content != "bg-surface" {
t.Errorf("unexpected literal contents: %+v", lits)
}
}
func TestLexerSingleQuoteExpressionPosition(t *testing.T) {
// Apostrophe in JSX text is not a string; a single-quoted attribute value is.
src := []byte("<p>don't click</p>\n<a class='bg-red-500 text-white'>x</a>\n")
lits := extractLiterals(src)
found := false
for _, l := range lits {
if l.content == "bg-red-500 text-white" {
found = true
}
}
if !found {
t.Errorf("single-quoted class attribute not extracted: %+v", lits)
}
}
func TestSplitVariants(t *testing.T) {
cases := []struct {
token string
base string
nvar int
}{
{"bg-red-500", "bg-red-500", 0},
{"dark:bg-surface", "bg-surface", 1},
{"dark:hover:text-ink", "text-ink", 2},
{"text-[color:red]", "text-[color:red]", 0}, // ':' inside [] is not a variant sep
}
for _, c := range cases {
v, base := splitVariants(c.token)
if base != c.base || len(v) != c.nvar {
t.Errorf("splitVariants(%q) = %v,%q; want %d variants, base %q", c.token, v, base, c.nvar, c.base)
}
}
}

436
go/cmd/aria-check/color.go Normal file
View File

@@ -0,0 +1,436 @@
package main
// color.go is the colour engine: it parses every colour syntax Tailwind can emit
// into one target space — gamma-encoded sRGB with an alpha channel — and from there
// computes the WCAG 2.x relative luminance and contrast ratio exactly as WebAIM's
// checker does (https://webaim.org/resources/contrastchecker/).
//
// sRGB is the target space on purpose. WCAG defines luminance in terms of sRGB, so
// converting there once means the contrast maths is a single well-specified formula
// and never depends on which syntax a colour was written in. The palette is authored
// in OKLCH, the semantic tokens in hex, and an app can drop an oklab()/rgb()/hsl()
// literal into an arbitrary value — all of them land here as an RGBA before any
// contrast is computed.
import (
"math"
"strconv"
"strings"
)
// RGBA is a colour in gamma-encoded sRGB. Channels and alpha are all in [0,1].
// This is aria-check's single internal colour representation — the "target
// colorspace" every parser converts into.
type RGBA struct {
R, G, B, A float64
}
// Opaque reports whether the colour needs no compositing.
func (c RGBA) Opaque() bool { return c.A >= 1 }
// over composites c (the source) onto an opaque backdrop using the standard
// source-over rule, in gamma space. WCAG contrast is only defined for opaque
// colours, so a translucent foreground or a translucent surface must be flattened
// against what sits behind it before its luminance means anything. Compositing in
// gamma-encoded sRGB (rather than linear) is the approximation browsers and the
// WebAIM checker effectively use.
func (c RGBA) over(bg RGBA) RGBA {
if c.Opaque() {
return c
}
a := c.A
return RGBA{
R: c.R*a + bg.R*(1-a),
G: c.G*a + bg.G*(1-a),
B: c.B*a + bg.B*(1-a),
A: 1,
}
}
// luminance is the WCAG relative luminance of an (assumed opaque) colour: linearise
// each sRGB channel, then weight. This is byte-for-byte the WebAIM formula, including
// its 0.03928 threshold.
func (c RGBA) luminance() float64 {
lin := func(ch float64) float64 {
if ch <= 0.03928 {
return ch / 12.92
}
return math.Pow((ch+0.055)/1.055, 2.4)
}
return 0.2126*lin(c.R) + 0.7152*lin(c.G) + 0.0722*lin(c.B)
}
// contrastRatio returns the WCAG contrast ratio between two opaque colours, in
// [1, 21]. Order does not matter. Callers must composite any translucency away first
// (see over) — this treats both colours as fully opaque.
func contrastRatio(a, b RGBA) float64 {
la, lb := a.luminance(), b.luminance()
if la < lb {
la, lb = lb, la
}
return (la + 0.05) / (lb + 0.05)
}
func clamp01(v float64) float64 {
if v < 0 {
return 0
}
if v > 1 {
return 1
}
return v
}
// parseLiteralColor parses a self-contained colour literal — one that names no CSS
// variable and is not a color-mix() (those are resolved in theme.go, which has the
// variable environment). It returns ok=false for anything it cannot turn into a
// concrete colour, including the deliberately-unresolvable keywords `currentcolor`,
// `inherit`, `transparent` (transparent is a real colour but alpha 0, handled here).
func parseLiteralColor(s string) (RGBA, bool) {
s = strings.TrimSpace(s)
if s == "" {
return RGBA{}, false
}
lower := strings.ToLower(s)
switch {
case strings.HasPrefix(s, "#"):
return parseHex(s)
case strings.HasPrefix(lower, "rgb"):
return parseRGBFunc(s)
case strings.HasPrefix(lower, "hsl"):
return parseHSLFunc(s)
case strings.HasPrefix(lower, "oklch("):
return parseOKLCH(s)
case strings.HasPrefix(lower, "oklab("):
return parseOKLab(s)
}
if c, ok := namedColors[lower]; ok {
return c, true
}
return RGBA{}, false
}
func parseHex(s string) (RGBA, bool) {
h := strings.TrimPrefix(s, "#")
// Expand shorthand #rgb / #rgba to full byte pairs.
switch len(h) {
case 3, 4:
var sb strings.Builder
for _, r := range h {
sb.WriteRune(r)
sb.WriteRune(r)
}
h = sb.String()
case 6, 8:
default:
return RGBA{}, false
}
val, err := strconv.ParseUint(h, 16, 64)
if err != nil {
return RGBA{}, false
}
c := RGBA{A: 1}
if len(h) == 8 {
c.R = float64((val>>24)&0xff) / 255
c.G = float64((val>>16)&0xff) / 255
c.B = float64((val>>8)&0xff) / 255
c.A = float64(val&0xff) / 255
} else {
c.R = float64((val>>16)&0xff) / 255
c.G = float64((val>>8)&0xff) / 255
c.B = float64(val&0xff) / 255
}
return c, true
}
// funcArgs splits the inside of a colour function into its space/comma-separated
// components and an optional trailing alpha introduced by `/`. Both the legacy
// comma syntax and the modern space syntax are accepted.
func funcArgs(s string) (parts []string, alpha string) {
open := strings.IndexByte(s, '(')
close := strings.LastIndexByte(s, ')')
if open < 0 || close < 0 || close < open {
return nil, ""
}
body := s[open+1 : close]
body = strings.ReplaceAll(body, ",", " ")
if i := strings.IndexByte(body, '/'); i >= 0 {
alpha = strings.TrimSpace(body[i+1:])
body = body[:i]
}
return strings.Fields(body), alpha
}
// numOrPct parses a number that may be a percentage. A percentage is scaled by
// pctBase (255 for rgb channels, 1 for alpha, 0.4 for oklab/oklch a/b/chroma).
func numOrPct(s string, pctBase float64) (float64, bool) {
s = strings.TrimSpace(s)
if s == "" || s == "none" {
return 0, true
}
if pct, ok := strings.CutSuffix(s, "%"); ok {
v, err := strconv.ParseFloat(pct, 64)
if err != nil {
return 0, false
}
return v / 100 * pctBase, true
}
v, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0, false
}
return v, true
}
func parseAlpha(s string) float64 {
if s == "" {
return 1
}
if v, ok := numOrPct(s, 1); ok {
return clamp01(v)
}
return 1
}
func parseRGBFunc(s string) (RGBA, bool) {
parts, alpha := funcArgs(s)
if len(parts) < 3 {
return RGBA{}, false
}
r, ok1 := numOrPct(parts[0], 255)
g, ok2 := numOrPct(parts[1], 255)
b, ok3 := numOrPct(parts[2], 255)
if !ok1 || !ok2 || !ok3 {
return RGBA{}, false
}
a := 1.0
if len(parts) >= 4 {
a = parseAlpha(parts[3])
} else if alpha != "" {
a = parseAlpha(alpha)
}
return RGBA{clamp01(r / 255), clamp01(g / 255), clamp01(b / 255), a}, true
}
func parseHSLFunc(s string) (RGBA, bool) {
parts, alpha := funcArgs(s)
if len(parts) < 3 {
return RGBA{}, false
}
h, ok1 := parseAngle(parts[0])
sat, ok2 := numOrPct(parts[1], 1) // percentage → [0,1]
l, ok3 := numOrPct(parts[2], 1)
if !ok1 || !ok2 || !ok3 {
return RGBA{}, false
}
a := 1.0
if len(parts) >= 4 {
a = parseAlpha(parts[3])
} else if alpha != "" {
a = parseAlpha(alpha)
}
r, g, b := hslToRGB(h, clamp01(sat), clamp01(l))
return RGBA{r, g, b, a}, true
}
func parseAngle(s string) (float64, bool) {
s = strings.TrimSpace(strings.ToLower(s))
s = strings.TrimSuffix(s, "deg")
if s == "none" {
return 0, true
}
v, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0, false
}
return v, true
}
func hslToRGB(h, s, l float64) (float64, float64, float64) {
h = math.Mod(math.Mod(h, 360)+360, 360) / 360
if s == 0 {
return l, l, l
}
var q float64
if l < 0.5 {
q = l * (1 + s)
} else {
q = l + s - l*s
}
p := 2*l - q
hue := func(t float64) float64 {
if t < 0 {
t++
}
if t > 1 {
t--
}
switch {
case t < 1.0/6:
return p + (q-p)*6*t
case t < 1.0/2:
return q
case t < 2.0/3:
return p + (q-p)*(2.0/3-t)*6
default:
return p
}
}
return hue(h + 1.0/3), hue(h), hue(h - 1.0/3)
}
func parseOKLCH(s string) (RGBA, bool) {
parts, alpha := funcArgs(s)
if len(parts) < 3 {
return RGBA{}, false
}
l, ok1 := numOrPct(parts[0], 1) // L: % → [0,1], or already 0..1
c, ok2 := numOrPct(parts[1], 0.4)
h, ok3 := parseAngle(parts[2])
if !ok1 || !ok2 || !ok3 {
return RGBA{}, false
}
a := 1.0
if len(parts) >= 4 {
a = parseAlpha(parts[3])
} else if alpha != "" {
a = parseAlpha(alpha)
}
rad := h * math.Pi / 180
return oklabToRGBA(l, c*math.Cos(rad), c*math.Sin(rad), a), true
}
func parseOKLab(s string) (RGBA, bool) {
parts, alpha := funcArgs(s)
if len(parts) < 3 {
return RGBA{}, false
}
l, ok1 := numOrPct(parts[0], 1)
aa, ok2 := numOrPct(parts[1], 0.4)
bb, ok3 := numOrPct(parts[2], 0.4)
if !ok1 || !ok2 || !ok3 {
return RGBA{}, false
}
alp := 1.0
if len(parts) >= 4 {
alp = parseAlpha(parts[3])
} else if alpha != "" {
alp = parseAlpha(alpha)
}
return oklabToRGBA(l, aa, bb, alp), true
}
// oklabToRGBA is Björn Ottosson's OKLab → linear sRGB transform, followed by the
// sRGB transfer function and a gamut clamp. Out-of-gamut OKLCH colours (the palette
// has a few) clamp per channel, which is what a browser paints too.
func oklabToRGBA(L, a, b, alpha float64) RGBA {
l_ := L + 0.3963377774*a + 0.2158037573*b
m_ := L - 0.1055613458*a - 0.0638541728*b
s_ := L - 0.0894841775*a - 1.2914855480*b
l := l_ * l_ * l_
m := m_ * m_ * m_
s := s_ * s_ * s_
lr := +4.0767416621*l - 3.3077115913*m + 0.2309699292*s
lg := -1.2684380046*l + 2.6097574011*m - 0.3413193965*s
lb := -0.0041960863*l - 0.7034186147*m + 1.7076147010*s
return RGBA{
R: clamp01(linearToSRGB(lr)),
G: clamp01(linearToSRGB(lg)),
B: clamp01(linearToSRGB(lb)),
A: alpha,
}
}
func linearToSRGB(c float64) float64 {
if c <= 0.0031308 {
return 12.92 * c
}
return 1.055*math.Pow(c, 1/2.4) - 0.055
}
// mixOKLab evaluates the two-colour case of CSS color-mix() in the oklab space,
// which is the form Tailwind emits for an opacity modifier
// (`color-mix(in oklab, <color> P%, transparent)`). Weights are normalised and the
// interpolation is alpha-premultiplied, matching the CSS spec closely enough for a
// contrast estimate.
func mixOKLab(c1 RGBA, w1 float64, c2 RGBA, w2 float64) RGBA {
if w1+w2 == 0 {
return c1
}
total := w1 + w2
w1 /= total
w2 /= total
l1, a1, b1 := rgbaToOKLab(c1)
l2, a2, b2 := rgbaToOKLab(c2)
// Premultiply the lab coordinates by alpha, interpolate, then un-premultiply.
pa := c1.A*w1 + c2.A*w2
L := (l1*c1.A*w1 + l2*c2.A*w2)
A := (a1*c1.A*w1 + a2*c2.A*w2)
B := (b1*c1.A*w1 + b2*c2.A*w2)
if pa > 0 {
L /= pa
A /= pa
B /= pa
}
return oklabToRGBA(L, A, B, pa)
}
// rgbaToOKLab inverts oklabToRGBA (sRGB → linear → OKLab), needed by mixOKLab.
func rgbaToOKLab(c RGBA) (L, a, b float64) {
lr := srgbToLinear(c.R)
lg := srgbToLinear(c.G)
lb := srgbToLinear(c.B)
l := 0.4122214708*lr + 0.5363325363*lg + 0.0514459929*lb
m := 0.2119034982*lr + 0.6806995451*lg + 0.1073969566*lb
s := 0.0883024619*lr + 0.2817188376*lg + 0.6299787005*lb
l_ := math.Cbrt(l)
m_ := math.Cbrt(m)
s_ := math.Cbrt(s)
return 0.2104542553*l_ + 0.7936177850*m_ - 0.0040720468*s_,
1.9779984951*l_ - 2.4285922050*m_ + 0.4505937099*s_,
0.0259040371*l_ + 0.7827717662*m_ - 0.8086757660*s_
}
func srgbToLinear(c float64) float64 {
if c <= 0.04045 {
return c / 12.92
}
return math.Pow((c+0.055)/1.055, 2.4)
}
// namedColors covers the CSS keywords likely to appear in an arbitrary value or a
// hand-written token. It is deliberately not the full 148-name list; extend as real
// usage demands. `transparent` is a real value (alpha 0); `currentcolor` and
// `inherit` are intentionally absent — they cannot be resolved statically.
var namedColors = map[string]RGBA{
"transparent": {0, 0, 0, 0},
"white": {1, 1, 1, 1},
"black": {0, 0, 0, 1},
"red": {1, 0, 0, 1},
"green": {0, 128.0 / 255, 0, 1},
"blue": {0, 0, 1, 1},
"yellow": {1, 1, 0, 1},
"cyan": {0, 1, 1, 1},
"aqua": {0, 1, 1, 1},
"magenta": {1, 0, 1, 1},
"fuchsia": {1, 0, 1, 1},
"gray": {128.0 / 255, 128.0 / 255, 128.0 / 255, 1},
"grey": {128.0 / 255, 128.0 / 255, 128.0 / 255, 1},
"silver": {192.0 / 255, 192.0 / 255, 192.0 / 255, 1},
"maroon": {128.0 / 255, 0, 0, 1},
"olive": {128.0 / 255, 128.0 / 255, 0, 1},
"lime": {0, 1, 0, 1},
"teal": {0, 128.0 / 255, 128.0 / 255, 1},
"navy": {0, 0, 128.0 / 255, 1},
"purple": {128.0 / 255, 0, 128.0 / 255, 1},
"orange": {1, 165.0 / 255, 0, 1},
}

View File

@@ -0,0 +1,94 @@
package main
import (
"math"
"testing"
)
func approx(t *testing.T, name string, got, want, tol float64) {
t.Helper()
if math.Abs(got-want) > tol {
t.Errorf("%s: got %.4f, want %.4f (±%.4f)", name, got, want, tol)
}
}
// The contrast ratios below are the values WebAIM's checker reports for the same
// colour pairs — the reference this tool is meant to match.
func TestContrastRatioKnownValues(t *testing.T) {
white := RGBA{1, 1, 1, 1}
black := RGBA{0, 0, 0, 1}
approx(t, "white/black", contrastRatio(white, black), 21.0, 0.01)
gray767676, _ := parseLiteralColor("#767676") // WebAIM's canonical AA-passing grey on white
approx(t, "#767676/white", contrastRatio(gray767676, white), 4.54, 0.03)
red, _ := parseLiteralColor("#ff0000")
approx(t, "red/white", contrastRatio(red, white), 4.0, 0.03)
blue, _ := parseLiteralColor("#0000ff")
approx(t, "blue/white", contrastRatio(blue, white), 8.59, 0.03)
}
// OKLCH is the palette's authored space; the whole checker depends on converting it
// to sRGB correctly. red-500 is oklch(63.7% 0.237 25.331) and Tailwind publishes it
// as #fb2c36.
func TestParseOKLCHMatchesTailwindHex(t *testing.T) {
c, ok := parseLiteralColor("oklch(63.7% 0.237 25.331)")
if !ok {
t.Fatal("failed to parse oklch red-500")
}
want, _ := parseHex("#fb2c36")
approx(t, "R", c.R, want.R, 2.0/255)
approx(t, "G", c.G, want.G, 2.0/255)
approx(t, "B", c.B, want.B, 2.0/255)
}
func TestParseLiteralColorForms(t *testing.T) {
cases := []struct {
in string
r, g, b, a float64
}{
{"#fff", 1, 1, 1, 1},
{"#ffffff", 1, 1, 1, 1},
{"#ff000080", 1, 0, 0, 128.0 / 255},
{"rgb(255, 0, 0)", 1, 0, 0, 1},
{"rgb(255 0 0 / 50%)", 1, 0, 0, 0.5},
{"rgba(0, 0, 255, 0.25)", 0, 0, 1, 0.25},
{"hsl(0 100% 50%)", 1, 0, 0, 1},
{"hsl(120, 100%, 50%)", 0, 1, 0, 1},
{"oklab(0 0 0)", 0, 0, 0, 1},
{"white", 1, 1, 1, 1},
{"transparent", 0, 0, 0, 0},
}
for _, c := range cases {
got, ok := parseLiteralColor(c.in)
if !ok {
t.Errorf("%q: failed to parse", c.in)
continue
}
approx(t, c.in+" R", got.R, c.r, 0.01)
approx(t, c.in+" G", got.G, c.g, 0.01)
approx(t, c.in+" B", got.B, c.b, 0.01)
approx(t, c.in+" A", got.A, c.a, 0.01)
}
}
func TestUnresolvableKeywords(t *testing.T) {
for _, kw := range []string{"currentcolor", "inherit", "unset", "var(--x)"} {
if _, ok := parseLiteralColor(kw); ok {
t.Errorf("%q should not resolve as a literal colour", kw)
}
}
}
// A translucent foreground must be flattened onto its backdrop before its contrast
// means anything.
func TestCompositeOver(t *testing.T) {
fg := RGBA{0, 0, 0, 0.5} // 50% black
bg := RGBA{1, 1, 1, 1} // white
got := fg.over(bg)
approx(t, "composited grey", got.R, 0.5, 0.001)
if !got.Opaque() {
t.Error("composited colour should be opaque")
}
}

228
go/cmd/aria-check/main.go Normal file
View File

@@ -0,0 +1,228 @@
// Command aria-check is a static accessibility linter for kjol front-ends. It reads
// source (Solid .tsx and gowasm .go alike — anything that authors Tailwind classes as
// string literals) and reports problems that can be caught without a browser.
//
// The first and only check today is COLOUR CONTRAST, implemented against the same
// algorithm as WebAIM's contrast checker (https://webaim.org/resources/contrastchecker/):
// WCAG 2.x relative luminance in sRGB, ratio (L1+0.05)/(L2+0.05).
//
// How it works:
//
// 1. Scan sources for string literals that hold class lists, keeping the utilities
// that co-occur in one literal together (contrast is about a foreground and a
// background on the SAME element — see scan.go).
// 2. Compile every bg-/text- token through kjol's own Tailwind engine (package tw)
// and read back the colours it resolves — palette OKLCH, hex tokens, semantic
// tokens, opacity modifiers and arbitrary values all included (theme.go).
// 3. For each co-occurring foreground/background pair, in both the light and dark
// appearances, composite away any translucency and measure the ratio against the
// WCAG threshold (check.go, color.go).
//
// Because the tokens are resolved by the real engine, aria-check stays correct as the
// design system changes: it never hard-codes a colour.
//
// Usage (globs/roots are relative to -base; omit them to scan the whole base):
//
// aria-check -base . -entry app/style.css frontend
// aria-check -base kjol/go/webui -level AAA
// aria-check -assume-surface -json .
//
// Exit status is non-zero when any contrast failure is found (unless -warn), so it
// drops straight into CI or a pre-commit hook.
//
// Extending it: contrast is one Checker; the scan + resolve scaffolding is meant to
// carry more. Natural next checks that are equally static-friendly — missing alt text
// on images, controls with no accessible label, heading-order jumps, redundant/absent
// ARIA roles — would each add a pass over the same file walk. See the closing notes in
// the repository discussion for the full list.
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
func main() {
var (
base = flag.String("base", ".", "root directory to scan and resolve -entry against")
entry = flag.String("entry", "", "app brand Tailwind entry stylesheet (optional; kjol's theme layer is always included)")
level = flag.String("level", "AA", "WCAG conformance level: AA or AAA")
min = flag.Float64("min", 0, "override the required ratio for normal-size text (AA only)")
exts = flag.String("ext", strings.Join(DefaultExts, ","), "comma-separated source file extensions to scan")
assumeSurface = flag.Bool("assume-surface", false, "also check text-only elements against the page surface colour")
asJSON = flag.Bool("json", false, "emit findings as JSON")
verbose = flag.Bool("v", false, "also report passing pairs")
warn = flag.Bool("warn", false, "always exit 0, even when failures are found")
)
flag.Parse()
opt := Options{Level: *level, MinOverride: *min, AssumeSurface: *assumeSurface}
groups, err := ScanTree(*base, flag.Args(), splitExts(*exts))
if err != nil {
fatal(err)
}
var entryCSS string
if *entry != "" {
b, err := os.ReadFile(*entry)
if err != nil {
fatal(err)
}
entryCSS = string(b)
}
resolver, err := NewResolver(entryCSS, *base, ColorCandidates(groups))
if err != nil {
fatal(fmt.Errorf("compiling theme: %w", err))
}
findings := Check(groups, resolver, opt)
sort.Slice(findings, func(i, j int) bool {
if findings[i].File != findings[j].File {
return findings[i].File < findings[j].File
}
if findings[i].Line != findings[j].Line {
return findings[i].Line < findings[j].Line
}
return findings[i].Ratio < findings[j].Ratio
})
failures := 0
for _, f := range findings {
if !f.Pass {
failures++
}
}
if *asJSON {
reportJSON(findings, *verbose)
} else {
reportText(findings, failures, len(groups), *verbose)
}
if failures > 0 && !*warn {
os.Exit(1)
}
}
func reportText(findings []Finding, failures, groups int, verbose bool) {
filesWithFail := map[string]bool{}
for _, f := range findings {
if f.Pass && !verbose {
continue
}
if !f.Pass {
filesWithFail[f.File] = true
}
fmt.Println(formatFinding(f))
}
fmt.Println()
if failures == 0 {
fmt.Printf("aria-check: no contrast failures (%d class groups scanned)\n", groups)
return
}
fmt.Printf("aria-check: %d contrast failure(s) across %d file(s) — %d class groups scanned\n",
failures, len(filesWithFail), groups)
}
func formatFinding(f Finding) string {
verdict := "FAIL"
if f.Pass {
verdict = "ok "
}
size := "normal"
if f.Large {
size = "large"
}
bg := f.BG
if bg == "" {
bg = "surface"
}
rel, err := filepath.Rel(".", f.File)
if err != nil {
rel = f.File
}
return fmt.Sprintf("%s:%d: %s %s %.2f:1 (need %.1f:1) %s on %s [%s %s]\n %s → %s %q",
rel, f.Line, verdict, f.ThemeName(), f.Ratio, f.Required,
f.FG, bg, f.ThemeName(), size,
hexOf(f.FGColor), hexOf(f.BGColor), truncate(f.Snip, 90))
}
type jsonFinding struct {
File string `json:"file"`
Line int `json:"line"`
Theme string `json:"theme"`
FG string `json:"fg"`
BG string `json:"bg"`
FGColor string `json:"fgColor"`
BGColor string `json:"bgColor"`
Ratio float64 `json:"ratio"`
Required float64 `json:"required"`
Large bool `json:"large"`
Pass bool `json:"pass"`
}
func reportJSON(findings []Finding, verbose bool) {
out := make([]jsonFinding, 0, len(findings))
for _, f := range findings {
if f.Pass && !verbose {
continue
}
bg := f.BG
if bg == "" {
bg = "surface"
}
out = append(out, jsonFinding{
File: f.File, Line: f.Line, Theme: f.ThemeName(),
FG: f.FG, BG: bg, FGColor: hexOf(f.FGColor), BGColor: hexOf(f.BGColor),
Ratio: round2(f.Ratio), Required: f.Required, Large: f.Large, Pass: f.Pass,
})
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
_ = enc.Encode(out)
}
func hexOf(c RGBA) string {
to := func(v float64) int { return int(clamp01(v)*255 + 0.5) }
if c.A < 1 {
return fmt.Sprintf("#%02x%02x%02x%02x", to(c.R), to(c.G), to(c.B), to(c.A))
}
return fmt.Sprintf("#%02x%02x%02x", to(c.R), to(c.G), to(c.B))
}
func round2(v float64) float64 { return float64(int(v*100+0.5)) / 100 }
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n-1] + "…"
}
func splitExts(s string) []string {
var out []string
for _, e := range strings.Split(s, ",") {
e = strings.TrimSpace(e)
if e == "" {
continue
}
if !strings.HasPrefix(e, ".") {
e = "." + e
}
out = append(out, strings.ToLower(e))
}
return out
}
func fatal(err error) {
fmt.Fprintln(os.Stderr, "aria-check:", err)
os.Exit(2)
}

253
go/cmd/aria-check/scan.go Normal file
View File

@@ -0,0 +1,253 @@
package main
// scan.go finds where colours are paired. The engine's own scanner (tw.Scan)
// flattens a source tree into a flat set of candidate classes, which is right for
// compiling CSS but wrong for contrast: contrast is a property of a foreground and a
// background that appear *together* on one element, and flattening throws the
// pairing away.
//
// So we do our own pass. The grouping unit is a single string literal: a class list
// is written as one string — `class="… bg-primary text-white …"`, or in Go
// `vdom.Attr("class", "…")` — and the utilities inside one literal are the ones that
// land on the same element. We pull each literal out with its line number, and hand
// its tokens on for classification. Colours split across two literals (a `clsx`-style
// merge) are not paired; that is the known limitation of a static, per-literal view.
import (
"io/fs"
"os"
"path/filepath"
"regexp"
"strings"
)
// ClassGroup is one string literal that plausibly holds a class list, with the
// tokens found inside it and where it lives.
type ClassGroup struct {
File string
Line int
Snip string // the literal's content, trimmed for display
Tokens []string
}
// looksLikeUtility is a cheap pre-filter: a literal is only interesting if it holds a
// token that could be a bg-/text- colour utility (optionally behind variants).
var looksLikeUtility = regexp.MustCompile(`(^|\s)([a-z0-9-]+:)*(bg|text)-`)
// DefaultExts are the source kinds a kjol/Tailwind project authors classes in.
var DefaultExts = []string{".tsx", ".ts", ".jsx", ".js", ".html", ".go"}
// ignoredDirs are never worth scanning and are often huge.
var ignoredDirs = map[string]bool{
".git": true, "node_modules": true, "vendor": true, "dist": true,
"build": true, "wwwroot": true, ".cache": true, "testdata": true,
}
// ScanTree walks roots (each relative to base, or base itself if none) and returns
// every class-list literal found in files whose extension is in exts.
func ScanTree(base string, roots []string, exts []string) ([]ClassGroup, error) {
extSet := map[string]bool{}
for _, e := range exts {
extSet[e] = true
}
if len(roots) == 0 {
roots = []string{"."}
}
var groups []ClassGroup
seenFile := map[string]bool{}
for _, root := range roots {
start := filepath.Join(base, root)
err := filepath.WalkDir(start, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
if ignoredDirs[d.Name()] {
return fs.SkipDir
}
return nil
}
if !extSet[strings.ToLower(filepath.Ext(path))] {
return nil
}
if seenFile[path] {
return nil
}
seenFile[path] = true
g, err := scanFile(path)
if err != nil {
return err
}
groups = append(groups, g...)
return nil
})
if err != nil {
return nil, err
}
}
return groups, nil
}
func scanFile(path string) ([]ClassGroup, error) {
src, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var groups []ClassGroup
for _, lit := range extractLiterals(src) {
if !looksLikeUtility.MatchString(lit.content) {
continue
}
groups = append(groups, ClassGroup{
File: path,
Line: lit.line,
Snip: collapse(lit.content),
Tokens: strings.Fields(lit.content),
})
}
return groups, nil
}
// literal is one string literal's content and the line it started on.
type literal struct {
content string
line int
}
// extractLiterals pulls string literals out of Go/TS/JS/HTML source with a small
// state machine. It skips `//` and `/* */` comments — the source of the nastiest
// desync, where an apostrophe in a comment ("panel's") or an unbalanced quote pairs
// with a delimiter far below and swallows unrelated code — and honours backslash
// escapes inside strings.
//
// Single-quoted strings are only opened in expression position (after an operator or
// opener, or an attribute `=`). That keeps apostrophes in JSX/HTML text (`don't`) and
// Go rune-in-prose from being read as string starts, while still catching real
// single-quoted class lists (`class='…'`, `cond ? 'a' : 'b'`).
func extractLiterals(src []byte) []literal {
var out []literal
n := len(src)
line := 1
var prev byte // most recent non-whitespace byte, for the '-in-expression test
for i := 0; i < n; {
c := src[i]
// Comments.
if c == '/' && i+1 < n && src[i+1] == '/' {
for i < n && src[i] != '\n' {
i++
}
continue
}
if c == '/' && i+1 < n && src[i+1] == '*' {
i += 2
for i+1 < n && !(src[i] == '*' && src[i+1] == '/') {
if src[i] == '\n' {
line++
}
i++
}
i += 2
continue
}
// String literals.
if c == '"' || c == '`' || (c == '\'' && exprPosition(prev)) {
quote := c
startLine := line
i++
var b strings.Builder
for i < n {
ch := src[i]
if ch == '\\' && i+1 < n {
b.WriteByte(ch)
b.WriteByte(src[i+1])
if src[i+1] == '\n' {
line++
}
i += 2
continue
}
if ch == quote {
i++
break
}
if ch == '\n' {
line++
}
b.WriteByte(ch)
i++
}
out = append(out, literal{content: b.String(), line: startLine})
prev = quote
continue
}
if c == '\n' {
line++
}
if c != ' ' && c != '\t' && c != '\r' && c != '\n' {
prev = c
}
i++
}
return out
}
// exprPosition reports whether a `'` following prev begins a string literal (rather
// than being an apostrophe in text or a Go rune after a value). prev is the previous
// non-whitespace byte; 0 means start of file.
func exprPosition(prev byte) bool {
switch prev {
case 0, '(', '[', '{', ',', ':', ';', '=', '?', '>', '<', '&', '|', '!', '+', '-', '*', '/', '~', '^', '%', '\\':
return true
}
return false
}
// collapse squeezes whitespace (including the newlines a multi-line class string may
// contain) to single spaces for a compact one-line display.
func collapse(s string) string {
return strings.Join(strings.Fields(s), " ")
}
// ColorCandidates returns the de-duplicated set of tokens across all groups that are
// shaped like a bg-/text- colour utility (optionally variant-prefixed). This is the
// candidate list handed to the Tailwind engine for compilation.
func ColorCandidates(groups []ClassGroup) []string {
set := map[string]bool{}
for _, g := range groups {
for _, tok := range g.Tokens {
if _, base := splitVariants(tok); strings.HasPrefix(base, "bg-") || strings.HasPrefix(base, "text-") {
set[tok] = true
}
}
}
out := make([]string, 0, len(set))
for tok := range set {
out = append(out, tok)
}
return out
}
// splitVariants separates a token's variant prefixes from its base utility. The base
// is the segment after the last top-level `:` — but a `:` inside an arbitrary value
// (`text-[color:red]`) or an escaped bracket must not be treated as a variant
// separator, so we split at bracket depth zero only.
func splitVariants(token string) (variants []string, base string) {
depth := 0
start := 0
for i := 0; i < len(token); i++ {
switch token[i] {
case '[', '(':
depth++
case ']', ')':
depth--
case ':':
if depth == 0 {
variants = append(variants, token[start:i])
start = i + 1
}
}
}
return variants, token[start:]
}

377
go/cmd/aria-check/theme.go Normal file
View File

@@ -0,0 +1,377 @@
package main
// theme.go turns a set of scanned Tailwind tokens into resolved colours by driving
// kjol's own Tailwind engine (package tw) and reading back what it emits. We do NOT
// re-implement utility parsing: we hand the engine every candidate, let it compile,
// and then read the CSS it produced. That keeps aria-check faithful to whatever the
// real build does — opacity modifiers, arbitrary values, semantic tokens, the lot —
// and correct-by-construction as the engine evolves.
//
// The engine gives us three things in its output:
//
// - the `:root` custom-property block → the LIGHT variable environment
// - the `.dark { … }` override block → the DARK variable environment (overlay)
// - the `@layer utilities` rules → token → colour-valued declaration
//
// A token's colour is then just: look up its declaration's value expression, and
// resolve it (var() chains and color-mix()) against the chosen environment.
import (
"regexp"
"strconv"
"strings"
"kjol/tw"
)
// Resolver holds everything needed to turn a token into a concrete colour in either
// theme.
type Resolver struct {
light map[string]string // --var → value expression, light theme
dark map[string]string // --var → value expression, dark theme (light overlaid)
// token → the colour declaration the engine emitted for it. prop is "color"
// (a text-* utility) or "background-color" (a bg-* utility); expr is the raw
// value, e.g. "var(--color-ink)" or "color-mix(in oklab, var(--color-red-500) 50%, transparent)".
tokens map[string]tokenDecl
}
type tokenDecl struct {
prop string
expr string
}
// surfaceExpr is the page background token; a translucent background composites onto
// it (see check.go). It is a plain --var lookup in whichever environment.
const surfaceVar = "--color-surface"
// NewResolver compiles candidates through the kjol Tailwind engine and indexes the
// result. entryCSS is the app's brand stylesheet (may be empty — kjol's own theme
// layer is always included via tw.CompileApp); baseDir is what any @import/@source in
// the entry resolves against.
func NewResolver(entryCSS, baseDir string, candidates []string) (*Resolver, error) {
if strings.TrimSpace(entryCSS) == "" {
entryCSS = "@theme {}"
}
css, _, err := tw.CompileApp(entryCSS, baseDir, candidates)
if err != nil {
return nil, err
}
r := &Resolver{
light: map[string]string{},
dark: map[string]string{},
tokens: map[string]tokenDecl{},
}
r.indexVars(css)
r.indexUtilities(css)
return r, nil
}
// reVarDecl matches a `--name: value;` custom-property declaration.
var reVarDecl = regexp.MustCompile(`(--[A-Za-z0-9-]+)\s*:\s*([^;]+);`)
// indexVars reads the theme `:root`/`:host` block into the light environment and the
// top-level `.dark { … }` rule into the dark overlay (which starts as a copy of
// light). The `.dark` rule we want is the design system's token override — selector
// exactly `.dark`, not the escaped utility selectors like `.dark\:bg-surface`.
func (r *Resolver) indexVars(css string) {
// Light: every custom property declared under the theme layer's :root/:host.
// The engine emits all theme variables there (it does not prune unused ones),
// so a single pass over the block captures the whole palette + tokens.
if root := blockBody(css, `:root, :host {`); root != "" {
for _, m := range reVarDecl.FindAllStringSubmatch(root, -1) {
r.light[m[1]] = strings.TrimSpace(m[2])
}
}
// Some variables (e.g. the FA style flags) sit in a plain `:root {` the engine
// passes through; fold those in too so nothing referenced dangles.
if root := blockBody(css, "\n:root {"); root != "" {
for _, m := range reVarDecl.FindAllStringSubmatch(root, -1) {
if _, ok := r.light[m[1]]; !ok {
r.light[m[1]] = strings.TrimSpace(m[2])
}
}
}
// Dark starts as a copy of light, then every top-level `.dark { … }` rule
// re-points a subset — the kjol design-system layer defines one, and an app's
// brand stylesheet may add more, so all of them are folded in, in order.
for k, v := range r.light {
r.dark[k] = v
}
for _, darkBody := range eachBlock(css, "\n.dark {") {
for _, m := range reVarDecl.FindAllStringSubmatch(darkBody, -1) {
r.dark[m[1]] = strings.TrimSpace(m[2])
}
}
}
// reColorDecl finds the first color / background-color declaration in a rule body,
// even when it is nested inside a variant wrapper (`&:where(.dark, …) { … }`).
var reColorDecl = regexp.MustCompile(`(?:^|[{\s])(background-color|color)\s*:\s*([^;]+);`)
// reUtilitySelector matches the start of one top-level utility rule and captures its
// (still CSS-escaped) selector, e.g. `.dark\:bg-surface {`.
var reUtilitySelector = regexp.MustCompile(`(?m)^\s{2}\.([^\s{]+)\s*\{`)
// indexUtilities walks the @layer utilities block and records, per token, the first
// colour declaration the engine produced for it. Tokens with no colour declaration
// (layout utilities, font sizes, …) are simply absent from the map — which is
// exactly how we tell a colour utility from a non-colour one.
func (r *Resolver) indexUtilities(css string) {
body := blockBody(css, "@layer utilities {")
if body == "" {
return
}
locs := reUtilitySelector.FindAllStringSubmatchIndex(body, -1)
for i, loc := range locs {
escSel := body[loc[2]:loc[3]]
// The rule body runs from this selector's opening brace to the next
// top-level rule (or the end of the layer). That span may contain nested
// braces; we only need the first colour declaration within it.
start := loc[1]
end := len(body)
if i+1 < len(locs) {
end = locs[i+1][0]
}
rule := body[start:end]
m := reColorDecl.FindStringSubmatch(rule)
if m == nil {
continue
}
token := unescapeIdent(escSel)
r.tokens[token] = tokenDecl{prop: m[1], expr: strings.TrimSpace(m[2])}
}
}
// blockBody returns the text between the braces of the first block whose header
// (including its opening `{`) matches marker. It is brace-aware, so nested rules are
// returned intact.
func blockBody(css, marker string) string {
idx := strings.Index(css, marker)
if idx < 0 {
return ""
}
open := idx + len(marker) - 1 // position of the '{' in the marker
depth := 0
for i := open; i < len(css); i++ {
switch css[i] {
case '{':
depth++
case '}':
depth--
if depth == 0 {
return css[open+1 : i]
}
}
}
return ""
}
// eachBlock returns the bodies of every block whose header matches marker, in order.
func eachBlock(css, marker string) []string {
var out []string
for {
idx := strings.Index(css, marker)
if idx < 0 {
return out
}
body := blockBody(css[idx:], marker)
out = append(out, body)
// Advance past this block's opening brace to find the next match.
css = css[idx+len(marker):]
}
}
// unescapeIdent reverses CSS identifier escaping so a compiled selector maps back to
// the token the scanner saw. It handles both backslash-escaped punctuation
// (`bg-\[\#fff\]` → `bg-[#fff]`) and numeric escapes (`\32 xl` → `2xl`).
func unescapeIdent(s string) string {
var b strings.Builder
for i := 0; i < len(s); i++ {
if s[i] != '\\' || i+1 >= len(s) {
b.WriteByte(s[i])
continue
}
i++
// Numeric escape: 16 hex digits, optional single trailing space.
if isHex(s[i]) {
j := i
for j < len(s) && j-i < 6 && isHex(s[j]) {
j++
}
var code int
for k := i; k < j; k++ {
code = code*16 + hexVal(s[k])
}
if j < len(s) && s[j] == ' ' {
j++
}
b.WriteRune(rune(code))
i = j - 1
continue
}
b.WriteByte(s[i])
}
return b.String()
}
func isHex(c byte) bool {
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
}
func hexVal(c byte) int {
switch {
case c >= '0' && c <= '9':
return int(c - '0')
case c >= 'a' && c <= 'f':
return int(c-'a') + 10
default:
return int(c-'A') + 10
}
}
// Colour resolution ---------------------------------------------------------
// theme selects which variable environment a resolution runs against.
type theme int
const (
light theme = iota
dark
)
func (r *Resolver) env(t theme) map[string]string {
if t == dark {
return r.dark
}
return r.light
}
// isColorToken reports whether a token compiled to a colour-valued text-*/bg-*
// utility, and which side it lands on. side is "fg" for a text colour, "bg" for a
// background colour, "" if the token is not a foreground/background colour utility.
func (r *Resolver) side(token string) string {
d, ok := r.tokens[token]
if !ok {
return ""
}
switch d.prop {
case "color":
return "fg"
case "background-color":
return "bg"
}
return ""
}
// resolveToken resolves a scanned token to a colour in the given theme. ok=false
// means the token is not a resolvable colour (unknown, or currentcolor/inherit).
func (r *Resolver) resolveToken(token string, t theme) (RGBA, bool) {
d, ok := r.tokens[token]
if !ok {
return RGBA{}, false
}
return r.resolveExpr(d.expr, t, 0)
}
// surface returns the page background colour for a theme — the backdrop a
// translucent background is flattened against.
func (r *Resolver) surface(t theme) (RGBA, bool) {
if v, ok := r.env(t)[surfaceVar]; ok {
return r.resolveExpr(v, t, 0)
}
return RGBA{}, false
}
var reVarFn = regexp.MustCompile(`^var\(\s*(--[A-Za-z0-9-]+)\s*(?:,\s*([^)]*))?\)$`)
// resolveExpr resolves a CSS colour value expression to an RGBA. It follows var()
// chains through the environment and evaluates the color-mix() form the engine emits
// for opacity; anything else is handed to the literal parser. depth guards against a
// pathological variable cycle.
func (r *Resolver) resolveExpr(expr string, t theme, depth int) (RGBA, bool) {
expr = strings.TrimSpace(expr)
if depth > 32 || expr == "" {
return RGBA{}, false
}
if strings.HasPrefix(expr, "var(") {
m := reVarFn.FindStringSubmatch(expr)
if m == nil {
return RGBA{}, false
}
if v, ok := r.env(t)[m[1]]; ok {
return r.resolveExpr(v, t, depth+1)
}
if m[2] != "" { // var() fallback
return r.resolveExpr(m[2], t, depth+1)
}
return RGBA{}, false
}
if strings.HasPrefix(expr, "color-mix(") {
return r.resolveColorMix(expr, t, depth)
}
return parseLiteralColor(expr)
}
// resolveColorMix evaluates `color-mix(in <space>, <c1> [p1%], <c2> [p2%])`. The
// mixing space in Tailwind's output is always oklab; we evaluate there. This covers
// the opacity form (`… <color> P%, transparent`) and hand-written arbitrary mixes.
func (r *Resolver) resolveColorMix(expr string, t theme, depth int) (RGBA, bool) {
inner := expr[strings.IndexByte(expr, '(')+1 : strings.LastIndexByte(expr, ')')]
parts := splitTopLevel(inner, ',')
if len(parts) != 3 {
return RGBA{}, false
}
// parts[0] is "in oklab" (or another space) — we always mix in oklab.
c1, w1, ok1 := r.mixComponent(parts[1], t, depth)
c2, w2, ok2 := r.mixComponent(parts[2], t, depth)
if !ok1 || !ok2 {
return RGBA{}, false
}
// If only one side gave a percentage, the other takes the remainder.
if w1 < 0 && w2 < 0 {
w1, w2 = 0.5, 0.5
} else if w1 < 0 {
w1 = clamp01(1 - w2)
} else if w2 < 0 {
w2 = clamp01(1 - w1)
}
return mixOKLab(c1, w1, c2, w2), true
}
// mixComponent parses one "<color> [P%]" argument of a color-mix(). A negative
// weight means no percentage was given.
func (r *Resolver) mixComponent(s string, t theme, depth int) (RGBA, float64, bool) {
s = strings.TrimSpace(s)
weight := -1.0
if i := strings.LastIndexByte(s, ' '); i >= 0 && strings.HasSuffix(s, "%") {
if v, err := strconv.ParseFloat(strings.TrimSuffix(s[i+1:], "%"), 64); err == nil {
weight = v / 100
s = strings.TrimSpace(s[:i])
}
}
c, ok := r.resolveExpr(s, t, depth+1)
return c, weight, ok
}
// splitTopLevel splits s on sep, ignoring separators nested inside parentheses.
func splitTopLevel(s string, sep byte) []string {
var out []string
depth, start := 0, 0
for i := 0; i < len(s); i++ {
switch s[i] {
case '(':
depth++
case ')':
depth--
case sep:
if depth == 0 {
out = append(out, strings.TrimSpace(s[start:i]))
start = i + 1
}
}
}
out = append(out, strings.TrimSpace(s[start:]))
return out
}

View File

@@ -1,217 +0,0 @@
package app
import (
"bytes"
"io"
"math/rand"
chart "github.com/wcharczuk/go-chart/v2"
. "kjol/vdom"
"kjol/wasmruntime"
ui "kjol/webui"
)
var chartLabels = []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}
// fixed initial data so the server SSR and the client's first render match.
func fixedChartData() []int { return []int{42, 17, 63, 28, 55, 9, 71} }
func randomValues() []int {
v := make([]int, len(chartLabels))
for i := range v {
v[i] = rand.Intn(95) + 5
}
return v
}
func renderSVG(c interface {
Render(chart.RendererProvider, io.Writer) error
}) string {
var buf bytes.Buffer
if c.Render(chart.SVG, &buf) != nil {
return "<p class=\"text-danger m-0\">chart error</p>"
}
return buf.String()
}
func barSVG(values []int) string {
bars := make([]chart.Value, len(values))
for i, v := range values {
bars[i] = chart.Value{Value: float64(v), Label: chartLabels[i%len(chartLabels)]}
}
return renderSVG(&chart.BarChart{
Title: "Weekly values (bar)",
TitleStyle: chart.Style{FontSize: 15},
Background: chart.Style{Padding: chart.Box{Top: 48, Left: 16, Right: 16, Bottom: 16}},
Height: 320, BarWidth: 48, Bars: bars,
})
}
func pieSVG(values []int) string {
vs := make([]chart.Value, len(values))
for i, v := range values {
vs[i] = chart.Value{Value: float64(v), Label: chartLabels[i%len(chartLabels)]}
}
return renderSVG(&chart.PieChart{
Title: "Share by day (pie)",
TitleStyle: chart.Style{FontSize: 15},
Background: chart.Style{Padding: chart.Box{Top: 48}},
Width: 320, Height: 320, Values: vs,
})
}
// chartSkeleton is what the SERVER puts where a chart is going to be: a box of the right
// height, so nothing jumps when the real one arrives.
func chartSkeleton(height string) *VNode {
return Div(Attr("class", "flex animate-pulse items-center justify-center rounded-default bg-surface-muted "+height),
Span(Attr("class", "text-xs text-ink-faint"), Text("drawing…")),
)
}
// newChartDrawing returns a signal that is FALSE on the server and on the client's first
// render, and true from the moment the WebAssembly has committed that first render.
//
// It is what keeps the charts CLIENT-DRAWN. go-chart is ordinary Go and would run just as
// happily on the server — it used to, and this page's markup carried two finished SVGs.
// Two reasons not to:
//
// - It is work the server does on every single request for a picture that only matters
// once the page is alive. Drawing it in the browser costs the server nothing and the
// reader nothing they can see.
// - It is the more honest demonstration. A Go charting library, compiled to WebAssembly,
// drawing an SVG in the browser is the thing this layer claims it can do. Shipping a
// server-rendered picture of one proves the opposite point.
//
// The false-on-first-render part is not optional: hydration walks the server's DOM
// alongside the client's first tree, so that tree has to be the SAME tree. Draw the charts
// on the client's first pass and the two disagree, and the reconciler has to rebuild what
// it should have adopted.
func newChartDrawing() *Signal[bool] {
drawn := NewSignal(false)
wasmruntime.AfterRender(func() {
if !drawn.Get() {
drawn.Set(true) // a write re-renders; the second pass draws for real
}
})
return drawn
}
// chartBox renders one chart, or the placeholder standing in for it. draw is a closure so
// that on the server go-chart is never called at all — not called and discarded, but never
// entered.
func chartBox(class, height string, drawn bool, draw func() string) *VNode {
if !drawn {
return Div(Attr("class", class), chartSkeleton(height))
}
return Div(Attr("class", class), Raw(draw()))
}
//gowasm:page /wasm/chart static layout=app
func ChartPage(d Deps) func() *VNode {
data := NewSignal(fixedChartData())
drawn := newChartDrawing()
return func() *VNode {
values := data.Get()
return docPage("Rendering", "SSR & hydration",
"A static route is rendered to HTML by the server, so the page is complete before any "+
"WebAssembly has downloaded. The same component then runs in the browser, adopts the markup "+
"that is already there, and takes over. One function, two runtimes.",
docSection("the-directive", "Marking a route static",
prose("static on the page directive is what puts a route in the server's pre-render set. Leave "+
"it off and the route renders on the client only — which is the right choice when the page "+
"is behind a login, or its content depends on something only the browser knows."),
code("app/chart.go", chartSnippet),
note("Hydration adopts, it does not rebuild",
"The client renders the same tree the server did and walks the existing DOM alongside it, "+
"wiring event handlers to the nodes that are already on the page. If the two trees "+
"disagree, the CLIENT wins — a stale server binary should not be able to pin a wrong "+
"class onto the page forever."),
),
docSection("charts", "A worked example: charts",
prose("These charts are SVG produced by go-chart — a plain Go library that knows nothing "+
"about the browser. They are drawn by the WEBASSEMBLY, in your browser, and never by the "+
"server: what the server sends is the two placeholders you may have seen for a moment, "+
"and the WebAssembly replaces them on its first commit."),
prose("That is the demonstration. A Go charting library, compiled to wasm, drawing an SVG in "+
"the browser is exactly what this layer claims it can do — and a server-rendered picture "+
"of a chart would prove the opposite point while looking identical. Shuffle redraws them, "+
"and no request is made."),
prose("The rest of the page IS server-rendered — the headings, the prose, the code you are "+
"reading. Static and client-drawn are not opposites: a route can be pre-rendered and still "+
"leave the expensive, browser-only parts of itself for the client."),
Div(Attr("class", "mt-4"),
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Icon: "chart-column", Text: "Shuffle data",
OnClick: func() { data.Set(randomValues()) }}),
),
Div(Attr("class", "mt-4 grid gap-4 lg:grid-cols-12"),
chartBox("lg:col-span-7 rounded-default border border-line bg-surface p-3 shadow-xs overflow-auto",
"h-[260px]", drawn.Get(), func() string { return barSVG(values) }),
chartBox("lg:col-span-5 rounded-default border border-line bg-surface p-3 shadow-xs overflow-auto",
"h-[320px]", drawn.Get(), func() string { return pieSVG(values) }),
),
note("go-chart lives in the EXAMPLE, not in kjol",
"The engine is standard-library-only. This example is its own Go module precisely so a "+
"charting dependency it happens to want does not become a dependency of everyone who "+
"uses the framework."),
),
docSection("api", "Reference",
apiTable(
apiRow{"//gowasm:page /path static", "Pre-render this route on the server, then hydrate it."},
apiRow{"vdom.RenderHTML", "Render a tree to an HTML string. This is what the server calls."},
apiRow{"wasmruntime.Hydrate", "Adopt server-rendered DOM instead of building it. The client's entry point for a static route."},
apiRow{"vdom.Raw", "Insert markup verbatim — how the SVG the WebAssembly drew gets in. The reconciler clears it correctly when the element is reused."},
),
),
)
}
}
const chartSnippet = `//gowasm:page /wasm/chart static layout=app
func ChartPage(d Deps) func() *VNode {
data := NewSignal(fixedChartData())
drawn := NewSignal(false) // false on the server AND on the first client render
// AfterRender is the post-commit hook. It fires once the WebAssembly has put its
// first tree on the page — the earliest moment at which drawing is a client act.
wasmruntime.AfterRender(func() {
if !drawn.Get() {
drawn.Set(true) // a write re-renders; the second pass draws
}
})
return func() *VNode {
return Div(
ui.Button(ui.ButtonProps{
Text: "Shuffle data",
OnClick: func() { data.Set(randomValues()) },
}),
// The server never enters barSVG: chartBox takes a CLOSURE, and calls it only
// once drawn is true. It renders the placeholder instead, and the WebAssembly
// swaps in the real chart on its first commit.
//
// drawn must be FALSE on the client's first render too. Hydration walks the
// server's DOM alongside the client's first tree, so the two have to BE the
// same tree; draw on that first pass and the reconciler rebuilds what it
// should have adopted.
chartBox("", "h-[260px]", drawn.Get(),
func() string { return barSVG(data.Get()) }),
)
}
}
// chartBox is the whole trick, and it is four lines.
func chartBox(class, height string, drawn bool, draw func() string) *VNode {
if !drawn {
return Div(Attr("class", class), chartSkeleton(height))
}
return Div(Attr("class", class), Raw(draw()))
}`

View File

@@ -1,93 +0,0 @@
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/dop251/goja v0.0.0-20260618133527-c9b2ea77db59 h1:DjKLmvKK9u15djHZ88N8M0DhgnHVgJJ8bnEe0h7Lga8=
github.com/dop251/goja v0.0.0-20260618133527-c9b2ea77db59/go.mod h1:Sc+QOu1WruvaaeT/cxFez/pXHpI9ZDjg/E8QNfSVveI=
github.com/evanw/esbuild v0.28.0 h1:V96ghtc5p5JnNUQIUsc5H3kr+AcFcMqOJll2ZmJW6Lo=
github.com/evanw/esbuild v0.28.0/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U=
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
github.com/tdewolff/minify/v2 v2.24.13 h1:xrcF7gKDnUszseEY9WX9mUlZII2v2Go/QAcAwRASw58=
github.com/tdewolff/minify/v2 v2.24.13/go.mod h1:emvwoYeIl8bfAKqRU5ww95LX9Gpggpqv/naal9a8Yq0=
github.com/tdewolff/parse/v2 v2.8.13 h1:si/8rLw5BZZTWCCiMm9A3f6x+RmqYfrkEeXCgpX5ick=
github.com/tdewolff/parse/v2 v2.8.13/go.mod h1:XdsoSFThlVIRIajAuqz1evNY7bagZS8LBOPA3aVopwQ=
github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk=
github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/wcharczuk/go-chart/v2 v2.1.2 h1:Y17/oYNuXwZg6TFag06qe8sBajwwsuvPiJJXcUcLL6E=
github.com/wcharczuk/go-chart/v2 v2.1.2/go.mod h1:Zi4hbaqlWpYajnXB2K22IUYVXRXaLfSGNNR7P4ukyyQ=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View File

@@ -1,31 +0,0 @@
Stack trace:
Frame Function Args
0007FFFFBD80 000210060304 (0007FFFFBF88, 0007FFFFCE00, 000000000002, 0007FFFFDC10) msys-2.0.dll+0x20304
FFFFFFFFFFFEFEDF 00021006237D (0007FFFFC730, 000000000000, 00000000017C, 000000000000) msys-2.0.dll+0x2237D
0007FFFFC490 0002100C1394 (0007FFFFC6F0, 000000000001, 0007FFFFC700, 000000000000) msys-2.0.dll+0x81394
000000000006 0002100BCE39 (000210221CCD, 000000000000, 000000000000, 7FF9520C00E8) msys-2.0.dll+0x7CE39
0007FFFFC848 0002100BD23A (0007FFFFC858, 000A00000000, 0000000000B5, 0000000000B5) msys-2.0.dll+0x7D23A
0007FFFFC848 0002102130B8 (000000000000, 000A00000030, 000000000000, 000000000000) msys-2.0.dll+0x1D30B8
0007FFFFC848 00010042A1E5 (0002100A76B3, 7FF900000000, 000210221CCD, 000100000001) grep.exe+0x2A1E5
0007FFFFCB80 000100404B7C (00000000000D, 000000000000, 000000000000, 000000000000) grep.exe+0x4B7C
0007FFFFCB80 000100429678 (0002100455E0, 000000000000, 000000000148, 000000000000) grep.exe+0x29678
0007FFFFCD30 000210047F01 (000000000000, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x7F01
000000000000 000210045AC3 (000000000000, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x5AC3
0007FFFFFFF0 000210045B74 (000000000000, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x5B74
End of stack trace
Loaded modules:
000100400000 grep.exe
7FF952CB0000 ntdll.dll
7FF9520C0000 KERNEL32.DLL
7FF94FFF0000 KERNELBASE.dll
0005603F0000 msys-iconv-2.dll
000430B30000 msys-intl-8.dll
000210040000 msys-2.0.dll
0004C36D0000 msys-pcre-1.dll
7FF951F50000 advapi32.dll
7FF9525E0000 msvcrt.dll
7FF952010000 sechost.dll
7FF9503A0000 bcrypt.dll
7FF952380000 RPCRT4.dll
7FF94F720000 CRYPTBASE.DLL
7FF9508C0000 bcryptPrimitives.dll

View File

@@ -1,4 +1,4 @@
# kjol-web — the kjol website # kjol-website — the kjol website
The landing page and documentation for the whole codebase. It is also the thing it The landing page and documentation for the whole codebase. It is also the thing it
documents: every page runs the code it describes, and there is not a screenshot of a documents: every page runs the code it describes, and there is not a screenshot of a
@@ -12,7 +12,7 @@ build the site out of both.
## Run it ## Run it
```sh ```sh
cd go/cmd/kjol-web cd go/cmd/kjol-website
go run ./server -build # cold build, then exit go run ./server -build # cold build, then exit
go run ./server # build, then SSR + /rsc + hot reload at http://localhost:8085 go run ./server # build, then SSR + /rsc + hot reload at http://localhost:8085
``` ```
@@ -121,7 +121,7 @@ one way.
## Its own module ## Its own module
`go.mod` declares module `kjolweb` with `replace kjol => ../..`, so its dependencies — `go.mod` declares module `kjolwebsite` with `replace kjol => ../..`, so its dependencies —
go-chart for the server-drawn charts, plus esbuild and goja by way of the bundler — stay go-chart for the server-drawn charts, plus esbuild and goja by way of the bundler — stay
out of kjol, whose engine packages are stdlib-only. `go build ./...` at the kjol root out of kjol, whose engine packages are stdlib-only. `go build ./...` at the kjol root
does not descend into this nested module; build it from here. does not descend into this nested module; build it from here.

View File

@@ -0,0 +1,110 @@
package app
import (
. "kjol/wasmruntime/vdom"
ui "kjol/webui"
)
var chartPageDays = []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}
// chartDemoData is a week of values; a non-zero seed reshuffles them deterministically, so
// "Shuffle" changes the chart without a data source and without a random that would differ
// between the server's render and the client's first one.
func chartDemoData(seed int) []float64 {
base := []float64{42, 17, 63, 28, 55, 9, 71}
if seed == 0 {
return base
}
out := make([]float64, len(base))
for i, b := range base {
m := (int(b)*7 + seed*13) % 80
if m < 4 {
m = 4
}
out[i] = float64(m)
}
return out
}
//gowasm:page /wasm/chart static layout=app
func ChartPage(d Deps) func() *VNode {
seed := NewSignal(0)
barChart := ui.NewChart()
areaChart := ui.NewChart()
return func() *VNode {
values := chartDemoData(seed.Get())
return docPage("Rendering", "SSR & hydration",
"A static route is rendered to HTML by the server, so the page is complete before any "+
"WebAssembly has downloaded. The same component then runs in the browser, adopts the markup "+
"that is already there, and takes over. One function, two runtimes.",
docSection("the-directive", "Marking a route static",
prose("static on the page directive is what puts a route in the server's pre-render set. Leave "+
"it off and the route renders on the client only — which is the right choice when the page "+
"is behind a login, or its content depends on something only the browser knows."),
code("app/chart.go", chartSnippet),
note("Hydration adopts, it does not rebuild",
"The client renders the same tree the server did and walks the existing DOM alongside it, "+
"wiring event handlers to the nodes that are already on the page. If the two trees "+
"disagree, the CLIENT wins — a stale server binary should not be able to pin a wrong "+
"class onto the page forever."),
),
docSection("charts", "A worked example: a chart",
prose("The two charts below are webui.Chart. Their SVG — axes, rounded columns, the smoothed "+
"area — is drawn on the SERVER and shipped in the page's HTML: view source and the marks "+
"are already there, complete before any WebAssembly runs. That is what static buys."),
prose("What the server cannot send is the interaction. The hover tooltip and the resize-to-fit "+
"come alive when the WebAssembly hydrates the page — it adopts the SVG already on screen and "+
"wires the pointer handlers to it, redrawing nothing. Server-rendered picture, client-side "+
"behaviour, one component. Shuffle re-renders it in the browser, and no request is made."),
Div(Attr("class", "mt-4"),
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Icon: "chart-column", Text: "Shuffle data",
OnClick: func() { seed.Set(seed.Get() + 1) }}),
),
Div(Attr("class", "mt-4 grid gap-4 lg:grid-cols-2"),
Div(Attr("class", "rounded-default border border-line bg-surface p-3 shadow-xs"),
barChart.Render(ui.ChartProps{Kind: ui.ChartBar, Labels: chartPageDays,
Series: []ui.ChartSeries{{Name: "Requests", Data: values}}, Height: 240})),
Div(Attr("class", "rounded-default border border-line bg-surface p-3 shadow-xs"),
areaChart.Render(ui.ChartProps{Kind: ui.ChartArea, Smooth: true, Labels: chartPageDays,
Series: []ui.ChartSeries{{Name: "Requests", Data: values}}, Height: 240})),
),
),
docSection("api", "Reference",
apiTable(
apiRow{"//gowasm:page /path static", "Pre-render this route on the server, then hydrate it."},
apiRow{"vdom.RenderHTML", "Render a tree to an HTML string. This is what the server calls."},
apiRow{"wasmruntime.Hydrate", "Adopt server-rendered DOM instead of building it. The client's entry point for a static route."},
apiRow{"vdom.Raw", "Insert markup verbatim — how webui.Chart's SVG string enters the tree. The reconciler clears it correctly when the element is reused."},
),
),
)
}
}
const chartSnippet = `//gowasm:page /wasm/chart static layout=app
func ChartPage(d Deps) func() *VNode {
seed := NewSignal(0)
chart := ui.NewChart() // a controller: holds hover + refs across renders
return func() *VNode {
return docPage("Rendering", "SSR & hydration",
// The chart's SVG is rendered on the SERVER and shipped in the HTML.
// On hydration the client adopts those nodes and wires the pointer
// handlers — the hover tooltip comes alive without redrawing anything.
chart.Render(ui.ChartProps{
Kind: ui.ChartBar,
Labels: []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"},
Series: []ui.ChartSeries{{Name: "Requests", Data: chartDemoData(seed.Get())}},
}),
ui.Button(ui.ButtonProps{Text: "Shuffle", OnClick: func() {
seed.Set(seed.Get() + 1) // a signal write re-renders, in the browser
}}),
)
}
}`

View File

@@ -1,7 +1,7 @@
package app package app
import ( import (
. "kjol/vdom" . "kjol/wasmruntime/vdom"
) )
// The C layer's documentation. // The C layer's documentation.
@@ -471,7 +471,7 @@ const unityTUSnippet = `// The whole layer, as one translation unit — the only
// compiler. Order matters: it is textual inclusion, not linking. // compiler. Order matters: it is textual inclusion, not linking.
#include "base/base_inc.c" // core, arena, strings. Everything below needs it. #include "base/base_inc.c" // core, arena, strings. Everything below needs it.
// The five backends declare their helpers `+ "`internal`" + ` (file-static), so they // The five backends declare their helpers ` + "`internal`" + ` (file-static), so they
// belong in the SAME TU as the dispatch that calls them. // belong in the SAME TU as the dispatch that calls them.
#include "lexer/lexer.c" #include "lexer/lexer.c"
#include "lexer/lexer_c.c" #include "lexer/lexer_c.c"
@@ -488,7 +488,7 @@ const unityTUSnippet = `// The whole layer, as one translation unit — the only
// ...and then your own program, compiled with all of it: // ...and then your own program, compiled with all of it:
#include "app/app.c"` #include "app/app.c"`
const baseCoreSnippet = `// The three meanings of `+ "`static`" + ` in C, given three names. const baseCoreSnippet = `// The three meanings of ` + "`static`" + ` in C, given three names.
#define internal static // a function private to this file #define internal static // a function private to this file
#define global static // a variable owned by this translation unit #define global static // a variable owned by this translation unit
#define local_persist static // a local that survives the call #define local_persist static // a local that survives the call

View File

@@ -5,8 +5,8 @@
package app package app
import ( import (
"kjol/rsc" "kjol/wasmruntime/rsc"
"kjol/vdom" "kjol/wasmruntime/vdom"
) )
// ServerCounter is a generated client stub for the server component of the same name. // ServerCounter is a generated client stub for the server component of the same name.

View File

@@ -1,11 +1,12 @@
package app package app
import ( import (
"strconv"
"strings" "strings"
"time" "time"
. "kjol/vdom"
"kjol/wasmruntime" "kjol/wasmruntime"
. "kjol/wasmruntime/vdom"
ui "kjol/webui" ui "kjol/webui"
) )
@@ -232,7 +233,7 @@ func badgesSection() func() *VNode {
ui.EnvBadge("staging"), ui.EnvBadge("staging"),
// Production deliberately renders NOTHING — the badge exists to tell you that // Production deliberately renders NOTHING — the badge exists to tell you that
// you are NOT in production, and a badge that is always there says nothing. // you are NOT in production, and a badge that is always there says nothing.
Span(Attr("class", "text-xs text-ink-muted"), Span(Attr("class", "text-ss text-ink-muted"),
Text("(production renders nothing — that is the point of it)")), Text("(production renders nothing — that is the point of it)")),
), ),
), ),
@@ -473,7 +474,7 @@ func selectsSection() func() *VNode {
field("Timezone", ui.FormTimezoneSelector(ui.FormSelectProps{ field("Timezone", ui.FormTimezoneSelector(ui.FormSelectProps{
Value: tz.Get(), OnChange: func(v string) { tz.Set(v) }})), Value: tz.Get(), OnChange: func(v string) { tz.Set(v) }})),
), ),
P(Attr("class", "mt-3 text-xs text-ink-muted"), P(Attr("class", "mt-3 text-ss text-ink-muted"),
Text("one="+orElse(one.Get(), "—")+" several="+orElse(strings.Join(langs.Get(), ","), "—"))), Text("one="+orElse(one.Get(), "—")+" several="+orElse(strings.Join(langs.Get(), ","), "—"))),
), ),
@@ -484,7 +485,7 @@ func selectsSection() func() *VNode {
OnSelect: func(o ui.FormSelectOption) { picked.Set(o.Label + " <" + o.Value + ">") }, OnSelect: func(o ui.FormSelectOption) { picked.Set(o.Label + " <" + o.Value + ">") },
}), }),
), ),
P(Attr("class", "mt-3 text-xs text-ink-muted"), P(Attr("class", "mt-3 text-ss text-ink-muted"),
Text("Two characters before it asks; 200 ms after you stop typing. A response for a query "+ Text("Two characters before it asks; 200 ms after you stop typing. A response for a query "+
"you have already typed past is DISCARDED rather than shown — which is the whole bug "+ "you have already typed past is DISCARDED rather than shown — which is the whole bug "+
"with hand-rolled autocompletes.")), "with hand-rolled autocompletes.")),
@@ -500,7 +501,7 @@ func selectsSection() func() *VNode {
ShowSelectAll: true, ShowSelectAll: true,
OnChange: func(v []string) { tags.Set(v) }, OnChange: func(v []string) { tags.Set(v) },
}), }),
P(Attr("class", "mt-3 text-xs text-ink-muted"), P(Attr("class", "mt-3 text-ss text-ink-muted"),
Text("Same selection model as the field above; only the thing you click on differs.")), Text("Same selection model as the field above; only the thing you click on differs.")),
), ),
) )
@@ -535,7 +536,7 @@ func togglesSection() func() *VNode {
demo("Signature pad — "+itoa(len(signed.Get()))+" bytes of SVG", demo("Signature pad — "+itoa(len(signed.Get()))+" bytes of SVG",
pad.Render(ui.SignaturePadProps{}), pad.Render(ui.SignaturePadProps{}),
P(Attr("class", "mt-3 text-xs text-ink-muted"), P(Attr("class", "mt-3 text-ss text-ink-muted"),
Text("Draw in it. Long strokes are smoothed; a single stray point is not a stroke and "+ Text("Draw in it. Long strokes are smoothed; a single stray point is not a stroke and "+
"is dropped, so a click does not leave a dot behind.")), "is dropped, so a click does not leave a dot behind.")),
), ),
@@ -584,7 +585,7 @@ func datesSection() func() *VNode {
OnNavigate: func(m time.Time) { month.Set(m) }, OnNavigate: func(m time.Time) { month.Set(m) },
}), }),
), ),
P(Attr("class", "mt-3 text-xs text-ink-muted"), P(Attr("class", "mt-3 text-ss text-ink-muted"),
Text("The same grid the picker drops down, usable directly when you want it inline.")), Text("The same grid the picker drops down, usable directly when you want it inline.")),
), ),
) )
@@ -618,7 +619,7 @@ func tablesSection() func() *VNode {
} }
return ui.AutoTablePDFHeader{ return ui.AutoTablePDFHeader{
Title: "Employees", Title: "Employees",
Subtitle: "Exported from the kjol-web components page", Subtitle: "Exported from the kjol-website components page",
ShowDate: true, ShowDate: true,
Orientation: orientation, Orientation: orientation,
} }
@@ -676,7 +677,7 @@ func tablesSection() func() *VNode {
SortDesc: gridDesc.Get(), SortDesc: gridDesc.Get(),
SetSortDesc: func(b bool) { gridDesc.Set(b) }, SetSortDesc: func(b bool) { gridDesc.Set(b) },
}), }),
P(Attr("class", "mt-3 text-xs text-ink-muted"), P(Attr("class", "mt-3 text-ss text-ink-muted"),
Text("Click a Qty or Price cell and type. The grid does not own the rows — it tells you "+ Text("Click a Qty or Price cell and type. The grid does not own the rows — it tells you "+
"which cell changed and hands the value back; what you do with it is yours.")), "which cell changed and hands the value back; what you do with it is yours.")),
), ),
@@ -801,7 +802,7 @@ func overlaysSection(push func(ui.ToastType, string)) func() *VNode {
tipFocus.Render(Span(Text("Shown on FOCUS, not hover — tab to the field")), tipFocus.Render(Span(Text("Shown on FOCUS, not hover — tab to the field")),
ui.FormInput(ui.FormInputProps{Placeholder: "Focus me"})), ui.FormInput(ui.FormInputProps{Placeholder: "Focus me"})),
), ),
P(Attr("class", "mt-3 text-xs text-ink-muted"), P(Attr("class", "mt-3 text-ss text-ink-muted"),
Text("Narrow the window and hover \"Right\": it flips to the left, and its arrow follows "+ Text("Narrow the window and hover \"Right\": it flips to the left, and its arrow follows "+
"it. A tooltip that only answers to a mouse is a tooltip a keyboard user cannot read.")), "it. A tooltip that only answers to a mouse is a tooltip a keyboard user cannot read.")),
), ),
@@ -862,7 +863,7 @@ func overlaysSection(push func(ui.ToastType, string)) func() *VNode {
hoverMenu.Item(ui.MenuItemProps{}, Text("Two")), hoverMenu.Item(ui.MenuItemProps{}, Text("Two")),
), ),
), ),
P(Attr("class", "mt-3 text-xs text-ink-muted"), P(Attr("class", "mt-3 text-ss text-ink-muted"),
Text("Opening one closes the other — a single-open manager. Submenus are exempt, or a "+ Text("Opening one closes the other — a single-open manager. Submenus are exempt, or a "+
"submenu would close the very menu it belongs to as it opened. The items raise toasts, "+ "submenu would close the very menu it belongs to as it opened. The items raise toasts, "+
"which is how you can see that an item really does close its own menu.")), "which is how you can see that an item really does close its own menu.")),
@@ -889,7 +890,7 @@ func overlaysSection(push func(ui.ToastType, string)) func() *VNode {
}, ui.ModalOptions{Size: ui.ModalSmall}) }, ui.ModalOptions{Size: ui.ModalSmall})
}}), }}),
), ),
P(Attr("class", "mt-3 text-xs text-ink-muted"), P(Attr("class", "mt-3 text-ss text-ink-muted"),
Text("Open the modal, then the nested one inside it, and press Escape twice: modals unwind "+ Text("Open the modal, then the nested one inside it, and press Escape twice: modals unwind "+
"ONE LAYER per press rather than all at once.")), "ONE LAYER per press rather than all at once.")),
@@ -994,7 +995,7 @@ func feedbackSection(toaster *ui.Toaster, tour *ui.Tutorial) func() *VNode {
demo("The guided tour", demo("The guided tour",
row("flex flex-wrap items-center gap-3", row("flex flex-wrap items-center gap-3",
tour.StartButton(0, "", Text("Take the tour")), tour.StartButton(0, "", Text("Take the tour")),
Span(Attr("class", "text-xs text-ink-muted"), Span(Attr("class", "text-ss text-ink-muted"),
Text("It dims the page, cuts a hole around each target, and animates the spotlight from "+ Text("It dims the page, cuts a hole around each target, and animates the spotlight from "+
"one to the next. Targets are CSS SELECTORS — the same section ids the sidebar jumps to.")), "one to the next. Targets are CSS SELECTORS — the same section ids the sidebar jumps to.")),
), ),
@@ -1006,7 +1007,7 @@ func feedbackSection(toaster *ui.Toaster, tour *ui.Tutorial) func() *VNode {
OnClick: flash.Fire}), OnClick: flash.Fire}),
ui.RemoteUpdateFlash(flash.Visible()), ui.RemoteUpdateFlash(flash.Visible()),
), ),
P(Attr("class", "mt-3 text-xs text-ink-muted"), P(Attr("class", "mt-3 text-ss text-ink-muted"),
Text("A two-second acknowledgement that data you are looking at was changed by somebody "+ Text("A two-second acknowledgement that data you are looking at was changed by somebody "+
"else. It is not a toast: it belongs next to the thing that moved, not in the corner.")), "else. It is not a toast: it belongs next to the thing that moved, not in the corner.")),
), ),
@@ -1071,7 +1072,7 @@ func navigationSection() func() *VNode {
demo("Accordion — one open at a time, or several", demo("Accordion — one open at a time, or several",
row("grid gap-6 lg:grid-cols-2", row("grid gap-6 lg:grid-cols-2",
row("flex flex-col gap-2", row("flex flex-col gap-2",
P(Attr("class", "text-xs font-semibold uppercase tracking-widest text-ink-faint"), P(Attr("class", "text-ss font-semibold uppercase tracking-widest text-ink-faint"),
Text("SingleAccordion")), Text("SingleAccordion")),
ui.SingleAccordion([]ui.AccordionItemData{ ui.SingleAccordion([]ui.AccordionItemData{
{Title: "What is Kjøl Wasm Web?", Content: panel("kjol's Go→WebAssembly UI engine.")}, {Title: "What is Kjøl Wasm Web?", Content: panel("kjol's Go→WebAssembly UI engine.")},
@@ -1080,7 +1081,7 @@ func navigationSection() func() *VNode {
}, acc.Get(), func(i int) { acc.Set(i) }), }, acc.Get(), func(i int) { acc.Set(i) }),
), ),
row("flex flex-col gap-2", row("flex flex-col gap-2",
P(Attr("class", "text-xs font-semibold uppercase tracking-widest text-ink-faint"), P(Attr("class", "text-ss font-semibold uppercase tracking-widest text-ink-faint"),
Text("Accordion (several at once)")), Text("Accordion (several at once)")),
ui.Accordion([]ui.AccordionItemData{ ui.Accordion([]ui.AccordionItemData{
{Title: "First", Content: panel("Open me.")}, {Title: "First", Content: panel("Open me.")},
@@ -1107,7 +1108,7 @@ func navigationSection() func() *VNode {
}}, }},
}, func(id string) { side.Set(id) }, ""), }, func(id string) { side.Set(id) }, ""),
), ),
P(Attr("class", "mt-3 text-xs text-ink-muted"), P(Attr("class", "mt-3 text-ss text-ink-muted"),
Text("It reports the id you clicked and nothing else. The sidebar on THIS page is the "+ Text("It reports the id you clicked and nothing else. The sidebar on THIS page is the "+
"same idea, wired to scroll the section into view.")), "same idea, wired to scroll the section into view.")),
), ),
@@ -1120,6 +1121,7 @@ func navigationSection() func() *VNode {
func searchSection() func() *VNode { func searchSection() func() *VNode {
q := NewSignal("") q := NewSignal("")
hit := NewSignal("") hit := NewSignal("")
andAmpQ := NewSignal("")
return func() *VNode { return func() *VNode {
options := []string{ options := []string{
@@ -1128,6 +1130,20 @@ func searchSection() func() *VNode {
"SegmentedButtons", "SignaturePad", "TabGroup", "ThemeToggle", "Toast", "ToggleSwitch", "Tooltip", "SegmentedButtons", "SignaturePad", "TabGroup", "ThemeToggle", "Toast", "ToggleSwitch", "Tooltip",
} }
// Both spellings on purpose: with AndAmpersand on, typing either finds both,
// and the exact spelling ranks above the substituted one. "Standard" / "Brand"
// hold an "and" that is not the whole word, so it is left untouched.
andAmpNames := []string{
"First Bank & Trust",
"First Bank and Trust Company",
"Smith & Wesson Financial",
"Johnson and Johnson Federal CU",
"Highland Savings & Loan",
"Standard Chartered",
"Brand Mortgage Group",
"AT&T Employees CU",
}
return docSection("search", "Fuzzy search", return docSection("search", "Fuzzy search",
prose("Subsequence matching with a typo tolerance, scored so the best hit sorts first, and "+ prose("Subsequence matching with a typo tolerance, scored so the best hit sorts first, and "+
"the matched characters highlighted in the result. \"atbl\" finds AutoTable; so does "+ "the matched characters highlighted in the result. \"atbl\" finds AutoTable; so does "+
@@ -1146,10 +1162,30 @@ func searchSection() func() *VNode {
}), }),
), ),
), ),
prose("Set AndAmpersand and the word \"and\" and the symbol \"&\" match each other, so "+
"\"First Bank and Trust\" also finds \"First Bank & Trust\". The exact spelling still wins — "+
"the substituted form is a penalized extra pass, not a free swap — and a stray \"and\" inside "+
"\"Standard\" or \"Brand\" is left alone."),
demo("AndAmpersand — type \"first bank and trust\", or \"smith & wesson\"",
row("max-w-md",
ui.FuzzyMatch(ui.FuzzyMatchProps{
Options: andAmpNames,
Query: andAmpQ.Get(),
AndAmpersand: true,
Placeholder: "Search bank names…",
MaxResults: 6,
ShowScores: true,
OnQueryChange: func(v string) { andAmpQ.Set(v) },
}),
),
),
apiTable( apiTable(
apiRow{"RankFuzzyMatches", "The scorer, headless. Use it and render the results yourself."}, apiRow{"RankFuzzyMatches", "The scorer, headless. Use it and render the results yourself."},
apiRow{"FuzzySegments", "Splits a result into matched / unmatched runs, for highlighting."}, apiRow{"FuzzySegments", "Splits a result into matched / unmatched runs, for highlighting."},
apiRow{"FuzzyMatchTypoTolerant", "One transposition or substitution forgiven."}, apiRow{"FuzzyMatchTypoTolerant", "One transposition or substitution forgiven."},
apiRow{"FuzzyOptions{AndAmpersand}", "Treat the word \"and\" and \"&\" as interchangeable, exact spelling still first."},
), ),
) )
} }
@@ -1157,43 +1193,105 @@ func searchSection() func() *VNode {
// ---- charts -------------------------------------------------------------- // ---- charts --------------------------------------------------------------
var chartDaysWasm = []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}
var pieLabelsWasm = []string{"Direct", "Search", "Social", "Email", "Referral"}
var regionsWasm = []string{"East", "Central", "Mountain", "Pacific"}
// A made-up per-state metric for the choropleth, and a few cities (lat/lng) — Anchorage
// and Honolulu land on albersUsa's Alaska and Hawaii insets.
var usSignups = map[string]float64{
"CA": 4820, "TX": 3910, "NY": 3120, "FL": 2870, "IL": 1740, "PA": 1610, "OH": 1490, "GA": 1450,
"NC": 1360, "MI": 1280, "WA": 1230, "AZ": 1180, "MA": 1120, "VA": 1090, "CO": 980, "TN": 940,
"NJ": 910, "OR": 720, "MN": 690, "WI": 610, "MO": 560, "MD": 540, "IN": 520, "NV": 480,
"UT": 430, "AL": 390, "SC": 360, "KY": 310, "LA": 300, "OK": 280, "CT": 260, "IA": 210,
"KS": 180, "AK": 140, "HI": 160, "ME": 120, "MT": 90, "WY": 60, "ND": 70, "SD": 80,
}
var usCities = []ui.USHeatmapPoint{
{Label: "Seattle", Lat: 47.6062, Lng: -122.3321, Value: 1230},
{Label: "San Francisco", Lat: 37.7749, Lng: -122.4194, Value: 2110},
{Label: "Denver", Lat: 39.7392, Lng: -104.9903, Value: 980},
{Label: "Chicago", Lat: 41.8781, Lng: -87.6298, Value: 1740},
{Label: "New York", Lat: 40.7128, Lng: -74.006, Value: 3120},
{Label: "Miami", Lat: 25.7617, Lng: -80.1918, Value: 1460},
{Label: "Anchorage", Lat: 61.2181, Lng: -149.9003, Value: 140},
{Label: "Honolulu", Lat: 21.3069, Lng: -157.8583, Value: 160},
}
func chartsSection() func() *VNode { func chartsSection() func() *VNode {
values := NewSignal(fixedChartData()) seed := NewSignal(0)
drawn := newChartDrawing() threeD := NewSignal(false)
depth := NewSignal(16.0)
tilt := NewSignal(0.6)
barC, donutC, areaC, lineC := ui.NewChart(), ui.NewChart(), ui.NewChart(), ui.NewChart()
horizC, stackC := ui.NewChart(), ui.NewChart()
threeDBar, threeDLine := ui.NewChart(), ui.NewChart()
heat := ui.NewUSHeatmap()
return func() *VNode { return func() *VNode {
v := values.Get() sh := seed.Get()
shuffle := func(base []float64) []float64 {
out := make([]float64, len(base))
for i, b := range base {
if sh == 0 {
out[i] = b
continue
}
m := (int(b)*7 + sh*13) % 80
if m < 4 {
m = 4
}
out[i] = float64(m)
}
return out
}
requests := ui.ChartSeries{Name: "Requests", Data: shuffle([]float64{42, 17, 63, 28, 55, 9, 71})}
errs := ui.ChartSeries{Name: "Errors", Data: shuffle([]float64{8, 3, 12, 6, 9, 2, 14})}
pie := ui.ChartSeries{Name: "Traffic", Data: shuffle([]float64{40, 25, 20, 15, 8})}
threeDLabel := "3D"
if threeD.Get() {
threeDLabel = "Flat"
}
return docSection("charts", "Charts", return docSection("charts", "Charts",
prose("These are SVG, produced by go-chart — a plain Go library that knows nothing about "+ prose("webui.Chart draws its own SVG — nice-scale axes, rounded columns, arc slices, a "+
"browsers. They are drawn by the WEBASSEMBLY, in your browser. The server never enters "+ "pointer crosshair — with no charting library. The geometry is pure Go, so the same shapes "+
"the drawing code at all; it renders a placeholder, and the wasm replaces it on its first "+ "the Solid kit draws on /js run here in the WebAssembly, against the same theme tokens. Change "+
"commit."), "the data and only the marks that moved re-render. A Title captions the plot, and the legend a "+
prose("A Go charting library, compiled to wasm, drawing an SVG in the browser is the thing "+ "multi-series or pie/donut chart draws is interactive — clicking a key toggles that series or slice."),
"this layer claims it can do. Server-rendering a picture of a chart would look identical "+
"and prove the opposite point."),
demo("Drawn in the browser, by Go", demo("Bar, donut, smooth area, two lines — one data set, and a 3D toggle",
row("grid gap-4 lg:grid-cols-12", row("grid gap-6 lg:grid-cols-12",
chartBox("lg:col-span-7 rounded-default border border-line bg-surface p-3 shadow-xs overflow-auto", Div(Attr("class", "lg:col-span-7"), barC.Render(ui.ChartProps{Kind: ui.ChartBar, Title: "Requests & errors this week", Labels: chartDaysWasm, Series: []ui.ChartSeries{requests, errs}, Height: 260, ThreeD: threeD.Get()})),
"h-[260px]", drawn.Get(), func() string { return barSVG(v) }), Div(Attr("class", "lg:col-span-5"), donutC.Render(ui.ChartProps{Kind: ui.ChartDonut, Title: "Traffic by source", Labels: pieLabelsWasm, Series: []ui.ChartSeries{pie}, Height: 260, ThreeD: threeD.Get()})),
chartBox("lg:col-span-5 rounded-default border border-line bg-surface p-3 shadow-xs overflow-auto", Div(Attr("class", "lg:col-span-7"), areaC.Render(ui.ChartProps{Kind: ui.ChartArea, Title: "Requests, smoothed", Smooth: true, Labels: chartDaysWasm, Series: []ui.ChartSeries{requests}, Height: 220, ThreeD: threeD.Get()})),
"h-[320px]", drawn.Get(), func() string { return pieSVG(v) }), Div(Attr("class", "lg:col-span-5"), lineC.Render(ui.ChartProps{Kind: ui.ChartLine, Title: "Requests & errors", Labels: chartDaysWasm, Series: []ui.ChartSeries{requests, errs}, Height: 220, ThreeD: threeD.Get()})),
), ),
row("mt-4 flex items-center gap-2", row("mt-4 flex items-center gap-3",
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Small: true, Text: "New data", ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Small: true, Text: "New data", OnClick: func() { seed.Set(seed.Get() + 1) }}),
OnClick: func() { values.Set(randomValues()) }}), ui.Button(ui.ButtonProps{Color: ui.ButtonNeutral, Small: true, Text: threeDLabel, OnClick: func() { threeD.Set(!threeD.Get()) }}),
Span(Attr("class", "text-xs text-ink-muted"), Span(Attr("class", "text-ss text-ink-muted"), Text("Each chart takes a Title; a multi-series chart also draws a legend. Click a legend key (Errors, or a donut slice) to hide it — the scale and marks recompute. 3D gives every plot a grid with depth — bars extrude, a line or area draws flat over it, the donut tilts.")),
Text("Re-drawn in Go, in the browser. No request is made.")),
), ),
), ),
note("webui.ReactiveChart is a stub, and says so", chart3DDemo(threeDBar, threeDLine, depth, tilt, requests, errs),
"The Solid kit's Chart wraps chart.js: it creates a Chart against a <canvas> 2D context "+
"on mount and pushes new data through an effect. None of that — canvas drawing, a JS "+ demo("Horizontal bars, and stacked",
"charting library, mount lifecycles — exists in the neutral Go runtime, so the Go port "+ row("grid gap-6 lg:grid-cols-2",
"keeps the props shape for parity and renders an empty canvas. Draw with go-chart "+ horizC.Render(ui.ChartProps{Kind: ui.ChartBar, Horizontal: true, Labels: chartDaysWasm, Series: []ui.ChartSeries{requests, errs}, Height: 260}),
"instead, as above. Pretending otherwise would be the one thing this site refuses to do."), stackC.Render(ui.ChartProps{Kind: ui.ChartBar, Stacked: true, Labels: regionsWasm, Series: []ui.ChartSeries{
{Name: "Requests", Data: shuffle([]float64{42, 55, 28, 63})},
{Name: "Errors", Data: shuffle([]float64{8, 9, 6, 12})},
{Name: "Retries", Data: shuffle([]float64{5, 7, 3, 9})},
}, Height: 260}),
),
),
demo("US heatmap — a value per state, with proportional lat/lng points on top",
heat.Render(ui.USHeatmapProps{Data: usSignups, Points: usCities, Proportional: true}),
Span(Attr("class", "mt-3 block text-ss text-ink-muted"),
Text("webui.USHeatmap shades each state on the choropleth ramp and projects lat/lng points "+
"with a Go albersUsa port — Anchorage and Honolulu land on the insets. Hover a state or a point.")),
),
) )
} }
} }
@@ -1300,6 +1398,54 @@ func orElse(s, fallback string) string {
} }
// row is a flex/grid container helper (appends *VNode children as Mods). // row is a flex/grid container helper (appends *VNode children as Mods).
// chart3DDemo is the "3D grid" panel: an extruded bar chart and a line chart (which rides
// the back wall, un-extruded) plus depth/tilt range sliders that drive both. Called from
// inside the render closure so reading depth/tilt makes it reactive and the charts re-render
// when a slider moves.
func chart3DDemo(barChart, lineChart *ui.Chart, depth, tilt *Signal[float64], requests, errs ui.ChartSeries) *VNode {
dep, t := depth.Get(), tilt.Get()
tv := t // addressable copy for the *float64 Tilt prop (both charts read the same value)
slider := func(label, valText, min, max, step, value string, on func(Event)) *VNode {
return El("label", Attr("class", "flex flex-col gap-1.5"),
Span(Attr("class", "flex items-center justify-between text-ss text-ink-soft"),
El("code", Attr("class", "font-mono"), Text(label)),
Span(Attr("class", "font-mono tabular-nums text-ink-muted"), Text(valText)),
),
Input(Attr("type", "range"), Attr("min", min), Attr("max", max), Attr("step", step),
Attr("value", value), Attr("class", "w-full accent-accent"), OnEvent(EVENT_INPUT, on)),
)
}
setFrom := func(sig *Signal[float64]) func(Event) {
return func(e Event) {
if v, err := strconv.ParseFloat(e.Value(), 64); err == nil {
sig.Set(v)
}
}
}
return demo("3D grid — bars extrude into it, lines draw flat on the front",
row("grid items-center gap-6 lg:grid-cols-12",
Div(Attr("class", "lg:col-span-4"),
barChart.Render(ui.ChartProps{Kind: ui.ChartBar, Title: "Bars", Labels: chartDaysWasm,
Series: []ui.ChartSeries{requests}, Height: 240, ThreeD: true, Depth: dep, Tilt: &tv}),
),
Div(Attr("class", "lg:col-span-4"),
lineChart.Render(ui.ChartProps{Kind: ui.ChartLine, Title: "Lines", Labels: chartDaysWasm,
Series: []ui.ChartSeries{requests, errs}, Height: 240, ThreeD: true, Depth: dep, Tilt: &tv}),
),
Div(Attr("class", "flex flex-col justify-center gap-5 lg:col-span-4"),
slider("depth", strconv.Itoa(int(dep))+"px", "0", "40", "1", strconv.FormatFloat(dep, 'f', 2, 64), setFrom(depth)),
slider("tilt", strconv.FormatFloat(t, 'f', 2, 64), "0", "1", "0.05", strconv.FormatFloat(t, 'f', 2, 64), setFrom(tilt)),
),
),
Span(Attr("class", "mt-3 block text-ss text-ink-muted"),
Text("The same depth and tilt give the grid itself perspective: a floor recedes from the value-0 "+
"baseline into a back wall. Bars extrude into that space; a line or area stays flat on the front "+
"plane, drawn over the grid and read against the front axis — the stroke is not itself extruded, "+
"since depth on a hairline reads as noise. tilt runs 1 (head-on, near-flat) to 0 (bird's-eye); depth "+
"is the sweep length in px.")),
)
}
func row(class string, children ...*VNode) *VNode { func row(class string, children ...*VNode) *VNode {
mods := []Mod{Attr("class", class)} mods := []Mod{Attr("class", class)}
for _, c := range children { for _, c := range children {
@@ -1337,7 +1483,6 @@ func languageOptions() []ui.FormSelectOption {
} }
} }
const kitSnippet = `// A component is a function taking a props struct. const kitSnippet = `// A component is a function taking a props struct.
ui.Button(ui.ButtonProps{ ui.Button(ui.ButtonProps{
Color: ui.ButtonPrimary, Color: ui.ButtonPrimary,

View File

@@ -5,7 +5,7 @@ import (
"strings" "strings"
"kjol/httputil" "kjol/httputil"
. "kjol/vdom" . "kjol/wasmruntime/vdom"
ui "kjol/webui" ui "kjol/webui"
) )

View File

@@ -4,7 +4,7 @@ import (
"strings" "strings"
"kjol/lexer" // syntax highlighting for the code blocks — a string in, HTML out "kjol/lexer" // syntax highlighting for the code blocks — a string in, HTML out
. "kjol/vdom" . "kjol/wasmruntime/vdom"
// The host API is dual-build — real under js/wasm, no-ops natively — so neutral page // The host API is dual-build — real under js/wasm, no-ops natively — so neutral page
// code can measure the browser and still server-render. This page uses it for exactly // code can measure the browser and still server-render. This page uses it for exactly
// one thing: reading the clock when hydration commits. // one thing: reading the clock when hydration commits.
@@ -89,7 +89,7 @@ func docPage(eyebrow, title, lede string, sections ...*VNode) *VNode {
mods := []Mod{Attr("class", "pb-16")} mods := []Mod{Attr("class", "pb-16")}
mods = append(mods, mods = append(mods,
Div(Attr("class", "border-b border-line pb-6"), Div(Attr("class", "border-b border-line pb-6"),
P(Attr("class", "text-xs font-semibold uppercase tracking-widest text-accent"), Text(eyebrow)), P(Attr("class", "text-ss font-semibold uppercase tracking-widest text-accent"), Text(eyebrow)),
H1(Attr("class", "mt-2 text-3xl font-semibold tracking-tight text-text-heading"), Text(title)), H1(Attr("class", "mt-2 text-3xl font-semibold tracking-tight text-text-heading"), Text(title)),
P(Attr("class", "mt-3 text-ink-muted leading-relaxed"), Text(lede)), P(Attr("class", "mt-3 text-ink-muted leading-relaxed"), Text(lede)),
), ),
@@ -150,7 +150,7 @@ func codeLang(caption, lang, src string) *VNode {
return Div(Attr("class", "mt-4 overflow-hidden rounded-default border border-neutral-800 bg-neutral-900"), return Div(Attr("class", "mt-4 overflow-hidden rounded-default border border-neutral-800 bg-neutral-900"),
Div(Attr("class", "flex items-center gap-2 border-b border-neutral-800 px-4 py-2"), Div(Attr("class", "flex items-center gap-2 border-b border-neutral-800 px-4 py-2"),
Span(Attr("class", "text-xs font-medium text-ink-faint font-mono"), Text(caption)), Span(Attr("class", "text-ss font-medium text-ink-faint font-mono"), Text(caption)),
Span(Attr("class", "ml-auto rounded-full bg-white/5 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-ink-faint"), Text(lang)), Span(Attr("class", "ml-auto rounded-full bg-white/5 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-ink-faint"), Text(lang)),
), ),
Pre(Attr("class", "overflow-x-auto px-4 py-3 text-[13px] leading-relaxed text-neutral-100 font-mono"), body), Pre(Attr("class", "overflow-x-auto px-4 py-3 text-[13px] leading-relaxed text-neutral-100 font-mono"), body),
@@ -164,7 +164,7 @@ func demo(title string, body ...*VNode) *VNode {
mods := []Mod{Attr("class", "mt-4 rounded-default border border-line bg-surface shadow-xs")} mods := []Mod{Attr("class", "mt-4 rounded-default border border-line bg-surface shadow-xs")}
mods = append(mods, mods = append(mods,
Div(Attr("class", "border-b border-line px-4 py-2"), Div(Attr("class", "border-b border-line px-4 py-2"),
Span(Attr("class", "text-xs text-ink-muted"), Text(title)), Span(Attr("class", "text-ss text-ink-muted"), Text(title)),
), ),
) )
inner := []Mod{Attr("class", "p-4")} inner := []Mod{Attr("class", "p-4")}

View File

@@ -1,7 +1,7 @@
package app package app
import ( import (
. "kjol/vdom" . "kjol/wasmruntime/vdom"
ui "kjol/webui" ui "kjol/webui"
) )

View File

@@ -3,7 +3,7 @@ package app
import ( import (
"strings" "strings"
. "kjol/vdom" . "kjol/wasmruntime/vdom"
ui "kjol/webui" ui "kjol/webui"
) )
@@ -247,7 +247,7 @@ func layerItem(d Deps, l Layer) *VNode {
Span(Attr("class", "rounded-full bg-surface-raised px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-ink-muted"), Span(Attr("class", "rounded-full bg-surface-raised px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-ink-muted"),
Text("reference")), Text("reference")),
), ),
Span(Attr("class", "text-xs text-ink-muted"), Text(l.Tagline)), Span(Attr("class", "text-ss text-ink-muted"), Text(l.Tagline)),
) )
} }
@@ -257,6 +257,6 @@ func layerItem(d Deps, l Layer) *VNode {
} }
return A(Attr("class", cls), Attr("href", l.Href), return A(Attr("class", cls), Attr("href", l.Href),
Span(Attr("class", "text-sm font-medium text-ink"), Text(l.Name)), Span(Attr("class", "text-sm font-medium text-ink"), Text(l.Name)),
Span(Attr("class", "text-xs text-ink-muted"), Text(l.Tagline)), Span(Attr("class", "text-ss text-ink-muted"), Text(l.Tagline)),
) )
} }

View File

@@ -1,4 +1,4 @@
// Package app holds the kjol-web site's Go/WASM pages and components as // Package app holds the kjol-website site's Go/WASM pages and components as
// standalone, platform-neutral functions (SSR on the server, hydrate on the // standalone, platform-neutral functions (SSR on the server, hydrate on the
// client). UI is built from the kjol webui kit + Tailwind utility classes. // client). UI is built from the kjol webui kit + Tailwind utility classes.
// //
@@ -15,7 +15,7 @@ import (
"strconv" "strconv"
"strings" "strings"
. "kjol/vdom" . "kjol/wasmruntime/vdom"
ui "kjol/webui" ui "kjol/webui"
) )
@@ -410,7 +410,7 @@ serving "./wwwroot" on http://localhost:8085`
func AboutPage(d Deps) func() *VNode { func AboutPage(d Deps) func() *VNode {
return func() *VNode { return func() *VNode {
return Div(Attr("class", "mx-auto max-w-3xl py-4"), return Div(Attr("class", "mx-auto max-w-3xl py-4"),
P(Attr("class", "text-xs font-semibold uppercase tracking-widest text-accent"), Text("About")), P(Attr("class", "text-ss font-semibold uppercase tracking-widest text-accent"), Text("About")),
H1(Attr("class", "mt-2 text-4xl font-semibold tracking-tight text-text-heading"), Text("Why this exists")), H1(Attr("class", "mt-2 text-4xl font-semibold tracking-tight text-text-heading"), Text("Why this exists")),
P(Attr("class", "mt-6 text-lg leading-relaxed text-ink-soft"), P(Attr("class", "mt-6 text-lg leading-relaxed text-ink-soft"),

View File

@@ -1,7 +1,7 @@
// Code generated by wasmgen. DO NOT EDIT. // Code generated by wasmgen. DO NOT EDIT.
package app package app
import "kjol/vdom" import "kjol/wasmruntime/vdom"
// Routes maps each //gowasm:page path to its instantiated render function. // Routes maps each //gowasm:page path to its instantiated render function.
func Routes(d Deps) map[string]func() *vdom.VNode { func Routes(d Deps) map[string]func() *vdom.VNode {

View File

@@ -4,7 +4,7 @@
package app package app
import "kjol/rsc" import "kjol/wasmruntime/rsc"
func init() { func init() {
rsc.Register("ServerCounter", ServerCounter) rsc.Register("ServerCounter", ServerCounter)

View File

@@ -3,13 +3,10 @@
package app package app
import ( import (
"bytes"
"strconv" "strconv"
"time" "time"
chart "github.com/wcharczuk/go-chart/v2" . "kjol/wasmruntime/vdom"
. "kjol/vdom"
ui "kjol/webui" ui "kjol/webui"
) )
@@ -17,7 +14,7 @@ import (
// component (same builders, signals, On handlers). The //gowasm:server directive // component (same builders, signals, On handlers). The //gowasm:server directive
// makes the build generate a client stub so calling ServerCounter() on the // makes the build generate a client stub so calling ServerCounter() on the
// frontend is identical to calling any component; the state and this render run // frontend is identical to calling any component; the state and this render run
// on the server (its chart is computed there with go-chart), and clicks // on the server (its chart SVG is drawn there by webui.ChartSVG), and clicks
// round-trip over /rsc. // round-trip over /rsc.
// //
// The chart plots the counter value against the wall-clock time of each click // The chart plots the counter value against the wall-clock time of each click
@@ -60,61 +57,24 @@ type clickPoint struct {
V int // counter value after the click V int // counter value after the click
} }
// clickChartSVG plots counter value vs. time-of-click (ms since the first // clickChartSVG plots the counter value at each click as a line, drawn on the server by
// click) as a line graph. Explicit axis ranges keep it valid for the tricky // webui.ChartSVG — the same chart geometry the client kit uses, rendered to a static SVG
// cases (a single click, or several clicks within the same millisecond). // string (no controller, no hover) because a server component's output is HTML.
func clickChartSVG(points []clickPoint) string { func clickChartSVG(points []clickPoint) string {
if len(points) == 0 { if len(points) == 0 {
return `<span class="text-muted">Click + / to plot the counter over time (ms since the first click).</span>` return `<span class="text-ink-muted">Click + / to plot the counter over each click.</span>`
} }
t0 := points[0].T vals := make([]float64, len(points))
xs := make([]float64, len(points)) labels := make([]string, len(points))
ys := make([]float64, len(points))
minY, maxY := 0.0, 0.0 // keep the zero baseline in view for context
for i, p := range points { for i, p := range points {
xs[i] = float64(p.T - t0) vals[i] = float64(p.V)
ys[i] = float64(p.V) labels[i] = strconv.Itoa(i + 1)
if ys[i] < minY {
minY = ys[i]
}
if ys[i] > maxY {
maxY = ys[i]
}
} }
maxX := xs[len(xs)-1] return ui.ChartSVG(ui.ChartProps{
if maxX <= 0 { Kind: ui.ChartLine,
maxX = 1 // rapid or single clicks: avoid a zero-width x-range Labels: labels,
} Series: []ui.ChartSeries{{Name: "Counter", Data: vals, Color: "var(--color-chart-4)"}},
if minY == maxY { Height: 240,
maxY++ // avoid a zero-height y-range NoTooltip: true,
} })
graph := chart.Chart{
Title: "Counter over time (computed on the server)",
TitleStyle: chart.Style{FontSize: 14},
Background: chart.Style{Padding: chart.Box{Top: 48, Left: 20, Right: 20, Bottom: 40}},
Height: 260,
XAxis: chart.XAxis{
Name: "ms since first click",
Range: &chart.ContinuousRange{Min: 0, Max: maxX},
},
YAxis: chart.YAxis{
Name: "counter",
Range: &chart.ContinuousRange{Min: minY, Max: maxY},
},
Series: []chart.Series{
chart.ContinuousSeries{
XValues: xs,
YValues: ys,
Style: chart.Style{
StrokeColor: chart.ColorGreen, StrokeWidth: 2,
DotColor: chart.ColorGreen, DotWidth: 4, // a dot at each click
},
},
},
}
var buf bytes.Buffer
if graph.Render(chart.SVG, &buf) != nil {
return `<span class="text-danger">chart error</span>`
}
return buf.String()
} }

View File

@@ -6,7 +6,7 @@ import (
"strings" "strings"
"testing" "testing"
"kjol/vdom" "kjol/wasmruntime/vdom"
"kjol/webui" "kjol/webui"
) )

View File

@@ -1,7 +1,7 @@
package app package app
import ( import (
. "kjol/vdom" . "kjol/wasmruntime/vdom"
ui "kjol/webui" ui "kjol/webui"
) )

View File

@@ -4,7 +4,7 @@ import (
"strings" "strings"
"testing" "testing"
"kjol/vdom" "kjol/wasmruntime/vdom"
) )
// The two-runtime demo.s whole claim is that its two panes are ONE function: the live // The two-runtime demo.s whole claim is that its two panes are ONE function: the live

View File

@@ -106,13 +106,13 @@ func Codegen() ([]byte, error) {
// names, and nothing else in the tree mentions text-teal-300. // names, and nothing else in the tree mentions text-teal-300.
func Tailwind() ([]byte, error) { func Tailwind() ([]byte, error) {
cmd := exec.Command("go", "run", "./cmd/twcss", cmd := exec.Command("go", "run", "./cmd/twcss",
"-entry", "cmd/kjol-web/css/app.css", "-entry", "cmd/kjol-website/css/app.css",
"-out", "cmd/kjol-web/wwwroot/app.css", "-out", "cmd/kjol-website/wwwroot/app.css",
"-base", ".", "-base", ".",
"webui/**/*.go", "webui/**/*.go",
"lexer/**/*.go", "lexer/**/*.go",
"cmd/kjol-web/app/**/*.go", "cmd/kjol-website/app/**/*.go",
"cmd/kjol-web/server/**/*.go", "cmd/kjol-website/server/**/*.go",
) )
cmd.Dir = kjolRoot cmd.Dir = kjolRoot
return cmd.CombinedOutput() return cmd.CombinedOutput()

View File

@@ -1,32 +1,73 @@
@import "tailwindcss"; /* ---------------------------------------------------------------------------
kjol-website brand stylesheet for the Kjol Go/WASM section (/wasm/*).
---------------------------------------------------------------------------
There is deliberately no `@import "tailwindcss"` and no `@custom-variant dark`
here. twcss compiles this file via tw.CompileAppFiles, which prepends kjol's
shared extension layer (tw/kjol_theme.css) the import, the class-based dark
variant, the semantic tokens (surface/line/ink/), and the chart palette. This
file carries only what is genuinely this app's: the brand.
The values below match the /js side's frontend/css/style.css on purpose same
Open Sans, same navy accent so that crossing between /wasm and /js reads as two
parts of ONE site rather than two demos that happen to share a domain. The two
sections are built by completely different pipelines; they should not look like it.
--------------------------------------------------------------------------- */
/* --------------------------------------------------------------------------- /* ---------------------------------------------------------------------------
Dark mode: `dark:` as a CLASS, not a media query. Open Sans self-hosted (files in wwwroot/fonts). One variable file per subset
--------------------------------------------------------------------------- carries weights 400700 upright and italic, so the four faces below cover the
Tailwind's built-in dark variant follows the operating system. A site with its own whole UI. unicode-range keeps the browser to the one subset a glyph needs, and
theme switch cannot use it: the OS says one thing, the switch says another, and the font-display: swap paints text in the fallback first rather than blocking on the
media query wins so the switch appears to do nothing. download. The same four blocks live in the /js side's frontend/css/style.css so
both front-ends render in one typeface. --font-sans (below) points at it. */
@font-face {
font-family: "Open Sans";
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url("/fonts/opensans-latin-normal.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Open Sans";
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url("/fonts/opensans-latin-ext-normal.woff2") format("woff2");
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, 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: "Open Sans";
font-style: italic;
font-weight: 400 700;
font-display: swap;
src: url("/fonts/opensans-latin-italic.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Open Sans";
font-style: italic;
font-weight: 400 700;
font-display: swap;
src: url("/fonts/opensans-latin-ext-italic.woff2") format("woff2");
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, 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;
}
This redefines it against a class on <html>, which webui.Theme toggles. The OS is /* The brand the values the shared kit's tokens are re-pointed to. Everything the
still respected: it is the DEFAULT (see the boot script in server/main.go), just no kit already defines (surfaces, lines, ink, the chart palette, the state colours)
longer the last word. comes from tw/kjol_theme.css; this block sets only what is this site's own. */
--------------------------------------------------------------------------- */
@custom-variant dark (&:where(.dark, .dark *));
/* App-side design tokens the webui kit references (Tailwind v4 @theme). Brand
values live with the app; the kit stays generic.
The surface/line/ink tokens are the kit's THEME CONTRACT (see webui.ThemeTokens):
components say bg-surface / border-line / text-ink and never name a colour, so the
whole kit changes theme by changing these ten values rather than by carrying a dark:
variant on four hundred class strings. */
@theme { @theme {
--radius-default: 0.375rem; --radius-default: 0.375rem;
/* The UI typeface. --font-sans is what Tailwind's preflight points html at, and
the kit's utilities (font-sans) resolve to, so this one line moves the whole
site onto Open Sans; the fallbacks cover the swap window and any glyph outside
the vendored subsets. */
--font-sans: "Open Sans", ui-sans-serif, system-ui, sans-serif;
/* Navy and red the flag, muted. kjøl is a Norwegian word and the palette says so. /* Navy and red the flag, muted. kjøl is a Norwegian word and the palette says so.
Both are dark and low-key: the page is mostly prose, code and tables, and the brand's Both are dark and low-key: the page is mostly prose, code and tables, and the brand's
job is to mark the few things you can act on, not to compete with them for attention. job is to mark the few things you can act on, not to compete with them for attention. */
(The previous sky blue did the same job, but said nothing.) */
--color-primary: #1e3a63; /* muted navy — FILLS; they carry white text */ --color-primary: #1e3a63; /* muted navy — FILLS; they carry white text */
--color-primary-hover: #16294a; --color-primary-hover: #16294a;
--color-primary-subtle: #eef2f8; /* a navy wash — tinted panels, badges, callouts */ --color-primary-subtle: #eef2f8; /* a navy wash — tinted panels, badges, callouts */
@@ -36,26 +77,12 @@
separate token from primary, because the two have opposite constraints: a fill must be separate token from primary, because the two have opposite constraints: a fill must be
dark enough for white text on TOP of it, and accent text must be readable ON the dark enough for white text on TOP of it, and accent text must be readable ON the
surface. Here they are the same hue navy but not the same value: the accent is a surface. Here they are the same hue navy but not the same value: the accent is a
touch deeper so a navy link on white is unmistakably a link. (The red is gone; the touch deeper so a navy link on white is unmistakably a link. */
brand is navy throughout now. Only the flag keeps its red field.) */
--color-accent: #1c3a66; /* navy — accent TEXT */ --color-accent: #1c3a66; /* navy — accent TEXT */
/* Surfaces, lines, ink — the kit's theme contract. */ /* Heading ink a touch stronger than body ink. Not part of the shared contract, so
--color-surface: #ffffff; the site names it here and re-points it in the .dark block below. */
--color-surface-muted: #fafafa;
--color-surface-raised: #f5f5f5;
--color-surface-strong: #e5e5e5;
--color-line: #e5e5e5;
--color-line-strong: #d4d4d4;
--color-ink: #171717;
--color-ink-soft: #525252;
--color-ink-muted: #737373;
--color-ink-faint: #a3a3a3;
--color-text-heading: #111827; --color-text-heading: #111827;
--color-text-on-dark: #f9fafb;
--color-text-on-dark-muted: #9ca3af;
} }
/* --------------------------------------------------------------------------- /* ---------------------------------------------------------------------------
@@ -75,37 +102,15 @@
--grid-line: rgba(30, 58, 99, 0.06); /* the navy, at the edge of visible */ --grid-line: rgba(30, 58, 99, 0.06); /* the navy, at the edge of visible */
} }
/* --------------------------------------------------------------------------- /* The dark values for the brand.
The dark theme.
--------------------------------------------------------------------------- ---------------------------------------------------------------------------
Only the token VALUES change. Not one component knows this block exists they ask The shared surfaces/lines/ink re-point themselves in tw/kjol_theme.css's own .dark
for bg-surface and text-ink, and here is where those come to mean something else. block; this one moves only the site's brand. Navy is too dark to read on a near-black
page, so both brand tokens climb to a lighter blue. The accent (TEXT) climbs furthest,
This is a plain rule, not another @theme block: @theme generates utilities, and these to a soft sky a link stays legible in; the fill climbs less, to a steel blue that still
are overrides of utilities that already exist. looks like a button and still carries white text. The tinted panel inverts outright,
because a pale wash on #101013 is not a tint, it is a white box. */
The surfaces are not pure black. Black gives a dark UI a hard, glaring edge against
white text and makes every border invisible; a very dark grey leaves room for the
raised surfaces and lines above it to actually be seen. --------------------------- */
.dark { .dark {
--color-surface: #101013;
--color-surface-muted: #17171b;
--color-surface-raised: #1f1f24;
--color-surface-strong: #2c2c33;
--color-line: #2a2a30;
--color-line-strong: #3d3d45;
--color-ink: #f2f2f3;
--color-ink-soft: #c6c6cc;
--color-ink-muted: #9a9aa3;
--color-ink-faint: #71717a;
/* Both brand tokens move in the dark, and both for the same reason now: navy is too dark
to read on a near-black page, so each climbs to a lighter blue.
The accent (TEXT) climbs furthest a link has to be legible at body-text weight, so it
goes to a soft sky. The fill climbs less: it only has to look like a button and still
carry white text (~7:1), so it lifts to a steel blue and stops there, well below where
the accent lands. */
--color-accent: #9fc1ec; --color-accent: #9fc1ec;
--color-primary: #2b4f80; --color-primary: #2b4f80;
--color-primary-hover: #37619b; --color-primary-hover: #37619b;
@@ -118,11 +123,12 @@
--grid-line: rgba(226, 232, 240, 0.05); --grid-line: rgba(226, 232, 240, 0.05);
} }
/* The page's own background painted before the app mounts, and behind it afterwards. /* Only the font. The page's background and text colour come from the shared layer's
Without this, a dark app sits in a white window. */ `html` rule, which paints them from --color-surface / --color-ink the tokens the
.dark block re-points. Setting them here would pin the page to white and leave a dark
app sitting in a white window. */
html { html {
background-color: var(--color-surface); font-family: var(--font-sans);
color: var(--color-ink);
} }
/* --------------------------------------------------------------------------- /* ---------------------------------------------------------------------------
@@ -176,7 +182,3 @@ html {
-webkit-mask-image: linear-gradient(to bottom, #000, #000 35%, transparent 100%); -webkit-mask-image: linear-gradient(to bottom, #000, #000 35%, transparent 100%);
mask-image: linear-gradient(to bottom, #000, #000 35%, transparent 100%); mask-image: linear-gradient(to bottom, #000, #000 35%, transparent 100%);
} }
/* (The hero glow that used to live here went with the hero. A coloured wash behind an
oversized headline is the most recognisable gesture in framework marketing, and this
page is not making that argument any more.) */

View File

@@ -1,21 +1,62 @@
/* --------------------------------------------------------------------------- /* ---------------------------------------------------------------------------
kjol-web brand stylesheet for the Kjol JS Web section (/js/*). kjol-website brand stylesheet for the Kjol JS Web section (/js/*).
--------------------------------------------------------------------------- ---------------------------------------------------------------------------
There is deliberately no `@import "tailwindcss"` here. The bundler PREPENDS There is deliberately no `@import "tailwindcss"` here. The bundler compiles this
kjol's shared scaffold (go/jsruntime/styles/theme.css) to this file, and that file via tw.CompileApp, which PREPENDS kjol's shared extension layer
scaffold does the import an @import has to come first, and this file no (tw/kjol_theme.css); that layer does the import an @import has to come first,
longer is. See jsbundler/css.go and the header of theme.css. and this file no longer is. See jsbundler/css.go and the header of kjol_theme.css.
What is left is only what is genuinely this app's: the brand. What is left is only what is genuinely this app's: the brand.
The values below match the Go/WASM section's css/app.css on purpose same The values below match the Go/WASM section's css/app.css on purpose same
Lora, same sky accent so that crossing between /wasm and /js reads as two Open Sans, same navy accent so that crossing between /wasm and /js reads as
parts of ONE site rather than two demos that happen to share a domain. The two parts of ONE site rather than two demos that happen to share a domain. The
two sections are built by completely different pipelines; they should not look two sections are built by completely different pipelines; they should not look
like it. like it.
--------------------------------------------------------------------------- */ --------------------------------------------------------------------------- */
/* Open Sans self-hosted (files in wwwroot/fonts). One variable file per subset
carries weights 400700 upright and italic. unicode-range keeps the browser to the
one subset a glyph needs; font-display: swap paints the fallback first. The same four
blocks live in the /wasm side's css/app.css so both front-ends share one typeface. */
@font-face {
font-family: "Open Sans";
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url("/fonts/opensans-latin-normal.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Open Sans";
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url("/fonts/opensans-latin-ext-normal.woff2") format("woff2");
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, 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: "Open Sans";
font-style: italic;
font-weight: 400 700;
font-display: swap;
src: url("/fonts/opensans-latin-italic.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Open Sans";
font-style: italic;
font-weight: 400 700;
font-display: swap;
src: url("/fonts/opensans-latin-ext-italic.woff2") format("woff2");
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, 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;
}
@theme { @theme {
/* The UI typeface see the @font-face blocks above. This one line points the kit's
font-sans utilities and the html rule below onto Open Sans. */
--font-sans: "Open Sans", ui-sans-serif, system-ui, sans-serif;
/* The brand: navy throughout, a muted flag-navy. These are the SAME values the Go/WASM /* The brand: navy throughout, a muted flag-navy. These are the SAME values the Go/WASM
section's css/app.css sets, and under the same names so the Layers menu, the section's css/app.css sets, and under the same names so the Layers menu, the
sidebar highlight and the callouts are the same navy on both sides of the site rather sidebar highlight and the callouts are the same navy on both sides of the site rather

View File

@@ -98,7 +98,7 @@ function LayerItem(props: { layer: Layer; current?: Layer }) {
reference reference
</span> </span>
</span> </span>
<span class="text-xs text-ink-muted">{props.layer.tagline}</span> <span class="text-ss text-ink-muted">{props.layer.tagline}</span>
</div> </div>
} }
> >
@@ -113,7 +113,7 @@ function LayerItem(props: { layer: Layer; current?: Layer }) {
} }
> >
<span class="text-sm font-medium text-ink">{props.layer.name}</span> <span class="text-sm font-medium text-ink">{props.layer.name}</span>
<span class="text-xs text-ink-muted">{props.layer.tagline}</span> <span class="text-ss text-ink-muted">{props.layer.tagline}</span>
</a> </a>
</Show> </Show>
); );

View File

@@ -83,7 +83,8 @@ import { CrmTabGroup, CrmSubTabGroup } from "@ui/CrmTabs";
import { Accordion, SingleAccordion } from "@ui/Accordion"; import { Accordion, SingleAccordion } from "@ui/Accordion";
import { SidebarNav } from "@ui/Sidebar"; import { SidebarNav } from "@ui/Sidebar";
import { FuzzyMatch } from "@ui/FuzzyMatch"; import { FuzzyMatch } from "@ui/FuzzyMatch";
import ReactiveChart from "@ui/Chart"; import { Chart, ChartSeries } from "@ui/Chart";
import { USHeatmap } from "@ui/USHeatmap";
import { ThemeToggle, useTheme } from "@ui/Theme"; import { ThemeToggle, useTheme } from "@ui/Theme";
import { Demo } from "../layout/Demo.tsx"; import { Demo } from "../layout/Demo.tsx";
@@ -111,7 +112,7 @@ function Panel(props: { title: string; children?: JSXElement }) {
return ( return (
<div class="mt-4 rounded-default border border-line bg-surface shadow-xs"> <div class="mt-4 rounded-default border border-line bg-surface shadow-xs">
<div class="border-b border-line px-4 py-2"> <div class="border-b border-line px-4 py-2">
<span class="text-xs text-ink-muted">{props.title}</span> <span class="text-ss text-ink-muted">{props.title}</span>
</div> </div>
<div class="p-4">{props.children}</div> <div class="p-4">{props.children}</div>
</div> </div>
@@ -152,7 +153,7 @@ function Body() {
return ( return (
<div> <div>
<div class="border-b border-line pb-6"> <div class="border-b border-line pb-6">
<p class="text-xs font-semibold uppercase tracking-widest text-primary">Kjøl JS Web</p> <p class="text-ss font-semibold uppercase tracking-widest text-primary">Kjøl JS Web</p>
<h1 class="mt-2 text-3xl font-semibold tracking-tight text-ink">Components</h1> <h1 class="mt-2 text-3xl font-semibold tracking-tight text-ink">Components</h1>
<p class="mt-3 leading-relaxed text-ink-muted"> <p class="mt-3 leading-relaxed text-ink-muted">
Every component in the Solid kit, running. Not a screenshot of one anywhere: each block Every component in the Solid kit, running. Not a screenshot of one anywhere: each block
@@ -312,7 +313,7 @@ function Badges() {
<span class="text-sm font-semibold text-ink">kjol</span> <span class="text-sm font-semibold text-ink">kjol</span>
<EnvBadge /> <EnvBadge />
</div> </div>
<p class="mt-3 text-xs text-ink-muted"> <p class="mt-3 text-ss text-ink-muted">
Nothing beside the wordmark? Then this build is production which is what it is telling Nothing beside the wordmark? Then this build is production which is what it is telling
you. you.
</p> </p>
@@ -428,7 +429,7 @@ function Icons() {
<Icon icon="star" size={18} solid /> <Icon icon="star" size={18} solid />
<Icon icon="star" size={18} solid={false} /> <Icon icon="star" size={18} solid={false} />
</div> </div>
<p class="mt-3 text-xs text-ink-muted"> <p class="mt-3 text-ss text-ink-muted">
The last two are the same name in the solid and regular styles. Which one you get by The last two are the same name in the solid and regular styles. Which one you get by
default is a CSS variable the app sets, read at runtime. default is a CSS variable the app sets, read at runtime.
</p> </p>
@@ -464,7 +465,7 @@ function Forms() {
email() && !isEmailValid(email()) ? "That is not an email address." : ""; email() && !isEmailValid(email()) ? "That is not an email address." : "";
<FormEmailInput <FormEmailInput
value={email} value={email()}
oninput={(e) => setEmail(e.currentTarget.value)} oninput={(e) => setEmail(e.currentTarget.value)}
error={emailError()} error={emailError()}
showIcon showIcon
@@ -474,21 +475,21 @@ function Forms() {
<Field label="Name"> <Field label="Name">
<FormInput <FormInput
placeholder="Ada Lovelace" placeholder="Ada Lovelace"
value={name} value={name()}
oninput={(e) => setName(e.currentTarget.value)} oninput={(e) => setName(e.currentTarget.value)}
/> />
</Field> </Field>
<Field label="Email"> <Field label="Email">
<FormEmailInput <FormEmailInput
placeholder="ada@example.com" placeholder="ada@example.com"
value={email} value={email()}
oninput={(e) => setEmail(e.currentTarget.value)} oninput={(e) => setEmail(e.currentTarget.value)}
error={emailError()} error={emailError()}
showIcon showIcon
/> />
</Field> </Field>
<Field label="Plan"> <Field label="Plan">
<FormSelect value={plan} onchange={(e) => setPlan(e.currentTarget.value)}> <FormSelect value={plan()} onchange={(e) => setPlan(e.currentTarget.value)}>
<option value="free">Free</option> <option value="free">Free</option>
<option value="pro">Pro</option> <option value="pro">Pro</option>
<option value="enterprise">Enterprise</option> <option value="enterprise">Enterprise</option>
@@ -498,7 +499,7 @@ function Forms() {
<FormTextarea <FormTextarea
rows={3} rows={3}
placeholder="Anything worth remembering…" placeholder="Anything worth remembering…"
value={notes} value={notes()}
oninput={(e) => setNotes(e.currentTarget.value)} oninput={(e) => setNotes(e.currentTarget.value)}
/> />
</Field> </Field>
@@ -532,11 +533,11 @@ function Forms() {
<FormFieldset legend="Account"> <FormFieldset legend="Account">
<div class="grid gap-4 sm:grid-cols-2"> <div class="grid gap-4 sm:grid-cols-2">
<Field label="Name"> <Field label="Name">
<FormInput value={name} oninput={(e) => setName(e.currentTarget.value)} /> <FormInput value={name()} oninput={(e) => setName(e.currentTarget.value)} />
</Field> </Field>
<Field label="Email (validated)"> <Field label="Email (validated)">
<FormEmailInput <FormEmailInput
value={email} value={email()}
oninput={(e) => setEmail(e.currentTarget.value)} oninput={(e) => setEmail(e.currentTarget.value)}
error={emailError()} error={emailError()}
showIcon showIcon
@@ -590,7 +591,7 @@ function Selects() {
<Field label="Language (searchable, one)"> <Field label="Language (searchable, one)">
<FormCombobox <FormCombobox
options={LANGUAGES} options={LANGUAGES}
value={one} value={one()}
onchange={setOne} onchange={setOne}
searchable searchable
placeholder="Pick one" placeholder="Pick one"
@@ -607,13 +608,13 @@ function Selects() {
/> />
</Field> </Field>
<Field label="State"> <Field label="State">
<FormCombobox options={US_STATES} value={state} onchange={setState} searchable /> <FormCombobox options={US_STATES} value={state()} onchange={setState} searchable />
</Field> </Field>
<Field label="Timezone"> <Field label="Timezone">
<FormTimezoneSelector value={tz} onchange={(e: any) => setTz(e?.currentTarget?.value ?? e)} /> <FormTimezoneSelector value={tz()} onchange={(e: any) => setTz(e?.currentTarget?.value ?? e)} />
</Field> </Field>
</div> </div>
<p class="mt-3 text-xs text-ink-muted"> <p class="mt-3 text-ss text-ink-muted">
one: <span class="font-mono text-ink">{one() || "—"}</span> · several:{" "} one: <span class="font-mono text-ink">{one() || "—"}</span> · several:{" "}
<span class="font-mono text-ink">{many().join(", ") || "—"}</span> <span class="font-mono text-ink">{many().join(", ") || "—"}</span>
</p> </p>
@@ -634,7 +635,7 @@ function Selects() {
onSelect={(value, option) => setPicked(option.label + " <" + value + ">")} onSelect={(value, option) => setPicked(option.label + " <" + value + ">")}
/> />
</div> </div>
<p class="mt-3 text-xs text-ink-muted"> <p class="mt-3 text-ss text-ink-muted">
Two characters before it asks; 200 ms after you stop typing. A response for a query you Two characters before it asks; 200 ms after you stop typing. A response for a query you
have already typed past is DISCARDED rather than shown which is the whole bug with have already typed past is DISCARDED rather than shown which is the whole bug with
hand-rolled autocompletes. hand-rolled autocompletes.
@@ -654,7 +655,7 @@ function Selects() {
searchable searchable
showSelectAll showSelectAll
/> />
<p class="mt-3 text-xs text-ink-muted"> <p class="mt-3 text-ss text-ink-muted">
Same selection model as the field above; only the thing you click on differs. Same selection model as the field above; only the thing you click on differs.
</p> </p>
</Panel> </Panel>
@@ -697,7 +698,7 @@ function Toggles() {
<Panel title={"Signature pad — " + signed().length + " bytes of SVG"}> <Panel title={"Signature pad — " + signed().length + " bytes of SVG"}>
<FormSignaturePad onchange={setSigned} /> <FormSignaturePad onchange={setSigned} />
<p class="mt-3 text-xs text-ink-muted"> <p class="mt-3 text-ss text-ink-muted">
Draw in it. Clearing it emits an empty string, so "did they sign?" is just a length check. Draw in it. Clearing it emits an empty string, so "did they sign?" is just a length check.
</p> </p>
</Panel> </Panel>
@@ -722,10 +723,10 @@ function Dates() {
<Panel title={"Pickers — picked: " + (date() || "nothing")}> <Panel title={"Pickers — picked: " + (date() || "nothing")}>
<div class="grid gap-4 sm:grid-cols-2"> <div class="grid gap-4 sm:grid-cols-2">
<Field label="Date (portaled; flips near the bottom)"> <Field label="Date (portaled; flips near the bottom)">
<DatePicker value={date} onchange={setDate} clearable placeholder="Pick a date" /> <DatePicker value={date()} onchange={setDate} clearable placeholder="Pick a date" />
</Field> </Field>
<Field label="Date of birth (inline, month/year selects)"> <Field label="Date of birth (inline, month/year selects)">
<DateOfBirthPicker value={dob} onchange={setDob} /> <DateOfBirthPicker value={dob()} onchange={setDob} />
</Field> </Field>
</div> </div>
</Panel> </Panel>
@@ -734,7 +735,7 @@ function Dates() {
<div class="max-w-xs"> <div class="max-w-xs">
<Calendar selected={day()} onSelect={setDay} /> <Calendar selected={day()} onSelect={setDay} />
</div> </div>
<p class="mt-3 text-xs text-ink-muted"> <p class="mt-3 text-ss text-ink-muted">
The same grid the picker drops down, usable directly when you want it inline. Pass{" "} The same grid the picker drops down, usable directly when you want it inline. Pass{" "}
<code class="font-mono">variant="month"</code> for the big version. <code class="font-mono">variant="month"</code> for the big version.
</p> </p>
@@ -858,7 +859,7 @@ function Tables() {
sortDesc={sortDesc()} sortDesc={sortDesc()}
setSortDesc={setSortDesc} setSortDesc={setSortDesc}
/> />
<p class="mt-3 text-xs text-ink-muted"> <p class="mt-3 text-ss text-ink-muted">
Click a Qty or Price cell and type; Tab and the arrow keys move between editable cells. Click a Qty or Price cell and type; Tab and the arrow keys move between editable cells.
The grid does not own the rows it tells you which cell changed and hands the value back. The grid does not own the rows it tells you which cell changed and hands the value back.
</p> </p>
@@ -971,7 +972,7 @@ function Overlays() {
<FormInput placeholder="Focus me" /> <FormInput placeholder="Focus me" />
</Tooltip> </Tooltip>
</div> </div>
<p class="mt-3 text-xs text-ink-muted"> <p class="mt-3 text-ss text-ink-muted">
A tooltip that only answers to a mouse is a tooltip a keyboard user cannot read. A tooltip that only answers to a mouse is a tooltip a keyboard user cannot read.
</p> </p>
</Panel> </Panel>
@@ -1047,7 +1048,7 @@ function Overlays() {
</MenuContent> </MenuContent>
</Menu> </Menu>
</div> </div>
<p class="mt-3 text-xs text-ink-muted"> <p class="mt-3 text-ss text-ink-muted">
The items raise toasts, which is how you can see that an item really does close its own The items raise toasts, which is how you can see that an item really does close its own
menu and that the one marked closeOnClick={"{false}"} does not. menu and that the one marked closeOnClick={"{false}"} does not.
</p> </p>
@@ -1063,7 +1064,7 @@ function Overlays() {
Open wizard Open wizard
</ButtonUI> </ButtonUI>
</div> </div>
<p class="mt-3 text-xs text-ink-muted"> <p class="mt-3 text-ss text-ink-muted">
Open the modal, then the nested one inside it, and press Escape twice: modals unwind ONE Open the modal, then the nested one inside it, and press Escape twice: modals unwind ONE
LAYER per press rather than all at once. LAYER per press rather than all at once.
</p> </p>
@@ -1125,7 +1126,7 @@ function Overlays() {
return ( return (
<Field label="Name (required to continue)"> <Field label="Name (required to continue)">
<FormInput <FormInput
value={wizardName} value={wizardName()}
placeholder="Ada Lovelace" placeholder="Ada Lovelace"
oninput={(e) => setWizardName(e.currentTarget.value)} oninput={(e) => setWizardName(e.currentTarget.value)}
/> />
@@ -1189,7 +1190,7 @@ function Feedback() {
Sticky (no timer) Sticky (no timer)
</ButtonUI> </ButtonUI>
</div> </div>
<p class="mt-3 text-xs text-ink-muted"> <p class="mt-3 text-ss text-ink-muted">
<code class="font-mono">useToast()</code> throws outside a{" "} <code class="font-mono">useToast()</code> throws outside a{" "}
<code class="font-mono">&lt;ToastProvider&gt;</code> which is one of only two providers <code class="font-mono">&lt;ToastProvider&gt;</code> which is one of only two providers
this kit has. this kit has.
@@ -1199,7 +1200,7 @@ function Feedback() {
<Panel title="The guided tour"> <Panel title="The guided tour">
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
<StartTutorialButton>Take the tour</StartTutorialButton> <StartTutorialButton>Take the tour</StartTutorialButton>
<span class="text-xs text-ink-muted"> <span class="text-ss text-ink-muted">
It dims the page, cuts a hole around each target, and animates the spotlight from one It dims the page, cuts a hole around each target, and animates the spotlight from one
to the next. Targets are CSS SELECTORS the same section ids the sidebar jumps to. to the next. Targets are CSS SELECTORS the same section ids the sidebar jumps to.
</span> </span>
@@ -1213,7 +1214,7 @@ function Feedback() {
</ButtonUI> </ButtonUI>
<RemoteUpdateFlash when={flash.visible()} /> <RemoteUpdateFlash when={flash.visible()} />
</div> </div>
<p class="mt-3 text-xs text-ink-muted"> <p class="mt-3 text-ss text-ink-muted">
A brief acknowledgement that data you are looking at was changed by somebody else. It is A brief acknowledgement that data you are looking at was changed by somebody else. It is
not a toast: it belongs next to the thing that moved, not in the corner. not a toast: it belongs next to the thing that moved, not in the corner.
</p> </p>
@@ -1268,7 +1269,7 @@ function Navigation() {
<Panel title="Accordion — one open at a time, or several"> <Panel title="Accordion — one open at a time, or several">
<div class="grid gap-6 lg:grid-cols-2"> <div class="grid gap-6 lg:grid-cols-2">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<p class="text-xs font-semibold uppercase tracking-widest text-ink-faint"> <p class="text-ss font-semibold uppercase tracking-widest text-ink-faint">
SingleAccordion SingleAccordion
</p> </p>
<SingleAccordion <SingleAccordion
@@ -1280,7 +1281,7 @@ function Navigation() {
/> />
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<p class="text-xs font-semibold uppercase tracking-widest text-ink-faint"> <p class="text-ss font-semibold uppercase tracking-widest text-ink-faint">
Accordion (several at once) Accordion (several at once)
</p> </p>
<Accordion <Accordion
@@ -1313,7 +1314,7 @@ function Navigation() {
onItemClick={setSide} onItemClick={setSide}
/> />
</div> </div>
<p class="mt-3 text-xs text-ink-muted"> <p class="mt-3 text-ss text-ink-muted">
It scrolls the element whose <code class="font-mono">id</code> matches the item into view It scrolls the element whose <code class="font-mono">id</code> matches the item into view
so these really do jump, because those sections really do exist on this page. so these really do jump, because those sections really do exist on this page.
</p> </p>
@@ -1328,7 +1329,21 @@ const KIT_NAMES = [
"AutoTable", "Accordion", "Alert", "Badge", "ButtonUI", "Calendar", "CellGrid", "FormCombobox", "AutoTable", "Accordion", "Alert", "Badge", "ButtonUI", "Calendar", "CellGrid", "FormCombobox",
"DatePicker", "FormInput", "Menu", "Modal", "FormMultiSelect", "Popover", "PrettyTable", "DatePicker", "FormInput", "Menu", "Modal", "FormMultiSelect", "Popover", "PrettyTable",
"SegmentedButtons", "FormSignaturePad", "TabGroup", "ThemeToggle", "ToastProvider", "ToggleSwitch", "SegmentedButtons", "FormSignaturePad", "TabGroup", "ThemeToggle", "ToastProvider", "ToggleSwitch",
"Tooltip", "TutorialProvider", "SidebarNav", "ReactiveChart", "FuzzyMatch", "Tooltip", "TutorialProvider", "SidebarNav", "Chart", "FuzzyMatch",
];
// Both "and" and "&" spellings appear here on purpose: with andAmpersand on, typing
// either finds both, and the exact spelling ranks above the substituted one.
// "Standard" / "Brand" hold an "and" that is NOT the whole word — left untouched.
const AND_AMP_NAMES = [
"First Bank & Trust",
"First Bank and Trust Company",
"Smith & Wesson Financial",
"Johnson and Johnson Federal CU",
"Highland Savings & Loan",
"Standard Chartered",
"Brand Mortgage Group",
"AT&T Employees CU",
]; ];
function Search() { function Search() {
@@ -1352,89 +1367,200 @@ function Search() {
onSelect={(value) => setHit(value)} onSelect={(value) => setHit(value)}
/> />
</div> </div>
<p class="mt-3 text-xs text-ink-muted"> <p class="mt-3 text-ss text-ink-muted">
The scorer is headless too: <code class="font-mono">rankFuzzyMatches</code> and{" "} The scorer is headless too: <code class="font-mono">rankFuzzyMatches</code> and{" "}
<code class="font-mono">fuzzySegments</code> give you the ranking and the highlight runs, <code class="font-mono">fuzzySegments</code> give you the ranking and the highlight runs,
and you render them however you like. and you render them however you like.
</p> </p>
</Panel> </Panel>
<Prose>
Pass <code class="font-mono">andAmpersand</code> and the word "and" and the symbol "&amp;"
match each other, so "First Bank and Trust" also finds "First Bank &amp; Trust". The exact
spelling still wins the substituted form is a penalized extra pass, not a free swap and a
stray "and" inside "Standard" or "Brand" is left alone.
</Prose>
<Panel title="andAmpersand — type “first bank and trust”, or “smith & wesson”">
<div class="max-w-md">
<FuzzyMatch
options={AND_AMP_NAMES}
andAmpersand
placeholder="Search bank names…"
maxResults={6}
showScores
/>
</div>
</Panel>
</Section> </Section>
); );
} }
// ---- charts -------------------------------------------------------------- // ---- charts --------------------------------------------------------------
const CHART_DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
// A made-up per-state metric for the choropleth, and a few cities (lat/lng) to drop on
// top of it — Anchorage and Honolulu included, to land on albersUsa's AK/HI insets.
const US_SIGNUPS: Record<string, number> = {
CA: 4820, TX: 3910, NY: 3120, FL: 2870, IL: 1740, PA: 1610, OH: 1490, GA: 1450,
NC: 1360, MI: 1280, WA: 1230, AZ: 1180, MA: 1120, VA: 1090, CO: 980, TN: 940,
NJ: 910, OR: 720, MN: 690, WI: 610, MO: 560, MD: 540, IN: 520, NV: 480,
UT: 430, AL: 390, SC: 360, KY: 310, LA: 300, OK: 280, CT: 260, IA: 210,
KS: 180, AK: 140, HI: 160, ME: 120, MT: 90, WY: 60, ND: 70, SD: 80,
};
const US_CITIES = [
{ label: "Seattle", lat: 47.6062, lng: -122.3321, value: 1230 },
{ label: "San Francisco", lat: 37.7749, lng: -122.4194, value: 2110 },
{ label: "Denver", lat: 39.7392, lng: -104.9903, value: 980 },
{ label: "Chicago", lat: 41.8781, lng: -87.6298, value: 1740 },
{ label: "New York", lat: 40.7128, lng: -74.006, value: 3120 },
{ label: "Miami", lat: 25.7617, lng: -80.1918, value: 1460 },
{ label: "Anchorage", lat: 61.2181, lng: -149.9003, value: 140 },
{ label: "Honolulu", lat: 21.3069, lng: -157.8583, value: 160 },
];
function Charts() { function Charts() {
const [seed, setSeed] = createSignal(0); const [seed, setSeed] = createSignal(0);
const [threeD, setThreeD] = createSignal(false);
const [depth, setDepth] = createSignal(16);
const [tilt, setTilt] = createSignal(0.6);
const data = () => { // Two series over the same week. seed() reshuffles them so you can watch the SVG
const base = [42, 17, 63, 28, 55, 9, 71]; // move — no teardown, no new instance, just the marks that changed.
const values = base.map((v) => (seed() === 0 ? v : Math.max(4, (v * 7 + seed() * 13) % 80))); const shuffle = (base: number[]) =>
return { base.map((v) => (seed() === 0 ? v : Math.max(4, (v * 7 + seed() * 13) % 80)));
labels: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"], const requests = (): ChartSeries => ({ name: "Requests", data: shuffle([42, 17, 63, 28, 55, 9, 71]) });
datasets: [ const errors = (): ChartSeries => ({ name: "Errors", data: shuffle([8, 3, 12, 6, 9, 2, 14]) });
{ const pieData = (): ChartSeries => ({ name: "Traffic", data: shuffle([40, 25, 20, 15, 8]) });
label: "Requests", const pieLabels = ["Direct", "Search", "Social", "Email", "Referral"];
data: values, const regions = ["East", "Central", "Mountain", "Pacific"];
// A literal, not var(--color-primary): chart.js paints a canvas, and a
// canvas cannot read a CSS variable. So this is the one place in the
// site that names the navy instead of asking for it — and it names the
// DARK theme's lighter navy, which is the only one of the two that
// reads on both a white page and a near-black one.
backgroundColor: "#2b4f80",
borderColor: "#2b4f80",
},
],
};
};
return ( return (
<Section id="charts" title="Charts"> <Section id="charts" title="Charts">
<Prose> <Prose>
<code class="font-mono">@ui/Chart</code> wraps chart.js: it creates a chart against a canvas <code class="font-mono">@ui/Chart</code> draws SVG no charting library, nothing vendored, no{" "}
on mount and pushes new data through an effect, so changing the data updates the chart rather <code class="font-mono">&lt;canvas&gt;</code>. Because the picture is JSX it is already reactive:
than rebuilding the page. change <code class="font-mono">series</code> and Solid re-renders the marks that moved. There is
no <code class="font-mono">update()</code> to call.
</Prose> </Prose>
<Prose> <Prose>
This is the one component in the kit that is genuinely different on the other side. The Go Being SVG buys two things a canvas cannot. The marks name the theme tokens
port keeps the props shape for parity and renders an empty canvas canvas drawing, a (<code class="font-mono">var(--color-chart-1)</code> ), so the palette inverts for dark mode with
JavaScript charting library and mount lifecycles do not exist in the neutral Go runtime. Kjol the rest of the site a canvas paints pixels and cannot read a CSS variable. And every mark is a
Wasm Web draws SVG with go-chart instead, and draws it in the browser, in WebAssembly, exactly real element, so hovering shows a crosshair and one tooltip listing every series at that point.
as this one draws to a canvas here. Same place, different library. Neither side pretends
otherwise.
</Prose> </Prose>
<Panel title="Bar and doughnut, from the same data"> <Panel title="Grouped bars, a smooth area, a two-line series, and a donut from one data set">
{/* The wrapper is h-full with maintainAspectRatio:false, so the PARENT must <div class="grid gap-6 lg:grid-cols-12">
have an explicit height or the canvas collapses to nothing at all. */} <div class="lg:col-span-7">
<div class="grid gap-4 lg:grid-cols-12"> <Chart kind="bar" title="Requests & errors this week" labels={CHART_DAYS} series={[requests(), errors()]} height={260} threeD={threeD()} />
<div class="h-72 lg:col-span-7">
<ReactiveChart
type="bar"
data={data()}
options={{ plugins: { legend: { display: false } } }}
/>
</div> </div>
<div class="h-72 lg:col-span-5"> <div class="lg:col-span-5">
<ReactiveChart type="doughnut" data={data()} /> <Chart kind="donut" title="Traffic by source" labels={pieLabels} series={[pieData()]} height={260} threeD={threeD()} />
</div>
<div class="lg:col-span-7">
<Chart kind="area" curve="smooth" title="Requests, smoothed" labels={CHART_DAYS} series={[requests()]} height={220} threeD={threeD()} />
</div>
<div class="lg:col-span-5">
<Chart kind="line" title="Requests & errors" labels={CHART_DAYS} series={[requests(), errors()]} height={220} threeD={threeD()} />
</div> </div>
</div> </div>
<div class="mt-4 flex items-center gap-3"> <div class="mt-4 flex items-center gap-3">
<ButtonUI color={BUTTON_COLOR_PRIMARY} small onclick={() => setSeed(seed() + 1)}> <ButtonUI color={BUTTON_COLOR_PRIMARY} small onclick={() => setSeed(seed() + 1)}>
New data New data
</ButtonUI> </ButtonUI>
<span class="text-xs text-ink-muted"> <ButtonUI color={BUTTON_COLOR_NEUTRAL} small onclick={() => setThreeD(!threeD())}>
The chart updates in place it is not torn down and rebuilt. {threeD() ? "Flat" : "3D"}
</ButtonUI>
<span class="text-ss text-ink-muted">
Each chart carries a <code class="font-mono">title</code>; a multi-series chart also draws a
legend. Click a legend key say <em>Errors</em> or a donut slice to hide it, and the scale
and marks recompute from what's left. The <code class="font-mono">threeD</code> prop gives
every plot a 3D grid bars extrude, a line or area draws flat over it, the donut tilts.
</span> </span>
</div> </div>
</Panel> </Panel>
<AlertYellow header="It has to be vendored" class="mt-6"> <Panel title="3D grid — bars extrude into it, lines draw flat on the front">
<code class="font-mono">Chart.tsx</code> imports chart.js at the top level and registers <div class="grid items-center gap-6 lg:grid-cols-12">
every controller on module load. An app that uses it must vendor chart.js and{" "} <div class="lg:col-span-4">
<code class="font-mono">@kurkle/color</code>, or the bundle does not degrade it fails to <Chart kind="bar" title="Bars" labels={CHART_DAYS}
evaluate, and you get an empty page and one line in the console. series={[requests()]} height={240} threeD depth={depth()} tilt={tilt()} />
</AlertYellow> </div>
<div class="lg:col-span-4">
<Chart kind="line" title="Lines" labels={CHART_DAYS}
series={[requests(), errors()]} height={240} threeD depth={depth()} tilt={tilt()} />
</div>
<div class="flex flex-col justify-center gap-5 lg:col-span-4">
<label class="flex flex-col gap-1.5">
<span class="flex items-center justify-between text-ss text-ink-soft">
<code class="font-mono">depth</code>
<span class="font-mono tabular-nums text-ink-muted">{depth()}px</span>
</span>
<input type="range" min="0" max="40" step="1" value={depth()}
class="w-full accent-accent"
oninput={(e) => setDepth(+e.currentTarget.value)} />
</label>
<label class="flex flex-col gap-1.5">
<span class="flex items-center justify-between text-ss text-ink-soft">
<code class="font-mono">tilt</code>
<span class="font-mono tabular-nums text-ink-muted">{tilt().toFixed(2)}</span>
</span>
<input type="range" min="0" max="1" step="0.05" value={tilt()}
class="w-full accent-accent"
oninput={(e) => setTilt(+e.currentTarget.value)} />
</label>
</div>
</div>
<p class="mt-3 text-ss text-ink-muted">
The same <code class="font-mono">depth</code> and <code class="font-mono">tilt</code> give the grid
itself perspective: a floor recedes from the value-0 baseline into a back wall. Bars extrude into
that space; a line or area stays flat on the front plane, drawn over the grid and read against the
front axis the stroke is <em>not</em> itself extruded, since depth on a hairline reads as noise.{" "}
<code class="font-mono">tilt</code> runs 1 (head-on, near-flat) to 0 (bird's-eye);{" "}
<code class="font-mono">depth</code> is the sweep length in px.
</p>
</Panel>
<Prose>
One <code class="font-mono">kind</code> prop picks the form {" "}
<code class="font-mono">"line" | "area" | "bar" | "pie" | "donut"</code> and{" "}
<code class="font-mono">stacked</code>, <code class="font-mono">horizontal</code>,{" "}
<code class="font-mono">curve</code>, <code class="font-mono">threeD</code>{" "}
(with <code class="font-mono">depth</code> / <code class="font-mono">tilt</code>),{" "}
<code class="font-mono">title</code>, <code class="font-mono">palette</code> and{" "}
<code class="font-mono">valueFormat</code> refine it. The legend it draws for a multi-series or
pie/donut chart is interactive clicking a key toggles that series or slice. The width is measured
from the container, so a chart fills whatever column you give it.
</Prose>
<Panel title="Horizontal bars, and stacked — the same kind, transposed and layered">
<div class="grid gap-6 lg:grid-cols-2">
<Chart kind="bar" horizontal labels={CHART_DAYS} series={[requests(), errors()]} height={260} />
<Chart kind="bar" stacked labels={regions}
series={[
{ name: "Requests", data: shuffle([42, 55, 28, 63]) },
{ name: "Errors", data: shuffle([8, 9, 6, 12]) },
{ name: "Retries", data: shuffle([5, 7, 3, 9]) },
]} height={260} />
</div>
<p class="mt-3 text-ss text-ink-muted">
<code class="font-mono">horizontal</code> runs the categories down the y-axis;{" "}
<code class="font-mono">stacked</code> layers the series with a 2px surface gap between segments.
</p>
</Panel>
<Panel title="US heatmap — a value per state, with proportional lat/lng points on top">
<USHeatmap data={US_SIGNUPS} points={US_CITIES} proportional valueFormat={(v) => v.toLocaleString("en-US")} />
<p class="mt-3 text-ss text-ink-muted">
<code class="font-mono">@ui/USHeatmap</code> shades each state on a themed sequential ramp and
projects <code class="font-mono">points</code> (latitude/longitude) with a dependency-free
albersUsa port so Anchorage and Honolulu land on the Alaska and Hawaii insets. With{" "}
<code class="font-mono">proportional</code>, each dot's area scales with its value. Hover a
state or a point.
</p>
</Panel>
</Section> </Section>
); );
} }
@@ -1508,7 +1634,7 @@ function Theming() {
)} )}
</For> </For>
</div> </div>
<p class="mt-3 text-xs text-ink-muted"> <p class="mt-3 text-ss text-ink-muted">
The swatch class is written out in full in the source, not built as{" "} The swatch class is written out in full in the source, not built as{" "}
<code class="font-mono">"bg-" + name</code>. Tailwind finds the classes it must compile by <code class="font-mono">"bg-" + name</code>. Tailwind finds the classes it must compile by
scanning the source for literal strings a concatenation is invisible to it, and every scanning the source for literal strings a concatenation is invisible to it, and every

View File

@@ -13,7 +13,7 @@ export function Overview() {
return ( return (
<div> <div>
<p class="text-xs font-semibold uppercase tracking-widest text-primary">Kjøl JS Web</p> <p class="text-ss font-semibold uppercase tracking-widest text-primary">Kjøl JS Web</p>
<h1 class="mt-2 text-3xl font-semibold tracking-tight text-ink"> <h1 class="mt-2 text-3xl font-semibold tracking-tight text-ink">
A Solid kit, built by a Go toolchain A Solid kit, built by a Go toolchain
</h1> </h1>
@@ -103,7 +103,7 @@ export function Overview() {
function Stage(props: { n: string; title: string; body: string }) { function Stage(props: { n: string; title: string; body: string }) {
return ( return (
<div class="flex gap-4 border-l-2 border-line pl-4"> <div class="flex gap-4 border-l-2 border-line pl-4">
<span class="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-surface-raised text-xs font-semibold text-ink-soft"> <span class="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-surface-raised text-ss font-semibold text-ink-soft">
{props.n} {props.n}
</span> </span>
<div> <div>

View File

@@ -29,7 +29,7 @@ export function Ssr() {
return ( return (
<div class="page-ssr mx-auto max-w-2xl px-4 py-14"> <div class="page-ssr mx-auto max-w-2xl px-4 py-14">
<p class="text-xs font-semibold uppercase tracking-widest text-primary">Kjøl JS Web</p> <p class="text-ss font-semibold uppercase tracking-widest text-primary">Kjøl JS Web</p>
<h1 class="mt-2 text-3xl font-semibold tracking-tight text-ink"> <h1 class="mt-2 text-3xl font-semibold tracking-tight text-ink">
This page was rendered by Go This page was rendered by Go
</h1> </h1>

View File

@@ -0,0 +1,8 @@
// Ambient shims for tsserver only. The vendored pdf-lib / pdfjs-dist here are trimmed to
// the runtime files the bundler pins (frontend/vendor/vendor.json), so their `.d.ts` type
// trees are absent and each package.json `types` field points at a file that was not
// vendored. @ui/AutoTable imports both; the bundler resolves them at build time, but the
// editor needs a declaration or it reports "cannot find module". These make them `any`,
// which is all this example needs — it does not exercise the PDF export path itself.
declare module "pdf-lib";
declare module "pdfjs-dist";

View File

@@ -5,12 +5,8 @@
"//3": "Only the files the bundler actually pins are vendored, not the whole npm packages — 3.5 MB rather than 62 MB of type definitions, CJS builds and documentation. If a subpath import is ever added that reaches outside dist/ or build/, this is the first place it will fail.", "//3": "Only the files the bundler actually pins are vendored, not the whole npm packages — 3.5 MB rather than 62 MB of type definitions, CJS builds and documentation. If a subpath import is ever added that reaches outside dist/ or build/, this is the first place it will fail.",
"//4": "chart.js (and its @kurkle/color dependency) are here for the same reason: @ui/Chart imports chart.js at the top level and registers every controller on module load. Its wrapper is h-full with maintainAspectRatio:false, so the PARENT must have an explicit height or the canvas collapses to nothing.",
"entrypoints": { "entrypoints": {
"pdf-lib": "pdf-lib/dist/pdf-lib.esm.js", "pdf-lib": "pdf-lib/dist/pdf-lib.esm.js",
"pdfjs-dist": "pdfjs-dist/build/pdf.mjs", "pdfjs-dist": "pdfjs-dist/build/pdf.mjs"
"chart.js": "chart.js/dist/chart.js",
"@kurkle/color": "@kurkle/color/dist/color.esm.js"
} }
} }

View File

@@ -1,25 +1,20 @@
// The example is its own module so its go-chart dependency (and freetype / // The example is its own module so its build/SSR toolchain (esbuild, goja, minify) stays
// x/image) stays out of the kjol module kjol's engine packages are // out of the kjol module kjol's engine packages are stdlib-only. kjol is resolved
// stdlib-only. kjol is resolved locally via the replace below (no publish step). // locally via the replace below (no publish step).
module kjolweb module kjolwebsite
go 1.26.3 go 1.26.3
require ( require kjol v0.0.0
github.com/wcharczuk/go-chart/v2 v2.1.2
kjol v0.0.0
)
require ( require (
github.com/dlclark/regexp2/v2 v2.2.1 // indirect github.com/dlclark/regexp2/v2 v2.2.1 // indirect
github.com/dop251/goja v0.0.0-20260618133527-c9b2ea77db59 // indirect github.com/dop251/goja v0.0.0-20260618133527-c9b2ea77db59 // indirect
github.com/evanw/esbuild v0.28.0 // indirect github.com/evanw/esbuild v0.28.0 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
github.com/tdewolff/minify/v2 v2.24.13 // indirect github.com/tdewolff/minify/v2 v2.24.13 // indirect
github.com/tdewolff/parse/v2 v2.8.13 // indirect github.com/tdewolff/parse/v2 v2.8.13 // indirect
golang.org/x/image v0.18.0 // indirect
golang.org/x/net v0.55.0 // indirect golang.org/x/net v0.55.0 // indirect
golang.org/x/sys v0.45.0 // indirect golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect golang.org/x/text v0.37.0 // indirect

View File

@@ -0,0 +1,27 @@
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/dop251/goja v0.0.0-20260618133527-c9b2ea77db59 h1:DjKLmvKK9u15djHZ88N8M0DhgnHVgJJ8bnEe0h7Lga8=
github.com/dop251/goja v0.0.0-20260618133527-c9b2ea77db59/go.mod h1:Sc+QOu1WruvaaeT/cxFez/pXHpI9ZDjg/E8QNfSVveI=
github.com/evanw/esbuild v0.28.0 h1:V96ghtc5p5JnNUQIUsc5H3kr+AcFcMqOJll2ZmJW6Lo=
github.com/evanw/esbuild v0.28.0/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U=
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
github.com/tdewolff/minify/v2 v2.24.13 h1:xrcF7gKDnUszseEY9WX9mUlZII2v2Go/QAcAwRASw58=
github.com/tdewolff/minify/v2 v2.24.13/go.mod h1:emvwoYeIl8bfAKqRU5ww95LX9Gpggpqv/naal9a8Yq0=
github.com/tdewolff/parse/v2 v2.8.13 h1:si/8rLw5BZZTWCCiMm9A3f6x+RmqYfrkEeXCgpX5ick=
github.com/tdewolff/parse/v2 v2.8.13/go.mod h1:XdsoSFThlVIRIajAuqz1evNY7bagZS8LBOPA3aVopwQ=
github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk=
github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=

View File

@@ -1,4 +1,4 @@
// Command server runs the kjol-web site on kjol's reusable wasmdevserver: // Command server runs the kjol-website site on kjol's reusable wasmdevserver:
// it SSRs the app's static routes, hosts the /rsc server-component endpoint, and // it SSRs the app's static routes, hosts the /rsc server-component endpoint, and
// hot-swaps the wasm into the browser on change. It shows the coupling // hot-swaps the wasm into the browser on change. It shows the coupling
// inversion — the framework (wasmdevserver) imports no app code; the app injects // inversion — the framework (wasmdevserver) imports no app code; the app injects
@@ -6,7 +6,7 @@
// //
// Run it from THIS directory (the relative paths below are resolved against it): // Run it from THIS directory (the relative paths below are resolved against it):
// //
// go run ./server # from go/cmd/kjol-web // go run ./server # from go/cmd/kjol-website
package main package main
import ( import (
@@ -16,13 +16,13 @@ import (
"net/http" "net/http"
"kjol/httputil" "kjol/httputil"
"kjol/vdom"
"kjol/wasmdevserver" "kjol/wasmdevserver"
"kjol/wasmruntime/vdom"
"kjol/webui" "kjol/webui"
"kjolweb/app" "kjolwebsite/app"
"kjolweb/build" "kjolwebsite/build"
"kjolweb/internal/handlers" "kjolwebsite/internal/handlers"
) )
func main() { func main() {
@@ -42,10 +42,11 @@ func main() {
Watch: *watch, Watch: *watch,
WatchDirs: []string{ WatchDirs: []string{
"app", "wasm", "css", // the Go/WASM app "app", "wasm", "css", // the Go/WASM app
"frontend", // the Solid app — a .tsx save rebuilds the JS bundle "frontend", // the Solid app — a .tsx save rebuilds the JS bundle
"../../webui", "../../vdom", "../../wasmruntime", "../../rsc", // the wasm engine "../../webui", "../../wasmruntime", // the wasm engine (vdom + rsc now live UNDER wasmruntime, and the watcher walks subdirs)
"../../lexer", // the code-block highlighter "../../lexer", // the code-block highlighter
"../../jsruntime/uikit", "../../jsruntime/styles", // the Solid kit + the shared theme "../../jsruntime/uikit", // the Solid kit
"../../tw", // the Tailwind engine + the shared kjol extension layer (kjol_theme.css)
}, },
Build: build.All, Build: build.All,
BuildCSS: build.Tailwind, // a .css save skips codegen+wasm and hot-swaps the stylesheet BuildCSS: build.Tailwind, // a .css save skips codegen+wasm and hot-swaps the stylesheet

View File

@@ -0,0 +1,32 @@
{
// TS config for the kjol-website front-end. The bundler resolves the @ui / @kjol / @appgen
// aliases at build time; the editor's TypeScript server needs them declared here or it
// reports every `import … from "@ui/…"` as an unresolved module. Mirrors the shape of a
// consuming app's root tsconfig, with the paths made relative to this nested example
// (the shared JS tree lives two levels up at go/jsruntime).
"compilerOptions": {
"target": "ES2025",
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
"noEmit": true,
"allowJs": true,
"checkJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"strict": false,
"lib": ["ES2025", "DOM", "DOM.Iterable"],
"paths": {
"@ui/*": ["../../jsruntime/uikit/*"],
"@kjol/*": ["../../jsruntime/*"],
"@appgen/*": ["./frontend/src/ui/generated/*"],
"*": ["../../jsruntime/runtime/*", "./frontend/vendor/*"]
}
},
"include": ["frontend/**/*", "../../jsruntime/**/*"],
"exclude": ["frontend/css/**/*", "frontend/vendor/**/*", "../../jsruntime/runtime/**/*", "../../jsruntime/types.d.ts"]
}

View File

@@ -5,9 +5,9 @@
package main package main
import ( import (
"kjolweb/app"
"kjol/vdom"
"kjol/wasmruntime" "kjol/wasmruntime"
"kjol/wasmruntime/vdom"
"kjolwebsite/app"
) )
func main() { func main() {

View File

@@ -1,8 +1,12 @@
// Command twcss compiles a Tailwind v4 stylesheet with kjol's native engine, // Command twcss compiles a kjol app's Tailwind stylesheet with kjol's native
// scanning explicit content globs for utility candidates. Unlike the app bundler // engine, scanning explicit content globs for utility candidates. Unlike the app
// (which is wired to the frontend tree) it takes the entry, output, and content // bundler (which is wired to the frontend tree) it takes the entry, output, and
// globs as flags/args, so it works for markup authored in any language — used by // content globs as flags/args, so it works for markup authored in any language —
// the kjol-web site, whose Go/WASM half writes its UI in Go. // used by the kjol-website site, whose Go/WASM half writes its UI in Go.
//
// It compiles via tw.CompileAppFiles, so the -entry stylesheet is layered onto
// kjol's shared extension layer: base Tailwind → kjol extensions → this stylesheet.
// The entry therefore carries only brand and names none of the shared tokens.
// //
// Usage (globs are relative to -base; pass "**" for a recursive walk): // Usage (globs are relative to -base; pass "**" for a recursive walk):
// //
@@ -32,7 +36,7 @@ func main() {
fmt.Fprintln(os.Stderr, "twcss:", err) fmt.Fprintln(os.Stderr, "twcss:", err)
os.Exit(1) os.Exit(1)
} }
css, err := tw.CompileFiles(string(src), *base, flag.Args()) css, err := tw.CompileAppFiles(string(src), *base, flag.Args())
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "twcss:", err) fmt.Fprintln(os.Stderr, "twcss:", err)
os.Exit(1) os.Exit(1)

View File

@@ -155,7 +155,7 @@ func write(path, tmpl string, data genData) {
const routesTmpl = `// Code generated by wasmgen. DO NOT EDIT. const routesTmpl = `// Code generated by wasmgen. DO NOT EDIT.
package app package app
import "kjol/vdom" import "kjol/wasmruntime/vdom"
// Routes maps each //gowasm:page path to its instantiated render function. // Routes maps each //gowasm:page path to its instantiated render function.
func Routes(d Deps) map[string]func() *vdom.VNode { func Routes(d Deps) map[string]func() *vdom.VNode {
@@ -191,7 +191,7 @@ const serverTmpl = `// Code generated by wasmgen. DO NOT EDIT.
package app package app
import "kjol/rsc" import "kjol/wasmruntime/rsc"
func init() { func init() {
{{range .Servers}} rsc.Register("{{.}}", {{.}}) {{range .Servers}} rsc.Register("{{.}}", {{.}})
@@ -205,8 +205,8 @@ const clientTmpl = `// Code generated by wasmgen. DO NOT EDIT.
package app package app
import ( import (
"kjol/rsc" "kjol/wasmruntime/rsc"
"kjol/vdom" "kjol/wasmruntime/vdom"
) )
{{range .Servers}}// {{.}} is a generated client stub for the server component of the same name. {{range .Servers}}// {{.}} is a generated client stub for the server component of the same name.

View File

@@ -94,14 +94,5 @@ func vendorDirs() []string {
return []string{filepath.Join(frontendDir, "vendor")} return []string{filepath.Join(frontendDir, "vendor")}
} }
// themeCSSPath is the shared Tailwind @theme scaffold prepended to the app's
// brand style.css, or "" in single-tree mode (the app's style.css is complete).
func themeCSSPath() string {
if webDir != "" {
return filepath.Join(webDir, "styles", "theme.css")
}
return ""
}
// faOutPath is where the generated FA registry is written (app-owned). // faOutPath is where the generated FA registry is written (app-owned).
func faOutPath() string { return filepath.Join(genTSDir, "faIcons.ts") } func faOutPath() string { return filepath.Join(genTSDir, "faIcons.ts") }

View File

@@ -78,17 +78,10 @@ func compileCSSBundle(label string, twSources []string, outName string) (bundleS
kitCands := tw.Scan(uikitDir(), []string{"**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"}) kitCands := tw.Scan(uikitDir(), []string{"**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"})
candidates = dedupStrings(append(candidates, kitCands...)) candidates = dedupStrings(append(candidates, kitCands...))
// Prepend the shared @theme scaffold (kjol jsruntime/styles/theme.css) ahead of the // CompileApp prepends kjol's shared extension layer (tokens, the class-based dark
// app's brand style.css so its tokens/vars are in scope. Absent in single-tree // variant, the chart palette) ahead of the app's brand style.css, so the app
// mode (the app's style.css is already complete). // stylesheet carries only brand: base Tailwind → kjol extensions → app brand.
input := string(src) compiled, count, err := tw.CompileApp(string(src), cssDir, candidates)
if tp := themeCSSPath(); tp != "" {
if theme, e := os.ReadFile(tp); e == nil {
input = string(theme) + "\n" + input
}
}
compiled, count, err := tw.Compile(input, cssDir, candidates)
if err != nil { if err != nil {
return bundleStats{}, fmt.Errorf("tailwind compile (%s): %w", label, err) return bundleStats{}, fmt.Errorf("tailwind compile (%s): %w", label, err)
} }

View File

@@ -119,6 +119,15 @@ func generateFAIcons() error {
} }
b.WriteString("};\n") b.WriteString("};\n")
// Skip the write when content is unchanged so the file's mtime doesn't
// advance on every SPA source edit — the dev watcher treats any mtime bump
// as a real change and hot-reloads Icons.tsx (the nearest HMR boundary that
// imports this file), which is wasteful when nothing about the icon set
// actually changed.
if existing, err := os.ReadFile(faOutPath()); err == nil && string(existing) == b.String() {
return nil
}
if err := os.MkdirAll(filepath.Dir(faOutPath()), 0755); err != nil { if err := os.MkdirAll(filepath.Dir(faOutPath()), 0755); err != nil {
return err return err
} }

View File

@@ -82,7 +82,7 @@ setTimeout(() => {
}, 0); }, 0);
`) `)
eps, err := loadVendorManifest(filepath.Join(frontendAbs, "vendor")) eps, err := loadVendorManifest([]string{filepath.Join(frontendAbs, "vendor")})
if err != nil { if err != nil {
t.Fatalf("vendor manifest: %v", err) t.Fatalf("vendor manifest: %v", err)
} }
@@ -90,7 +90,7 @@ setTimeout(() => {
hub: newHub(), hub: newHub(),
frontend: frontendAbs, frontend: frontendAbs,
srcRoot: tmpSrc, srcRoot: tmpSrc,
vendor: filepath.Join(frontendAbs, "vendor"), vendorDirs: []string{filepath.Join(frontendAbs, "vendor")},
graph: newModuleGraph(), graph: newModuleGraph(),
vendorCache: map[string][]byte{}, vendorCache: map[string][]byte{},
cssTrigger: make(chan struct{}, 1), cssTrigger: make(chan struct{}, 1),

View File

@@ -27,9 +27,9 @@ func TestDevServerAssetURL(t *testing.T) {
[]byte(`import u from "pdfjs-dist/build/pdf.worker.min.mjs?url"; []byte(`import u from "pdfjs-dist/build/pdf.worker.min.mjs?url";
export const url = u;`), 0o644) export const url = u;`), 0o644)
eps, _ := loadVendorManifest(filepath.Join(frontendAbs, "vendor")) eps, _ := loadVendorManifest([]string{filepath.Join(frontendAbs, "vendor")})
d := &devServer{hub: newHub(), frontend: frontendAbs, srcRoot: tmpSrc, d := &devServer{hub: newHub(), frontend: frontendAbs, srcRoot: tmpSrc,
vendor: filepath.Join(frontendAbs, "vendor"), graph: newModuleGraph(), vendorDirs: []string{filepath.Join(frontendAbs, "vendor")}, graph: newModuleGraph(),
vendorCache: map[string][]byte{}, cssTrigger: make(chan struct{}, 1), vendorEntrypoints: eps} vendorCache: map[string][]byte{}, cssTrigger: make(chan struct{}, 1), vendorEntrypoints: eps}
out, err := d.transformModule(filepath.Join(tmpSrc, "uses-worker.tsx")) out, err := d.transformModule(filepath.Join(tmpSrc, "uses-worker.tsx"))

View File

@@ -57,9 +57,9 @@ func TestServeModuleCompileErrorServesOverlay(t *testing.T) {
os.WriteFile(filepath.Join(tmpSrc, "Broken.tsx"), os.WriteFile(filepath.Join(tmpSrc, "Broken.tsx"),
[]byte("export default function Broken() {\n return <div><span></div>;\n}\n"), 0o644) []byte("export default function Broken() {\n return <div><span></div>;\n}\n"), 0o644)
eps, _ := loadVendorManifest(filepath.Join(frontendAbs, "vendor")) eps, _ := loadVendorManifest([]string{filepath.Join(frontendAbs, "vendor")})
d := &devServer{hub: newHub(), frontend: frontendAbs, srcRoot: tmpSrc, d := &devServer{hub: newHub(), frontend: frontendAbs, srcRoot: tmpSrc,
vendor: filepath.Join(frontendAbs, "vendor"), graph: newModuleGraph(), vendorDirs: []string{filepath.Join(frontendAbs, "vendor")}, graph: newModuleGraph(),
vendorCache: map[string][]byte{}, cssTrigger: make(chan struct{}, 1), vendorEntrypoints: eps} vendorCache: map[string][]byte{}, cssTrigger: make(chan struct{}, 1), vendorEntrypoints: eps}
req := httptest.NewRequest("GET", srcURLPrefix+"Broken.tsx", nil) req := httptest.NewRequest("GET", srcURLPrefix+"Broken.tsx", nil)

View File

@@ -26,10 +26,12 @@ func esbuildDefine() map[string]string {
} }
// resolveEntryPoint returns the path (relative to frontendDir) of the // resolveEntryPoint returns the path (relative to frontendDir) of the
// SPA entry point, preferring app.tsx over app.js. // SPA entry point, preferring app.tsx, then app.ts, then app.js.
func resolveEntryPoint() string { func resolveEntryPoint() string {
if _, err := os.Stat(filepath.Join(frontendDir, "src/app.tsx")); err == nil { for _, e := range []string{"src/app.tsx", "src/app.ts"} {
return "src/app.tsx" if _, err := os.Stat(filepath.Join(frontendDir, e)); err == nil {
return e
}
} }
return "src/app.js" return "src/app.js"
} }
@@ -79,8 +81,16 @@ func bundleJSEntry(entry, outName string) (bundleStats, error) {
plugins := []esbuild.Plugin{aliasPlugin(), defaultExportShimPlugin(), Plugin()} plugins := []esbuild.Plugin{aliasPlugin(), defaultExportShimPlugin(), Plugin()}
plugins = append(plugins, assetURLPlugin(), vendorManifestPlugin(entrypoints, vdirs)) plugins = append(plugins, assetURLPlugin(), vendorManifestPlugin(entrypoints, vdirs))
// esbuild wants forward slashes and a leading "./"; a relative path with the
// OS separator (filepath.Join gives backslashes on Windows) or without "./" is
// read as a bare package specifier and fails to resolve.
entryPath := filepath.ToSlash(entry)
if !filepath.IsAbs(entry) && !strings.HasPrefix(entryPath, "./") && !strings.HasPrefix(entryPath, "../") {
entryPath = "./" + entryPath
}
result := esbuild.Build(esbuild.BuildOptions{ result := esbuild.Build(esbuild.BuildOptions{
EntryPoints: []string{entry}, EntryPoints: []string{entryPath},
Outfile: filepath.Join(outputDir, outName), Outfile: filepath.Join(outputDir, outName),
Bundle: true, Bundle: true,
Write: true, Write: true,

View File

@@ -54,3 +54,7 @@ export function numberToStringWithCommas(n: number):string {
return result; return result;
} }
export function clamp(val: number, min: number, max: number) : number {
return Math.min(Math.max(val, min), max);
}

View File

@@ -1,6 +1,10 @@
// Package jsruntime is the JS/TS tree of kjol: the Solid component kit, the // Package jsruntime is the JS/TS tree of kjol: the Solid component kit, the
// vendored Solid runtime, the FontAwesome SVG source kit, the shared Tailwind // vendored Solid runtime, the FontAwesome SVG source kit, and the generic TS
// @theme scaffold, and the generic TS scaffolding (auth, hooks, ssr, utils). // scaffolding (auth, hooks, ssr, utils).
//
// The shared Tailwind design tokens are NOT here — they live in kjol/tw as the
// extension layer (tw/kjol_theme.css), which the Tailwind engine layers between
// its own defaults and an app's brand. See tw.CompileApp.
// //
// It holds no Go beyond this file. The package exists so the tree has a stable // It holds no Go beyond this file. The package exists so the tree has a stable
// home inside the Go module rather than a sibling directory the build has to go // home inside the Go module rather than a sibling directory the build has to go
@@ -26,9 +30,6 @@
// per-app registry (@appgen/faIcons); the registry is app-owned and is // per-app registry (@appgen/faIcons); the registry is app-owned and is
// not committed here. An app's own frontend/icons is searched first, so // not committed here. An app's own frontend/icons is searched first, so
// an app with a fuller kit keeps it (see jsbundler.iconsDirs). // an app with a fuller kit keeps it (see jsbundler.iconsDirs).
// styles/ theme.css — the @theme scaffold + :root fa vars, prepended to the
// app's brand style.css by the bundler. Brand color/font tokens stay
// app-side.
// //
// The rest (auth/ hooks/ ssr/ utils/ env.ts basic.ts finance.ts superfun.ts) is // The rest (auth/ hooks/ ssr/ utils/ env.ts basic.ts finance.ts superfun.ts) is
// generic TS scaffolding; apps import it as @kjol/*. Concrete permission // generic TS scaffolding; apps import it as @kjol/*. Concrete permission

View File

@@ -17,7 +17,7 @@ Only the styles in `jsbundler`'s `faStyleDirs` are read — currently `regular/`
<https://fontawesome.com/license> <https://fontawesome.com/license>
This is a **subset**, not the full kit: the ~43 icons the `uikit/` components and This is a **subset**, not the full kit: the ~43 icons the `uikit/` components and
the `kjol-web` example actually reference, in `regular` and `solid`. The full kit the `kjol-website` example actually reference, in `regular` and `solid`. The full kit
is ~81,000 files across 17 style directories and has no business in a shared is ~81,000 files across 17 style directories and has no business in a shared
submodule. submodule.

View File

@@ -0,0 +1,108 @@
# `timedotgo`
Golang's [time](https://pkg.go.dev/time) is excellent. This is a small,
close-as-reasonable port of the API to typescript with full support
for time zone conversions, parsing and formatting.
- [GitHub](https://github.com/rednexela1941/timedotgo)
- [Documentation](https://rednexela1941.github.io/timedotgo/)
# Installation
`npm install timedotgo`
# Examples
## Formatting
```ts
import * as time from "timedotgo";
// 0, 1, 2, 3, 4, 5, 6, 7 -- simple as.
const format = "Monday January 02 03:04:05.000 PM -07:00:00";
const now = time.Now();
const california = now.In("America/Los_Angeles");
const berlin = now.In("Europe/Berlin");
console.log("Right now, it is:");
console.log("Local:", now.Format(format));
console.log("UTC:", now.UTC().Format(format));
console.log("California:", california.Format(format));
console.log("Berlin:", berlin.Format(format));
```
### Output
```
Right now, it is:
Local: Tuesday June 03 12:15:03.191 PM -04:00:00
UTC: Tuesday June 03 04:15:03.191 PM +00:00:00
California: Tuesday June 03 09:15:03.191 AM -07:00:00
Berlin: Tuesday June 03 06:15:03.191 PM +02:00:00
```
## Parsing
```ts
import * as time from "timedotgo";
const date_string = "Dec 31, 2025 17:30";
const format = "Jan 02, 2006 15:04";
const t = time.Parse(format, date_string);
const next_day = t.Add(24 * time.Hour);
console.log(`Happy New Year ${next_day.Year()}!`);
const t2 = time.ParseInLocation("2006-01-02", "2025-01-01", "America/Chicago");
console.log(t2.String());
```
### Output
```
Happy New Year 2026!
2025-01-01 00:00:00 -0600 CST
```
## Dates
```ts
import * as time from "timedotgo";
// create a time
const christmas = time.DateAt(
2025, // year
12, // month
25, // day
7, // hour
30, // minute
15, // second
928, // millisecond
"America/New_York", // IANA location
);
// create a time from unix timestamp.
const unixZero = time.UnixMilli(0);
console.log(
"It has been",
time.Since(unixZero),
"milliseconds since the creation of unix.",
);
console.log(
"And we only have",
time.Until(christmas),
"milliseconds until Christmas morning.",
);
```
### Output
```
It has been 1748967303281 milliseconds since the creation of unix.
And we only have 17698512643 milliseconds until Christmas morning.
```

View File

@@ -0,0 +1,356 @@
/**
* Duration (milliseconds)
*/
export type Duration = number;
/**
* Millisecond is the base duration unit.
*/
export declare const Millisecond: Duration;
/**
* Second = 1000 * Millisecond
*/
export declare const Second: Duration;
/**
* Minute = 60 * Second
*/
export declare const Minute: Duration;
/**
* Hour = 60 * Minute
*/
export declare const Hour: Duration;
/**
* These are predefined layouts for use in Time.Format and time.Parse.
* The reference time used in these layouts is the specific time stamp:
*
* 01/02 03:04:05PM '06 -0700
*
* (January 2, 15:04:05, 2006, in time zone seven hours west of GMT).
* That value is recorded as the constant named Layout, listed below. As a
* Unix time, this is 1136239445. Since MST is GMT-0700, the reference would be
* printed by the Unix date command as:
*
* Mon Jan 2 15:04:05 MST 2006
*
* It is a regrettable historic error that the date uses the American
* convention of putting the numerical month before the day.
*
* The example for Time.Format demonstrates the working of the layout string in
* detail and is a good reference.
*
* Note that the RFC822, RFC850, and RFC1123 formats should be applied only
* to local times. Applying them to UTC times will use "UTC" as the time zone
* abbreviation, while strictly speaking those RFCs require the use of "GMT"
* in that case. When using the RFC1123 or RFC1123Z formats for parsing,
* note that these formats define a leading zero for the day-in-month portion,
* which is not strictly allowed by RFC 1123. This will result in an error
* when parsing date strings that occur in the first 9 days of a given month.
* In general RFC1123Z should be used instead of RFC1123 for servers that
* insist on that format, and RFC3339 should be preferred for new protocols.
* RFC3339, RFC822, RFC822Z, RFC1123, and RFC1123Z are useful for formatting;
* when used with time.Parse they do not accept all the time formats permitted
* by the RFCs and they do accept time formats not formally defined. The
* RFC3339Nano format removes trailing zeros from the seconds field and thus
* may not sort correctly once formatted.
*
* Most programs can use one of the defined constants as the layout passed
* to Format or Parse. The rest of this comment can be ignored unless you are
* creating a custom layout string.
*
* To define your own format, write down what the reference time would look
* like formatted your way; see the values of constants like ANSIC, StampMicro
* or Kitchen for examples. The model is to demonstrate what the reference
* time looks like so that the Format and Parse methods can apply the same
* transformation to a general time value.
*
* Here is a summary of the components of a layout string. Each element shows
* by example the formatting of an element of the reference time. Only these
* values are recognized. Text in the layout string that is not recognized as
* part of the reference time is echoed verbatim during Format and expected to
* appear verbatim in the input to Parse.
*
* Year: "2006" "06"
* Month: "Jan" "January" "01" "1"
* Day of the week: "Mon" "Monday"
* Day of the month: "2" "_2" "02"
* Day of the year: "__2" "002"
* Hour: "15" "3" "03" (PM or AM)
* Minute: "4" "04"
* Second: "5" "05"
* AM/PM mark: "PM"
*
* Numeric time zone offsets format as follows:
*
* "-0700" ±hhmm
* "-07:00" ±hh:mm
* "-07" ±hh
* "-070000" ±hhmmss
* "-07:00:00" ±hh:mm:ss
*
* Replacing the sign in the format with a Z triggers the ISO 8601 behavior of
* printing Z instead of an offset for the UTC zone. Thus:
*
* "Z0700" Z or ±hhmm
* "Z07:00" Z or ±hh:mm
* "Z07" Z or ±hh
* "Z070000" Z or ±hhmmss
* "Z07:00:00" Z or ±hh:mm:ss
*
* Within the format string, the underscores in "_2" and "__2" represent spaces
* that may be replaced by digits if the following number has multiple digits,
* for compatibility with fixed-width Unix time formats. A leading zero
* represents a zero-padded value.
*
* The formats __2 and 002 are space-padded and zero-padded three-character day
* of year; there is no unpadded day of year format.
*
* A comma or decimal point followed by one or more zeros represents a
* fractional second, printed to the given number of decimal places. A comma or
* decimal point followed by one or more nines represents a fractional second,
* printed to the given number of decimal places, with trailing zeros removed.
* For example "15:04:05,000" or "15:04:05.000" formats or parses with
* millisecond precision.
*
* Some valid layouts are invalid time values for time.Parse, due to formats
* such as _ for space padding and Z for zone information.
*/
export declare const Layout = "01/02 03:04:05PM '06 -0700";
export declare const ANSIC = "Mon Jan _2 15:04:05 2006";
export declare const UnixDate = "Mon Jan _2 15:04:05 MST 2006";
export declare const RubyDate = "Mon Jan 02 15:04:05 -0700 2006";
export declare const RFC822 = "02 Jan 06 15:04 MST";
export declare const RFC822Z = "02 Jan 06 15:04 -0700";
export declare const RFC850 = "Monday, 02-Jan-06 15:04:05 MST";
export declare const RFC1123 = "Mon, 02 Jan 2006 15:04:05 MST";
export declare const RFC1123Z = "Mon, 02 Jan 2006 15:04:05 -0700";
export declare const RFC3339 = "2006-01-02T15:04:05Z07:00";
export declare const RFC3339Nano = "2006-01-02T15:04:05.999999999Z07:00";
export declare const Kitchen = "3:04PM";
export declare const Stamp = "Jan _2 15:04:05";
export declare const StampMilli = "Jan _2 15:04:05.000";
export declare const StampMicro = "Jan _2 15:04:05.000000";
export declare const StampNano = "Jan _2 15:04:05.000000000";
export declare const DateTime = "2006-01-02 15:04:05";
export declare const DateOnly = "2006-01-02";
export declare const TimeOnly = "15:04:05";
/**
* IANA: eg. "America/New_York"
* see here: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
*/
export type IANA = string;
/**
* Local represents the system's local time zone.
*/
export declare const Local: IANA;
/**
* UTC represents Universal Coordinated Time (UTC).
*/
export declare const UTC: IANA;
/**
* List available locations/IANA names.
*/
export declare function ListAvailableIANAs(): IANA[];
/**
* A Month specifies a month of the year (January = 1, ...).
*/
export declare enum Month {
January = 1,
February = 2,
March = 3,
April = 4,
May = 5,
June = 6,
July = 7,
August = 8,
September = 9,
October = 10,
November = 11,
December = 12
}
/**
* A Weekday specifies a day of the week (Sunday = 0, ...).
*/
export declare enum Weekday {
Sunday = 0,
Monday = 1,
Tuesday = 2,
Wednesday = 3,
Thursday = 4,
Friday = 5,
Saturday = 6
}
/**
* A Time represents an instant in time with millisecond precision.
*/
export interface Time {
/**
* In returns a copy of t representing the same time instant, but with the
* copy's location information set to loc for display purposes.
*/
In(location: IANA): Time;
/**
* Clock returns the hour, minute, and second within the day specified by t.
*/
Clock(): {
hour: number;
minute: number;
second: number;
};
/**
* Date returns the year, month, and day in which t occurs.
*/
Date(): {
year: number;
month: Month;
day: number;
};
/**
* Weekday returns the day of the week specified by t.
*/
Weekday(): Weekday;
/**
* YearDay returns the day of the year specified by t, in the range [1,365] for
* non-leap years, and [1,366] in leap years.
*/
YearDay(): number;
/**
* Year returns the year in which t occurs.
*/
Year(): number;
/**
* Month returns the month of the year specified by t.
*/
Month(): Month;
/**
* Day returns the day of the month specified by t.
*/
Day(): number;
/**
* Hour returns the hour within the day specified by t, in the range [0, 23].
*/
Hour(): number;
/**
* Minute returns the minute offset within the hour specified by t, in the
* range [0, 59].
*/
Minute(): number;
/**
* Second returns the second offset within the minute specified by t, in the
* range [0, 59].
*/
Second(): number;
/**
* Millisecond returns the millisecond offset within the second specified by t,
* in the range [0, 1000].
*/
Millisecond(): number;
/**
* Zone computes the time zone in effect at time t, returning the abbreviated
* name of the zone (such as "CET") and its offset in seconds east of UTC.
*/
Zone(): {
name: string;
offset: number;
};
/**
* UTC returns t with the location set to UTC.
*/
UTC(): Time;
/**
* Local returns t with the location set to local time.
*/
Local(): Time;
/**
* JSDate returns a javascript date object at time t.
*/
JSDate(): Date;
/**
* String returns the time formatted using the format string
*
* "2006-01-02 15:04:05.999999999 -0700 MST"
*
* The returned string is meant for debugging; for a stable serialized
* representation, use t.Format with an explicit format string.
*/
String(): string;
UnixMilli(): number;
/**
* Unix returns t as a Unix time, the number of seconds elapsed since January
* 1, 1970 UTC. The result does not depend on the location associated with t.
*/
Unix(): number;
/**
* After reports whether the time instant t is after u.
*/
After(u: Time): boolean;
/**
* Before reports whether the time instant t is before u.
*/
Before(u: Time): boolean;
/** Equal reports whether t and u represent the same time instant. Two times
* can be equal even if they are in different locations. For example, 6:00
* +0200 and 4:00 UTC are Equal.
*/
Equal(u: Time): boolean;
/**
* Sub returns the duration t-u.
*/
Sub(u: Time): Duration;
/**
* Add returns the time t+d.
*/
Add(d: Duration): Time;
/**
* Format returns a textual representation of the time value formatted
* according to the layout defined by the argument. See the documentation for
* the constant called Layout to see how to represent the layout format.
*/
Format(layout: string): string;
}
/**
* FromJSDate to convert a javascript Date object to Time.
*/
export declare function FromJSDate(jsDate: Date): Time;
/**
* Now returns the current local time.
*/
export declare function Now(): Time;
/**
* Unix returns the local Time corresponding to the given Unix time,
* sec seconds since January 1, 1970 UTC.
*/
export declare function Unix(seconds: number): Time;
/**
* UnixMilli returns the local Time corresponding to the given Unix time,
* msec milliseconds since January 1, 1970 UTC.
*/
export declare function UnixMilli(millis: number): Time;
/**
* Since returns the time elapsed since t. It is shorthand for
* time.Now().Sub(t).
*/
export declare function Since(t: Time): Duration;
/**
* Until returns the duration until t. It is shorthand for t.Sub(time.Now()).
*/
export declare function Until(t: Time): Duration;
/**
* Replicates golangs time.Date(...) function for creating Time objects.
* note that nanoseconds is replaced with milliseconds for javascript
* and the name is DateAt (to avoid conflict with JS built-in Date).
*/
export declare function DateAt(year: number, month: Month, day: number, hour: number, min: number, sec: number, milli: number, loc: IANA): Time;
/**
* ParseInLocation is like Parse but in the absence of time zone information,
* Parse interprets a time as UTC and ParseInLocation interprets the time
* as in the given location. Unlike go, no attempt is made to match an abbreviation
* inside the given timezone. Location should be a valid IANA timezone identifier.
*/
export declare function ParseInLocation(layout: string, value: string, location: IANA): Time;
/**
* Parse parses a formatted string and returns the time value it represents.
* See the documentation for the constant called Layout to see how to represent
* the format. The second argument must be parseable using the format string
* (layout) provided as the first argument.
*/
export declare function Parse(layout: string, value: string): Time;
//# sourceMappingURL=Time.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"Time.d.ts","sourceRoot":"","sources":["../src/Time.ts"],"names":[],"mappings":"AAGA;;GAEG;AACH,MAAM,MAAM,QAAQ,GAAG,MAAM,CAAC;AAE9B;;GAEG;AACH,eAAO,MAAM,WAAW,EAAE,QAAY,CAAC;AACvC;;GAEG;AACH,eAAO,MAAM,MAAM,EAAE,QAA6B,CAAC;AACnD;;GAEG;AACH,eAAO,MAAM,MAAM,EAAE,QAAsB,CAAC;AAC5C;;GAEG;AACH,eAAO,MAAM,IAAI,EAAE,QAAsB,CAAC;AAE1C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8FG;AACH,eAAO,MAAM,MAAM,+BAA+B,CAAC;AACnD,eAAO,MAAM,KAAK,6BAA6B,CAAC;AAChD,eAAO,MAAM,QAAQ,iCAAiC,CAAC;AACvD,eAAO,MAAM,QAAQ,mCAAmC,CAAC;AACzD,eAAO,MAAM,MAAM,wBAAwB,CAAC;AAC5C,eAAO,MAAM,OAAO,0BAA0B,CAAC;AAC/C,eAAO,MAAM,MAAM,mCAAmC,CAAC;AACvD,eAAO,MAAM,OAAO,kCAAkC,CAAC;AACvD,eAAO,MAAM,QAAQ,oCAAoC,CAAC;AAC1D,eAAO,MAAM,OAAO,8BAA8B,CAAC;AACnD,eAAO,MAAM,WAAW,wCAAwC,CAAC;AACjE,eAAO,MAAM,OAAO,WAAW,CAAC;AAChC,eAAO,MAAM,KAAK,oBAAoB,CAAC;AACvC,eAAO,MAAM,UAAU,wBAAwB,CAAC;AAChD,eAAO,MAAM,UAAU,2BAA2B,CAAC;AACnD,eAAO,MAAM,SAAS,8BAA8B,CAAC;AACrD,eAAO,MAAM,QAAQ,wBAAwB,CAAC;AAC9C,eAAO,MAAM,QAAQ,eAAe,CAAC;AACrC,eAAO,MAAM,QAAQ,aAAa,CAAC;AAEnC;;;GAGG;AACH,MAAM,MAAM,IAAI,GAAG,MAAM,CAAC;AAE1B;;GAEG;AACH,eAAO,MAAM,KAAK,EAAE,IAEnB,CAAC;AACF;;GAEG;AACH,eAAO,MAAM,GAAG,EAAE,IAAgB,CAAC;AAEnC;;GAEG;AACH,wBAAgB,kBAAkB,IAAI,IAAI,EAAE,CAG3C;AAED;;GAEG;AACH,oBAAY,KAAK;IACf,OAAO,IAAI;IACX,QAAQ,IAAA;IACR,KAAK,IAAA;IACL,KAAK,IAAA;IACL,GAAG,IAAA;IACH,IAAI,IAAA;IACJ,IAAI,IAAA;IACJ,MAAM,IAAA;IACN,SAAS,IAAA;IACT,OAAO,KAAA;IACP,QAAQ,KAAA;IACR,QAAQ,KAAA;CACT;AAED;;GAEG;AACH,oBAAY,OAAO;IACjB,MAAM,IAAI;IACV,MAAM,IAAA;IACN,OAAO,IAAA;IACP,SAAS,IAAA;IACT,QAAQ,IAAA;IACR,MAAM,IAAA;IACN,QAAQ,IAAA;CACT;AAED;;GAEG;AACH,MAAM,WAAW,IAAI;IACnB;;;OAGG;IACH,EAAE,CAAC,QAAQ,EAAE,IAAI,GAAG,IAAI,CAAC;IACzB;;OAEG;IACH,KAAK,IAAI;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IAC1D;;OAEG;IACH,IAAI,IAAI;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,KAAK,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,CAAC;IACpD;;OAEG;IACH,OAAO,IAAI,OAAO,CAAC;IACnB;;;OAGG;IACH,OAAO,IAAI,MAAM,CAAC;IAClB;;OAEG;IACH,IAAI,IAAI,MAAM,CAAC;IACf;;OAEG;IACH,KAAK,IAAI,KAAK,CAAC;IACf;;OAEG;IACH,GAAG,IAAI,MAAM,CAAC;IACd;;OAEG;IACH,IAAI,IAAI,MAAM,CAAC;IACf;;;OAGG;IACH,MAAM,IAAI,MAAM,CAAC;IACjB;;;OAGG;IACH,MAAM,IAAI,MAAM,CAAC;IACjB;;;OAGG;IACH,WAAW,IAAI,MAAM,CAAC;IACtB;;;OAGG;IACH,IAAI,IAAI;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IACzC;;OAEG;IACH,GAAG,IAAI,IAAI,CAAC;IACZ;;OAEG;IACH,KAAK,IAAI,IAAI,CAAC;IACd;;OAEG;IACH,MAAM,IAAI,IAAI,CAAC;IACf;;;;;;;OAOG;IACH,MAAM,IAAI,MAAM,CAAC;IACjB,SAAS,IAAI,MAAM,CAAC;IACpB;;;OAGG;IACH,IAAI,IAAI,MAAM,CAAC;IACf;;OAEG;IACH,KAAK,CAAC,CAAC,EAAE,IAAI,GAAG,OAAO,CAAC;IACxB;;OAEG;IACH,MAAM,CAAC,CAAC,EAAE,IAAI,GAAG,OAAO,CAAC;IACzB;;;OAGG;IACH,KAAK,CAAC,CAAC,EAAE,IAAI,GAAG,OAAO,CAAC;IACxB;;OAEG;IACH,GAAG,CAAC,CAAC,EAAE,IAAI,GAAG,QAAQ,CAAC;IACvB;;OAEG;IACH,GAAG,CAAC,CAAC,EAAE,QAAQ,GAAG,IAAI,CAAC;IACvB;;;;OAIG;IACH,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC;CAChC;AAED;;GAEG;AACH,wBAAgB,UAAU,CAAC,MAAM,EAAE,IAAI,GAAG,IAAI,CAE7C;AAED;;GAEG;AACH,wBAAgB,GAAG,IAAI,IAAI,CAE1B;AAED;;;GAGG;AACH,wBAAgB,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAE1C;AAED;;;GAGG;AACH,wBAAgB,SAAS,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAE9C;AAED;;;GAGG;AACH,wBAAgB,KAAK,CAAC,CAAC,EAAE,IAAI,GAAG,QAAQ,CAEvC;AAED;;GAEG;AACH,wBAAgB,KAAK,CAAC,CAAC,EAAE,IAAI,GAAG,QAAQ,CAEvC;AAED;;;;GAIG;AACH,wBAAgB,MAAM,CACpB,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,KAAK,EACZ,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,MAAM,EACZ,GAAG,EAAE,MAAM,EACX,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,IAAI,GACR,IAAI,CAEN;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAC7B,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,IAAI,GACb,IAAI,CAEN;AAED;;;;;GAKG;AACH,wBAAgB,KAAK,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAEzD"}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,2 @@
export { Month, Weekday, Time, Local, UTC, Unix, UnixMilli, Now, Parse, ParseInLocation, FromJSDate, Since, Until, DateAt, ListAvailableIANAs, IANA, Duration, Millisecond, Second, Minute, Hour, Layout, ANSIC, UnixDate, RubyDate, RFC822, RFC822Z, RFC850, RFC1123, RFC1123Z, RFC3339, RFC3339Nano, Kitchen, Stamp, StampMilli, StampMicro, StampNano, DateTime, DateOnly, TimeOnly, } from "./Time.js";
//# sourceMappingURL=index.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,EACL,OAAO,EACP,IAAI,EACJ,KAAK,EACL,GAAG,EACH,IAAI,EACJ,SAAS,EACT,GAAG,EACH,KAAK,EACL,eAAe,EACf,UAAU,EACV,KAAK,EACL,KAAK,EACL,MAAM,EACN,kBAAkB,EAClB,IAAI,EACJ,QAAQ,EACR,WAAW,EACX,MAAM,EACN,MAAM,EACN,IAAI,EACJ,MAAM,EACN,KAAK,EACL,QAAQ,EACR,QAAQ,EACR,MAAM,EACN,OAAO,EACP,MAAM,EACN,OAAO,EACP,QAAQ,EACR,OAAO,EACP,WAAW,EACX,OAAO,EACP,KAAK,EACL,UAAU,EACV,UAAU,EACV,SAAS,EACT,QAAQ,EACR,QAAQ,EACR,QAAQ,GACT,MAAM,WAAW,CAAC"}

View File

@@ -0,0 +1,2 @@
export { Month, Weekday, Local, UTC, Unix, UnixMilli, Now, Parse, ParseInLocation, FromJSDate, Since, Until, DateAt, ListAvailableIANAs, Millisecond, Second, Minute, Hour, Layout, ANSIC, UnixDate, RubyDate, RFC822, RFC822Z, RFC850, RFC1123, RFC1123Z, RFC3339, RFC3339Nano, Kitchen, Stamp, StampMilli, StampMicro, StampNano, DateTime, DateOnly, TimeOnly, } from "./Time.js";
//# sourceMappingURL=index.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,EACL,OAAO,EAEP,KAAK,EACL,GAAG,EACH,IAAI,EACJ,SAAS,EACT,GAAG,EACH,KAAK,EACL,eAAe,EACf,UAAU,EACV,KAAK,EACL,KAAK,EACL,MAAM,EACN,kBAAkB,EAGlB,WAAW,EACX,MAAM,EACN,MAAM,EACN,IAAI,EACJ,MAAM,EACN,KAAK,EACL,QAAQ,EACR,QAAQ,EACR,MAAM,EACN,OAAO,EACP,MAAM,EACN,OAAO,EACP,QAAQ,EACR,OAAO,EACP,WAAW,EACX,OAAO,EACP,KAAK,EACL,UAAU,EACV,UAAU,EACV,SAAS,EACT,QAAQ,EACR,QAAQ,EACR,QAAQ,GACT,MAAM,WAAW,CAAC"}

View File

@@ -0,0 +1,36 @@
{
"name": "timedotgo",
"version": "1.0.2",
"description": "Golangs excellent \"time\" API ported to typescript.",
"license": "MIT",
"author": "rednexela1941",
"repository": {
"type": "git",
"url": "git+https://github.com/rednexela1941/timedotgo"
},
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist", "src", "README.md"],
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "npx tsc && npm run docs",
"build-test": "npx tsc && node ./build_test.js",
"readme": "npm run build-test && ./bin/README.pl > README.md",
"tsc": "npx tsc -w",
"docs": "npm run readme && npx typedoc",
"test": "npm run build-test && node --enable-source-maps tests/out/tests/run_all.js"
},
"devDependencies": {
"esbuild": "^0.25.5",
"prettier": "^3.5.3",
"typedoc": "^0.28.5",
"typedoc-plugin-markdown": "^4.6.3",
"typescript": "^5.8.3"
}
}

Some files were not shown because too many files have changed in this diff Show More