Compare commits
30 Commits
dacd68b617
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| cad6688174 | |||
| 2c14831c35 | |||
| a167f24cd3 | |||
| 8a922cbed1 | |||
| 50bef85367 | |||
| ee84f10cf0 | |||
| b0b281a831 | |||
| de7a6c6bdb | |||
| b9864864a6 | |||
| c0ddef5923 | |||
| cae2997009 | |||
| 03b5bea72d | |||
| 35f0ea6db3 | |||
| 93acf143f2 | |||
| 85d07f069e | |||
| 4f0418ea6e | |||
| dda0e738d4 | |||
| f190f436c3 | |||
| dacd67354f | |||
| d69a3ecffa | |||
| 530fdf6f75 | |||
| 055dbe3004 | |||
| 81f0c0624e | |||
| 093bad311e | |||
| 2477c2d6a2 | |||
| 550e97aa9b | |||
| 13a60a90a1 | |||
| bf42426c0b | |||
| 342de9e18a | |||
| f2e0d22255 |
18
.gitignore
vendored
18
.gitignore
vendored
@@ -84,20 +84,20 @@ wwwroot/public.bundle.min.js
|
||||
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
|
||||
# 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
|
||||
# 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.
|
||||
go/cmd/kjol-web/wwwroot/bundle.min.*
|
||||
go/cmd/kjol-web/wwwroot/public.bundle.min.*
|
||||
go/cmd/kjol-web/wwwroot/app.css
|
||||
go/cmd/kjol-web/wwwroot/wasm_exec.js
|
||||
go/cmd/kjol-web/wwwroot/vendor/
|
||||
go/cmd/kjol-web/frontend/src/ui/generated/
|
||||
go/cmd/kjol-web/internal/handlers/public_pages.gen.go
|
||||
go/cmd/kjol-website/wwwroot/bundle.min.*
|
||||
go/cmd/kjol-website/wwwroot/public.bundle.min.*
|
||||
go/cmd/kjol-website/wwwroot/app.css
|
||||
go/cmd/kjol-website/wwwroot/wasm_exec.js
|
||||
go/cmd/kjol-website/wwwroot/vendor/
|
||||
go/cmd/kjol-website/frontend/src/ui/generated/
|
||||
go/cmd/kjol-website/internal/handlers/public_pages.gen.go
|
||||
# kjol framework (git submodule; deny-list above would otherwise ignore it)
|
||||
!/kjol
|
||||
|
||||
44
.vscode/launch.json
vendored
44
.vscode/launch.json
vendored
@@ -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
|
||||
// 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
|
||||
// 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.
|
||||
//
|
||||
// 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",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "kjol-web: dev server",
|
||||
"name": "kjol-website: dev server",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
"program": "${workspaceFolder}/go/cmd/kjol-web/server",
|
||||
"cwd": "${workspaceFolder}/go/cmd/kjol-web",
|
||||
"program": "${workspaceFolder}/go/cmd/kjol-website/server",
|
||||
"cwd": "${workspaceFolder}/go/cmd/kjol-website",
|
||||
"env": { "GOWORK": "off" },
|
||||
"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",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
"program": "${workspaceFolder}/go/cmd/kjol-web/server",
|
||||
"cwd": "${workspaceFolder}/go/cmd/kjol-web",
|
||||
"program": "${workspaceFolder}/go/cmd/kjol-website/server",
|
||||
"cwd": "${workspaceFolder}/go/cmd/kjol-website",
|
||||
"env": { "GOWORK": "off" },
|
||||
// -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
|
||||
// bundles there; without it you would be debugging against stale artefacts — and
|
||||
// with no bundle at all, every /js/* page would come up blank.
|
||||
"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",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
// 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
|
||||
// them and Go will not let you import a main.
|
||||
"program": "${workspaceFolder}/go/cmd/kjol-web/server",
|
||||
"cwd": "${workspaceFolder}/go/cmd/kjol-web",
|
||||
"program": "${workspaceFolder}/go/cmd/kjol-website/server",
|
||||
"cwd": "${workspaceFolder}/go/cmd/kjol-website",
|
||||
"env": { "GOWORK": "off" },
|
||||
"args": ["-build"]
|
||||
},
|
||||
{
|
||||
"name": "kjol-web: codegen (wasmgen)",
|
||||
"name": "kjol-website: codegen (wasmgen)",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
"program": "${workspaceFolder}/go/cmd/wasmgen",
|
||||
"cwd": "${workspaceFolder}/go/cmd/kjol-web",
|
||||
"cwd": "${workspaceFolder}/go/cmd/kjol-website",
|
||||
"env": { "GOWORK": "off" },
|
||||
"args": ["./app"]
|
||||
},
|
||||
{
|
||||
"name": "kjol-web: bundle (Solid)",
|
||||
"name": "kjol-website: bundle (Solid)",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
// 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.
|
||||
"program": "${workspaceFolder}/go/cmd/bundle",
|
||||
"cwd": "${workspaceFolder}/go/cmd/kjol-web",
|
||||
"cwd": "${workspaceFolder}/go/cmd/kjol-website",
|
||||
"env": { "GOWORK": "off" },
|
||||
"args": [
|
||||
"-app", "frontend",
|
||||
"-web", "../../jsruntime",
|
||||
|
||||
54
.vscode/tasks.json
vendored
54
.vscode/tasks.json
vendored
@@ -1,60 +1,66 @@
|
||||
{
|
||||
// Tasks for the kjol repo.
|
||||
//
|
||||
// kjol-web — 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
|
||||
// kjol-website — the website, and the runnable example of both web layers — is a nested
|
||||
// module at go/cmd/kjol-website, so its tasks set cwd there; the module-wide Go tasks run
|
||||
// in go/.
|
||||
//
|
||||
// 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.
|
||||
// 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
|
||||
// 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
|
||||
// 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
|
||||
// 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
|
||||
// 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.
|
||||
//
|
||||
// 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",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "kjol-web: prebuild",
|
||||
"label": "kjol-website: prebuild",
|
||||
"detail": "Codegen -> Tailwind -> Solid bundle. Dependency of the run + debug configs.",
|
||||
"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": [],
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"label": "kjol-web: codegen",
|
||||
"label": "kjol-website: codegen",
|
||||
"detail": "Regenerate app/*.gen.go from the //gowasm: directives (pages, layouts, server components)",
|
||||
"type": "shell",
|
||||
"command": "go",
|
||||
"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"],
|
||||
"presentation": { "reveal": "silent", "panel": "shared" },
|
||||
"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",
|
||||
"type": "shell",
|
||||
"command": "go",
|
||||
"args": [
|
||||
"run", "./cmd/twcss",
|
||||
"-entry", "cmd/kjol-web/css/app.css",
|
||||
"-out", "cmd/kjol-web/wwwroot/app.css",
|
||||
"-entry", "cmd/kjol-website/css/app.css",
|
||||
"-out", "cmd/kjol-website/wwwroot/app.css",
|
||||
"-base", ".",
|
||||
"webui/**/*.go",
|
||||
"cmd/kjol-web/app/**/*.go",
|
||||
"cmd/kjol-web/server/**/*.go"
|
||||
"cmd/kjol-website/app/**/*.go",
|
||||
"cmd/kjol-website/server/**/*.go"
|
||||
],
|
||||
// Run from the kjol module root so the Tailwind engine's deps resolve in kjol's
|
||||
// go.mod, not the site's.
|
||||
@@ -64,7 +70,7 @@
|
||||
"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.*",
|
||||
"type": "shell",
|
||||
"command": "go",
|
||||
@@ -77,19 +83,19 @@
|
||||
"-out", "wwwroot",
|
||||
"-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"],
|
||||
"presentation": { "reveal": "silent", "panel": "shared" },
|
||||
"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.",
|
||||
"type": "shell",
|
||||
"command": "go",
|
||||
"args": ["run", "./server"],
|
||||
"options": { "cwd": "${workspaceFolder}/go/cmd/kjol-web" },
|
||||
"dependsOn": ["kjol-web: prebuild"],
|
||||
"options": { "cwd": "${workspaceFolder}/go/cmd/kjol-website", "env": { "GOWORK": "off" } },
|
||||
"dependsOn": ["kjol-website: prebuild"],
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "go",
|
||||
@@ -107,18 +113,18 @@
|
||||
"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.",
|
||||
"type": "shell",
|
||||
"command": "go",
|
||||
"args": ["run", "./server", "-build"],
|
||||
"options": { "cwd": "${workspaceFolder}/go/cmd/kjol-web" },
|
||||
"options": { "cwd": "${workspaceFolder}/go/cmd/kjol-website", "env": { "GOWORK": "off" } },
|
||||
"problemMatcher": ["$go"],
|
||||
"group": "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",
|
||||
"command": "go build ./...",
|
||||
"options": { "cwd": "${workspaceFolder}/go" },
|
||||
@@ -155,11 +161,11 @@
|
||||
"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.",
|
||||
"type": "shell",
|
||||
"command": "go test ./...",
|
||||
"options": { "cwd": "${workspaceFolder}/go/cmd/kjol-web" },
|
||||
"options": { "cwd": "${workspaceFolder}/go/cmd/kjol-website", "env": { "GOWORK": "off" } },
|
||||
"problemMatcher": ["$go"],
|
||||
"group": "test"
|
||||
}
|
||||
|
||||
39
CLAUDE.md
39
CLAUDE.md
@@ -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
|
||||
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`
|
||||
|
||||
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`
|
||||
`wasmruntime` `rsc` `wasmdevserver`, and `webui` — a Tailwind-styled component kit ported
|
||||
from `jsruntime/uikit`; author components in pure Go compiled to WebAssembly; all
|
||||
stdlib-only), and `cmd/{bundle,twcss,migrate,loc,passgen,typecheck,wasmgen}`.
|
||||
l4g lexer security snailmail validation jsbundler tw`, plus the **gowasm** web-UI engine
|
||||
(`wasmruntime` and its sub-packages `wasmruntime/vdom` + `wasmruntime/rsc`, `wasmdevserver`,
|
||||
and `webui` — a Tailwind-styled component kit ported from `jsruntime/uikit`; author
|
||||
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
|
||||
`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
|
||||
`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
|
||||
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.
|
||||
|
||||
**`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
|
||||
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
|
||||
@@ -117,8 +132,8 @@ tokens).
|
||||
`@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
|
||||
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
|
||||
bundler prepends it, so the app's `style.css` carries brand only.
|
||||
(see `webui.ThemeTokens`); for the Solid kit `tw/kjol_theme.css` defines them and the Tailwind
|
||||
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
|
||||
`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.
|
||||
- `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
|
||||
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`.
|
||||
- `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
|
||||
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 |
|
||||
| `httputil.CorsMiddleware(CorsConfig{...})` | allowed domains + bundle-version source injected |
|
||||
| `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)
|
||||
|
||||
|
||||
308
go/cmd/aria-check/check.go
Normal file
308
go/cmd/aria-check/check.go
Normal 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)})
|
||||
}
|
||||
175
go/cmd/aria-check/check_test.go
Normal file
175
go/cmd/aria-check/check_test.go
Normal 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
436
go/cmd/aria-check/color.go
Normal 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},
|
||||
}
|
||||
94
go/cmd/aria-check/color_test.go
Normal file
94
go/cmd/aria-check/color_test.go
Normal 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
228
go/cmd/aria-check/main.go
Normal 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
253
go/cmd/aria-check/scan.go
Normal 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
377
go/cmd/aria-check/theme.go
Normal 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: 1–6 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
|
||||
}
|
||||
@@ -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()))
|
||||
}`
|
||||
@@ -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=
|
||||
@@ -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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
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
|
||||
|
||||
```sh
|
||||
cd go/cmd/kjol-web
|
||||
cd go/cmd/kjol-website
|
||||
go run ./server -build # cold build, then exit
|
||||
go run ./server # build, then SSR + /rsc + hot reload at http://localhost:8085
|
||||
```
|
||||
@@ -121,7 +121,7 @@ one way.
|
||||
|
||||
## 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
|
||||
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.
|
||||
110
go/cmd/kjol-website/app/chart.go
Normal file
110
go/cmd/kjol-website/app/chart.go
Normal 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
|
||||
}}),
|
||||
)
|
||||
}
|
||||
}`
|
||||
@@ -1,7 +1,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
. "kjol/vdom"
|
||||
. "kjol/wasmruntime/vdom"
|
||||
)
|
||||
|
||||
// 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.
|
||||
#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.
|
||||
#include "lexer/lexer.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:
|
||||
#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 global static // a variable owned by this translation unit
|
||||
#define local_persist static // a local that survives the call
|
||||
@@ -5,8 +5,8 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"kjol/rsc"
|
||||
"kjol/vdom"
|
||||
"kjol/wasmruntime/rsc"
|
||||
"kjol/wasmruntime/vdom"
|
||||
)
|
||||
|
||||
// ServerCounter is a generated client stub for the server component of the same name.
|
||||
@@ -1,11 +1,12 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
. "kjol/vdom"
|
||||
"kjol/wasmruntime"
|
||||
. "kjol/wasmruntime/vdom"
|
||||
ui "kjol/webui"
|
||||
)
|
||||
|
||||
@@ -232,7 +233,7 @@ func badgesSection() func() *VNode {
|
||||
ui.EnvBadge("staging"),
|
||||
// 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.
|
||||
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)")),
|
||||
),
|
||||
),
|
||||
@@ -473,7 +474,7 @@ func selectsSection() func() *VNode {
|
||||
field("Timezone", ui.FormTimezoneSelector(ui.FormSelectProps{
|
||||
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(), ","), "—"))),
|
||||
),
|
||||
|
||||
@@ -484,7 +485,7 @@ func selectsSection() func() *VNode {
|
||||
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 "+
|
||||
"you have already typed past is DISCARDED rather than shown — which is the whole bug "+
|
||||
"with hand-rolled autocompletes.")),
|
||||
@@ -500,7 +501,7 @@ func selectsSection() func() *VNode {
|
||||
ShowSelectAll: true,
|
||||
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.")),
|
||||
),
|
||||
)
|
||||
@@ -535,7 +536,7 @@ func togglesSection() func() *VNode {
|
||||
|
||||
demo("Signature pad — "+itoa(len(signed.Get()))+" bytes of SVG",
|
||||
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 "+
|
||||
"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) },
|
||||
}),
|
||||
),
|
||||
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.")),
|
||||
),
|
||||
)
|
||||
@@ -618,7 +619,7 @@ func tablesSection() func() *VNode {
|
||||
}
|
||||
return ui.AutoTablePDFHeader{
|
||||
Title: "Employees",
|
||||
Subtitle: "Exported from the kjol-web components page",
|
||||
Subtitle: "Exported from the kjol-website components page",
|
||||
ShowDate: true,
|
||||
Orientation: orientation,
|
||||
}
|
||||
@@ -676,7 +677,7 @@ func tablesSection() func() *VNode {
|
||||
SortDesc: gridDesc.Get(),
|
||||
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 "+
|
||||
"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")),
|
||||
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 "+
|
||||
"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")),
|
||||
),
|
||||
),
|
||||
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 "+
|
||||
"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.")),
|
||||
@@ -889,7 +890,7 @@ func overlaysSection(push func(ui.ToastType, string)) func() *VNode {
|
||||
}, 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 "+
|
||||
"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",
|
||||
row("flex flex-wrap items-center gap-3",
|
||||
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 "+
|
||||
"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}),
|
||||
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 "+
|
||||
"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",
|
||||
row("grid gap-6 lg:grid-cols-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")),
|
||||
ui.SingleAccordion([]ui.AccordionItemData{
|
||||
{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) }),
|
||||
),
|
||||
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)")),
|
||||
ui.Accordion([]ui.AccordionItemData{
|
||||
{Title: "First", Content: panel("Open me.")},
|
||||
@@ -1107,7 +1108,7 @@ func navigationSection() func() *VNode {
|
||||
}},
|
||||
}, 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 "+
|
||||
"same idea, wired to scroll the section into view.")),
|
||||
),
|
||||
@@ -1120,6 +1121,7 @@ func navigationSection() func() *VNode {
|
||||
func searchSection() func() *VNode {
|
||||
q := NewSignal("")
|
||||
hit := NewSignal("")
|
||||
andAmpQ := NewSignal("")
|
||||
|
||||
return func() *VNode {
|
||||
options := []string{
|
||||
@@ -1128,6 +1130,20 @@ func searchSection() func() *VNode {
|
||||
"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",
|
||||
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 "+
|
||||
@@ -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(
|
||||
apiRow{"RankFuzzyMatches", "The scorer, headless. Use it and render the results yourself."},
|
||||
apiRow{"FuzzySegments", "Splits a result into matched / unmatched runs, for highlighting."},
|
||||
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 --------------------------------------------------------------
|
||||
|
||||
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 {
|
||||
values := NewSignal(fixedChartData())
|
||||
drawn := newChartDrawing()
|
||||
seed := NewSignal(0)
|
||||
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 {
|
||||
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",
|
||||
prose("These are SVG, produced by go-chart — a plain Go library that knows nothing about "+
|
||||
"browsers. They are drawn by the WEBASSEMBLY, in your browser. The server never enters "+
|
||||
"the drawing code at all; it renders a placeholder, and the wasm replaces it on its first "+
|
||||
"commit."),
|
||||
prose("A Go charting library, compiled to wasm, drawing an SVG in the browser is the thing "+
|
||||
"this layer claims it can do. Server-rendering a picture of a chart would look identical "+
|
||||
"and prove the opposite point."),
|
||||
prose("webui.Chart draws its own SVG — nice-scale axes, rounded columns, arc slices, a "+
|
||||
"pointer crosshair — with no charting library. The geometry is pure Go, so the same shapes "+
|
||||
"the Solid kit draws on /js run here in the WebAssembly, against the same theme tokens. Change "+
|
||||
"the data and only the marks that moved re-render. A Title captions the plot, and the legend a "+
|
||||
"multi-series or pie/donut chart draws is interactive — clicking a key toggles that series or slice."),
|
||||
|
||||
demo("Drawn in the browser, by Go",
|
||||
row("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(v) }),
|
||||
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(v) }),
|
||||
demo("Bar, donut, smooth area, two lines — one data set, and a 3D toggle",
|
||||
row("grid gap-6 lg:grid-cols-12",
|
||||
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()})),
|
||||
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()})),
|
||||
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()})),
|
||||
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",
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Small: true, Text: "New data",
|
||||
OnClick: func() { values.Set(randomValues()) }}),
|
||||
Span(Attr("class", "text-xs text-ink-muted"),
|
||||
Text("Re-drawn in Go, in the browser. No request is made.")),
|
||||
row("mt-4 flex items-center gap-3",
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Small: true, Text: "New data", OnClick: func() { seed.Set(seed.Get() + 1) }}),
|
||||
ui.Button(ui.ButtonProps{Color: ui.ButtonNeutral, Small: true, Text: threeDLabel, OnClick: func() { threeD.Set(!threeD.Get()) }}),
|
||||
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.")),
|
||||
),
|
||||
),
|
||||
|
||||
note("webui.ReactiveChart is a stub, and says so",
|
||||
"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 "+
|
||||
"charting library, mount lifecycles — exists in the neutral Go runtime, so the Go port "+
|
||||
"keeps the props shape for parity and renders an empty canvas. Draw with go-chart "+
|
||||
"instead, as above. Pretending otherwise would be the one thing this site refuses to do."),
|
||||
chart3DDemo(threeDBar, threeDLine, depth, tilt, requests, errs),
|
||||
|
||||
demo("Horizontal bars, and stacked",
|
||||
row("grid gap-6 lg:grid-cols-2",
|
||||
horizC.Render(ui.ChartProps{Kind: ui.ChartBar, Horizontal: true, Labels: chartDaysWasm, Series: []ui.ChartSeries{requests, errs}, Height: 260}),
|
||||
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).
|
||||
// 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 {
|
||||
mods := []Mod{Attr("class", class)}
|
||||
for _, c := range children {
|
||||
@@ -1337,7 +1483,6 @@ func languageOptions() []ui.FormSelectOption {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const kitSnippet = `// A component is a function taking a props struct.
|
||||
ui.Button(ui.ButtonProps{
|
||||
Color: ui.ButtonPrimary,
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"kjol/httputil"
|
||||
. "kjol/vdom"
|
||||
. "kjol/wasmruntime/vdom"
|
||||
ui "kjol/webui"
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"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
|
||||
// code can measure the browser and still server-render. This page uses it for exactly
|
||||
// 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 = append(mods,
|
||||
Div(Attr("class", "border-b border-line pb-6"),
|
||||
P(Attr("class", "text-xs font-semibold uppercase tracking-widest text-accent"), Text(eyebrow)),
|
||||
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)),
|
||||
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"),
|
||||
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)),
|
||||
),
|
||||
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 = append(mods,
|
||||
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")}
|
||||
@@ -1,7 +1,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
. "kjol/vdom"
|
||||
. "kjol/wasmruntime/vdom"
|
||||
ui "kjol/webui"
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ package app
|
||||
import (
|
||||
"strings"
|
||||
|
||||
. "kjol/vdom"
|
||||
. "kjol/wasmruntime/vdom"
|
||||
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"),
|
||||
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),
|
||||
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)),
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
// client). UI is built from the kjol webui kit + Tailwind utility classes.
|
||||
//
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
. "kjol/vdom"
|
||||
. "kjol/wasmruntime/vdom"
|
||||
|
||||
ui "kjol/webui"
|
||||
)
|
||||
@@ -410,7 +410,7 @@ serving "./wwwroot" on http://localhost:8085`
|
||||
func AboutPage(d Deps) func() *VNode {
|
||||
return func() *VNode {
|
||||
return Div(Attr("class", "mx-auto max-w-3xl py-4"),
|
||||
P(Attr("class", "text-xs font-semibold uppercase tracking-widest text-accent"), Text("About")),
|
||||
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")),
|
||||
|
||||
P(Attr("class", "mt-6 text-lg leading-relaxed text-ink-soft"),
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by wasmgen. DO NOT EDIT.
|
||||
package app
|
||||
|
||||
import "kjol/vdom"
|
||||
import "kjol/wasmruntime/vdom"
|
||||
|
||||
// Routes maps each //gowasm:page path to its instantiated render function.
|
||||
func Routes(d Deps) map[string]func() *vdom.VNode {
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
package app
|
||||
|
||||
import "kjol/rsc"
|
||||
import "kjol/wasmruntime/rsc"
|
||||
|
||||
func init() {
|
||||
rsc.Register("ServerCounter", ServerCounter)
|
||||
@@ -3,13 +3,10 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
chart "github.com/wcharczuk/go-chart/v2"
|
||||
|
||||
. "kjol/vdom"
|
||||
. "kjol/wasmruntime/vdom"
|
||||
ui "kjol/webui"
|
||||
)
|
||||
|
||||
@@ -17,7 +14,7 @@ import (
|
||||
// component (same builders, signals, On handlers). The //gowasm:server directive
|
||||
// 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
|
||||
// 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.
|
||||
//
|
||||
// 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
|
||||
}
|
||||
|
||||
// clickChartSVG plots counter value vs. time-of-click (ms since the first
|
||||
// click) as a line graph. Explicit axis ranges keep it valid for the tricky
|
||||
// cases (a single click, or several clicks within the same millisecond).
|
||||
// clickChartSVG plots the counter value at each click as a line, drawn on the server by
|
||||
// webui.ChartSVG — the same chart geometry the client kit uses, rendered to a static SVG
|
||||
// string (no controller, no hover) because a server component's output is HTML.
|
||||
func clickChartSVG(points []clickPoint) string {
|
||||
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
|
||||
xs := make([]float64, len(points))
|
||||
ys := make([]float64, len(points))
|
||||
minY, maxY := 0.0, 0.0 // keep the zero baseline in view for context
|
||||
vals := make([]float64, len(points))
|
||||
labels := make([]string, len(points))
|
||||
for i, p := range points {
|
||||
xs[i] = float64(p.T - t0)
|
||||
ys[i] = float64(p.V)
|
||||
if ys[i] < minY {
|
||||
minY = ys[i]
|
||||
}
|
||||
if ys[i] > maxY {
|
||||
maxY = ys[i]
|
||||
}
|
||||
vals[i] = float64(p.V)
|
||||
labels[i] = strconv.Itoa(i + 1)
|
||||
}
|
||||
maxX := xs[len(xs)-1]
|
||||
if maxX <= 0 {
|
||||
maxX = 1 // rapid or single clicks: avoid a zero-width x-range
|
||||
}
|
||||
if minY == maxY {
|
||||
maxY++ // avoid a zero-height y-range
|
||||
}
|
||||
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()
|
||||
return ui.ChartSVG(ui.ChartProps{
|
||||
Kind: ui.ChartLine,
|
||||
Labels: labels,
|
||||
Series: []ui.ChartSeries{{Name: "Counter", Data: vals, Color: "var(--color-chart-4)"}},
|
||||
Height: 240,
|
||||
NoTooltip: true,
|
||||
})
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"kjol/vdom"
|
||||
"kjol/wasmruntime/vdom"
|
||||
"kjol/webui"
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
. "kjol/vdom"
|
||||
. "kjol/wasmruntime/vdom"
|
||||
ui "kjol/webui"
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"kjol/vdom"
|
||||
"kjol/wasmruntime/vdom"
|
||||
)
|
||||
|
||||
// The two-runtime demo.s whole claim is that its two panes are ONE function: the live
|
||||
@@ -106,13 +106,13 @@ func Codegen() ([]byte, error) {
|
||||
// names, and nothing else in the tree mentions text-teal-300.
|
||||
func Tailwind() ([]byte, error) {
|
||||
cmd := exec.Command("go", "run", "./cmd/twcss",
|
||||
"-entry", "cmd/kjol-web/css/app.css",
|
||||
"-out", "cmd/kjol-web/wwwroot/app.css",
|
||||
"-entry", "cmd/kjol-website/css/app.css",
|
||||
"-out", "cmd/kjol-website/wwwroot/app.css",
|
||||
"-base", ".",
|
||||
"webui/**/*.go",
|
||||
"lexer/**/*.go",
|
||||
"cmd/kjol-web/app/**/*.go",
|
||||
"cmd/kjol-web/server/**/*.go",
|
||||
"cmd/kjol-website/app/**/*.go",
|
||||
"cmd/kjol-website/server/**/*.go",
|
||||
)
|
||||
cmd.Dir = kjolRoot
|
||||
return cmd.CombinedOutput()
|
||||
@@ -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.
|
||||
---------------------------------------------------------------------------
|
||||
Tailwind's built-in dark variant follows the operating system. A site with its own
|
||||
theme switch cannot use it: the OS says one thing, the switch says another, and the
|
||||
media query wins — so the switch appears to do nothing.
|
||||
Open Sans — self-hosted (files in wwwroot/fonts). One variable file per subset
|
||||
carries weights 400–700 upright and italic, so the four faces below cover the
|
||||
whole UI. unicode-range keeps the browser to the one subset a glyph needs, and
|
||||
font-display: swap paints text in the fallback first rather than blocking on the
|
||||
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
|
||||
still respected: it is the DEFAULT (see the boot script in server/main.go), just no
|
||||
longer the last word.
|
||||
--------------------------------------------------------------------------- */
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* App-side design tokens the webui kit references (Tailwind v4 @theme). Brand
|
||||
values live with the app; the kit stays generic.
|
||||
|
||||
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. */
|
||||
/* The brand — the values the shared kit's tokens are re-pointed to. Everything the
|
||||
kit already defines (surfaces, lines, ink, the chart palette, the state colours)
|
||||
comes from tw/kjol_theme.css; this block sets only what is this site's own. */
|
||||
@theme {
|
||||
--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.
|
||||
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.
|
||||
(The previous sky blue did the same job, but said nothing.) */
|
||||
job is to mark the few things you can act on, not to compete with them for attention. */
|
||||
--color-primary: #1e3a63; /* muted navy — FILLS; they carry white text */
|
||||
--color-primary-hover: #16294a;
|
||||
--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
|
||||
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
|
||||
touch deeper so a navy link on white is unmistakably a link. (The red is gone; the
|
||||
brand is navy throughout now. Only the flag keeps its red field.) */
|
||||
touch deeper so a navy link on white is unmistakably a link. */
|
||||
--color-accent: #1c3a66; /* navy — accent TEXT */
|
||||
|
||||
/* Surfaces, lines, ink — the kit's theme contract. */
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-muted: #fafafa;
|
||||
--color-surface-raised: #f5f5f5;
|
||||
--color-surface-strong: #e5e5e5;
|
||||
--color-line: #e5e5e5;
|
||||
--color-line-strong: #d4d4d4;
|
||||
--color-ink: #171717;
|
||||
--color-ink-soft: #525252;
|
||||
--color-ink-muted: #737373;
|
||||
--color-ink-faint: #a3a3a3;
|
||||
|
||||
/* Heading ink — a touch stronger than body ink. Not part of the shared contract, so
|
||||
the site names it here and re-points it in the .dark block below. */
|
||||
--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 */
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
The dark theme.
|
||||
/* The dark values for the brand.
|
||||
---------------------------------------------------------------------------
|
||||
Only the token VALUES change. Not one component knows this block exists — they ask
|
||||
for bg-surface and text-ink, and here is where those come to mean something else.
|
||||
|
||||
This is a plain rule, not another @theme block: @theme generates utilities, and these
|
||||
are overrides of utilities that already exist.
|
||||
|
||||
The surfaces are not pure black. Black gives a dark UI a hard, glaring edge against
|
||||
white text and makes every border invisible; a very dark grey leaves room for the
|
||||
raised surfaces and lines above it to actually be seen. --------------------------- */
|
||||
The shared surfaces/lines/ink re-point themselves in tw/kjol_theme.css's own .dark
|
||||
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,
|
||||
to a soft sky a link stays legible in; the fill climbs less, to a steel blue that still
|
||||
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. */
|
||||
.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-primary: #2b4f80;
|
||||
--color-primary-hover: #37619b;
|
||||
@@ -118,11 +123,12 @@
|
||||
--grid-line: rgba(226, 232, 240, 0.05);
|
||||
}
|
||||
|
||||
/* The page's own background — painted before the app mounts, and behind it afterwards.
|
||||
Without this, a dark app sits in a white window. */
|
||||
/* Only the font. The page's background and text colour come from the shared layer's
|
||||
`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 {
|
||||
background-color: var(--color-surface);
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
@@ -176,7 +182,3 @@ html {
|
||||
-webkit-mask-image: linear-gradient(to bottom, #000, #000 35%, transparent 100%);
|
||||
mask-image: linear-gradient(to bottom, #000, #000 35%, transparent 100%);
|
||||
}
|
||||
|
||||
/* (The hero glow that used to live here went with the hero. A coloured wash behind an
|
||||
oversized headline is the most recognisable gesture in framework marketing, and this
|
||||
page is not making that argument any more.) */
|
||||
@@ -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
|
||||
kjol's shared scaffold (go/jsruntime/styles/theme.css) to this file, and that
|
||||
scaffold does the import — an @import has to come first, and this file no
|
||||
longer is. See jsbundler/css.go and the header of theme.css.
|
||||
There is deliberately no `@import "tailwindcss"` here. The bundler compiles this
|
||||
file via tw.CompileApp, which PREPENDS kjol's shared extension layer
|
||||
(tw/kjol_theme.css); that layer does the import — an @import has to come first,
|
||||
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.
|
||||
|
||||
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
|
||||
parts of ONE site rather than two demos that happen to share a domain. The
|
||||
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.
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
/* Open Sans — self-hosted (files in wwwroot/fonts). One variable file per subset
|
||||
carries weights 400–700 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 {
|
||||
/* 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
|
||||
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
|
||||
@@ -98,7 +98,7 @@ function LayerItem(props: { layer: Layer; current?: Layer }) {
|
||||
reference
|
||||
</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>
|
||||
}
|
||||
>
|
||||
@@ -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-xs text-ink-muted">{props.layer.tagline}</span>
|
||||
<span class="text-ss text-ink-muted">{props.layer.tagline}</span>
|
||||
</a>
|
||||
</Show>
|
||||
);
|
||||
@@ -83,7 +83,8 @@ import { CrmTabGroup, CrmSubTabGroup } from "@ui/CrmTabs";
|
||||
import { Accordion, SingleAccordion } from "@ui/Accordion";
|
||||
import { SidebarNav } from "@ui/Sidebar";
|
||||
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 { Demo } from "../layout/Demo.tsx";
|
||||
@@ -111,7 +112,7 @@ function Panel(props: { title: string; children?: JSXElement }) {
|
||||
return (
|
||||
<div class="mt-4 rounded-default border border-line bg-surface shadow-xs">
|
||||
<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 class="p-4">{props.children}</div>
|
||||
</div>
|
||||
@@ -152,7 +153,7 @@ function Body() {
|
||||
return (
|
||||
<div>
|
||||
<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>
|
||||
<p class="mt-3 leading-relaxed text-ink-muted">
|
||||
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>
|
||||
<EnvBadge />
|
||||
</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
|
||||
you.
|
||||
</p>
|
||||
@@ -428,7 +429,7 @@ function Icons() {
|
||||
<Icon icon="star" size={18} solid />
|
||||
<Icon icon="star" size={18} solid={false} />
|
||||
</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
|
||||
default is a CSS variable the app sets, read at runtime.
|
||||
</p>
|
||||
@@ -464,7 +465,7 @@ function Forms() {
|
||||
email() && !isEmailValid(email()) ? "That is not an email address." : "";
|
||||
|
||||
<FormEmailInput
|
||||
value={email}
|
||||
value={email()}
|
||||
oninput={(e) => setEmail(e.currentTarget.value)}
|
||||
error={emailError()}
|
||||
showIcon
|
||||
@@ -474,21 +475,21 @@ function Forms() {
|
||||
<Field label="Name">
|
||||
<FormInput
|
||||
placeholder="Ada Lovelace"
|
||||
value={name}
|
||||
value={name()}
|
||||
oninput={(e) => setName(e.currentTarget.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Email">
|
||||
<FormEmailInput
|
||||
placeholder="ada@example.com"
|
||||
value={email}
|
||||
value={email()}
|
||||
oninput={(e) => setEmail(e.currentTarget.value)}
|
||||
error={emailError()}
|
||||
showIcon
|
||||
/>
|
||||
</Field>
|
||||
<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="pro">Pro</option>
|
||||
<option value="enterprise">Enterprise</option>
|
||||
@@ -498,7 +499,7 @@ function Forms() {
|
||||
<FormTextarea
|
||||
rows={3}
|
||||
placeholder="Anything worth remembering…"
|
||||
value={notes}
|
||||
value={notes()}
|
||||
oninput={(e) => setNotes(e.currentTarget.value)}
|
||||
/>
|
||||
</Field>
|
||||
@@ -532,11 +533,11 @@ function Forms() {
|
||||
<FormFieldset legend="Account">
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Name">
|
||||
<FormInput value={name} oninput={(e) => setName(e.currentTarget.value)} />
|
||||
<FormInput value={name()} oninput={(e) => setName(e.currentTarget.value)} />
|
||||
</Field>
|
||||
<Field label="Email (validated)">
|
||||
<FormEmailInput
|
||||
value={email}
|
||||
value={email()}
|
||||
oninput={(e) => setEmail(e.currentTarget.value)}
|
||||
error={emailError()}
|
||||
showIcon
|
||||
@@ -590,7 +591,7 @@ function Selects() {
|
||||
<Field label="Language (searchable, one)">
|
||||
<FormCombobox
|
||||
options={LANGUAGES}
|
||||
value={one}
|
||||
value={one()}
|
||||
onchange={setOne}
|
||||
searchable
|
||||
placeholder="Pick one"
|
||||
@@ -607,13 +608,13 @@ function Selects() {
|
||||
/>
|
||||
</Field>
|
||||
<Field label="State">
|
||||
<FormCombobox options={US_STATES} value={state} onchange={setState} searchable />
|
||||
<FormCombobox options={US_STATES} value={state()} onchange={setState} searchable />
|
||||
</Field>
|
||||
<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>
|
||||
</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:{" "}
|
||||
<span class="font-mono text-ink">{many().join(", ") || "—"}</span>
|
||||
</p>
|
||||
@@ -634,7 +635,7 @@ function Selects() {
|
||||
onSelect={(value, option) => setPicked(option.label + " <" + value + ">")}
|
||||
/>
|
||||
</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
|
||||
have already typed past is DISCARDED rather than shown — which is the whole bug with
|
||||
hand-rolled autocompletes.
|
||||
@@ -654,7 +655,7 @@ function Selects() {
|
||||
searchable
|
||||
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.
|
||||
</p>
|
||||
</Panel>
|
||||
@@ -697,7 +698,7 @@ function Toggles() {
|
||||
|
||||
<Panel title={"Signature pad — " + signed().length + " bytes of SVG"}>
|
||||
<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.
|
||||
</p>
|
||||
</Panel>
|
||||
@@ -722,10 +723,10 @@ function Dates() {
|
||||
<Panel title={"Pickers — picked: " + (date() || "nothing")}>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<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 label="Date of birth (inline, month/year selects)">
|
||||
<DateOfBirthPicker value={dob} onchange={setDob} />
|
||||
<DateOfBirthPicker value={dob()} onchange={setDob} />
|
||||
</Field>
|
||||
</div>
|
||||
</Panel>
|
||||
@@ -734,7 +735,7 @@ function Dates() {
|
||||
<div class="max-w-xs">
|
||||
<Calendar selected={day()} onSelect={setDay} />
|
||||
</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{" "}
|
||||
<code class="font-mono">variant="month"</code> for the big version.
|
||||
</p>
|
||||
@@ -858,7 +859,7 @@ function Tables() {
|
||||
sortDesc={sortDesc()}
|
||||
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.
|
||||
The grid does not own the rows — it tells you which cell changed and hands the value back.
|
||||
</p>
|
||||
@@ -971,7 +972,7 @@ function Overlays() {
|
||||
<FormInput placeholder="Focus me" />
|
||||
</Tooltip>
|
||||
</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.
|
||||
</p>
|
||||
</Panel>
|
||||
@@ -1047,7 +1048,7 @@ function Overlays() {
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
</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
|
||||
menu — and that the one marked closeOnClick={"{false}"} does not.
|
||||
</p>
|
||||
@@ -1063,7 +1064,7 @@ function Overlays() {
|
||||
Open wizard
|
||||
</ButtonUI>
|
||||
</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
|
||||
LAYER per press rather than all at once.
|
||||
</p>
|
||||
@@ -1125,7 +1126,7 @@ function Overlays() {
|
||||
return (
|
||||
<Field label="Name (required to continue)">
|
||||
<FormInput
|
||||
value={wizardName}
|
||||
value={wizardName()}
|
||||
placeholder="Ada Lovelace"
|
||||
oninput={(e) => setWizardName(e.currentTarget.value)}
|
||||
/>
|
||||
@@ -1189,7 +1190,7 @@ function Feedback() {
|
||||
Sticky (no timer)
|
||||
</ButtonUI>
|
||||
</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"><ToastProvider></code> — which is one of only two providers
|
||||
this kit has.
|
||||
@@ -1199,7 +1200,7 @@ function Feedback() {
|
||||
<Panel title="The guided tour">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<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
|
||||
to the next. Targets are CSS SELECTORS — the same section ids the sidebar jumps to.
|
||||
</span>
|
||||
@@ -1213,7 +1214,7 @@ function Feedback() {
|
||||
</ButtonUI>
|
||||
<RemoteUpdateFlash when={flash.visible()} />
|
||||
</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
|
||||
not a toast: it belongs next to the thing that moved, not in the corner.
|
||||
</p>
|
||||
@@ -1268,7 +1269,7 @@ function Navigation() {
|
||||
<Panel title="Accordion — one open at a time, or several">
|
||||
<div class="grid gap-6 lg:grid-cols-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
|
||||
</p>
|
||||
<SingleAccordion
|
||||
@@ -1280,7 +1281,7 @@ function Navigation() {
|
||||
/>
|
||||
</div>
|
||||
<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)
|
||||
</p>
|
||||
<Accordion
|
||||
@@ -1313,7 +1314,7 @@ function Navigation() {
|
||||
onItemClick={setSide}
|
||||
/>
|
||||
</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 —
|
||||
so these really do jump, because those sections really do exist on this page.
|
||||
</p>
|
||||
@@ -1328,7 +1329,21 @@ const KIT_NAMES = [
|
||||
"AutoTable", "Accordion", "Alert", "Badge", "ButtonUI", "Calendar", "CellGrid", "FormCombobox",
|
||||
"DatePicker", "FormInput", "Menu", "Modal", "FormMultiSelect", "Popover", "PrettyTable",
|
||||
"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() {
|
||||
@@ -1352,89 +1367,200 @@ function Search() {
|
||||
onSelect={(value) => setHit(value)}
|
||||
/>
|
||||
</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{" "}
|
||||
<code class="font-mono">fuzzySegments</code> give you the ranking and the highlight runs,
|
||||
and you render them however you like.
|
||||
</p>
|
||||
</Panel>
|
||||
|
||||
<Prose>
|
||||
Pass <code class="font-mono">andAmpersand</code> 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.
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 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() {
|
||||
const [seed, setSeed] = createSignal(0);
|
||||
const [threeD, setThreeD] = createSignal(false);
|
||||
const [depth, setDepth] = createSignal(16);
|
||||
const [tilt, setTilt] = createSignal(0.6);
|
||||
|
||||
const data = () => {
|
||||
const base = [42, 17, 63, 28, 55, 9, 71];
|
||||
const values = base.map((v) => (seed() === 0 ? v : Math.max(4, (v * 7 + seed() * 13) % 80)));
|
||||
return {
|
||||
labels: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
|
||||
datasets: [
|
||||
{
|
||||
label: "Requests",
|
||||
data: values,
|
||||
// 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",
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
// Two series over the same week. seed() reshuffles them so you can watch the SVG
|
||||
// move — no teardown, no new instance, just the marks that changed.
|
||||
const shuffle = (base: number[]) =>
|
||||
base.map((v) => (seed() === 0 ? v : Math.max(4, (v * 7 + seed() * 13) % 80)));
|
||||
const requests = (): ChartSeries => ({ name: "Requests", data: shuffle([42, 17, 63, 28, 55, 9, 71]) });
|
||||
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]) });
|
||||
const pieLabels = ["Direct", "Search", "Social", "Email", "Referral"];
|
||||
const regions = ["East", "Central", "Mountain", "Pacific"];
|
||||
|
||||
return (
|
||||
<Section id="charts" title="Charts">
|
||||
<Prose>
|
||||
<code class="font-mono">@ui/Chart</code> wraps chart.js: it creates a chart against a canvas
|
||||
on mount and pushes new data through an effect, so changing the data updates the chart rather
|
||||
than rebuilding the page.
|
||||
<code class="font-mono">@ui/Chart</code> draws SVG — no charting library, nothing vendored, no{" "}
|
||||
<code class="font-mono"><canvas></code>. Because the picture is JSX it is already reactive:
|
||||
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>
|
||||
This is the one component in the kit that is genuinely different on the other side. The Go
|
||||
port keeps the props shape for parity and renders an empty canvas — canvas drawing, a
|
||||
JavaScript charting library and mount lifecycles do not exist in the neutral Go runtime. Kjol
|
||||
Wasm Web draws SVG with go-chart instead, and draws it in the browser, in WebAssembly, exactly
|
||||
as this one draws to a canvas here. Same place, different library. Neither side pretends
|
||||
otherwise.
|
||||
Being SVG buys two things a canvas cannot. The marks name the theme tokens
|
||||
(<code class="font-mono">var(--color-chart-1)</code> …), so the palette inverts for dark mode with
|
||||
the rest of the site — a canvas paints pixels and cannot read a CSS variable. And every mark is a
|
||||
real element, so hovering shows a crosshair and one tooltip listing every series at that point.
|
||||
</Prose>
|
||||
|
||||
<Panel title="Bar and doughnut, from the same data">
|
||||
{/* The wrapper is h-full with maintainAspectRatio:false, so the PARENT must
|
||||
have an explicit height or the canvas collapses to nothing at all. */}
|
||||
<div class="grid gap-4 lg:grid-cols-12">
|
||||
<div class="h-72 lg:col-span-7">
|
||||
<ReactiveChart
|
||||
type="bar"
|
||||
data={data()}
|
||||
options={{ plugins: { legend: { display: false } } }}
|
||||
/>
|
||||
<Panel title="Grouped bars, a smooth area, a two-line series, and a donut — from one data set">
|
||||
<div class="grid gap-6 lg:grid-cols-12">
|
||||
<div class="lg:col-span-7">
|
||||
<Chart kind="bar" title="Requests & errors this week" labels={CHART_DAYS} series={[requests(), errors()]} height={260} threeD={threeD()} />
|
||||
</div>
|
||||
<div class="h-72 lg:col-span-5">
|
||||
<ReactiveChart type="doughnut" data={data()} />
|
||||
<div class="lg:col-span-5">
|
||||
<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 class="mt-4 flex items-center gap-3">
|
||||
<ButtonUI color={BUTTON_COLOR_PRIMARY} small onclick={() => setSeed(seed() + 1)}>
|
||||
New data
|
||||
</ButtonUI>
|
||||
<span class="text-xs text-ink-muted">
|
||||
The chart updates in place — it is not torn down and rebuilt.
|
||||
<ButtonUI color={BUTTON_COLOR_NEUTRAL} small onclick={() => setThreeD(!threeD())}>
|
||||
{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>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<AlertYellow header="It has to be vendored" class="mt-6">
|
||||
<code class="font-mono">Chart.tsx</code> imports chart.js at the top level and registers
|
||||
every controller on module load. An app that uses it must vendor chart.js and{" "}
|
||||
<code class="font-mono">@kurkle/color</code>, or the bundle does not degrade — it fails to
|
||||
evaluate, and you get an empty page and one line in the console.
|
||||
</AlertYellow>
|
||||
<Panel title="3D grid — bars extrude into it, lines draw flat on the front">
|
||||
<div class="grid items-center gap-6 lg:grid-cols-12">
|
||||
<div class="lg:col-span-4">
|
||||
<Chart kind="bar" title="Bars" labels={CHART_DAYS}
|
||||
series={[requests()]} height={240} threeD depth={depth()} tilt={tilt()} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1508,7 +1634,7 @@ function Theming() {
|
||||
)}
|
||||
</For>
|
||||
</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{" "}
|
||||
<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
|
||||
@@ -13,7 +13,7 @@ export function Overview() {
|
||||
|
||||
return (
|
||||
<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">
|
||||
A Solid kit, built by a Go toolchain
|
||||
</h1>
|
||||
@@ -103,7 +103,7 @@ export function Overview() {
|
||||
function Stage(props: { n: string; title: string; body: string }) {
|
||||
return (
|
||||
<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}
|
||||
</span>
|
||||
<div>
|
||||
@@ -29,7 +29,7 @@ export function Ssr() {
|
||||
|
||||
return (
|
||||
<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">
|
||||
This page was rendered by Go
|
||||
</h1>
|
||||
8
go/cmd/kjol-website/frontend/src/vendor.d.ts
vendored
Normal file
8
go/cmd/kjol-website/frontend/src/vendor.d.ts
vendored
Normal 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";
|
||||
@@ -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.",
|
||||
|
||||
"//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": {
|
||||
"pdf-lib": "pdf-lib/dist/pdf-lib.esm.js",
|
||||
"pdfjs-dist": "pdfjs-dist/build/pdf.mjs",
|
||||
"chart.js": "chart.js/dist/chart.js",
|
||||
"@kurkle/color": "@kurkle/color/dist/color.esm.js"
|
||||
"pdfjs-dist": "pdfjs-dist/build/pdf.mjs"
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,20 @@
|
||||
// The example is its own module so its go-chart dependency (and freetype /
|
||||
// x/image) stays out of the kjol module — kjol's engine packages are
|
||||
// stdlib-only. kjol is resolved locally via the replace below (no publish step).
|
||||
module kjolweb
|
||||
// The example is its own module so its build/SSR toolchain (esbuild, goja, minify) stays
|
||||
// out of the kjol module — kjol's engine packages are stdlib-only. kjol is resolved
|
||||
// locally via the replace below (no publish step).
|
||||
module kjolwebsite
|
||||
|
||||
go 1.26.3
|
||||
|
||||
require (
|
||||
github.com/wcharczuk/go-chart/v2 v2.1.2
|
||||
kjol v0.0.0
|
||||
)
|
||||
require kjol v0.0.0
|
||||
|
||||
require (
|
||||
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
|
||||
github.com/dop251/goja v0.0.0-20260618133527-c9b2ea77db59 // indirect
|
||||
github.com/evanw/esbuild v0.28.0 // 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/tdewolff/minify/v2 v2.24.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/sys v0.45.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
27
go/cmd/kjol-website/go.sum
Normal file
27
go/cmd/kjol-website/go.sum
Normal 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=
|
||||
@@ -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
|
||||
// hot-swaps the wasm into the browser on change. It shows the coupling
|
||||
// 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):
|
||||
//
|
||||
// go run ./server # from go/cmd/kjol-web
|
||||
// go run ./server # from go/cmd/kjol-website
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -16,13 +16,13 @@ import (
|
||||
"net/http"
|
||||
|
||||
"kjol/httputil"
|
||||
"kjol/vdom"
|
||||
"kjol/wasmdevserver"
|
||||
"kjol/wasmruntime/vdom"
|
||||
"kjol/webui"
|
||||
|
||||
"kjolweb/app"
|
||||
"kjolweb/build"
|
||||
"kjolweb/internal/handlers"
|
||||
"kjolwebsite/app"
|
||||
"kjolwebsite/build"
|
||||
"kjolwebsite/internal/handlers"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -42,10 +42,11 @@ func main() {
|
||||
Watch: *watch,
|
||||
WatchDirs: []string{
|
||||
"app", "wasm", "css", // the Go/WASM app
|
||||
"frontend", // the Solid app — a .tsx save rebuilds the JS bundle
|
||||
"../../webui", "../../vdom", "../../wasmruntime", "../../rsc", // the wasm engine
|
||||
"../../lexer", // the code-block highlighter
|
||||
"../../jsruntime/uikit", "../../jsruntime/styles", // the Solid kit + the shared theme
|
||||
"frontend", // the Solid app — a .tsx save rebuilds the JS bundle
|
||||
"../../webui", "../../wasmruntime", // the wasm engine (vdom + rsc now live UNDER wasmruntime, and the watcher walks subdirs)
|
||||
"../../lexer", // the code-block highlighter
|
||||
"../../jsruntime/uikit", // the Solid kit
|
||||
"../../tw", // the Tailwind engine + the shared kjol extension layer (kjol_theme.css)
|
||||
},
|
||||
Build: build.All,
|
||||
BuildCSS: build.Tailwind, // a .css save skips codegen+wasm and hot-swaps the stylesheet
|
||||
32
go/cmd/kjol-website/tsconfig.json
Normal file
32
go/cmd/kjol-website/tsconfig.json
Normal 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"]
|
||||
}
|
||||
@@ -5,9 +5,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"kjolweb/app"
|
||||
"kjol/vdom"
|
||||
"kjol/wasmruntime"
|
||||
"kjol/wasmruntime/vdom"
|
||||
"kjolwebsite/app"
|
||||
)
|
||||
|
||||
func main() {
|
||||
Binary file not shown.
Binary file not shown.
BIN
go/cmd/kjol-website/wwwroot/fonts/opensans-latin-italic.woff2
Normal file
BIN
go/cmd/kjol-website/wwwroot/fonts/opensans-latin-italic.woff2
Normal file
Binary file not shown.
BIN
go/cmd/kjol-website/wwwroot/fonts/opensans-latin-normal.woff2
Normal file
BIN
go/cmd/kjol-website/wwwroot/fonts/opensans-latin-normal.woff2
Normal file
Binary file not shown.
@@ -1,8 +1,12 @@
|
||||
// Command twcss compiles a Tailwind v4 stylesheet with kjol's native engine,
|
||||
// scanning explicit content globs for utility candidates. Unlike the app bundler
|
||||
// (which is wired to the frontend tree) it takes the entry, output, and content
|
||||
// globs as flags/args, so it works for markup authored in any language — used by
|
||||
// the kjol-web site, whose Go/WASM half writes its UI in Go.
|
||||
// Command twcss compiles a kjol app's Tailwind stylesheet with kjol's native
|
||||
// engine, scanning explicit content globs for utility candidates. Unlike the app
|
||||
// bundler (which is wired to the frontend tree) it takes the entry, output, and
|
||||
// content globs as flags/args, so it works for markup authored in any language —
|
||||
// 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):
|
||||
//
|
||||
@@ -32,7 +36,7 @@ func main() {
|
||||
fmt.Fprintln(os.Stderr, "twcss:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
css, err := tw.CompileFiles(string(src), *base, flag.Args())
|
||||
css, err := tw.CompileAppFiles(string(src), *base, flag.Args())
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "twcss:", err)
|
||||
os.Exit(1)
|
||||
|
||||
@@ -155,7 +155,7 @@ func write(path, tmpl string, data genData) {
|
||||
const routesTmpl = `// Code generated by wasmgen. DO NOT EDIT.
|
||||
package app
|
||||
|
||||
import "kjol/vdom"
|
||||
import "kjol/wasmruntime/vdom"
|
||||
|
||||
// Routes maps each //gowasm:page path to its instantiated render function.
|
||||
func Routes(d Deps) map[string]func() *vdom.VNode {
|
||||
@@ -191,7 +191,7 @@ const serverTmpl = `// Code generated by wasmgen. DO NOT EDIT.
|
||||
|
||||
package app
|
||||
|
||||
import "kjol/rsc"
|
||||
import "kjol/wasmruntime/rsc"
|
||||
|
||||
func init() {
|
||||
{{range .Servers}} rsc.Register("{{.}}", {{.}})
|
||||
@@ -205,8 +205,8 @@ const clientTmpl = `// Code generated by wasmgen. DO NOT EDIT.
|
||||
package app
|
||||
|
||||
import (
|
||||
"kjol/rsc"
|
||||
"kjol/vdom"
|
||||
"kjol/wasmruntime/rsc"
|
||||
"kjol/wasmruntime/vdom"
|
||||
)
|
||||
|
||||
{{range .Servers}}// {{.}} is a generated client stub for the server component of the same name.
|
||||
|
||||
@@ -94,14 +94,5 @@ func vendorDirs() []string {
|
||||
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).
|
||||
func faOutPath() string { return filepath.Join(genTSDir, "faIcons.ts") }
|
||||
|
||||
@@ -78,17 +78,10 @@ func compileCSSBundle(label string, twSources []string, outName string) (bundleS
|
||||
kitCands := tw.Scan(uikitDir(), []string{"**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"})
|
||||
candidates = dedupStrings(append(candidates, kitCands...))
|
||||
|
||||
// Prepend the shared @theme scaffold (kjol jsruntime/styles/theme.css) ahead of the
|
||||
// app's brand style.css so its tokens/vars are in scope. Absent in single-tree
|
||||
// mode (the app's style.css is already complete).
|
||||
input := string(src)
|
||||
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)
|
||||
// CompileApp prepends kjol's shared extension layer (tokens, the class-based dark
|
||||
// variant, the chart palette) ahead of the app's brand style.css, so the app
|
||||
// stylesheet carries only brand: base Tailwind → kjol extensions → app brand.
|
||||
compiled, count, err := tw.CompileApp(string(src), cssDir, candidates)
|
||||
if err != nil {
|
||||
return bundleStats{}, fmt.Errorf("tailwind compile (%s): %w", label, err)
|
||||
}
|
||||
|
||||
@@ -119,6 +119,15 @@ func generateFAIcons() error {
|
||||
}
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ setTimeout(() => {
|
||||
}, 0);
|
||||
`)
|
||||
|
||||
eps, err := loadVendorManifest(filepath.Join(frontendAbs, "vendor"))
|
||||
eps, err := loadVendorManifest([]string{filepath.Join(frontendAbs, "vendor")})
|
||||
if err != nil {
|
||||
t.Fatalf("vendor manifest: %v", err)
|
||||
}
|
||||
@@ -90,7 +90,7 @@ setTimeout(() => {
|
||||
hub: newHub(),
|
||||
frontend: frontendAbs,
|
||||
srcRoot: tmpSrc,
|
||||
vendor: filepath.Join(frontendAbs, "vendor"),
|
||||
vendorDirs: []string{filepath.Join(frontendAbs, "vendor")},
|
||||
graph: newModuleGraph(),
|
||||
vendorCache: map[string][]byte{},
|
||||
cssTrigger: make(chan struct{}, 1),
|
||||
|
||||
@@ -27,9 +27,9 @@ func TestDevServerAssetURL(t *testing.T) {
|
||||
[]byte(`import u from "pdfjs-dist/build/pdf.worker.min.mjs?url";
|
||||
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,
|
||||
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}
|
||||
|
||||
out, err := d.transformModule(filepath.Join(tmpSrc, "uses-worker.tsx"))
|
||||
|
||||
@@ -57,9 +57,9 @@ func TestServeModuleCompileErrorServesOverlay(t *testing.T) {
|
||||
os.WriteFile(filepath.Join(tmpSrc, "Broken.tsx"),
|
||||
[]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,
|
||||
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}
|
||||
|
||||
req := httptest.NewRequest("GET", srcURLPrefix+"Broken.tsx", nil)
|
||||
|
||||
@@ -26,10 +26,12 @@ func esbuildDefine() map[string]string {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if _, err := os.Stat(filepath.Join(frontendDir, "src/app.tsx")); err == nil {
|
||||
return "src/app.tsx"
|
||||
for _, e := range []string{"src/app.tsx", "src/app.ts"} {
|
||||
if _, err := os.Stat(filepath.Join(frontendDir, e)); err == nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
return "src/app.js"
|
||||
}
|
||||
@@ -79,8 +81,16 @@ func bundleJSEntry(entry, outName string) (bundleStats, error) {
|
||||
plugins := []esbuild.Plugin{aliasPlugin(), defaultExportShimPlugin(), Plugin()}
|
||||
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{
|
||||
EntryPoints: []string{entry},
|
||||
EntryPoints: []string{entryPath},
|
||||
Outfile: filepath.Join(outputDir, outName),
|
||||
Bundle: true,
|
||||
Write: true,
|
||||
|
||||
@@ -53,4 +53,8 @@ export function numberToStringWithCommas(n: number):string {
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function clamp(val: number, min: number, max: number) : number {
|
||||
return Math.min(Math.max(val, min), max);
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
// 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
|
||||
// @theme scaffold, and the generic TS scaffolding (auth, hooks, ssr, utils).
|
||||
// vendored Solid runtime, the FontAwesome SVG source kit, and the generic TS
|
||||
// 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
|
||||
// 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
|
||||
// not committed here. An app's own frontend/icons is searched first, so
|
||||
// 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
|
||||
// generic TS scaffolding; apps import it as @kjol/*. Concrete permission
|
||||
|
||||
@@ -17,7 +17,7 @@ Only the styles in `jsbundler`'s `faStyleDirs` are read — currently `regular/`
|
||||
<https://fontawesome.com/license>
|
||||
|
||||
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
|
||||
submodule.
|
||||
|
||||
|
||||
108
go/jsruntime/runtime/timedotgo/README.md
Normal file
108
go/jsruntime/runtime/timedotgo/README.md
Normal 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.
|
||||
```
|
||||
|
||||
356
go/jsruntime/runtime/timedotgo/dist/Time.d.ts
vendored
Normal file
356
go/jsruntime/runtime/timedotgo/dist/Time.d.ts
vendored
Normal 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
|
||||
1
go/jsruntime/runtime/timedotgo/dist/Time.d.ts.map
vendored
Normal file
1
go/jsruntime/runtime/timedotgo/dist/Time.d.ts.map
vendored
Normal 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"}
|
||||
1531
go/jsruntime/runtime/timedotgo/dist/Time.js
vendored
Normal file
1531
go/jsruntime/runtime/timedotgo/dist/Time.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
go/jsruntime/runtime/timedotgo/dist/Time.js.map
vendored
Normal file
1
go/jsruntime/runtime/timedotgo/dist/Time.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
2
go/jsruntime/runtime/timedotgo/dist/index.d.ts
vendored
Normal file
2
go/jsruntime/runtime/timedotgo/dist/index.d.ts
vendored
Normal 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
|
||||
1
go/jsruntime/runtime/timedotgo/dist/index.d.ts.map
vendored
Normal file
1
go/jsruntime/runtime/timedotgo/dist/index.d.ts.map
vendored
Normal 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"}
|
||||
2
go/jsruntime/runtime/timedotgo/dist/index.js
vendored
Normal file
2
go/jsruntime/runtime/timedotgo/dist/index.js
vendored
Normal 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
|
||||
1
go/jsruntime/runtime/timedotgo/dist/index.js.map
vendored
Normal file
1
go/jsruntime/runtime/timedotgo/dist/index.js.map
vendored
Normal 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"}
|
||||
36
go/jsruntime/runtime/timedotgo/package.json
Normal file
36
go/jsruntime/runtime/timedotgo/package.json
Normal 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
Reference in New Issue
Block a user